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