From 9c0006e2dc9b31a124c4d4fa1140235c75b78128 Mon Sep 17 00:00:00 2001 From: Vitor Hideyoshi Date: Wed, 1 Apr 2026 14:24:13 -0300 Subject: [PATCH] feat: add create article form and related components --- docker-compose.yml | 2 +- package-lock.json | 46 ++++ package.json | 3 + src/app/(pages)/admin/page.tsx | 14 +- src/lib/feature/article/article.external.ts | 2 + src/lib/feature/article/article.model.ts | 2 +- src/lib/feature/article/article.service.ts | 4 + .../internal/create-article-form.tsx | 197 +++++++++++++++ src/ui/components/shadcn/button.tsx | 2 +- src/ui/components/shadcn/card.tsx | 102 ++++++++ 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 ++ tests/setup/__mocks__/fileMock.js | 1 - 16 files changed, 815 insertions(+), 10 deletions(-) create mode 100644 src/ui/components/internal/create-article-form.tsx create mode 100644 src/ui/components/shadcn/card.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/docker-compose.yml b/docker-compose.yml index 0e70bee..57d664d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,7 +5,7 @@ services: image: postgres:16 restart: always environment: - POSTGRES_DB: absolute + POSTGRES_DB: local_db POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres volumes: diff --git a/package-lock.json b/package-lock.json index 3d95ba4..268e91f 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.0", "shadcn": "^4.1.1", + "slugify": "^1.6.8", "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.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.72.0.tgz", + "integrity": "sha512-V4v6jubaf6JAurEaVnT9aUPKFbNtDgohj5CIgVGyPHvT9wRx5OZHVjz31GsxnPNI278XMu+ruFz+wGOscHaLKw==", + "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.8", + "resolved": "https://registry.npmjs.org/slugify/-/slugify-1.6.8.tgz", + "integrity": "sha512-HVk9X1E0gz3mSpoi60h/saazLKXKaZThMLU3u/aNwoYn8/xQyX2MGxL0ui2eaokkD7tF+Zo+cKTHUbe1mmmGzA==", + "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..c5b33c2 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.0", "shadcn": "^4.1.1", + "slugify": "^1.6.8", "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/lib/feature/article/article.external.ts b/src/lib/feature/article/article.external.ts index 693f7fc..26b97bb 100644 --- a/src/lib/feature/article/article.external.ts +++ b/src/lib/feature/article/article.external.ts @@ -36,6 +36,8 @@ export const saveArticle = async ( if (!session || !session?.user || session?.user.role !== 'admin') { throw new Error('Unauthorized: Only admin users can save articles.'); } + article.authorId = session.user.id; + return await service.saveArticle(article); }; diff --git a/src/lib/feature/article/article.model.ts b/src/lib/feature/article/article.model.ts index c2b1ade..6207293 100644 --- a/src/lib/feature/article/article.model.ts +++ b/src/lib/feature/article/article.model.ts @@ -7,7 +7,7 @@ export const CreateArticleModel = z.object({ description: z.string(), coverImageUrl: z.string(), content: z.string(), - authorId: z.string(), + authorId: z.string().optional(), }); export type CreateArticleModel = z.infer; diff --git a/src/lib/feature/article/article.service.ts b/src/lib/feature/article/article.service.ts index 76e9398..78d61f8 100644 --- a/src/lib/feature/article/article.service.ts +++ b/src/lib/feature/article/article.service.ts @@ -115,6 +115,10 @@ export const saveArticle = async ( ): Promise => { const articleRepository = await getRepository(ArticleEntity); + if (!article.authorId) { + throw new Error('Author ID is required to save an article'); + } + if (!!(await articleRepository.findOneBy({ slug: article.slug }))) { throw new Error(`Article with slug ${article.slug} already exists`); } 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..6b2283f --- /dev/null +++ b/src/ui/components/internal/create-article-form.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { saveArticle } from '@/lib/feature/article/article.external'; +import { Button } from '@/ui/components/shadcn/button'; +import { + Field, + 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 { useEffect } 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 formSchema = z.object({ + title: z.string().min(3).max(255), + slug: z.string().min(3), + description: z.string().min(10), + coverImageUrl: z.string().url(), + content: z.string().min(10), + }); + + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + title: '', + slug: '', + description: '', + coverImageUrl: '', + content: '', + }, + }); + + const title = useWatch({ + control: form.control, + name: 'title', + }); + useEffect(() => { + if (!title) return; + + form.setValue('slug', slugify(title).toLowerCase()); + }, [form, title]); + + async function onSubmit(data: z.infer) { + try { + const result = await saveArticle(data); + toast.success('Article created successfully!', { + description: `Article "${result.title}" has been created.`, + position: 'bottom-right', + }); + form.reset(); + } catch (error) { + toast.error('Failed to create article', { + description: + error instanceof Error + ? error.message + : 'An error occurred', + position: 'bottom-right', + }); + } + } + + return ( +
+ + ( + + + Title + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Slug + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Description + + + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Cover Image URL + + + {fieldState.invalid && ( + + )} + + )} + /> + ( + + + Content + + + + + {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/card.tsx b/src/ui/components/shadcn/card.tsx new file mode 100644 index 0000000..6ef353d --- /dev/null +++ b/src/ui/components/shadcn/card.tsx @@ -0,0 +1,102 @@ +import { cn } from '@/ui/components/shadcn/lib/utils'; +import * as React from 'react'; + +function Card({ + className, + size = 'default', + ...props +}: React.ComponentProps<'div'> & { size?: 'default' | 'sm' }) { + return ( +
img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl', + className + )} + {...props} + /> + ); +} + +function CardHeader({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardTitle({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardDescription({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardAction({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardContent({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +function CardFooter({ className, ...props }: React.ComponentProps<'div'>) { + return ( +
+ ); +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +}; 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 ( +