feature/adds-admin-add-article #1
22
plan-fileUploadField.prompt.md
Normal file
22
plan-fileUploadField.prompt.md
Normal 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`.
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'>
|
||||
|
||||
113
src/ui/components/internal/file-upload-field.tsx
Normal file
113
src/ui/components/internal/file-upload-field.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user