mirror of
https://github.com/SrIzan10/stack.git
synced 2026-06-06 01:06:54 +00:00
feat: update to nextjs 15 and some more additions
This commit is contained in:
8
.prettierrc
Normal file
8
.prettierrc
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"useTabs": false,
|
||||
"printWidth": 100,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "es5",
|
||||
"semi": true
|
||||
}
|
||||
6
.vscode/settings.json
vendored
Normal file
6
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"editor.tabSize": 2,
|
||||
"editor.detectIndentation": false,
|
||||
"editor.insertSpaces": true,
|
||||
"editor.rulers": [100]
|
||||
}
|
||||
@@ -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
|
||||
19
package.json
19
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"
|
||||
},
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center p-4 lg:p-8">
|
||||
<div className="w-full max-w-md m-auto space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold pb-1">Log In</h1>
|
||||
</div>
|
||||
<form action={formAction}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Username</Label>
|
||||
<Input name="username" id="username" required type="text" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input name="password" id="password" required type="password" />
|
||||
</div>
|
||||
<SubmitButton buttonText="Log In" className="w-full" />
|
||||
<div className="text-center text-sm">
|
||||
No account?
|
||||
<Link className="underline pl-1" href="/auth/signUp">
|
||||
Create one!
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
placeholder: 'Username',
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
placeholder: 'Password',
|
||||
},
|
||||
]}
|
||||
schemaName="login"
|
||||
action={login}
|
||||
submitText="Log In"
|
||||
submitClassname="w-full"
|
||||
/>
|
||||
<div className="text-center text-sm">
|
||||
No account?
|
||||
<Link className="underline pl-1" href="/auth/signUp">
|
||||
Create one!
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex items-center p-6 lg:p-8">
|
||||
<div className="w-full max-w-md m-auto space-y-8">
|
||||
<div className="text-center">
|
||||
<h1 className="text-4xl font-bold pb-1">Sign Up</h1>
|
||||
</div>
|
||||
<form action={signupAction}>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="username">Username</Label>
|
||||
<Input name="username" required type="text" id="username" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input name="password" required type="password" id="password" />
|
||||
</div>
|
||||
<SubmitButton buttonText="Create account" className="w-full" />
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?
|
||||
<Link className="underline pl-1" href="/auth/login">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
<UniversalForm
|
||||
fields={[
|
||||
{
|
||||
name: 'username',
|
||||
label: 'Username',
|
||||
placeholder: 'Username',
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: 'Password',
|
||||
type: 'password',
|
||||
placeholder: 'Password',
|
||||
},
|
||||
]}
|
||||
schemaName="register"
|
||||
action={signup}
|
||||
submitText="Sign Up"
|
||||
submitClassname="w-full"
|
||||
/>
|
||||
<div className="text-center text-sm">
|
||||
Already have an account?
|
||||
<Link className="underline pl-1" href="/auth/login">
|
||||
Login
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-900" id="features">
|
||||
<section className="w-full py-12 md:py-24 lg:py-32 bg-mantle" id="features">
|
||||
<div className="container px-4 md:px-6">
|
||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<nav className="flex items-center h-16 px-4 border-b gap-3 shrink-0">
|
||||
@@ -63,12 +68,15 @@ export default function Navbar() {
|
||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem className="cursor-pointer" onClick={() => {
|
||||
logoutAction()
|
||||
}}>
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
logout();
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</>
|
||||
|
||||
112
src/components/app/UniversalForm/UniversalForm.tsx
Normal file
112
src/components/app/UniversalForm/UniversalForm.tsx
Normal file
@@ -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<T extends z.ZodType>({
|
||||
fields,
|
||||
schemaName,
|
||||
action,
|
||||
onActionComplete,
|
||||
defaultValues,
|
||||
submitText = 'Submit',
|
||||
submitClassname,
|
||||
otherSubmitButton,
|
||||
submitButtonDivClassname,
|
||||
}: UniversalFormProps<T>) {
|
||||
// @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<string, any> = {};
|
||||
fields.forEach((field) => {
|
||||
values[field.name] = field.value ?? ''; // Use empty string as fallback
|
||||
});
|
||||
return { ...values, ...defaultValues };
|
||||
}, [fields, defaultValues]);
|
||||
|
||||
const form = useForm<z.infer<T>>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: initialValues as z.infer<T>,
|
||||
});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (state && !state.success) {
|
||||
toast.error(state.error);
|
||||
}
|
||||
if (state) {
|
||||
onActionComplete?.(state);
|
||||
}
|
||||
}, [state, onActionComplete]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form action={formAction} className="space-y-2">
|
||||
{fields.map((field) => (
|
||||
<FormField
|
||||
key={field.name}
|
||||
control={form.control}
|
||||
name={field.name as Path<z.infer<T>>}
|
||||
render={({ field: formField }) => (
|
||||
<FormItem>
|
||||
{field.type !== 'hidden' && <FormLabel>{field.label}</FormLabel>}
|
||||
<FormControl>
|
||||
{field.textArea ? (
|
||||
<Textarea
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
value={formField.value ?? ''}
|
||||
rows={field.textAreaRows ?? 5}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type={field.type || 'text'}
|
||||
placeholder={field.placeholder}
|
||||
{...formField}
|
||||
value={formField.value ?? ''}
|
||||
/>
|
||||
)}
|
||||
</FormControl>
|
||||
{field.description && <FormDescription>{field.description}</FormDescription>}
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
<div className={cn("flex gap-2 py-2", submitButtonDivClassname)}>
|
||||
{otherSubmitButton}
|
||||
<SubmitButton buttonText={submitText} className={submitClassname} />
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
26
src/components/app/UniversalForm/types.ts
Normal file
26
src/components/app/UniversalForm/types.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { z } from 'zod';
|
||||
import { HTMLInputTypeAttribute } from 'react';
|
||||
import { schemaDb } from './UniversalForm';
|
||||
|
||||
export type FormFieldConfig = {
|
||||
name: string;
|
||||
label: string;
|
||||
type?: HTMLInputTypeAttribute;
|
||||
placeholder?: string;
|
||||
description?: string;
|
||||
value?: string;
|
||||
textArea?: boolean;
|
||||
textAreaRows?: number;
|
||||
};
|
||||
|
||||
export type UniversalFormProps<T extends z.ZodType> = {
|
||||
fields: FormFieldConfig[];
|
||||
schemaName: (typeof schemaDb)[number]['name'];
|
||||
action: (prev: any, formData: FormData) => void;
|
||||
onActionComplete?: (result: unknown) => void;
|
||||
defaultValues?: Partial<z.infer<T>>;
|
||||
submitText?: string;
|
||||
submitClassname?: string;
|
||||
otherSubmitButton?: React.ReactNode;
|
||||
submitButtonDivClassname?: string;
|
||||
};
|
||||
178
src/components/ui/form.tsx
Normal file
178
src/components/ui/form.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
||||
import { Slot } from "@radix-ui/react-slot"
|
||||
import {
|
||||
Controller,
|
||||
ControllerProps,
|
||||
FieldPath,
|
||||
FieldValues,
|
||||
FormProvider,
|
||||
useFormContext,
|
||||
} from "react-hook-form"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Label } from "@/components/ui/label"
|
||||
|
||||
const Form = FormProvider
|
||||
|
||||
type FormFieldContextValue<
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
> = {
|
||||
name: TName
|
||||
}
|
||||
|
||||
const FormFieldContext = React.createContext<FormFieldContextValue>(
|
||||
{} as FormFieldContextValue
|
||||
)
|
||||
|
||||
const FormField = <
|
||||
TFieldValues extends FieldValues = FieldValues,
|
||||
TName extends FieldPath<TFieldValues> = FieldPath<TFieldValues>
|
||||
>({
|
||||
...props
|
||||
}: ControllerProps<TFieldValues, TName>) => {
|
||||
return (
|
||||
<FormFieldContext.Provider value={{ name: props.name }}>
|
||||
<Controller {...props} />
|
||||
</FormFieldContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
const useFormField = () => {
|
||||
const fieldContext = React.useContext(FormFieldContext)
|
||||
const itemContext = React.useContext(FormItemContext)
|
||||
const { getFieldState, formState } = useFormContext()
|
||||
|
||||
const fieldState = getFieldState(fieldContext.name, formState)
|
||||
|
||||
if (!fieldContext) {
|
||||
throw new Error("useFormField should be used within <FormField>")
|
||||
}
|
||||
|
||||
const { id } = itemContext
|
||||
|
||||
return {
|
||||
id,
|
||||
name: fieldContext.name,
|
||||
formItemId: `${id}-form-item`,
|
||||
formDescriptionId: `${id}-form-item-description`,
|
||||
formMessageId: `${id}-form-item-message`,
|
||||
...fieldState,
|
||||
}
|
||||
}
|
||||
|
||||
type FormItemContextValue = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const FormItemContext = React.createContext<FormItemContextValue>(
|
||||
{} as FormItemContextValue
|
||||
)
|
||||
|
||||
const FormItem = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const id = React.useId()
|
||||
|
||||
return (
|
||||
<FormItemContext.Provider value={{ id }}>
|
||||
<div ref={ref} className={cn("space-y-2", className)} {...props} />
|
||||
</FormItemContext.Provider>
|
||||
)
|
||||
})
|
||||
FormItem.displayName = "FormItem"
|
||||
|
||||
const FormLabel = React.forwardRef<
|
||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { error, formItemId } = useFormField()
|
||||
|
||||
return (
|
||||
<Label
|
||||
ref={ref}
|
||||
className={cn(error && "text-destructive", className)}
|
||||
htmlFor={formItemId}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormLabel.displayName = "FormLabel"
|
||||
|
||||
const FormControl = React.forwardRef<
|
||||
React.ElementRef<typeof Slot>,
|
||||
React.ComponentPropsWithoutRef<typeof Slot>
|
||||
>(({ ...props }, ref) => {
|
||||
const { error, formItemId, formDescriptionId, formMessageId } = useFormField()
|
||||
|
||||
return (
|
||||
<Slot
|
||||
ref={ref}
|
||||
id={formItemId}
|
||||
aria-describedby={
|
||||
!error
|
||||
? `${formDescriptionId}`
|
||||
: `${formDescriptionId} ${formMessageId}`
|
||||
}
|
||||
aria-invalid={!!error}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormControl.displayName = "FormControl"
|
||||
|
||||
const FormDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => {
|
||||
const { formDescriptionId } = useFormField()
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formDescriptionId}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
FormDescription.displayName = "FormDescription"
|
||||
|
||||
const FormMessage = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, children, ...props }, ref) => {
|
||||
const { error, formMessageId } = useFormField()
|
||||
const body = error ? String(error?.message) : children
|
||||
|
||||
if (!body) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<p
|
||||
ref={ref}
|
||||
id={formMessageId}
|
||||
className={cn("text-sm font-medium text-destructive", className)}
|
||||
{...props}
|
||||
>
|
||||
{body}
|
||||
</p>
|
||||
)
|
||||
})
|
||||
FormMessage.displayName = "FormMessage"
|
||||
|
||||
export {
|
||||
useFormField,
|
||||
Form,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormControl,
|
||||
FormDescription,
|
||||
FormMessage,
|
||||
FormField,
|
||||
}
|
||||
22
src/components/ui/textarea.tsx
Normal file
22
src/components/ui/textarea.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import * as React from "react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const Textarea = React.forwardRef<
|
||||
HTMLTextAreaElement,
|
||||
React.ComponentProps<"textarea">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Textarea.displayName = "Textarea"
|
||||
|
||||
export { Textarea }
|
||||
@@ -1,92 +1,85 @@
|
||||
"use server"
|
||||
'use server';
|
||||
|
||||
import { cookies } from "next/headers";
|
||||
import { lucia, validateRequest } from ".";
|
||||
import { redirect } from "next/navigation";
|
||||
import prisma from "../db";
|
||||
import { generateId } from "lucia";
|
||||
import { accountSchema } from "./zod";
|
||||
import { hash, verify } from "@node-rs/argon2";
|
||||
import { cookies } from 'next/headers';
|
||||
import { lucia, validateRequest } from '.';
|
||||
import { redirect } from 'next/navigation';
|
||||
import prisma from '../db';
|
||||
import { generateId } from 'lucia';
|
||||
import { accountSchema } from '../form/zod';
|
||||
import { hash, verify } from '@node-rs/argon2';
|
||||
import zodVerify from '../zodVerify';
|
||||
|
||||
export async function logout() {
|
||||
const { session } = await validateRequest();
|
||||
await lucia.invalidateSession(session!.id);
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect("/auth/login");
|
||||
const { session } = await validateRequest();
|
||||
await lucia.invalidateSession(session!.id);
|
||||
const sessionCookie = lucia.createBlankSessionCookie();
|
||||
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect('/auth/login');
|
||||
}
|
||||
|
||||
export async function login(prev: any, data: FormData) {
|
||||
const checkSchema = await accountSchema.safeParseAsync(Object.fromEntries(data.entries()))
|
||||
if (!checkSchema.success)
|
||||
return {
|
||||
error: `From ${checkSchema.error.errors[0].path[0]}: ${checkSchema.error.errors[0].message}`,
|
||||
success: false,
|
||||
};
|
||||
const { username, password } = checkSchema.data;
|
||||
const zod = await zodVerify(accountSchema, data);
|
||||
if (!zod.success) return zod;
|
||||
const { username, password } = zod.data;
|
||||
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username
|
||||
}
|
||||
})
|
||||
if (!existingUser) {
|
||||
// NOTE:
|
||||
// Returning immediately allows malicious actors to figure out valid usernames from response times,
|
||||
// allowing them to only focus on guessing passwords in brute-force attacks.
|
||||
// As a preventive measure, you may want to hash passwords even for invalid usernames.
|
||||
// However, valid usernames can be already be revealed with the signup page among other methods.
|
||||
// It will also be much more resource intensive.
|
||||
// Since protecting against this is non-trivial,
|
||||
// it is crucial your implementation is protected against brute-force attacks with login throttling etc.
|
||||
// If usernames are public, you may outright tell the user that the username is invalid.
|
||||
return {
|
||||
error: "Incorrect username or password",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
const existingUser = await prisma.user.findUnique({
|
||||
where: {
|
||||
username: username,
|
||||
},
|
||||
});
|
||||
if (!existingUser) {
|
||||
// NOTE:
|
||||
// Returning immediately allows malicious actors to figure out valid usernames from response times,
|
||||
// allowing them to only focus on guessing passwords in brute-force attacks.
|
||||
// As a preventive measure, you may want to hash passwords even for invalid usernames.
|
||||
// However, valid usernames can be already be revealed with the signup page among other methods.
|
||||
// It will also be much more resource intensive.
|
||||
// Since protecting against this is non-trivial,
|
||||
// it is crucial your implementation is protected against brute-force attacks with login throttling etc.
|
||||
// If usernames are public, you may outright tell the user that the username is invalid.
|
||||
return {
|
||||
error: 'Incorrect username or password',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const validPassword = await verify(existingUser.hashed_password, password);
|
||||
if (!validPassword) {
|
||||
return {
|
||||
error: "Incorrect username or password",
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
const validPassword = await verify(existingUser.hashed_password, password);
|
||||
if (!validPassword) {
|
||||
return {
|
||||
error: 'Incorrect username or password',
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
|
||||
const session = await lucia.createSession(existingUser.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect("/");
|
||||
const session = await lucia.createSession(existingUser.id, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
export async function signup(prev: any, formData: FormData): Promise<ActionResult> {
|
||||
const checkSchema = await accountSchema.safeParseAsync(Object.fromEntries(formData.entries()))
|
||||
if (!checkSchema.success)
|
||||
return {
|
||||
error: `From ${checkSchema.error.errors[0].path[0]}: ${checkSchema.error.errors[0].message}`,
|
||||
success: false,
|
||||
};
|
||||
const { username, password } = checkSchema.data;
|
||||
const zod = await zodVerify(accountSchema, formData);
|
||||
if (!zod.success) return zod;
|
||||
const { username, password } = zod.data;
|
||||
|
||||
const hashedPassword = await hash(password);
|
||||
const userId = generateId(15);
|
||||
const hashedPassword = await hash(password);
|
||||
const userId = generateId(15);
|
||||
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
username: username,
|
||||
hashed_password: hashedPassword
|
||||
}
|
||||
});
|
||||
await prisma.user.create({
|
||||
data: {
|
||||
id: userId,
|
||||
username: username,
|
||||
hashed_password: hashedPassword,
|
||||
},
|
||||
});
|
||||
|
||||
const session = await lucia.createSession(userId, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect("/");
|
||||
const session = await lucia.createSession(userId, {});
|
||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||
return redirect('/');
|
||||
}
|
||||
|
||||
interface ActionResult {
|
||||
error: string | null;
|
||||
success: boolean | null;
|
||||
error: string | null;
|
||||
success: boolean | null;
|
||||
}
|
||||
@@ -23,7 +23,7 @@ export const lucia = new Lucia(adapter, {
|
||||
}
|
||||
});
|
||||
export const validateRequest = cache(async () => {
|
||||
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null
|
||||
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null
|
||||
|
||||
if (!sessionId)
|
||||
return {
|
||||
@@ -35,7 +35,7 @@ export const validateRequest = cache(async () => {
|
||||
try {
|
||||
if (session && session.fresh) {
|
||||
const sessionCookie = lucia.createSessionCookie(session.id)
|
||||
cookies().set(
|
||||
(await cookies()).set(
|
||||
sessionCookie.name,
|
||||
sessionCookie.value,
|
||||
sessionCookie.attributes
|
||||
@@ -43,7 +43,7 @@ export const validateRequest = cache(async () => {
|
||||
}
|
||||
if (!session) {
|
||||
const sessionCookie = lucia.createBlankSessionCookie()
|
||||
cookies().set(
|
||||
(await cookies()).set(
|
||||
sessionCookie.name,
|
||||
sessionCookie.value,
|
||||
sessionCookie.attributes
|
||||
|
||||
@@ -1,9 +1,13 @@
|
||||
"use client"
|
||||
'use client';
|
||||
|
||||
import * as React from "react"
|
||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
||||
import { type ThemeProviderProps } from "next-themes"
|
||||
import * as React from 'react';
|
||||
const NextThemesProvider = dynamic(() => import('next-themes').then((e) => e.ThemeProvider), {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
import { type ThemeProviderProps } from 'next-themes';
|
||||
import dynamic from 'next/dynamic';
|
||||
|
||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
|
||||
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
|
||||
}
|
||||
34
src/lib/zodVerify.ts
Normal file
34
src/lib/zodVerify.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
'use server';
|
||||
|
||||
import { ZodType } from 'zod';
|
||||
|
||||
type SuccessResult<T> = {
|
||||
success: true;
|
||||
data: T;
|
||||
};
|
||||
|
||||
type ErrorResult = {
|
||||
success: false;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type VerifyResult<T> = SuccessResult<T> | ErrorResult;
|
||||
|
||||
export default async function zodVerify<T>(schema: ZodType<T>, data: FormData | Object): Promise<VerifyResult<T>> {
|
||||
let obj: any = data;
|
||||
if (data instanceof FormData) {
|
||||
obj = Object.fromEntries(data.entries());
|
||||
}
|
||||
|
||||
const zod = schema.safeParse(obj);
|
||||
if (!zod.success) {
|
||||
return {
|
||||
error: `From ${zod.error.errors[0].path[0]}: ${zod.error.errors[0].message}`,
|
||||
success: false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: zod.data,
|
||||
};
|
||||
}
|
||||
@@ -52,6 +52,15 @@ const config = {
|
||||
DEFAULT: "hsl(var(--card))",
|
||||
foreground: "hsl(var(--card-foreground))",
|
||||
},
|
||||
surface1: {
|
||||
DEFAULT: "hsl(var(--surface-1))",
|
||||
},
|
||||
surface2: {
|
||||
DEFAULT: "hsl(var(--surface-2))",
|
||||
},
|
||||
mantle: {
|
||||
DEFAULT: "hsl(var(--mantle))",
|
||||
},
|
||||
},
|
||||
borderRadius: {
|
||||
lg: "var(--radius)",
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"lib": [
|
||||
"dom",
|
||||
"dom.iterable",
|
||||
"esnext"
|
||||
],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
@@ -18,9 +22,19 @@
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"target": "ES2017"
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts"
|
||||
],
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user