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 (
+
+ );
+};
+
+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 (
+