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',
cards: 10,
enabled: true,
order: 0,
},
{
id: 'pack-2',
title: 'Pack Two',
cards: 5,
enabled: true,
order: 1,
},
],
total: 2,

View file

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

View file

@ -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 })
}
/>

View file

@ -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 })
}
/>

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 { 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>

View file

@ -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