feat: add file upload section for cover image and article content in create article form
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import { saveArticle } from '@/lib/feature/article/article.external';
|
||||
import { FileUploadField } from '@/ui/components/internal/file-upload-field';
|
||||
import { Button } from '@/ui/components/shadcn/button';
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
@@ -15,21 +15,48 @@ import {
|
||||
InputGroupTextarea,
|
||||
} from '@/ui/components/shadcn/input-group';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
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';
|
||||
|
||||
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 fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [coverImageFile, setCoverImageFile] = useState<File | null>(null);
|
||||
const [contentFile, setContentFile] = useState<File | null>(null);
|
||||
const coverImageUrlRef = useRef<string | null>(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(),
|
||||
content: z.instanceof(File),
|
||||
coverImageUrl: z.string().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<z.infer<typeof formSchema>>({
|
||||
@@ -39,7 +66,7 @@ export const CreateArticleForm = () => {
|
||||
slug: '',
|
||||
description: '',
|
||||
coverImageUrl: '',
|
||||
content: undefined,
|
||||
content: '',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -49,26 +76,28 @@ export const CreateArticleForm = () => {
|
||||
});
|
||||
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);
|
||||
setContentFile(null);
|
||||
}, []);
|
||||
|
||||
const handleFormSubmit = useCallback(
|
||||
async (data: z.infer<typeof formSchema>) => {
|
||||
try {
|
||||
const result = await saveArticle({
|
||||
...data,
|
||||
content: await data.content.text(),
|
||||
});
|
||||
const result = await saveArticle({ ...data });
|
||||
toast.success('Article created successfully!', {
|
||||
description: `Article "${result.title}" has been created.`,
|
||||
position: 'bottom-right',
|
||||
});
|
||||
form.reset();
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
resetFiles();
|
||||
} catch (error) {
|
||||
toast.error('Failed to create article', {
|
||||
description:
|
||||
@@ -79,16 +108,60 @@ export const CreateArticleForm = () => {
|
||||
});
|
||||
}
|
||||
},
|
||||
[form, resetFiles]
|
||||
);
|
||||
|
||||
const handleCoverImageFileChange = useCallback(
|
||||
(file: File | null) => {
|
||||
if (coverImageUrlRef.current) {
|
||||
URL.revokeObjectURL(coverImageUrlRef.current);
|
||||
coverImageUrlRef.current = null;
|
||||
}
|
||||
setCoverImageFile(file);
|
||||
if (file) {
|
||||
const url = URL.createObjectURL(file);
|
||||
coverImageUrlRef.current = url;
|
||||
form.setValue('coverImageUrl', url);
|
||||
} else {
|
||||
form.setValue('coverImageUrl', '');
|
||||
}
|
||||
},
|
||||
[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 (
|
||||
<form
|
||||
id='form-create-article'
|
||||
// eslint-disable-next-line react-hooks/refs
|
||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||
>
|
||||
<FieldGroup>
|
||||
<FieldGroup className='gap-7'>
|
||||
<Controller
|
||||
name='title'
|
||||
control={form.control}
|
||||
@@ -155,58 +228,58 @@ export const CreateArticleForm = () => {
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='coverImageUrl'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='form-create-article-cover-image-url'>
|
||||
Cover Image URL
|
||||
</FieldLabel>
|
||||
<Input
|
||||
{...field}
|
||||
id='form-create-article-cover-image-url'
|
||||
aria-invalid={fieldState.invalid}
|
||||
placeholder='https://example.com/image.jpg'
|
||||
type='url'
|
||||
autoComplete='off'
|
||||
/>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<Controller
|
||||
name='content'
|
||||
control={form.control}
|
||||
render={({ field, fieldState }) => (
|
||||
<Field data-invalid={fieldState.invalid}>
|
||||
<FieldLabel htmlFor='form-create-article-content'>
|
||||
Content (Markdown File)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
ref={fileInputRef}
|
||||
id='form-create-article-content'
|
||||
type='file'
|
||||
accept='.md,.markdown'
|
||||
aria-invalid={fieldState.invalid}
|
||||
onChange={(event) =>
|
||||
field.onChange(
|
||||
event.target.files &&
|
||||
event.target.files[0]
|
||||
)
|
||||
}
|
||||
/>
|
||||
<FieldDescription>
|
||||
Select your article.
|
||||
</FieldDescription>
|
||||
{fieldState.invalid && (
|
||||
<FieldError errors={[fieldState.error]} />
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
/>
|
||||
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||
<FileUploadField
|
||||
file={coverImageFile}
|
||||
onFileChange={handleCoverImageFileChange}
|
||||
accept='image/*'
|
||||
validate={validateImageFile}
|
||||
onFileReject={handleCoverImageReject}
|
||||
label='Cover image'
|
||||
description='PNG, JPG, GIF, WebP accepted'
|
||||
error={form.formState.errors.coverImageUrl?.message}
|
||||
icon={
|
||||
<svg
|
||||
className='size-6 shrink-0 text-muted-foreground'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z'
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
<FileUploadField
|
||||
file={contentFile}
|
||||
onFileChange={handleContentFileChange}
|
||||
accept='.md,.markdown,.txt'
|
||||
validate={validateContentFile}
|
||||
onFileReject={handleContentFileReject}
|
||||
label='Markdown content'
|
||||
description='.md / .markdown / .txt accepted'
|
||||
error={form.formState.errors.content?.message}
|
||||
icon={
|
||||
<svg
|
||||
className='size-6 shrink-0 text-muted-foreground'
|
||||
fill='none'
|
||||
stroke='currentColor'
|
||||
viewBox='0 0 24 24'
|
||||
>
|
||||
<path
|
||||
strokeLinecap='round'
|
||||
strokeLinejoin='round'
|
||||
strokeWidth={2}
|
||||
d='M7 21h10a2 2 0 002-2V9.414a1 1 0 00-.293-.707l-5.414-5.414A1 1 0 0012.586 3H7a2 2 0 00-2 2v14a2 2 0 002 2z'
|
||||
/>
|
||||
</svg>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</FieldGroup>
|
||||
<div className='flex w-full justify-end'>
|
||||
<Button type='submit' className='mt-6'>
|
||||
|
||||
Reference in New Issue
Block a user