feat: add file upload section for cover image and article content in create article form
This commit is contained in:
@@ -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`.
|
|
||||||
|
|
||||||
1
public/img/icons/cover-image.svg
Normal file
1
public/img/icons/cover-image.svg
Normal 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 |
1
public/img/icons/markdown-content.svg
Normal file
1
public/img/icons/markdown-content.svg
Normal 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 |
@@ -15,11 +15,14 @@ import {
|
|||||||
InputGroupTextarea,
|
InputGroupTextarea,
|
||||||
} from '@/ui/components/shadcn/input-group';
|
} from '@/ui/components/shadcn/input-group';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import Image from 'next/image';
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { Controller, useForm, useWatch } from 'react-hook-form';
|
import { Controller, useForm, useWatch } from 'react-hook-form';
|
||||||
import slugify from 'slugify';
|
import slugify from 'slugify';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { z } from 'zod';
|
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 {
|
function isImageFile(file: File): boolean {
|
||||||
return file.type.startsWith('image/');
|
return file.type.startsWith('image/');
|
||||||
@@ -37,11 +40,15 @@ function isContentFile(file: File): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function validateImageFile(file: File): string | null {
|
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 {
|
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 = () => {
|
export const CreateArticleForm = () => {
|
||||||
@@ -159,6 +166,7 @@ export const CreateArticleForm = () => {
|
|||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
id='form-create-article'
|
id='form-create-article'
|
||||||
|
// eslint-disable-next-line react-hooks/refs
|
||||||
onSubmit={form.handleSubmit(handleFormSubmit)}
|
onSubmit={form.handleSubmit(handleFormSubmit)}
|
||||||
>
|
>
|
||||||
<FieldGroup className='gap-7'>
|
<FieldGroup className='gap-7'>
|
||||||
@@ -239,19 +247,12 @@ export const CreateArticleForm = () => {
|
|||||||
description='PNG, JPG, GIF, WebP accepted'
|
description='PNG, JPG, GIF, WebP accepted'
|
||||||
error={form.formState.errors.coverImageUrl?.message}
|
error={form.formState.errors.coverImageUrl?.message}
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<Image
|
||||||
className='size-6 shrink-0 text-muted-foreground'
|
src={ImageLogo}
|
||||||
fill='none'
|
alt=''
|
||||||
stroke='currentColor'
|
aria-hidden='true'
|
||||||
viewBox='0 0 24 24'
|
className='size-6 shrink-0 opacity-60'
|
||||||
>
|
|
||||||
<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
|
<FileUploadField
|
||||||
@@ -264,19 +265,12 @@ export const CreateArticleForm = () => {
|
|||||||
description='.md / .markdown / .txt accepted'
|
description='.md / .markdown / .txt accepted'
|
||||||
error={form.formState.errors.content?.message}
|
error={form.formState.errors.content?.message}
|
||||||
icon={
|
icon={
|
||||||
<svg
|
<Image
|
||||||
className='size-6 shrink-0 text-muted-foreground'
|
src={MarkdownLogo}
|
||||||
fill='none'
|
alt=''
|
||||||
stroke='currentColor'
|
aria-hidden='true'
|
||||||
viewBox='0 0 24 24'
|
className='size-6 shrink-0 opacity-60'
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user