'use client'; import { saveArticle } from '@/lib/feature/article/article.external'; import { uploadFile } from '@/lib/storage/storage.utils'; import { FileUploadField } from '@/ui/components/internal/file-upload-field'; import { Button } from '@/ui/components/shadcn/button'; import { Field, FieldError, FieldGroup, FieldLabel, } from '@/ui/components/shadcn/field'; import { Input } from '@/ui/components/shadcn/input'; import { InputGroup, InputGroupTextarea, } from '@/ui/components/shadcn/input-group'; import { zodResolver } from '@hookform/resolvers/zod'; import Image from 'next/image'; import { useCallback, useEffect, useRef, useState } from 'react'; import { Controller, useForm, useWatch } from 'react-hook-form'; import slugify from 'slugify'; import { toast } from 'sonner'; import { z } from 'zod'; import ImageLogo from '~/public/img/icons/cover-image.svg'; import MarkdownLogo from '~/public/img/icons/markdown-content.svg'; function isImageFile(file: File): boolean { return file.type.startsWith('image/'); } function isContentFile(file: File): boolean { const extension = file.name.split('.').pop()?.toLowerCase() ?? ''; return ( file.type === 'text/markdown' || file.type === 'text/plain' || extension === 'md' || extension === 'markdown' || extension === 'txt' ); } function validateImageFile(file: File): string | null { return isImageFile(file) ? null : 'Only image files are allowed for cover image'; } function validateContentFile(file: File): string | null { return isContentFile(file) ? null : 'Only markdown or text files are allowed'; } export const CreateArticleForm = () => { const [coverImageFile, setCoverImageFile] = useState(null); const [coverImageUploading, setCoverImageUploading] = useState(false); const [contentFile, setContentFile] = useState(null); const coverImageUrlRef = useRef(null); const formSchema = z.object({ title: z.string().min(3).max(255), slug: z.string().min(3), description: z.string().min(10), coverImageUrl: z.url('Cover image URL must be a valid URL'), content: z .string() .min(10, 'Article content must have at least 10 characters'), }); const form = useForm>({ resolver: zodResolver(formSchema), defaultValues: { title: '', slug: '', description: '', coverImageUrl: '', content: '', }, }); const title = useWatch({ control: form.control, name: 'title' }); const coverImageUrl = useWatch({ control: form.control, name: 'coverImageUrl', }); useEffect(() => { if (!title) return; form.setValue('slug', slugify(title).toLowerCase()); }, [form, title]); const resetFiles = useCallback(() => { if (coverImageUrlRef.current) { URL.revokeObjectURL(coverImageUrlRef.current); coverImageUrlRef.current = null; } setCoverImageFile(null); setCoverImageUploading(false); setContentFile(null); }, []); const handleFormSubmit = useCallback( async (data: z.infer) => { try { const result = await saveArticle({ ...data }); toast.success('Article created successfully!', { description: `Article "${result.title}" has been created.`, position: 'bottom-right', }); form.reset(); resetFiles(); } catch (error) { toast.error('Failed to create article', { description: error instanceof Error ? error.message : 'An error occurred', position: 'bottom-right', }); } }, [form, resetFiles] ); const handleCoverImageFileChange = useCallback( async (file: File | null) => { if (coverImageUrlRef.current) { URL.revokeObjectURL(coverImageUrlRef.current); coverImageUrlRef.current = null; } setCoverImageFile(file); if (!file) { setCoverImageFile(null); setCoverImageUploading(false); form.setValue('coverImageUrl', ''); return; } setCoverImageUploading(true); const fileMetadataResult = await uploadFile(file); setCoverImageUploading(false); if (!fileMetadataResult.ok) { setCoverImageFile(null); form.setValue('coverImageUrl', ''); toast((fileMetadataResult.error as Error).message); return; } const fileMetadata = fileMetadataResult.value; coverImageUrlRef.current = fileMetadata.signedUrl; form.setValue('coverImageUrl', fileMetadata.signedUrl); }, [form] ); const handleContentFileChange = useCallback( async (file: File | null) => { setContentFile(file); if (file) { const content = await file.text(); form.setValue('content', content); } else { form.setValue('content', ''); } }, [form] ); const handleCoverImageReject = useCallback( (_file: File, message: string) => { toast.error(`Cover image rejected: ${message}`); }, [] ); const handleContentFileReject = useCallback( (_file: File, message: string) => { toast.error(`Content file rejected: ${message}`); }, [] ); return (
( Title {fieldState.invalid && ( )} )} /> ( Slug {fieldState.invalid && ( )} )} /> ( Description {fieldState.invalid && ( )} )} />
); }; export default CreateArticleForm;