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}`}
|
className={`${montserrat.className} ${sourceCodePro.className}`}
|
||||||
suppressHydrationWarning
|
suppressHydrationWarning
|
||||||
>
|
>
|
||||||
<body className={'relative h-screen bg-background antialiased'}>
|
<body className={'relative min-h-screen bg-background antialiased'}>
|
||||||
<Provider
|
<Provider
|
||||||
attribute='class'
|
attribute='class'
|
||||||
defaultTheme='system'
|
defaultTheme='system'
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { saveArticle } from '@/lib/feature/article/article.external';
|
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 { Button } from '@/ui/components/shadcn/button';
|
||||||
import {
|
import {
|
||||||
Field,
|
Field,
|
||||||
FieldDescription,
|
|
||||||
FieldError,
|
FieldError,
|
||||||
FieldGroup,
|
FieldGroup,
|
||||||
FieldLabel,
|
FieldLabel,
|
||||||
@@ -15,21 +15,48 @@ 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 { useCallback, useEffect, useRef } 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';
|
||||||
|
|
||||||
|
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 = () => {
|
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({
|
const formSchema = z.object({
|
||||||
title: z.string().min(3).max(255),
|
title: z.string().min(3).max(255),
|
||||||
slug: z.string().min(3),
|
slug: z.string().min(3),
|
||||||
description: z.string().min(10),
|
description: z.string().min(10),
|
||||||
coverImageUrl: z.url(),
|
coverImageUrl: z.string().url('Cover image URL must be a valid URL'),
|
||||||
content: z.instanceof(File),
|
content: z
|
||||||
|
.string()
|
||||||
|
.min(10, 'Article content must have at least 10 characters'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const form = useForm<z.infer<typeof formSchema>>({
|
const form = useForm<z.infer<typeof formSchema>>({
|
||||||
@@ -39,7 +66,7 @@ export const CreateArticleForm = () => {
|
|||||||
slug: '',
|
slug: '',
|
||||||
description: '',
|
description: '',
|
||||||
coverImageUrl: '',
|
coverImageUrl: '',
|
||||||
content: undefined,
|
content: '',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -49,26 +76,28 @@ export const CreateArticleForm = () => {
|
|||||||
});
|
});
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!title) return;
|
if (!title) return;
|
||||||
|
|
||||||
form.setValue('slug', slugify(title).toLowerCase());
|
form.setValue('slug', slugify(title).toLowerCase());
|
||||||
}, [form, title]);
|
}, [form, title]);
|
||||||
|
|
||||||
|
const resetFiles = useCallback(() => {
|
||||||
|
if (coverImageUrlRef.current) {
|
||||||
|
URL.revokeObjectURL(coverImageUrlRef.current);
|
||||||
|
coverImageUrlRef.current = null;
|
||||||
|
}
|
||||||
|
setCoverImageFile(null);
|
||||||
|
setContentFile(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleFormSubmit = useCallback(
|
const handleFormSubmit = useCallback(
|
||||||
async (data: z.infer<typeof formSchema>) => {
|
async (data: z.infer<typeof formSchema>) => {
|
||||||
try {
|
try {
|
||||||
const result = await saveArticle({
|
const result = await saveArticle({ ...data });
|
||||||
...data,
|
|
||||||
content: await data.content.text(),
|
|
||||||
});
|
|
||||||
toast.success('Article created successfully!', {
|
toast.success('Article created successfully!', {
|
||||||
description: `Article "${result.title}" has been created.`,
|
description: `Article "${result.title}" has been created.`,
|
||||||
position: 'bottom-right',
|
position: 'bottom-right',
|
||||||
});
|
});
|
||||||
form.reset();
|
form.reset();
|
||||||
|
resetFiles();
|
||||||
if (fileInputRef.current) {
|
|
||||||
fileInputRef.current.value = '';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
toast.error('Failed to create article', {
|
toast.error('Failed to create article', {
|
||||||
description:
|
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]
|
[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 (
|
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>
|
<FieldGroup className='gap-7'>
|
||||||
<Controller
|
<Controller
|
||||||
name='title'
|
name='title'
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@@ -155,58 +228,58 @@ export const CreateArticleForm = () => {
|
|||||||
</Field>
|
</Field>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Controller
|
<div className='grid grid-cols-1 gap-4 sm:grid-cols-2'>
|
||||||
name='coverImageUrl'
|
<FileUploadField
|
||||||
control={form.control}
|
file={coverImageFile}
|
||||||
render={({ field, fieldState }) => (
|
onFileChange={handleCoverImageFileChange}
|
||||||
<Field data-invalid={fieldState.invalid}>
|
accept='image/*'
|
||||||
<FieldLabel htmlFor='form-create-article-cover-image-url'>
|
validate={validateImageFile}
|
||||||
Cover Image URL
|
onFileReject={handleCoverImageReject}
|
||||||
</FieldLabel>
|
label='Cover image'
|
||||||
<Input
|
description='PNG, JPG, GIF, WebP accepted'
|
||||||
{...field}
|
error={form.formState.errors.coverImageUrl?.message}
|
||||||
id='form-create-article-cover-image-url'
|
icon={
|
||||||
aria-invalid={fieldState.invalid}
|
<svg
|
||||||
placeholder='https://example.com/image.jpg'
|
className='size-6 shrink-0 text-muted-foreground'
|
||||||
type='url'
|
fill='none'
|
||||||
autoComplete='off'
|
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'
|
||||||
/>
|
/>
|
||||||
{fieldState.invalid && (
|
</svg>
|
||||||
<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>
|
<FileUploadField
|
||||||
Select your article.
|
file={contentFile}
|
||||||
</FieldDescription>
|
onFileChange={handleContentFileChange}
|
||||||
{fieldState.invalid && (
|
accept='.md,.markdown,.txt'
|
||||||
<FieldError errors={[fieldState.error]} />
|
validate={validateContentFile}
|
||||||
)}
|
onFileReject={handleContentFileReject}
|
||||||
</Field>
|
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>
|
</FieldGroup>
|
||||||
<div className='flex w-full justify-end'>
|
<div className='flex w-full justify-end'>
|
||||||
<Button type='submit' className='mt-6'>
|
<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