feat: update to nextjs 15 and some more additions

This commit is contained in:
2024-12-26 19:17:29 +01:00
parent 70d60e53d1
commit b2eb8c9e74
21 changed files with 1834 additions and 357 deletions

8
.prettierrc Normal file
View File

@@ -0,0 +1,8 @@
{
"useTabs": false,
"printWidth": 100,
"tabWidth": 2,
"singleQuote": true,
"trailingComma": "es5",
"semi": true
}

6
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,6 @@
{
"editor.tabSize": 2,
"editor.detectIndentation": false,
"editor.insertSpaces": true,
"editor.rulers": [100]
}

View File

@@ -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

View File

@@ -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"
}, },

View File

@@ -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>
); );
} }

View File

@@ -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> );
)
} }

View File

@@ -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">

View File

@@ -4,67 +4,77 @@
@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;
} }
@@ -78,3 +88,9 @@
@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;
}

View File

@@ -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>

View 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>
);
}

View 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
View 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,
}

View 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 }

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
View 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,
};
}

View File

@@ -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)",

View File

@@ -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"
]
} }

1263
yarn.lock

File diff suppressed because it is too large Load Diff