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:
|
services:
|
||||||
psql:
|
psql:
|
||||||
image: postgres
|
image: postgres
|
||||||
@@ -7,6 +5,6 @@ services:
|
|||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: dfsjhkdswkjntelsmldbfvsgknl5t
|
POSTGRES_PASSWORD: dfsjhkdswkjntelsmldbfvsgknl5t
|
||||||
volumes:
|
volumes:
|
||||||
- psql:/var/lib/postgresql/data
|
- ./psql:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- 5555:5432
|
- 5555:5432
|
||||||
19
package.json
19
package.json
@@ -4,32 +4,36 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "docker compose --file dev/docker-compose.yml up -d && next dev",
|
"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",
|
"build": "prisma generate && next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"db:generate": "prisma generate",
|
"db:generate": "prisma generate",
|
||||||
"db:migrate": "prisma migrate dev --name"
|
"db:migrate": "prisma migrate dev --name",
|
||||||
|
"ui:add": "shadcn add"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@hookform/resolvers": "^3.9.1",
|
||||||
"@lucia-auth/adapter-prisma": "^4.0.1",
|
"@lucia-auth/adapter-prisma": "^4.0.1",
|
||||||
"@node-rs/argon2": "^2.0.2",
|
"@node-rs/argon2": "^2.0.2",
|
||||||
"@prisma/client": "^6.0.1",
|
"@prisma/client": "^6.0.1",
|
||||||
"@radix-ui/react-avatar": "^1.0.4",
|
"@radix-ui/react-avatar": "^1.0.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
"@radix-ui/react-dropdown-menu": "^2.1.2",
|
||||||
"@radix-ui/react-label": "^2.1.0",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.0",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
"lucia": "^3.1.1",
|
"lucia": "^3.1.1",
|
||||||
"lucide-react": "^0.368.0",
|
"lucide-react": "^0.368.0",
|
||||||
"next": "^14.2.3",
|
"next": "^15.1.2",
|
||||||
"next-themes": "^0.4.4",
|
"next-themes": "^0.4.4",
|
||||||
"react": "^18",
|
"react": "19",
|
||||||
"react-dom": "^18",
|
"react-dom": "19",
|
||||||
|
"react-hook-form": "^7.54.2",
|
||||||
"sonner": "^1.4.41",
|
"sonner": "^1.4.41",
|
||||||
"tailwind-merge": "^2.2.2",
|
"tailwind-merge": "^2.2.2",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
"zod": "^3.23.8"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
@@ -39,6 +43,7 @@
|
|||||||
"eslint-config-next": "14.2.0",
|
"eslint-config-next": "14.2.0",
|
||||||
"postcss": "^8",
|
"postcss": "^8",
|
||||||
"prisma": "^6.0.1",
|
"prisma": "^6.0.1",
|
||||||
|
"shadcn": "^2.1.8",
|
||||||
"tailwindcss": "^3.4.1",
|
"tailwindcss": "^3.4.1",
|
||||||
"typescript": "^5"
|
"typescript": "^5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
"use client";
|
'use client';
|
||||||
import { Label } from "@/components/ui/label";
|
import Link from 'next/link';
|
||||||
import { Input } from "@/components/ui/input";
|
import { login } from '@/lib/auth/actions';
|
||||||
import Link from "next/link";
|
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||||
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";
|
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [formData, formAction] = useFormState(login, null);
|
|
||||||
useEffect(() => {
|
|
||||||
if (formData?.error) {
|
|
||||||
toast.error(formData.error)
|
|
||||||
}
|
|
||||||
}, [formData])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center p-4 lg:p-8">
|
<div className="flex items-center p-4 lg:p-8">
|
||||||
<div className="w-full max-w-md m-auto space-y-8">
|
<div className="w-full max-w-md m-auto space-y-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold pb-1">Log In</h1>
|
<h1 className="text-4xl font-bold pb-1">Log In</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action={formAction}>
|
<UniversalForm
|
||||||
<div className="space-y-4">
|
fields={[
|
||||||
<div className="space-y-2">
|
{
|
||||||
<Label htmlFor="email">Username</Label>
|
name: 'username',
|
||||||
<Input name="username" id="username" required type="text" />
|
label: 'Username',
|
||||||
</div>
|
placeholder: 'Username',
|
||||||
<div className="space-y-2">
|
},
|
||||||
<Label htmlFor="password">Password</Label>
|
{
|
||||||
<Input name="password" id="password" required type="password" />
|
name: 'password',
|
||||||
</div>
|
label: 'Password',
|
||||||
<SubmitButton buttonText="Log In" className="w-full" />
|
type: 'password',
|
||||||
|
placeholder: 'Password',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
schemaName="login"
|
||||||
|
action={login}
|
||||||
|
submitText="Log In"
|
||||||
|
submitClassname="w-full"
|
||||||
|
/>
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
No account?
|
No account?
|
||||||
<Link className="underline pl-1" href="/auth/signUp">
|
<Link className="underline pl-1" href="/auth/signUp">
|
||||||
@@ -40,8 +36,6 @@ export default function Page() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,46 @@
|
|||||||
'use client'
|
'use client';
|
||||||
import { Label } from "@/components/ui/label"
|
import { Label } from '@/components/ui/label';
|
||||||
import { Input } from "@/components/ui/input"
|
import { Input } from '@/components/ui/input';
|
||||||
import Link from "next/link"
|
import Link from 'next/link';
|
||||||
import { signup } from "@/lib/auth/actions";
|
import { signup } from '@/lib/auth/actions';
|
||||||
import SubmitButton from "@/components/app/SubmitButton/SubmitButton";
|
import SubmitButton from '@/components/app/SubmitButton/SubmitButton';
|
||||||
import { useFormState } from "react-dom";
|
import { useActionState } from 'react';
|
||||||
import { toast } from "sonner";
|
import { toast } from 'sonner';
|
||||||
import { useEffect } from "react";
|
import { useEffect } from 'react';
|
||||||
|
import { UniversalForm } from '@/components/app/UniversalForm/UniversalForm';
|
||||||
|
|
||||||
export default function Page() {
|
export default function Page() {
|
||||||
const [signupData, signupAction] = useFormState(signup, null)
|
const [signupData, signupAction] = useActionState(signup, null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (signupData?.error) {
|
if (signupData?.error) {
|
||||||
toast.error(signupData.error)
|
toast.error(signupData.error);
|
||||||
}
|
}
|
||||||
}, [signupData])
|
}, [signupData]);
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center p-6 lg:p-8">
|
<div className="flex items-center p-6 lg:p-8">
|
||||||
<div className="w-full max-w-md m-auto space-y-8">
|
<div className="w-full max-w-md m-auto space-y-8">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-4xl font-bold pb-1">Sign Up</h1>
|
<h1 className="text-4xl font-bold pb-1">Sign Up</h1>
|
||||||
</div>
|
</div>
|
||||||
<form action={signupAction}>
|
<UniversalForm
|
||||||
<div className="space-y-4">
|
fields={[
|
||||||
<div className="space-y-2">
|
{
|
||||||
<Label htmlFor="username">Username</Label>
|
name: 'username',
|
||||||
<Input name="username" required type="text" id="username" />
|
label: 'Username',
|
||||||
</div>
|
placeholder: 'Username',
|
||||||
<div className="space-y-2">
|
},
|
||||||
<Label htmlFor="password">Password</Label>
|
{
|
||||||
<Input name="password" required type="password" id="password" />
|
name: 'password',
|
||||||
</div>
|
label: 'Password',
|
||||||
<SubmitButton buttonText="Create account" className="w-full" />
|
type: 'password',
|
||||||
|
placeholder: 'Password',
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
schemaName="register"
|
||||||
|
action={signup}
|
||||||
|
submitText="Sign Up"
|
||||||
|
submitClassname="w-full"
|
||||||
|
/>
|
||||||
<div className="text-center text-sm">
|
<div className="text-center text-sm">
|
||||||
Already have an account?
|
Already have an account?
|
||||||
<Link className="underline pl-1" href="/auth/login">
|
<Link className="underline pl-1" href="/auth/login">
|
||||||
@@ -39,8 +48,6 @@ export default function Page() {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@@ -26,7 +26,7 @@ export default function Home() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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="container px-4 md:px-6">
|
||||||
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
<div className="flex flex-col items-center justify-center space-y-4 text-center">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -2,79 +2,95 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
:root {
|
:root {
|
||||||
--background: 220 23.077% 94.902%; /* base */
|
--background: 220 23.077% 94.902%;
|
||||||
--foreground: 233.793 16.022% 35.490%; /* text */
|
--foreground: 233.793 16.022% 35.49%;
|
||||||
|
|
||||||
--muted: 222.857 15.909% 82.745%; /* surface0 */
|
--muted: 222.857 15.909% 82.745%;
|
||||||
--muted-foreground: 233.333 12.796% 41.373%; /* subtext1 */
|
--muted-foreground: 233.333 12.796% 41.373%;
|
||||||
|
|
||||||
--popover: 220 23.077% 94.902%; /* base */
|
--popover: 220 23.077% 94.902%;
|
||||||
--popover-foreground: 233.793 16.022% 35.490%; /* text */
|
--popover-foreground: 233.793 16.022% 35.49%;
|
||||||
|
|
||||||
--card: 220 23.077% 94.902%; /* base */
|
--card: 220 23.077% 94.902%;
|
||||||
--card-foreground: 233.793 16.022% 35.490%; /* text */
|
--card-foreground: 233.793 16.022% 35.49%;
|
||||||
|
|
||||||
--border: 225 13.559% 76.863%; /* surface1 */
|
--border: 225 13.559% 76.863%;
|
||||||
--input: 225 13.559% 76.863%; /* surface1 */
|
--input: 225 13.559% 76.863%;
|
||||||
|
|
||||||
--primary: 219.907 91.489% 53.922%; /* blue */
|
--primary: 219.907 91.489% 53.922%;
|
||||||
--primary-foreground: 220 23.077% 94.902%; /* base */
|
--primary-foreground: 220 23.077% 94.902%;
|
||||||
|
|
||||||
--secondary: 222.857 15.909% 82.745%; /* surface0 */
|
--secondary: 222.857 15.909% 82.745%;
|
||||||
--secondary-foreground: 233.793 16.022% 35.490%; /* text */
|
--secondary-foreground: 233.793 16.022% 35.49%;
|
||||||
|
|
||||||
--accent: 222.857 15.909% 82.745%; /* surface0 */
|
--accent: 222.857 15.909% 82.745%;
|
||||||
--accent-foreground: 233.793 16.022% 35.490%; /* text */
|
--accent-foreground: 233.793 16.022% 35.49%;
|
||||||
|
|
||||||
--destructive: 347.077 86.667% 44.118%; /* red */
|
--destructive: 347.077 86.667% 44.118%;
|
||||||
--destructive-foreground: 220 21.951% 91.961%; /* mantle */
|
--destructive-foreground: 220 21.951% 91.961%;
|
||||||
|
|
||||||
--ring: 233.793 16.022% 35.490%; /* text */
|
--ring: 233.793 16.022% 35.49%;
|
||||||
|
|
||||||
|
--surface-1: 225 14% 77%;
|
||||||
|
--surface-2: 227 12% 71%;
|
||||||
|
|
||||||
|
--mantle: 220 22% 92%;
|
||||||
|
|
||||||
--radius: 0.5rem;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark {
|
.dark {
|
||||||
--background: 240 21.053% 14.902%; /* base */
|
--background: 240 21.053% 14.902%;
|
||||||
--foreground: 226.154 63.934% 88.039%; /* text */
|
--foreground: 226.154 63.934% 88.039%;
|
||||||
|
|
||||||
--muted: 236.842 16.239% 22.941%; /* surface0 */
|
--muted: 236.842 16.239% 22.941%;
|
||||||
--muted-foreground: 226.667 35.294% 80.000%; /* subtext1 */
|
--muted-foreground: 226.667 35.294% 80%;
|
||||||
|
|
||||||
--popover: 240 21.053% 14.902%; /* base */
|
--popover: 240 21.053% 14.902%;
|
||||||
--popover-foreground: 226.154 63.934% 88.039%; /* text */
|
--popover-foreground: 226.154 63.934% 88.039%;
|
||||||
|
|
||||||
--card: 240 21.053% 14.902%; /* base */
|
--card: 240 21.053% 14.902%;
|
||||||
--card-foreground: 226.154 63.934% 88.039%; /* text */
|
--card-foreground: 226.154 63.934% 88.039%;
|
||||||
|
|
||||||
--border: 234.286 13.208% 31.176%; /* surface1 */
|
--border: 234.286 13.208% 31.176%;
|
||||||
--input: 234.286 13.208% 31.176%; /* surface1 */
|
--input: 234.286 13.208% 31.176%;
|
||||||
|
|
||||||
--primary: 217.168 91.870% 75.882%; /* blue */
|
--primary: 217.168 91.87% 75.882%;
|
||||||
--primary-foreground: 240 21.053% 14.902%; /* base */
|
--primary-foreground: 240 21.053% 14.902%;
|
||||||
|
|
||||||
--secondary: 236.842 16.239% 22.941%; /* surface0 */
|
--secondary: 236.842 16.239% 22.941%;
|
||||||
--secondary-foreground: 226.154 63.934% 88.039%; /* text */
|
--secondary-foreground: 226.154 63.934% 88.039%;
|
||||||
|
|
||||||
--accent: 236.842 16.239% 22.941%; /* surface0 */
|
--accent: 236.842 16.239% 22.941%;
|
||||||
--accent-foreground: 226.154 63.934% 88.039%; /* text */
|
--accent-foreground: 226.154 63.934% 88.039%;
|
||||||
|
|
||||||
--destructive: 343.269 81.250% 74.902%; /* red */
|
--destructive: 343.269 81.25% 74.902%;
|
||||||
--destructive-foreground: 240 21.311% 11.961%; /* mantle */
|
--destructive-foreground: 240 21.311% 11.961%;
|
||||||
|
|
||||||
--ring: 226.154 63.934% 88.039%; /* text */
|
--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;
|
--radius: 0.5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
@apply border-border;
|
@apply border-border;
|
||||||
}
|
}
|
||||||
body {
|
body {
|
||||||
@apply bg-background text-foreground;
|
@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.
|
* v0 by Vercel.
|
||||||
* @see https://v0.dev/t/igzEEdGqAvH
|
* @see https://v0.dev/t/igzEEdGqAvH
|
||||||
* Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
|
* Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app
|
||||||
*/
|
*/
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from '@/components/ui/button';
|
||||||
import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuGroup, DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
import {
|
||||||
import { logout } from "@/lib/auth/actions"
|
DropdownMenu,
|
||||||
import { useSession } from "@/lib/providers/SessionProvider"
|
DropdownMenuTrigger,
|
||||||
import Link from "next/link"
|
DropdownMenuContent,
|
||||||
import { useState } from "react"
|
DropdownMenuLabel,
|
||||||
import { useFormState } from "react-dom"
|
DropdownMenuSeparator,
|
||||||
import MobileNavbarLinks from "../MobileNavbarLinks/MobileNavbarLinks"
|
DropdownMenuGroup,
|
||||||
import { ThemeSwitcher } from "../ThemeSwitcher/ThemeSwitcher"
|
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 = [
|
export const links = [
|
||||||
{ href: '/', name: 'Home' },
|
{ href: '/', name: 'Home' },
|
||||||
{ href: 'https://github.com/SrIzan10/stack', name: 'Github' },
|
{ href: 'https://github.com/SrIzan10/stack', name: 'Github' },
|
||||||
{ href: '/protected', name: 'Protected route' }
|
{ href: '/protected', name: 'Protected route' },
|
||||||
]
|
];
|
||||||
|
|
||||||
function NavbarLinks() {
|
function NavbarLinks() {
|
||||||
return (
|
return (
|
||||||
@@ -36,7 +42,6 @@ function NavbarLinks() {
|
|||||||
|
|
||||||
export default function Navbar() {
|
export default function Navbar() {
|
||||||
const { user } = useSession();
|
const { user } = useSession();
|
||||||
const [, logoutAction] = useFormState(logout, null)
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<nav className="flex items-center h-16 px-4 border-b gap-3 shrink-0">
|
<nav className="flex items-center h-16 px-4 border-b gap-3 shrink-0">
|
||||||
@@ -63,9 +68,12 @@ export default function Navbar() {
|
|||||||
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
<DropdownMenuLabel>My Account</DropdownMenuLabel>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuGroup>
|
<DropdownMenuGroup>
|
||||||
<DropdownMenuItem className="cursor-pointer" onClick={() => {
|
<DropdownMenuItem
|
||||||
logoutAction()
|
className="cursor-pointer"
|
||||||
}}>
|
onClick={() => {
|
||||||
|
logout();
|
||||||
|
}}
|
||||||
|
>
|
||||||
Sign out
|
Sign out
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
|
|||||||
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,35 +1,32 @@
|
|||||||
"use server"
|
'use server';
|
||||||
|
|
||||||
import { cookies } from "next/headers";
|
import { cookies } from 'next/headers';
|
||||||
import { lucia, validateRequest } from ".";
|
import { lucia, validateRequest } from '.';
|
||||||
import { redirect } from "next/navigation";
|
import { redirect } from 'next/navigation';
|
||||||
import prisma from "../db";
|
import prisma from '../db';
|
||||||
import { generateId } from "lucia";
|
import { generateId } from 'lucia';
|
||||||
import { accountSchema } from "./zod";
|
import { accountSchema } from '../form/zod';
|
||||||
import { hash, verify } from "@node-rs/argon2";
|
import { hash, verify } from '@node-rs/argon2';
|
||||||
|
import zodVerify from '../zodVerify';
|
||||||
|
|
||||||
export async function logout() {
|
export async function logout() {
|
||||||
const { session } = await validateRequest();
|
const { session } = await validateRequest();
|
||||||
await lucia.invalidateSession(session!.id);
|
await lucia.invalidateSession(session!.id);
|
||||||
const sessionCookie = lucia.createBlankSessionCookie();
|
const sessionCookie = lucia.createBlankSessionCookie();
|
||||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||||
return redirect("/auth/login");
|
return redirect('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function login(prev: any, data: FormData) {
|
export async function login(prev: any, data: FormData) {
|
||||||
const checkSchema = await accountSchema.safeParseAsync(Object.fromEntries(data.entries()))
|
const zod = await zodVerify(accountSchema, data);
|
||||||
if (!checkSchema.success)
|
if (!zod.success) return zod;
|
||||||
return {
|
const { username, password } = zod.data;
|
||||||
error: `From ${checkSchema.error.errors[0].path[0]}: ${checkSchema.error.errors[0].message}`,
|
|
||||||
success: false,
|
|
||||||
};
|
|
||||||
const { username, password } = checkSchema.data;
|
|
||||||
|
|
||||||
const existingUser = await prisma.user.findUnique({
|
const existingUser = await prisma.user.findUnique({
|
||||||
where: {
|
where: {
|
||||||
username: username
|
username: username,
|
||||||
}
|
},
|
||||||
})
|
});
|
||||||
if (!existingUser) {
|
if (!existingUser) {
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// Returning immediately allows malicious actors to figure out valid usernames from response times,
|
// Returning immediately allows malicious actors to figure out valid usernames from response times,
|
||||||
@@ -41,7 +38,7 @@ export async function login(prev: any, data: FormData) {
|
|||||||
// it is crucial your implementation is protected against brute-force attacks with login throttling etc.
|
// 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.
|
// If usernames are public, you may outright tell the user that the username is invalid.
|
||||||
return {
|
return {
|
||||||
error: "Incorrect username or password",
|
error: 'Incorrect username or password',
|
||||||
success: false,
|
success: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -49,25 +46,21 @@ export async function login(prev: any, data: FormData) {
|
|||||||
const validPassword = await verify(existingUser.hashed_password, password);
|
const validPassword = await verify(existingUser.hashed_password, password);
|
||||||
if (!validPassword) {
|
if (!validPassword) {
|
||||||
return {
|
return {
|
||||||
error: "Incorrect username or password",
|
error: 'Incorrect username or password',
|
||||||
success: false,
|
success: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await lucia.createSession(existingUser.id, {});
|
const session = await lucia.createSession(existingUser.id, {});
|
||||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||||
return redirect("/");
|
return redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function signup(prev: any, formData: FormData): Promise<ActionResult> {
|
export async function signup(prev: any, formData: FormData): Promise<ActionResult> {
|
||||||
const checkSchema = await accountSchema.safeParseAsync(Object.fromEntries(formData.entries()))
|
const zod = await zodVerify(accountSchema, formData);
|
||||||
if (!checkSchema.success)
|
if (!zod.success) return zod;
|
||||||
return {
|
const { username, password } = zod.data;
|
||||||
error: `From ${checkSchema.error.errors[0].path[0]}: ${checkSchema.error.errors[0].message}`,
|
|
||||||
success: false,
|
|
||||||
};
|
|
||||||
const { username, password } = checkSchema.data;
|
|
||||||
|
|
||||||
const hashedPassword = await hash(password);
|
const hashedPassword = await hash(password);
|
||||||
const userId = generateId(15);
|
const userId = generateId(15);
|
||||||
@@ -76,14 +69,14 @@ export async function signup(prev: any, formData: FormData): Promise<ActionResul
|
|||||||
data: {
|
data: {
|
||||||
id: userId,
|
id: userId,
|
||||||
username: username,
|
username: username,
|
||||||
hashed_password: hashedPassword
|
hashed_password: hashedPassword,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const session = await lucia.createSession(userId, {});
|
const session = await lucia.createSession(userId, {});
|
||||||
const sessionCookie = lucia.createSessionCookie(session.id);
|
const sessionCookie = lucia.createSessionCookie(session.id);
|
||||||
cookies().set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
(await cookies()).set(sessionCookie.name, sessionCookie.value, sessionCookie.attributes);
|
||||||
return redirect("/");
|
return redirect('/');
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActionResult {
|
interface ActionResult {
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export const lucia = new Lucia(adapter, {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
export const validateRequest = cache(async () => {
|
export const validateRequest = cache(async () => {
|
||||||
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null
|
const sessionId = (await cookies()).get(lucia.sessionCookieName)?.value ?? null
|
||||||
|
|
||||||
if (!sessionId)
|
if (!sessionId)
|
||||||
return {
|
return {
|
||||||
@@ -35,7 +35,7 @@ export const validateRequest = cache(async () => {
|
|||||||
try {
|
try {
|
||||||
if (session && session.fresh) {
|
if (session && session.fresh) {
|
||||||
const sessionCookie = lucia.createSessionCookie(session.id)
|
const sessionCookie = lucia.createSessionCookie(session.id)
|
||||||
cookies().set(
|
(await cookies()).set(
|
||||||
sessionCookie.name,
|
sessionCookie.name,
|
||||||
sessionCookie.value,
|
sessionCookie.value,
|
||||||
sessionCookie.attributes
|
sessionCookie.attributes
|
||||||
@@ -43,7 +43,7 @@ export const validateRequest = cache(async () => {
|
|||||||
}
|
}
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const sessionCookie = lucia.createBlankSessionCookie()
|
const sessionCookie = lucia.createBlankSessionCookie()
|
||||||
cookies().set(
|
(await cookies()).set(
|
||||||
sessionCookie.name,
|
sessionCookie.name,
|
||||||
sessionCookie.value,
|
sessionCookie.value,
|
||||||
sessionCookie.attributes
|
sessionCookie.attributes
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
"use client"
|
'use client';
|
||||||
|
|
||||||
import * as React from "react"
|
import * as React from 'react';
|
||||||
import { ThemeProvider as NextThemesProvider } from "next-themes"
|
const NextThemesProvider = dynamic(() => import('next-themes').then((e) => e.ThemeProvider), {
|
||||||
import { type ThemeProviderProps } from "next-themes"
|
ssr: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
import { type ThemeProviderProps } from 'next-themes';
|
||||||
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
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))",
|
DEFAULT: "hsl(var(--card))",
|
||||||
foreground: "hsl(var(--card-foreground))",
|
foreground: "hsl(var(--card-foreground))",
|
||||||
},
|
},
|
||||||
|
surface1: {
|
||||||
|
DEFAULT: "hsl(var(--surface-1))",
|
||||||
|
},
|
||||||
|
surface2: {
|
||||||
|
DEFAULT: "hsl(var(--surface-2))",
|
||||||
|
},
|
||||||
|
mantle: {
|
||||||
|
DEFAULT: "hsl(var(--mantle))",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
borderRadius: {
|
borderRadius: {
|
||||||
lg: "var(--radius)",
|
lg: "var(--radius)",
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -18,9 +22,19 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
}
|
"./src/*"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
"target": "ES2017"
|
||||||
"exclude": ["node_modules"]
|
},
|
||||||
|
"include": [
|
||||||
|
"next-env.d.ts",
|
||||||
|
"**/*.ts",
|
||||||
|
"**/*.tsx",
|
||||||
|
".next/types/**/*.ts"
|
||||||
|
],
|
||||||
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user