fix: category form — async save with error display, drag-and-drop photos, 20MB body limit

This commit is contained in:
2026-03-23 12:17:53 +03:00
parent 1f761e65cf
commit 5540d0cdfa
2 changed files with 77 additions and 23 deletions

View File

@@ -31,6 +31,7 @@ export async function buildApp() {
transport: { target: 'pino-pretty', options: { colorize: true } }, transport: { target: 'pino-pretty', options: { colorize: true } },
}), }),
}, },
bodyLimit: 20 * 1024 * 1024, // 20MB — for base64 photo uploads
}) })
// ── Security ─────────────────────────────────────────────────────────────── // ── Security ───────────────────────────────────────────────────────────────

View File

@@ -31,7 +31,7 @@ function fromApi(c: CategoryApi): RoomCategory {
interface CategoryFormProps { interface CategoryFormProps {
category?: RoomCategory category?: RoomCategory
onClose: () => void onClose: () => void
onSave: (cat: RoomCategory) => void onSave: (cat: RoomCategory) => Promise<void>
} }
function CategoryForm({ category, onClose, onSave }: CategoryFormProps) { function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
@@ -44,13 +44,17 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? []) const [amenities, setAmenities] = useState<string[]>(category?.amenities ?? [])
const [photos, setPhotos] = useState<string[]>(category?.photos ?? []) const [photos, setPhotos] = useState<string[]>(category?.photos ?? [])
const [photoIdx, setPhotoIdx] = useState(0) const [photoIdx, setPhotoIdx] = useState(0)
const [saving, setSaving] = useState(false)
const [saveError, setSaveError] = useState('')
const [dragging, setDragging] = useState(false)
const fileRef = useRef<HTMLInputElement>(null) const fileRef = useRef<HTMLInputElement>(null)
const toggleAmenity = (a: string) => const toggleAmenity = (a: string) =>
setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a]) setAmenities(prev => prev.includes(a) ? prev.filter(x => x !== a) : [...prev, a])
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => { const readFiles = (files: FileList | null) => {
Array.from(e.target.files ?? []).forEach(file => { Array.from(files ?? []).forEach(file => {
if (!file.type.startsWith('image/')) return
const reader = new FileReader() const reader = new FileReader()
reader.onload = ev => { reader.onload = ev => {
const url = ev.target?.result as string const url = ev.target?.result as string
@@ -58,25 +62,42 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
} }
reader.readAsDataURL(file) reader.readAsDataURL(file)
}) })
}
const handlePhotoUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
readFiles(e.target.files)
e.target.value = '' e.target.value = ''
} }
const handleDrop = (e: React.DragEvent) => {
e.preventDefault()
setDragging(false)
readFiles(e.dataTransfer.files)
}
const removePhoto = (i: number) => { const removePhoto = (i: number) => {
setPhotos(prev => prev.filter((_, idx) => idx !== i)) setPhotos(prev => prev.filter((_, idx) => idx !== i))
setPhotoIdx(p => Math.max(0, p - 1)) setPhotoIdx(p => Math.max(0, p - 1))
} }
const handleSave = () => { const handleSave = async () => {
if (!name.trim()) return if (!name.trim() || saving) return
onSave({ setSaving(true)
setSaveError('')
try {
await onSave({
id: category?.id ?? `cat-${Date.now()}`, id: category?.id ?? `cat-${Date.now()}`,
hotelId: category?.hotelId ?? 'hotel-1', hotelId: category?.hotelId ?? '',
name: name.trim(), name: name.trim(),
description, description,
color, color,
amenities, amenities,
photos, photos,
}) })
} catch (err) {
setSaveError(err instanceof Error ? err.message : 'Ошибка сохранения')
setSaving(false)
}
} }
return ( return (
@@ -188,8 +209,22 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
<> <>
{photos.length > 0 ? ( {photos.length > 0 ? (
<div className="space-y-3"> <div className="space-y-3">
<div className="relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700" style={{ height: 220 }}> <div
className={cn(
'relative rounded-xl overflow-hidden bg-slate-100 dark:bg-slate-700 transition-colors',
dragging && 'ring-2 ring-brand-500 ring-offset-2',
)}
style={{ height: 220 }}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<img src={photos[photoIdx]} alt="" className="w-full h-full object-cover" /> <img src={photos[photoIdx]} alt="" className="w-full h-full object-cover" />
{dragging && (
<div className="absolute inset-0 bg-brand-600/40 flex items-center justify-center">
<p className="text-white font-semibold text-sm">Отпустите для добавления</p>
</div>
)}
<button <button
onClick={() => removePhoto(photoIdx)} onClick={() => removePhoto(photoIdx)}
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-600 text-white flex items-center justify-center" className="absolute top-2 right-2 w-7 h-7 rounded-full bg-red-600 text-white flex items-center justify-center"
@@ -210,9 +245,21 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
</div> </div>
</div> </div>
) : ( ) : (
<div className="flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed border-slate-300 dark:border-slate-600 text-slate-400"> <div
<ImagePlus size={28} className="mb-2 opacity-40" /> className={cn(
<p className="text-sm">Фото категории не загружены</p> 'flex flex-col items-center justify-center h-40 rounded-xl border-2 border-dashed transition-colors cursor-pointer',
dragging
? 'border-brand-500 bg-brand-50 dark:bg-brand-900/20 text-brand-600'
: 'border-slate-300 dark:border-slate-600 text-slate-400 hover:border-slate-400',
)}
onClick={() => fileRef.current?.click()}
onDragOver={e => { e.preventDefault(); setDragging(true) }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
>
<ImagePlus size={28} className="mb-2 opacity-60" />
<p className="text-sm font-medium">{dragging ? 'Отпустите файлы' : 'Перетащите фото или нажмите'}</p>
<p className="text-xs mt-0.5 opacity-60">JPG, PNG, WEBP</p>
</div> </div>
)} )}
<input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} /> <input ref={fileRef} type="file" accept="image/*" multiple className="hidden" onChange={handlePhotoUpload} />
@@ -225,14 +272,20 @@ function CategoryForm({ category, onClose, onSave }: CategoryFormProps) {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700 flex justify-end gap-3"> <div className="shrink-0 px-6 py-4 border-t border-slate-200 dark:border-slate-700">
<button onClick={onClose} className="btn-secondary">Отмена</button> {saveError && (
<button onClick={handleSave} className="btn-primary" disabled={!name.trim()}> <p className="text-xs text-red-500 mb-3">{saveError}</p>
)}
<div className="flex justify-end gap-3">
<button onClick={onClose} className="btn-secondary" disabled={saving}>Отмена</button>
<button onClick={handleSave} className="btn-primary" disabled={!name.trim() || saving}>
{saving ? <Loader2 size={14} className="animate-spin" /> : null}
{isEdit ? 'Сохранить' : 'Создать категорию'} {isEdit ? 'Сохранить' : 'Создать категорию'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
) )
} }