diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..49d911d --- /dev/null +++ b/.prettierrc @@ -0,0 +1,8 @@ +{ + "useTabs": false, + "printWidth": 100, + "tabWidth": 2, + "singleQuote": true, + "trailingComma": "es5", + "semi": true +} \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..592a4a7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "editor.tabSize": 2, + "editor.detectIndentation": false, + "editor.insertSpaces": true, + "editor.rulers": [100] +} \ No newline at end of file diff --git a/dev/docker-compose.yml b/dev/docker-compose.yml index 7f29a72..910cc0b 100644 --- a/dev/docker-compose.yml +++ b/dev/docker-compose.yml @@ -1,5 +1,3 @@ -volumes: - psql: services: psql: image: postgres @@ -7,6 +5,6 @@ services: POSTGRES_USER: postgres POSTGRES_PASSWORD: dfsjhkdswkjntelsmldbfvsgknl5t volumes: - - psql:/var/lib/postgresql/data + - ./psql:/var/lib/postgresql/data ports: - 5555:5432 \ No newline at end of file diff --git a/package.json b/package.json index 3f83660..1462ae6 100644 --- a/package.json +++ b/package.json @@ -4,32 +4,36 @@ "private": true, "scripts": { "dev": "docker compose --file dev/docker-compose.yml up -d && next dev", + "setup": "docker compose --file dev/docker-compose.yml up -d && prisma migrate deploy", "build": "prisma generate && next build", "start": "next start", "lint": "next lint", "db:generate": "prisma generate", - "db:migrate": "prisma migrate dev --name" + "db:migrate": "prisma migrate dev --name", + "ui:add": "shadcn add" }, "dependencies": { + "@hookform/resolvers": "^3.9.1", "@lucia-auth/adapter-prisma": "^4.0.1", "@node-rs/argon2": "^2.0.2", "@prisma/client": "^6.0.1", "@radix-ui/react-avatar": "^1.0.4", "@radix-ui/react-dropdown-menu": "^2.1.2", - "@radix-ui/react-label": "^2.1.0", - "@radix-ui/react-slot": "^1.1.0", + "@radix-ui/react-label": "^2.1.1", + "@radix-ui/react-slot": "^1.1.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.0", "lucia": "^3.1.1", "lucide-react": "^0.368.0", - "next": "^14.2.3", + "next": "^15.1.2", "next-themes": "^0.4.4", - "react": "^18", - "react-dom": "^18", + "react": "19", + "react-dom": "19", + "react-hook-form": "^7.54.2", "sonner": "^1.4.41", "tailwind-merge": "^2.2.2", "tailwindcss-animate": "^1.0.7", - "zod": "^3.23.8" + "zod": "^3.24.1" }, "devDependencies": { "@types/node": "^20", @@ -39,6 +43,7 @@ "eslint-config-next": "14.2.0", "postcss": "^8", "prisma": "^6.0.1", + "shadcn": "^2.1.8", "tailwindcss": "^3.4.1", "typescript": "^5" }, diff --git a/src/app/(public)/auth/login/page.tsx b/src/app/(public)/auth/login/page.tsx index 69e5e0f..4e51803 100644 --- a/src/app/(public)/auth/login/page.tsx +++ b/src/app/(public)/auth/login/page.tsx @@ -1,46 +1,40 @@ -"use client"; -import { Label } from "@/components/ui/label"; -import { Input } from "@/components/ui/input"; -import Link from "next/link"; -import { useFormState } from "react-dom"; -import { login } from "@/lib/auth/actions"; -import SubmitButton from "@/components/app/SubmitButton/SubmitButton"; -import { useEffect } from "react"; -import { toast } from "sonner"; +'use client'; +import Link from 'next/link'; +import { login } from '@/lib/auth/actions'; +import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm'; export default function Page() { - const [formData, formAction] = useFormState(login, null); - useEffect(() => { - if (formData?.error) { - toast.error(formData.error) - } - }, [formData]) - return (

Log In

-
-
-
- - -
-
- - -
- -
- No account? - - Create one! - -
-
-
+ +
+ No account? + + Create one! + +
); diff --git a/src/app/(public)/auth/signUp/page.tsx b/src/app/(public)/auth/signUp/page.tsx index a59c3e9..40fc2b2 100644 --- a/src/app/(public)/auth/signUp/page.tsx +++ b/src/app/(public)/auth/signUp/page.tsx @@ -1,46 +1,53 @@ -'use client' -import { Label } from "@/components/ui/label" -import { Input } from "@/components/ui/input" -import Link from "next/link" -import { signup } from "@/lib/auth/actions"; -import SubmitButton from "@/components/app/SubmitButton/SubmitButton"; -import { useFormState } from "react-dom"; -import { toast } from "sonner"; -import { useEffect } from "react"; +'use client'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import Link from 'next/link'; +import { signup } from '@/lib/auth/actions'; +import SubmitButton from '@/components/app/SubmitButton/SubmitButton'; +import { useActionState } from 'react'; +import { toast } from 'sonner'; +import { useEffect } from 'react'; +import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm'; export default function Page() { - const [signupData, signupAction] = useFormState(signup, null) + const [signupData, signupAction] = useActionState(signup, null); useEffect(() => { if (signupData?.error) { - toast.error(signupData.error) + toast.error(signupData.error); } - }, [signupData]) + }, [signupData]); return (

Sign Up

-
-
-
- - -
-
- - -
- -
- Already have an account? - - Login - -
+ +
+ Already have an account? + + Login +
-
- ) -} \ No newline at end of file + ); +} diff --git a/src/app/(public)/page.tsx b/src/app/(public)/page.tsx index 21d9fd0..8b3d0bc 100644 --- a/src/app/(public)/page.tsx +++ b/src/app/(public)/page.tsx @@ -26,7 +26,7 @@ export default function Home() {
-
+
diff --git a/src/app/globals.css b/src/app/globals.css index 010b5af..173f5d0 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,79 +2,95 @@ @tailwind components; @tailwind utilities; - @layer base { - :root { - --background: 220 23.077% 94.902%; /* base */ - --foreground: 233.793 16.022% 35.490%; /* text */ +@layer base { + :root { + --background: 220 23.077% 94.902%; + --foreground: 233.793 16.022% 35.49%; - --muted: 222.857 15.909% 82.745%; /* surface0 */ - --muted-foreground: 233.333 12.796% 41.373%; /* subtext1 */ + --muted: 222.857 15.909% 82.745%; + --muted-foreground: 233.333 12.796% 41.373%; - --popover: 220 23.077% 94.902%; /* base */ - --popover-foreground: 233.793 16.022% 35.490%; /* text */ + --popover: 220 23.077% 94.902%; + --popover-foreground: 233.793 16.022% 35.49%; - --card: 220 23.077% 94.902%; /* base */ - --card-foreground: 233.793 16.022% 35.490%; /* text */ + --card: 220 23.077% 94.902%; + --card-foreground: 233.793 16.022% 35.49%; - --border: 225 13.559% 76.863%; /* surface1 */ - --input: 225 13.559% 76.863%; /* surface1 */ + --border: 225 13.559% 76.863%; + --input: 225 13.559% 76.863%; - --primary: 219.907 91.489% 53.922%; /* blue */ - --primary-foreground: 220 23.077% 94.902%; /* base */ + --primary: 219.907 91.489% 53.922%; + --primary-foreground: 220 23.077% 94.902%; - --secondary: 222.857 15.909% 82.745%; /* surface0 */ - --secondary-foreground: 233.793 16.022% 35.490%; /* text */ + --secondary: 222.857 15.909% 82.745%; + --secondary-foreground: 233.793 16.022% 35.49%; - --accent: 222.857 15.909% 82.745%; /* surface0 */ - --accent-foreground: 233.793 16.022% 35.490%; /* text */ + --accent: 222.857 15.909% 82.745%; + --accent-foreground: 233.793 16.022% 35.49%; - --destructive: 347.077 86.667% 44.118%; /* red */ - --destructive-foreground: 220 21.951% 91.961%; /* mantle */ + --destructive: 347.077 86.667% 44.118%; + --destructive-foreground: 220 21.951% 91.961%; - --ring: 233.793 16.022% 35.490%; /* text */ + --ring: 233.793 16.022% 35.49%; - --radius: 0.5rem; - } + --surface-1: 225 14% 77%; + --surface-2: 227 12% 71%; - .dark { - --background: 240 21.053% 14.902%; /* base */ - --foreground: 226.154 63.934% 88.039%; /* text */ + --mantle: 220 22% 92%; - --muted: 236.842 16.239% 22.941%; /* surface0 */ - --muted-foreground: 226.667 35.294% 80.000%; /* subtext1 */ - - --popover: 240 21.053% 14.902%; /* base */ - --popover-foreground: 226.154 63.934% 88.039%; /* text */ - - --card: 240 21.053% 14.902%; /* base */ - --card-foreground: 226.154 63.934% 88.039%; /* text */ - - --border: 234.286 13.208% 31.176%; /* surface1 */ - --input: 234.286 13.208% 31.176%; /* surface1 */ - - --primary: 217.168 91.870% 75.882%; /* blue */ - --primary-foreground: 240 21.053% 14.902%; /* base */ - - --secondary: 236.842 16.239% 22.941%; /* surface0 */ - --secondary-foreground: 226.154 63.934% 88.039%; /* text */ - - --accent: 236.842 16.239% 22.941%; /* surface0 */ - --accent-foreground: 226.154 63.934% 88.039%; /* text */ - - --destructive: 343.269 81.250% 74.902%; /* red */ - --destructive-foreground: 240 21.311% 11.961%; /* mantle */ - - --ring: 226.154 63.934% 88.039%; /* text */ - - --radius: 0.5rem; - } + --radius: 0.5rem; } - @layer base { - * { - @apply border-border; - } - body { - @apply bg-background text-foreground; - } - } \ No newline at end of file + .dark { + --background: 240 21.053% 14.902%; + --foreground: 226.154 63.934% 88.039%; + + --muted: 236.842 16.239% 22.941%; + --muted-foreground: 226.667 35.294% 80%; + + --popover: 240 21.053% 14.902%; + --popover-foreground: 226.154 63.934% 88.039%; + + --card: 240 21.053% 14.902%; + --card-foreground: 226.154 63.934% 88.039%; + + --border: 234.286 13.208% 31.176%; + --input: 234.286 13.208% 31.176%; + + --primary: 217.168 91.87% 75.882%; + --primary-foreground: 240 21.053% 14.902%; + + --secondary: 236.842 16.239% 22.941%; + --secondary-foreground: 226.154 63.934% 88.039%; + + --accent: 236.842 16.239% 22.941%; + --accent-foreground: 226.154 63.934% 88.039%; + + --destructive: 343.269 81.25% 74.902%; + --destructive-foreground: 240 21.311% 11.961%; + + --ring: 226.154 63.934% 88.039%; + + --surface-1: 234 13% 31%; + --surface-2: 233 12% 39%; + + --mantle: 240 21.311% 11.961%; + + --radius: 0.5rem; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} +h1 { + @apply scroll-m-20 text-4xl font-extrabold tracking-tight lg:text-5xl; +} +h2 { + @apply scroll-m-20 pb-2 text-3xl font-semibold tracking-tight first:mt-0; +} diff --git a/src/components/app/NavBar/NavBar.tsx b/src/components/app/NavBar/NavBar.tsx index 299003b..d651644 100644 --- a/src/components/app/NavBar/NavBar.tsx +++ b/src/components/app/NavBar/NavBar.tsx @@ -1,26 +1,32 @@ -"use client" +'use client'; -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar" +import { Avatar, AvatarFallback } from '@/components/ui/avatar'; /** * v0 by Vercel. * @see https://v0.dev/t/igzEEdGqAvH * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app */ -import { Button } from "@/components/ui/button" -import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu" -import { logout } from "@/lib/auth/actions" -import { useSession } from "@/lib/providers/SessionProvider" -import Link from "next/link" -import { useState } from "react" -import { useFormState } from "react-dom" -import MobileNavbarLinks from "../MobileNavbarLinks/MobileNavbarLinks" -import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher" +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuGroup, + DropdownMenuItem, +} from '@/components/ui/dropdown-menu'; +import { logout } from '@/lib/auth/actions'; +import { useSession } from '@/lib/providers/SessionProvider'; +import Link from 'next/link'; +import MobileNavbarLinks from '../MobileNavbarLinks/MobileNavbarLinks'; +import { ThemeSwitcher } from '../ThemeSwitcher/ThemeSwitcher'; export const links = [ { href: '/', name: 'Home' }, { href: 'https://github.com/SrIzan10/stack', name: 'Github' }, - { href: '/protected', name: 'Protected route' } -] + { href: '/protected', name: 'Protected route' }, +]; function NavbarLinks() { return ( @@ -36,7 +42,6 @@ function NavbarLinks() { export default function Navbar() { const { user } = useSession(); - const [, logoutAction] = useFormState(logout, null) return ( <> ); -} \ No newline at end of file +} diff --git a/src/components/app/UniversalForm/UniversalForm.tsx b/src/components/app/UniversalForm/UniversalForm.tsx new file mode 100644 index 0000000..d6dd253 --- /dev/null +++ b/src/components/app/UniversalForm/UniversalForm.tsx @@ -0,0 +1,112 @@ +'use client'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Path, PathValue, useForm } from 'react-hook-form'; +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { z } from 'zod'; +import type { UniversalFormProps } from './types'; +import SubmitButton from '../SubmitButton/SubmitButton'; +import { useActionState } from 'react'; +import React from 'react'; +import { toast } from 'sonner'; +import { Textarea } from '@/components/ui/textarea'; +import { cn } from '@/lib/utils'; +import { accountSchema } from '@/lib/form/zod'; + +export const schemaDb = [ + { name: 'login', zod: accountSchema }, + { name: 'register', zod: accountSchema }, +] as const; + +export function UniversalForm({ + fields, + schemaName, + action, + onActionComplete, + defaultValues, + submitText = 'Submit', + submitClassname, + otherSubmitButton, + submitButtonDivClassname, +}: UniversalFormProps) { + // @ts-ignore idk why this error is happening, first apprearing on the react 19 update. + const [state, formAction] = useActionState<{ success: boolean; error?: string }>(action, null); + const schema = schemaDb.find((s) => s.name === schemaName)?.zod; + + if (!schema) { + throw new Error(`Schema "${schemaName}" not found`); + } + + // Initialize default values for all fields + const initialValues = React.useMemo(() => { + const values: Record = {}; + fields.forEach((field) => { + values[field.name] = field.value ?? ''; // Use empty string as fallback + }); + return { ...values, ...defaultValues }; + }, [fields, defaultValues]); + + const form = useForm>({ + resolver: zodResolver(schema), + defaultValues: initialValues as z.infer, + }); + + React.useEffect(() => { + if (state && !state.success) { + toast.error(state.error); + } + if (state) { + onActionComplete?.(state); + } + }, [state, onActionComplete]); + + return ( +
+ + {fields.map((field) => ( + >} + render={({ field: formField }) => ( + + {field.type !== 'hidden' && {field.label}} + + {field.textArea ? ( +