This commit is contained in:
Dmitry 2026-01-07 16:35:50 +03:00
parent 841a6bdc74
commit cb29331d58
7 changed files with 149 additions and 187 deletions

View file

@ -39,14 +39,12 @@ describe('TestPacksManager', () => {
title: 'Pack One', title: 'Pack One',
cards: 10, cards: 10,
enabled: true, enabled: true,
order: 0,
}, },
{ {
id: 'pack-2', id: 'pack-2',
title: 'Pack Two', title: 'Pack Two',
cards: 5, cards: 5,
enabled: true, enabled: true,
order: 1,
}, },
], ],
total: 2, total: 2,

View file

@ -123,6 +123,7 @@ function TestPacksManagerInner({
const fallbackId = Array.from(nextSelected)[0] const fallbackId = Array.from(nextSelected)[0]
const fallback = fallbackId ? packsById.get(fallbackId) : undefined const fallback = fallbackId ? packsById.get(fallbackId) : undefined
if (fallback?.color) onSelectedPackColorChange(fallback.color) if (fallback?.color) onSelectedPackColorChange(fallback.color)
else onSelectedPackColorChange('')
} }
return return
@ -140,8 +141,8 @@ function TestPacksManagerInner({
}) })
} }
if (onSelectedPackColorChange) { if (onSelectedPackColorChange && pack.color) {
onSelectedPackColorChange(pack.color ?? '') onSelectedPackColorChange(pack.color)
} }
} }

View file

@ -111,7 +111,7 @@ export function InputButtonsQuestionForm({
<ImageUpload <ImageUpload
label="Question Image" label="Question Image"
value={image} value={image}
onChange={(value) => setImage(value || '')} onChange={(value: string | undefined) => setImage(value || '')}
/> />
</div> </div>
@ -181,7 +181,7 @@ export function InputButtonsQuestionForm({
<ImageUpload <ImageUpload
label="Image" label="Image"
value={button.image || ''} value={button.image || ''}
onChange={(value) => onChange={(value: string | undefined) =>
updateButton(index, { image: value || undefined }) updateButton(index, { image: value || undefined })
} }
/> />

View file

@ -102,7 +102,7 @@ export function SimpleQuestionForm({
<ImageUpload <ImageUpload
label="Question Image" label="Question Image"
value={image} value={image}
onChange={(value) => setImage(value || '')} onChange={(value: string | undefined) => setImage(value || '')}
/> />
</div> </div>
@ -172,7 +172,7 @@ export function SimpleQuestionForm({
<ImageUpload <ImageUpload
label="Image" label="Image"
value={button.image || ''} value={button.image || ''}
onChange={(value) => onChange={(value: string | undefined) =>
updateButton(index, { image: value || undefined }) updateButton(index, { image: value || undefined })
} }
/> />

View 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>
)
}

View file

@ -3,7 +3,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { toast } from 'sonner' import { toast } from 'sonner'
import { packsApi, isPacksApiError } from '@/api/packs' import { packsApi, isPacksApiError } from '@/api/packs'
import { formatApiError, getDetailedErrorMessage } from '@/lib/error-utils' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
@ -36,8 +36,6 @@ import {
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Textarea } from '@/components/ui/textarea'
import { Checkbox } from '@/components/ui/checkbox' 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' import { Plus, Search, Edit, Trash2, ChevronLeft, ChevronRight } from 'lucide-react'
export default function PacksPage() { export default function PacksPage() {
@ -52,19 +50,11 @@ export default function PacksPage() {
// Form state // Form state
const [formData, setFormData] = useState({ const [formData, setFormData] = useState({
title: '', name: '',
subtitle: '',
description: '', description: '',
color: '', category: '',
enabled: true, isPublic: true,
googlePlayId: '', questions: [] as Question[],
rustoreId: '',
appStoreId: '',
price: '',
order: 0,
version: '',
size: 0,
cover: undefined as string | undefined,
}) })
@ -136,19 +126,11 @@ export default function PacksPage() {
const openCreateDialog = () => { const openCreateDialog = () => {
setSelectedPack(null) setSelectedPack(null)
setFormData({ setFormData({
title: '', name: '',
subtitle: '',
description: '', description: '',
color: '', category: '',
enabled: true, isPublic: true,
googlePlayId: '', questions: [],
rustoreId: '',
appStoreId: '',
price: '',
order: 0,
version: '',
size: 0,
cover: undefined,
}) })
setIsDialogOpen(true) setIsDialogOpen(true)
} }
@ -158,19 +140,11 @@ export default function PacksPage() {
const fullPack = await packsApi.getPack(pack.id) const fullPack = await packsApi.getPack(pack.id)
setSelectedPack(fullPack) setSelectedPack(fullPack)
setFormData({ setFormData({
title: fullPack.title || '', name: fullPack.name || '',
subtitle: fullPack.subtitle || '',
description: fullPack.description || '', description: fullPack.description || '',
color: fullPack.color || '', category: fullPack.category || '',
enabled: fullPack.enabled ?? true, isPublic: fullPack.isPublic ?? true,
googlePlayId: fullPack.googlePlayId || '', questions: fullPack.questions || [],
rustoreId: fullPack.rustoreId || '',
appStoreId: fullPack.appStoreId || '',
price: fullPack.price || '',
order: fullPack.order || 0,
version: fullPack.version || '',
size: fullPack.size || 0,
cover: fullPack.cover,
}) })
setIsDialogOpen(true) setIsDialogOpen(true)
@ -191,27 +165,29 @@ export default function PacksPage() {
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!formData.title.trim()) { if (!formData.name.trim()) {
toast.error('Title is required') 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 return
} }
const packData: EditCardPackDto = { const packData: EditCardPackDto = {
// Only include id for updates, not for new packs // Only include id for updates, not for new packs
...(selectedPack && { id: selectedPack.id }), ...(selectedPack && { id: selectedPack.id }),
title: formData.title.trim(), name: formData.name.trim(),
subtitle: formData.subtitle.trim() || undefined, description: formData.description.trim(),
description: formData.description.trim() || undefined, category: formData.category.trim(),
color: formData.color.trim() || undefined, isPublic: formData.isPublic,
enabled: formData.enabled, questions: formData.questions,
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,
} }
if (selectedPack) { if (selectedPack) {
@ -336,12 +312,7 @@ export default function PacksPage() {
<TableRow key={pack.id}> <TableRow key={pack.id}>
<TableCell className="font-mono text-sm">{pack.id}</TableCell> <TableCell className="font-mono text-sm">{pack.id}</TableCell>
<TableCell> <TableCell>
<div> <div className="font-medium">{pack.title}</div>
<div className="font-medium">{pack.title}</div>
{pack.subtitle && (
<div className="text-sm text-muted-foreground">{pack.subtitle}</div>
)}
</div>
</TableCell> </TableCell>
<TableCell>{pack.cards}</TableCell> <TableCell>{pack.cards}</TableCell>
<TableCell> <TableCell>
@ -426,146 +397,57 @@ export default function PacksPage() {
</DialogHeader> </DialogHeader>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<div className="grid gap-4 py-4"> <div className="grid gap-4 py-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="title">Title *</Label> <Input
<Input id="name"
id="title" value={formData.name}
value={formData.title} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))} placeholder="Pack name"
placeholder="Pack title" required
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> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="description">Description</Label> <Label htmlFor="description">Description *</Label>
<Textarea <Textarea
id="description" id="description"
value={formData.description} value={formData.description}
onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, description: e.target.value }))} onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => setFormData(prev => ({ ...prev, description: e.target.value }))}
placeholder="Pack description" placeholder="Pack description"
rows={3} rows={3}
required
/> />
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="space-y-2">
<div className="space-y-2"> <Label htmlFor="category">Category *</Label>
<Label htmlFor="color">Color</Label> <Input
<ColorPaletteInput id="category"
id="color" value={formData.category}
value={formData.color} onChange={(e) => setFormData(prev => ({ ...prev, category: e.target.value }))}
onChange={(value) => setFormData(prev => ({ ...prev, color: value }))} placeholder="Pack category"
disabled={createMutation.isPending || updateMutation.isPending} required
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> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Checkbox <Checkbox
id="enabled" id="isPublic"
checked={formData.enabled} checked={formData.isPublic}
onCheckedChange={(checked) => setFormData(prev => ({ ...prev, enabled: checked as boolean }))} onCheckedChange={(checked) => setFormData(prev => ({ ...prev, isPublic: checked as boolean }))}
/> />
<Label htmlFor="enabled">Enabled</Label> <Label htmlFor="isPublic">Public</Label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Store IDs</Label> <Label>Questions</Label>
<div className="grid grid-cols-3 gap-4"> <p className="text-sm text-muted-foreground">
<div className="space-y-2"> {formData.questions.length} question(s) in this pack
<Label htmlFor="googlePlayId" className="text-xs">Google Play</Label> </p>
<Input <p className="text-xs text-muted-foreground">
id="googlePlayId" Questions can be managed through the question editor
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)
</p> </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>
</div> </div>

View file

@ -55,6 +55,10 @@ export interface CardPackPreviewDto {
title: string title: string
cards: number cards: number
enabled: boolean 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 // User model matching backend Prisma schema