From 534d206f0eeeba60c8d2ea907bdafa1fcbfa5820 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 9 Apr 2026 23:07:47 -0300 Subject: [PATCH] feat: add file upload section for cover image and article content in create article form --- plan-fileUploadField.prompt.md | 22 ++ src/app/layout.tsx | 2 +- .../internal/create-article-form.tsx | 211 ++++++++++++------ .../components/internal/file-upload-field.tsx | 113 ++++++++++ 4 files changed, 278 insertions(+), 70 deletions(-) create mode 100644 plan-fileUploadField.prompt.md create mode 100644 src/ui/components/internal/file-upload-field.tsx diff --git a/plan-fileUploadField.prompt.md b/plan-fileUploadField.prompt.md new file mode 100644 index 0000000..a849162 --- /dev/null +++ b/plan-fileUploadField.prompt.md @@ -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 `` block in `create-article-form.tsx` with two `` 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`. + diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3ce288f..d8e6441 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -43,7 +43,7 @@ export default async function RootLayout({ className={`${montserrat.className} ${sourceCodePro.className}`} suppressHydrationWarning > - + { - const fileInputRef = useRef(null); + const [coverImageFile, setCoverImageFile] = useState(null); + const [contentFile, setContentFile] = useState(null); + const coverImageUrlRef = useRef(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>({ @@ -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) => { 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 (
- + { )} /> - ( - - - Cover Image URL - - - {fieldState.invalid && ( - - )} - - )} - /> - ( - - - Content (Markdown File) - - - field.onChange( - event.target.files && - event.target.files[0] - ) - } - /> - - Select your article. - - {fieldState.invalid && ( - - )} - - )} - /> +
+ + + + } + /> + + + + } + /> +
+ + + )} + + + + {description && {description}} + {error && } + + ); +};