Files
hideyoshi-blog/src/ui/components/internal/create-article-form.tsx

305 lines
11 KiB
TypeScript

'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<File | null>(null);
const [coverImageUploading, setCoverImageUploading] = useState(false);
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('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>>({
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<typeof formSchema>) => {
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 (
<form
id='form-create-article'
// eslint-disable-next-line react-hooks/refs
onSubmit={form.handleSubmit(handleFormSubmit)}
>
<FieldGroup className='gap-7'>
<Controller
name='title'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-title'>
Title
</FieldLabel>
<Input
{...field}
id='form-create-article-title'
aria-invalid={fieldState.invalid}
placeholder='Article title'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='slug'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-slug'>
Slug
</FieldLabel>
<Input
{...field}
id='form-create-article-slug'
aria-invalid={fieldState.invalid}
placeholder='article-slug'
autoComplete='off'
/>
{fieldState.invalid && (
<FieldError errors={[fieldState.error]} />
)}
</Field>
)}
/>
<Controller
name='description'
control={form.control}
render={({ field, fieldState }) => (
<Field data-invalid={fieldState.invalid}>
<FieldLabel htmlFor='form-create-article-description'>
Description
</FieldLabel>
<InputGroup>
<InputGroupTextarea
{...field}
id='form-create-article-description'
placeholder='A simple but nice description of the article here.'
rows={3}
className='min-h-24 resize-none'
aria-invalid={fieldState.invalid}
/>
</InputGroup>
{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}
previewUrl={coverImageUrl || undefined}
isUploading={coverImageUploading}
icon={
<Image
src={ImageLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
<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={
<Image
src={MarkdownLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
</div>
</FieldGroup>
<div className='flex w-full justify-end'>
<Button type='submit' className='mt-6'>
Create Article
</Button>
</div>
</form>
);
};
export default CreateArticleForm;