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

This commit is contained in:
2026-04-10 00:07:11 -03:00
parent 534d206f0e
commit fb8c07d32e
4 changed files with 24 additions and 50 deletions

View File

@@ -1,22 +0,0 @@
# 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

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke="#4B5563" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m4 16 4.586-4.586a2 2 0 0 1 2.828 0L16 16m-2-2 1.586-1.586a2 2 0 0 1 2.828 0L20 14m-6-6h.01M6 20h12a2 2 0 0 0 2-2V6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2" class="dark:stroke-[#9CA3AF]"/></svg>

After

Width:  |  Height:  |  Size: 364 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path stroke="#4B5563" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 21h10a2 2 0 0 0 2-2V9.414a1 1 0 0 0-.293-.707l-5.414-5.414A1 1 0 0 0 12.586 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2" class="dark:stroke-[#9CA3AF]"/></svg>

After

Width:  |  Height:  |  Size: 315 B

View File

@@ -15,11 +15,14 @@ import {
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/');
@@ -37,11 +40,15 @@ function isContentFile(file: File): boolean {
}
function validateImageFile(file: File): string | null {
return isImageFile(file) ? null : 'Only image files are allowed for cover image';
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';
return isContentFile(file)
? null
: 'Only markdown or text files are allowed';
}
export const CreateArticleForm = () => {
@@ -159,6 +166,7 @@ export const CreateArticleForm = () => {
return (
<form
id='form-create-article'
// eslint-disable-next-line react-hooks/refs
onSubmit={form.handleSubmit(handleFormSubmit)}
>
<FieldGroup className='gap-7'>
@@ -239,19 +247,12 @@ export const CreateArticleForm = () => {
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>
<Image
src={ImageLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
<FileUploadField
@@ -264,19 +265,12 @@ export const CreateArticleForm = () => {
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>
<Image
src={MarkdownLogo}
alt=''
aria-hidden='true'
className='size-6 shrink-0 opacity-60'
/>
}
/>
</div>