/
masterOK
/
FEF
Обзор
Документация
Войти
/
masterOK
/
FEF
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
CreateArticlePage.tsx
421 строка
15 KB
masterOK
upload files
10 ноя 2025, 19:52
10 ноя 2025, 19:52
2cb4a49
Код
Авторство
О чём код?
import { useState } from "react"; import { useQuery, useMutation } from "@tanstack/react-query"; import { useLocation } from "wouter"; import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { useToast } from "@/hooks/use-toast"; import { Loader2, Plus, X } from "lucide-react"; import { Badge } from "@/components/ui/badge"; import { apiRequest, queryClient } from "@/lib/queryClient"; import { insertArticleSchema } from "@shared/schema"; interface CarBrand { id: string; name: string; logoUrl?: string; } interface Category { id: string; name: string; } const formSchema = insertArticleSchema .omit({ authorId: true }) .extend({ category: z.string().min(1, "Выберите категорию"), }); type FormValues = z.infer<typeof formSchema>; export default function CreateArticlePage() { const [, navigate] = useLocation(); const { toast } = useToast(); const [imageUrl, setImageUrl] = useState(""); const [tagInput, setTagInput] = useState(""); const form = useForm<FormValues>({ resolver: zodResolver(formSchema), defaultValues: { title: "", content: "", category: "", carBrandId: "", thumbnailUrl: "", images: [], tags: [], isPublished: false, }, }); const { data: categories = [], isLoading: categoriesLoading } = useQuery<Category[]>({ queryKey: ["/api/categories"], }); const { data: carBrands = [], isLoading: brandsLoading } = useQuery<CarBrand[]>({ queryKey: ["/api/car-brands"], }); const createArticleMutation = useMutation({ mutationFn: async (articleData: any) => { return await apiRequest("POST", "/api/articles", articleData); }, onSuccess: (data: any) => { toast({ title: "Успешно!", description: "Статья создана и отправлена на модерацию", }); queryClient.invalidateQueries({ queryKey: ["/api/articles"] }); navigate(`/articles/${data.id}`); }, onError: (error: any) => { toast({ title: "Ошибка", description: error.message || "Не удалось создать статью", variant: "destructive", }); }, }); const handleAddImage = () => { if (imageUrl.trim()) { const currentImages = form.getValues("images") || []; form.setValue("images", [...currentImages, imageUrl.trim()]); setImageUrl(""); } }; const handleRemoveImage = (index: number) => { const currentImages = form.getValues("images") || []; form.setValue("images", currentImages.filter((_, i) => i !== index)); }; const handleAddTag = () => { const trimmedTag = tagInput.trim(); if (trimmedTag) { const currentTags = form.getValues("tags") || []; if (!currentTags.includes(trimmedTag)) { form.setValue("tags", [...currentTags, trimmedTag]); setTagInput(""); } } }; const handleRemoveTag = (tag: string) => { const currentTags = form.getValues("tags") || []; form.setValue("tags", currentTags.filter(t => t !== tag)); }; const onSubmit = (values: FormValues) => { const articleData = { ...values, carBrandId: values.carBrandId || undefined, thumbnailUrl: values.thumbnailUrl || undefined, images: values.images && values.images.length > 0 ? values.images : undefined, tags: values.tags && values.tags.length > 0 ? values.tags : undefined, }; createArticleMutation.mutate(articleData); }; if (categoriesLoading || brandsLoading) { return ( <div className="min-h-screen bg-muted/30 flex items-center justify-center"> <Loader2 className="h-8 w-8 animate-spin text-primary" /> </div> ); } const images = form.watch("images") || []; const tags = form.watch("tags") || []; return ( <div className="min-h-screen bg-muted/30"> <div className="bg-gradient-to-r from-primary/10 to-primary/5 py-12 mb-8"> <div className="container mx-auto px-4"> <h1 className="text-4xl font-bold mb-4" data-testid="text-page-title"> Создать статью </h1> <p className="text-lg text-muted-foreground max-w-2xl"> Поделитесь своими знаниями с сообществом USLUGI.ru </p> </div> </div> <div className="container mx-auto px-4 pb-12"> <Card className="max-w-3xl mx-auto"> <CardHeader> <CardTitle>Новая статья</CardTitle> <CardDescription> Статья будет отправлена на модерацию перед публикацией </CardDescription> </CardHeader> <CardContent> <Form {...form}> <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> <FormField control={form.control} name="title" render={({ field }) => ( <FormItem> <FormLabel> Название <span className="text-destructive">*</span> </FormLabel> <FormControl> <Input {...field} placeholder="Например: Как заменить масло в двигателе" data-testid="input-title" /> </FormControl> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="content" render={({ field }) => ( <FormItem> <FormLabel> Содержание <span className="text-destructive">*</span> </FormLabel> <FormControl> <Textarea {...field} placeholder="Подробное описание..." rows={10} data-testid="input-content" /> </FormControl> <FormMessage /> </FormItem> )} /> <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <FormField control={form.control} name="category" render={({ field }) => ( <FormItem> <FormLabel> Категория <span className="text-destructive">*</span> </FormLabel> <Select onValueChange={field.onChange} value={field.value || undefined}> <FormControl> <SelectTrigger data-testid="select-category"> <SelectValue placeholder="Выберите категорию" /> </SelectTrigger> </FormControl> <SelectContent> {categories.map((cat) => ( <SelectItem key={cat.id} value={cat.name}> {cat.name} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> <FormField control={form.control} name="carBrandId" render={({ field }) => ( <FormItem> <FormLabel>Марка автомобиля (опционально)</FormLabel> <Select onValueChange={field.onChange} value={field.value || undefined}> <FormControl> <SelectTrigger data-testid="select-car-brand"> <SelectValue placeholder="Не указано" /> </SelectTrigger> </FormControl> <SelectContent> {carBrands.map((brand) => ( <SelectItem key={brand.id} value={brand.id}> {brand.name} </SelectItem> ))} </SelectContent> </Select> <FormMessage /> </FormItem> )} /> </div> <FormField control={form.control} name="thumbnailUrl" render={({ field }) => ( <FormItem> <FormLabel>URL миниатюры (опционально)</FormLabel> <FormControl> <Input {...field} value={field.value || ""} placeholder="https://example.com/image.jpg" type="url" data-testid="input-thumbnail" /> </FormControl> <FormMessage /> </FormItem> )} /> <div className="space-y-2"> <FormLabel>Изображения (опционально)</FormLabel> <div className="flex gap-2"> <Input value={imageUrl} onChange={(e) => setImageUrl(e.target.value)} placeholder="Вставьте URL изображения" type="url" data-testid="input-image-url" /> <Button type="button" variant="outline" onClick={handleAddImage} disabled={!imageUrl.trim()} data-testid="button-add-image" > <Plus className="h-4 w-4" /> </Button> </div> {images.length > 0 && ( <div className="grid grid-cols-2 md:grid-cols-3 gap-2 mt-3"> {images.map((img, index) => ( <div key={index} className="relative group rounded-md border overflow-hidden" data-testid={`image-preview-${index}`} > <img src={img} alt={`Preview ${index + 1}`} className="w-full h-24 object-cover" /> <Button type="button" variant="destructive" size="icon" className="absolute top-1 right-1 opacity-0 group-hover:opacity-100 transition-opacity" onClick={() => handleRemoveImage(index)} data-testid={`button-remove-image-${index}`} > <X className="h-3 w-3" /> </Button> </div> ))} </div> )} </div> <div className="space-y-2"> <FormLabel>Теги (опционально)</FormLabel> <div className="flex gap-2"> <Input value={tagInput} onChange={(e) => setTagInput(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleAddTag(); } }} placeholder="Введите тег и нажмите Enter" data-testid="input-tag" /> <Button type="button" variant="outline" onClick={handleAddTag} disabled={!tagInput.trim()} data-testid="button-add-tag" > <Plus className="h-4 w-4" /> </Button> </div> {tags.length > 0 && ( <div className="flex flex-wrap gap-2 mt-3"> {tags.map((tag, index) => ( <Badge key={index} variant="secondary" className="gap-1 pr-1" data-testid={`badge-tag-${index}`} > {tag} <button type="button" className="ml-1 rounded-full hover-elevate p-0.5" onClick={() => handleRemoveTag(tag)} data-testid={`button-remove-tag-${index}`} > <X className="h-3 w-3" /> </button> </Badge> ))} </div> )} </div> <div className="flex gap-3 pt-4"> <Button type="submit" disabled={createArticleMutation.isPending} data-testid="button-submit" > {createArticleMutation.isPending ? ( <> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> Создание... </> ) : ( "Создать статью" )} </Button> <Button type="button" variant="outline" onClick={() => navigate("/articles")} disabled={createArticleMutation.isPending} data-testid="button-cancel" > Отмена </Button> </div> </form> </Form> </CardContent> </Card> </div> </div> ); }