From 11acc19e648ffeae423aaef7a2479870e77f5d26 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Thu, 9 Apr 2026 22:41:46 -0300 Subject: [PATCH] feat: add create article form and admin page for article management --- package-lock.json | 46 ++++ package.json | 3 + src/app/(pages)/admin/page.tsx | 14 +- .../internal/create-article-form.tsx | 220 ++++++++++++++++ src/ui/components/shadcn/button.tsx | 2 +- src/ui/components/shadcn/field.tsx | 237 ++++++++++++++++++ src/ui/components/shadcn/input-group.tsx | 155 ++++++++++++ src/ui/components/shadcn/input.tsx | 18 ++ src/ui/components/shadcn/label.tsx | 23 ++ src/ui/components/shadcn/textarea.tsx | 17 ++ 10 files changed, 728 insertions(+), 7 deletions(-) create mode 100644 src/ui/components/internal/create-article-form.tsx create mode 100644 src/ui/components/shadcn/field.tsx create mode 100644 src/ui/components/shadcn/input-group.tsx create mode 100644 src/ui/components/shadcn/input.tsx create mode 100644 src/ui/components/shadcn/label.tsx create mode 100644 src/ui/components/shadcn/textarea.tsx diff --git a/package-lock.json b/package-lock.json index cef1f6b..a9962a8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.1.0", "dependencies": { "@clerk/nextjs": "^7.0.7", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.95.2", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", @@ -22,7 +23,9 @@ "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.72.1", "shadcn": "^4.1.1", + "slugify": "^1.6.9", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", @@ -1281,6 +1284,18 @@ "hono": "^4" } }, + "node_modules/@hookform/resolvers": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.2.2.tgz", + "integrity": "sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.1", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", @@ -4520,6 +4535,12 @@ "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", "license": "MIT" }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@swc/helpers": { "version": "0.5.15", "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", @@ -13796,6 +13817,22 @@ "react": "^19.2.4" } }, + "node_modules/react-hook-form": { + "version": "7.72.1", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.1.tgz", + "integrity": "sha512-RhwBoy2ygeVZje+C+bwJ8g0NjTdBmDlJvAUHTxRjTmSUKPYsKfMphkS2sgEMotsY03bP358yEYlnUeZy//D9Ig==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", @@ -14668,6 +14705,15 @@ "node": ">=8" } }, + "node_modules/slugify": { + "version": "1.6.9", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.9.tgz", + "integrity": "sha512-vZ7rfeehZui7wQs438JXBckYLkIIdfHOXsaVEUMyS5fHo1483l1bMdo0EDSWYclY0yZKFOipDy4KHuKs6ssvdg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", diff --git a/package.json b/package.json index 5bb96ff..e9abcf5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ }, "dependencies": { "@clerk/nextjs": "^7.0.7", + "@hookform/resolvers": "^5.2.2", "@tanstack/react-query": "^5.95.2", "@vercel/analytics": "^2.0.1", "class-variance-authority": "^0.7.1", @@ -31,7 +32,9 @@ "radix-ui": "^1.4.3", "react": "19.2.4", "react-dom": "19.2.4", + "react-hook-form": "^7.72.1", "shadcn": "^4.1.1", + "slugify": "^1.6.9", "sonner": "^2.0.7", "tailwind-merge": "^3.5.0", "tw-animate-css": "^1.4.0", diff --git a/src/app/(pages)/admin/page.tsx b/src/app/(pages)/admin/page.tsx index cbd5433..9e839c7 100644 --- a/src/app/(pages)/admin/page.tsx +++ b/src/app/(pages)/admin/page.tsx @@ -1,12 +1,14 @@ -import { siteConfig } from '@/site.config'; +import CreateArticleForm from '@/ui/components/internal/create-article-form'; -const Home = async () => { +const AdminPage = async () => { return ( -
-

Admin

-

Welcome {siteConfig.name}!

+
+
+

Create New Article

+ +
); }; -export default Home; +export default AdminPage; diff --git a/src/ui/components/internal/create-article-form.tsx b/src/ui/components/internal/create-article-form.tsx new file mode 100644 index 0000000..37eaa79 --- /dev/null +++ b/src/ui/components/internal/create-article-form.tsx @@ -0,0 +1,220 @@ +'use client'; + +import { saveArticle } from '@/lib/feature/article/article.external'; +import { Button } from '@/ui/components/shadcn/button'; +import { + Field, + FieldDescription, + FieldError, + FieldGroup, + FieldLabel, +} from '@/ui/components/shadcn/field'; +import { Input } from '@/ui/components/shadcn/input'; +import { + InputGroup, + InputGroupTextarea, +} from '@/ui/components/shadcn/input-group'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useCallback, useEffect, useRef } from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; +import slugify from 'slugify'; +import { toast } from 'sonner'; +import { z } from 'zod'; + +export const CreateArticleForm = () => { + const fileInputRef = 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), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + title: '', + slug: '', + description: '', + coverImageUrl: '', + content: undefined, + }, + }); + + const title = useWatch({ + control: form.control, + name: 'title', + }); + useEffect(() => { + if (!title) return; + + form.setValue('slug', slugify(title).toLowerCase()); + }, [form, title]); + + const handleFormSubmit = useCallback( + async (data: z.infer) => { + try { + const result = await saveArticle({ + ...data, + content: await data.content.text(), + }); + toast.success('Article created successfully!', { + description: `Article "${result.title}" has been created.`, + position: 'bottom-right', + }); + form.reset(); + + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } catch (error) { + toast.error('Failed to create article', { + description: + error instanceof Error + ? error.message + : 'An error occurred', + position: 'bottom-right', + }); + } + }, + [form] + ); + + return ( +
+ + ( + + + Title + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Slug + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Description + + + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Cover Image URL + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Content (Markdown File) + + + field.onChange( + event.target.files && + event.target.files[0] + ) + } + /> + + Select your article. + + {fieldState.invalid && ( + + )} + + )} + /> + +
+ +
+
+ ); +}; + +export default CreateArticleForm; diff --git a/src/ui/components/shadcn/button.tsx b/src/ui/components/shadcn/button.tsx index 1a6fef1..c4c5e30 100644 --- a/src/ui/components/shadcn/button.tsx +++ b/src/ui/components/shadcn/button.tsx @@ -24,7 +24,7 @@ const buttonVariants = cva( 'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3", sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5", - lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3', + lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2', icon: 'size-8', 'icon-xs': "size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3", diff --git a/src/ui/components/shadcn/field.tsx b/src/ui/components/shadcn/field.tsx new file mode 100644 index 0000000..0c9f325 --- /dev/null +++ b/src/ui/components/shadcn/field.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { Label } from '@/ui/components/shadcn/label'; +import { cn } from '@/ui/components/shadcn/lib/utils'; +import { Separator } from '@/ui/components/shadcn/separator'; +import { cva, type VariantProps } from 'class-variance-authority'; +import { useMemo } from 'react'; + +function FieldSet({ className, ...props }: React.ComponentProps<'fieldset'>) { + return ( +
[data-slot=checkbox-group]]:gap-3 has-[>[data-slot=radio-group]]:gap-3', + className + )} + {...props} + /> + ); +} + +function FieldLegend({ + className, + variant = 'legend', + ...props +}: React.ComponentProps<'legend'> & { variant?: 'legend' | 'label' }) { + return ( + + ); +} + +function FieldGroup({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +const fieldVariants = cva( + 'group/field flex w-full gap-2 data-[invalid=true]:text-destructive', + { + variants: { + orientation: { + vertical: 'flex-col *:w-full [&>.sr-only]:w-auto', + horizontal: + 'flex-row items-center has-[>[data-slot=field-content]]:items-start *:data-[slot=field-label]:flex-auto has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + responsive: + 'flex-col *:w-full @md/field-group:flex-row @md/field-group:items-center @md/field-group:*:w-auto @md/field-group:has-[>[data-slot=field-content]]:items-start @md/field-group:*:data-[slot=field-label]:flex-auto [&>.sr-only]:w-auto @md/field-group:has-[>[data-slot=field-content]]:[&>[role=checkbox],[role=radio]]:mt-px', + }, + }, + defaultVariants: { + orientation: 'vertical', + }, + } +); + +function Field({ + className, + orientation = 'vertical', + ...props +}: React.ComponentProps<'div'> & VariantProps) { + return ( +
+ ); +} + +function FieldContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function FieldLabel({ + className, + ...props +}: React.ComponentProps) { + return ( +