feat: add file upload section for cover image and article content in create article form

This commit is contained in:
2026-04-09 23:07:47 -03:00
parent ff8df41d4b
commit 534d206f0e
4 changed files with 278 additions and 70 deletions

View File

@@ -0,0 +1,22 @@
# Plan: Refactor FileUploadSection into generic FileUploadField
Replace the composite, internally-coupled `FileUploadSection` with a generic, controlled `FileUploadField` component. Business logic (validation helpers, toast errors, URL generation) moves up into `CreateArticleForm`, and two `FileUploadField` instances replace the single `FileUploadSection`.
## Steps
1. Create `file-upload-field.tsx` as a controlled `FileUploadField` component accepting `file: File | null`, `onFileChange: (file: File | null) => void`, `accept`, `validate`, `label`, `description`, and `error` props — rendering the `FileUpload`/`Field` UI shell with no internal business logic.
2. Move `isImageFile`, `isContentFile`, `generateFakeImageUrl`, and rejection-toast handlers into `create-article-form.tsx`, adding `coverImageFile` and `contentFile` state entries (or keeping them as `useForm` values directly).
3. Replace the `<FileUploadSection>` block in `create-article-form.tsx` with two `<FileUploadField>` instances (one for cover image, one for content), wiring each one to the form state via `Controller` or direct `setValue`.
4. Delete `file-upload-section.tsx` now that it has no consumers.
## Further Considerations
1. **`onFileChange` vs `setFile`** — `onFileChange` follows idiomatic React event-prop naming; `setFile` reads more like a state-setter leak. `onFileChange` is recommended but either works.
2. **State ownership for files** — Cover image needs a `File` reference for URL revocation cleanup; should this live in a `useState` inside the form or in the `FileUploadField` itself via a ref? Keeping it inside the form is cleaner for reset logic.
3. **Null on clear** — The `onFileChange(null)` case (user deletes the file) should also clear the related form field value (`coverImageUrl``''`, `content``''`), so the handler in the form needs to handle both `File` and `null`.

View File

@@ -43,7 +43,7 @@ export default async function RootLayout({
className={`${montserrat.className} ${sourceCodePro.className}`}
suppressHydrationWarning
>
<body className={'relative h-screen bg-background antialiased'}>
<body className={'relative min-h-screen bg-background antialiased'}>
<Provider
attribute='class'
defaultTheme='system'

View File

@@ -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'>

View File

@@ -0,0 +1,113 @@
'use client';
import { Button } from '@/ui/components/shadcn/button';
import {
Field,
FieldDescription,
FieldError,
} from '@/ui/components/shadcn/field';
import {
FileUpload,
FileUploadDropzone,
FileUploadItem,
FileUploadItemDelete,
FileUploadItemMetadata,
FileUploadItemPreview,
FileUploadList,
FileUploadTrigger,
} from '@/ui/components/shadcn/file-upload';
import { X } from 'lucide-react';
import React, { useCallback } from 'react';
export interface FileUploadFieldProps {
file: File | null;
onFileChange: (file: File | null) => void;
accept?: string;
validate?: (file: File) => string | null;
onFileReject?: (file: File, message: string) => void;
label?: string;
description?: string;
error?: string;
icon?: React.ReactNode;
}
export const FileUploadField: React.FC<FileUploadFieldProps> = ({
file,
onFileChange,
accept,
validate,
onFileReject,
label = 'File',
description,
error,
icon,
}) => {
const handleAccept = useCallback(
(files: File[]) => {
const accepted = files[0];
if (accepted) onFileChange(accepted);
},
[onFileChange]
);
const handleValueChange = useCallback(
(files: File[]) => {
if (files.length === 0) onFileChange(null);
},
[onFileChange]
);
return (
<Field data-invalid={!!error}>
<FileUpload
value={file ? [file] : []}
onValueChange={handleValueChange}
onAccept={handleAccept}
onFileReject={onFileReject}
onFileValidate={validate}
accept={accept}
maxFiles={1}
multiple={false}
label={label}
className='min-w-0'
>
<FileUploadDropzone className='p-3'>
{icon}
<div className='flex flex-col gap-0.5 text-center'>
<p className='text-xs font-medium text-foreground'>
{label}
</p>
<p className='text-xs text-muted-foreground'>
Drag & drop or{' '}
<FileUploadTrigger className='cursor-pointer font-medium text-primary underline-offset-4 hover:underline'>
browse
</FileUploadTrigger>
</p>
</div>
</FileUploadDropzone>
<FileUploadList>
{file && (
<FileUploadItem value={file}>
<FileUploadItemPreview />
<FileUploadItemMetadata size='sm' />
<FileUploadItemDelete asChild>
<Button
type='button'
variant='ghost'
size='icon'
className='ml-auto size-7 shrink-0'
>
<X className='size-3.5' />
</Button>
</FileUploadItemDelete>
</FileUploadItem>
)}
</FileUploadList>
</FileUpload>
{description && <FieldDescription>{description}</FieldDescription>}
{error && <FieldError errors={[new Error(error)]} />}
</Field>
);
};