feat: initial v1 commit

This commit is contained in:
2024-04-27 10:26:28 +00:00
parent 7914d79aa3
commit 075f2e094e
39 changed files with 4875 additions and 2 deletions

View File

@@ -0,0 +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";
export default function Page() {
const [, formAction] = useFormState(login, null);
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>
</div>
</div>
);
}

View File

@@ -0,0 +1,46 @@
'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";
export default function Page() {
const [signupData, signupAction] = useFormState(signup, null)
useEffect(() => {
if (signupData?.error) {
toast.error(signupData.error)
}
}, [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/signIn">
Login
</Link>
</div>
</div>
</form>
</div>
</div>
)
}

BIN
src/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

80
src/app/globals.css Normal file
View File

@@ -0,0 +1,80 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 220 23.077% 94.902%; /* base */
--foreground: 233.793 16.022% 35.490%; /* text */
--muted: 222.857 15.909% 82.745%; /* surface0 */
--muted-foreground: 233.333 12.796% 41.373%; /* subtext1 */
--popover: 220 23.077% 94.902%; /* base */
--popover-foreground: 233.793 16.022% 35.490%; /* text */
--card: 220 23.077% 94.902%; /* base */
--card-foreground: 233.793 16.022% 35.490%; /* text */
--border: 225 13.559% 76.863%; /* surface1 */
--input: 225 13.559% 76.863%; /* surface1 */
--primary: 219.907 91.489% 53.922%; /* blue */
--primary-foreground: 220 23.077% 94.902%; /* base */
--secondary: 222.857 15.909% 82.745%; /* surface0 */
--secondary-foreground: 233.793 16.022% 35.490%; /* text */
--accent: 222.857 15.909% 82.745%; /* surface0 */
--accent-foreground: 233.793 16.022% 35.490%; /* text */
--destructive: 347.077 86.667% 44.118%; /* red */
--destructive-foreground: 220 21.951% 91.961%; /* mantle */
--ring: 233.793 16.022% 35.490%; /* text */
--radius: 0.5rem;
}
.dark {
--background: 240 21.053% 14.902%; /* base */
--foreground: 226.154 63.934% 88.039%; /* text */
--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;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

41
src/app/layout.tsx Normal file
View File

@@ -0,0 +1,41 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import Navbar from "@/components/app/NavBar/NavBar";
import { SessionProvider } from "@/lib/providers/SessionProvider";
import { validateRequest } from "@/lib/auth";
import { Toaster } from "@/components/ui/sonner"
import { ThemeProvider } from "@/lib/providers/ThemeProvider";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "stack",
description: "The tech stack for your next(.js) project.",
};
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const sessionData = await validateRequest()
return (
<html lang="en">
<body className={inter.className}>
<SessionProvider value={sessionData}>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<Navbar />
{children}
<Toaster />
</ThemeProvider>
</SessionProvider>
</body>
</html>
);
}

112
src/app/page.tsx Normal file
View File

@@ -0,0 +1,112 @@
// https://v0.dev/r/DxCSk58T8pM
import { Button } from "@/components/ui/button";
import Link from "next/link";
export default function Home() {
return (
<>
<main className="flex-1">
<section className="w-full py-12 md:py-24 lg:py-32 xl:py-48">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl lg:text-6xl/none">
The modern tech stack for your next(.js) project
</h1>
<p className="mx-auto max-w-[700px] text-gray-500 md:text-xl dark:text-gray-400">
stack is a comprehensive tech stack that includes everything you need to build and deploy your next
web application.
</p>
</div>
<div className="space-x-4">
<Link href="https://github.com/SrIzan10/stack">
<Button>Start right NOW!</Button>
</Link>
</div>
</div>
</div>
</section>
<section className="w-full py-12 md:py-24 lg:py-32 bg-gray-100 dark:bg-gray-900" 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">
<div className="inline-block rounded-lg bg-gray-100 px-3 py-1 text-sm dark:bg-gray-800">
Key Features
</div>
<h2 className="text-3xl font-bold tracking-tighter sm:text-5xl">
Everything you need to build and deploy
</h2>
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
Stack is a comprehensive tech stack that includes everything you need to build and deploy your next
web application.
</p>
</div>
</div>
<div className="mx-auto max-w-5xl items-center gap-6 py-12 lg:grid-cols-2 lg:gap-12">
<div className="flex flex-col justify-center items-center text-center space-y-4">
<ul className="grid gap-6">
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Next.js</h3>
<p className="text-gray-500 dark:text-gray-400">
Build server-rendered React applications with Next.js.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Prisma</h3>
<p className="text-gray-500 dark:text-gray-400">
Access your database with Prisma, the best way to work with databases in Ethan&apos;s opinion
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Tailwind CSS</h3>
<p className="text-gray-500 dark:text-gray-400">
Style your application with Tailwind CSS, the utility-first CSS framework.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">shadcn/ui</h3>
<p className="text-gray-500 dark:text-gray-400">
Develop your application with Vite, the next-generation frontend tooling.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Vercel</h3>
<p className="text-gray-500 dark:text-gray-400">
Deploy your application to the cloud with Vercel, the serverless platform.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Cloudflare Pages</h3>
<p className="text-gray-500 dark:text-gray-400">
Take advantage of the speed of Cloudflare to host your serverless websites.
</p>
</div>
</li>
<li>
<div className="grid gap-1">
<h3 className="text-xl font-bold">Lucia</h3>
<p className="text-gray-500 dark:text-gray-400">
Manage authentication with Lucia auth, the best selfhosted authentication library.
</p>
</div>
</li>
</ul>
</div>
</div>
</div>
</section>
</main>
</>
);
}

View File

@@ -0,0 +1,14 @@
import { validateRequest } from "@/lib/auth";
import { redirect } from "next/navigation";
export default async function Page() {
const { user } = await validateRequest()
if (!user) return redirect('/auth/signIn')
return (
<div>
<h1 className="text-3xl font-bold text-center">Welcome {user?.username}!</h1>
<p>You are actually on a protected route!</p>
<p>Your ID is: {user.id}</p>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuGroup,
DropdownMenuItem,
} from "@/components/ui/dropdown-menu";
import Link from "next/link";
import { links } from "../NavBar/NavBar";
export default function MobileNavbarLinks() {
return (
<div className="flex md:hidden">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button>Menu</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>stack</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
{links.map((link) => (
<Link key={link.href} href={link.href}>
<DropdownMenuItem>{link.name}</DropdownMenuItem>
</Link>
))}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -0,0 +1,82 @@
"use client"
import { Avatar, AvatarFallback, AvatarImage } 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"
export const links = [
{ href: '/', name: 'Home' },
{ href: 'https://github.com/SrIzan10/stack', name: 'Github' },
{ href: '/protected', name: 'Protected route' }
]
function NavbarLinks() {
return (
<>
{links.map((link) => (
<Link key={link.href} href={link.href}>
<Button variant={'link'}>{link.name}</Button>
</Link>
))}
</>
);
}
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">
<Link href="/" className="hidden md:flex">
<Button>stack</Button>
</Link>
<MobileNavbarLinks />
<div className="hidden md:flex">
<NavbarLinks />
</div>
<div className="flex-1" />
<ThemeSwitcher />
{user ? (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer">
<Avatar>
<AvatarImage src={"https://srizan.dev/pfp.webp"} alt="@srizan" />
<AvatarFallback>SI</AvatarFallback>
</Avatar>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56">
<DropdownMenuLabel>My Account</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem className="cursor-pointer" onClick={() => {
logoutAction()
}}>
Sign out
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</>
) : (
<Link href="/auth/signIn">
<Button variant="outline">Sign in</Button>
</Link>
)}
</nav>
</>
);
}

View File

@@ -0,0 +1,18 @@
'use client'
import { Button, ButtonProps, buttonVariants } from "@/components/ui/button"
import { VariantProps } from "class-variance-authority"
import { useFormStatus } from "react-dom"
export default function SubmitButton(props: Props) {
const { pending } = useFormStatus()
return (
<Button type="submit" loading={pending} {...props}>
{props.buttonText}
</Button>
)
}
export interface Props extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
buttonText: string;
}

View File

@@ -0,0 +1,40 @@
"use client"
import * as React from "react"
import { Moon, Sun } from "lucide-react"
import { useTheme } from "next-themes"
import { Button } from "@/components/ui/button"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
export function ThemeSwitcher() {
const { setTheme } = useTheme()
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon">
<Sun className="h-[1.2rem] w-[1.2rem] rotate-0 scale-100 transition-all dark:-rotate-90 dark:scale-0" />
<Moon className="absolute h-[1.2rem] w-[1.2rem] rotate-90 scale-0 transition-all dark:rotate-0 dark:scale-100" />
<span className="sr-only">Toggle theme</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => setTheme("light")}>
Light
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("dark")}>
Dark
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setTheme("system")}>
System
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}

View File

@@ -0,0 +1,50 @@
"use client"
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,63 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import { LoaderCircle } from "lucide-react"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
defaultOutlineHover:
"border border-input bg-primary text-primary-foreground hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10"
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean;
loading?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, loading, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
disabled={loading}
ref={ref}
{...props}
>
{loading && <LoaderCircle className={cn('h-4 w-4 animate-spin', props.children && 'mr-2')} />}
{props.children}
</Comp>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,200 @@
"use client"
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium 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",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,26 @@
"use client"
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,31 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner"
type ToasterProps = React.ComponentProps<typeof Sonner>
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
},
}}
{...props}
/>
)
}
export { Toaster }

125
src/lib/auth/actions.ts Normal file
View File

@@ -0,0 +1,125 @@
"use server"
import { cookies } from "next/headers";
import { lucia, validateRequest } from ".";
import { redirect } from "next/navigation";
import prisma from "../db";
import { Argon2id } from "oslo/password";
import { generateId } from "lucia";
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/signIn");
}
export async function login(prev: any, data: FormData) {
const username = data.get("username");
if (
typeof username !== "string" ||
username.length < 3 ||
username.length > 31 ||
!/^[a-z0-9_-]+$/.test(username)
) {
return {
error: "Invalid username",
success: false,
};
}
const password = data.get("password");
if (typeof password !== "string" || password.length < 6 || password.length > 255) {
return {
error: "Invalid 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 new Argon2id().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("/");
}
export async function signup(prev: any, formData: FormData): Promise<ActionResult> {
"use server";
const username = formData.get("username");
console.log(username)
// username must be between 4 ~ 31 characters, and only consists of lowercase letters, 0-9, -, and _
// keep in mind some database (e.g. mysql) are case insensitive
if (
typeof username !== "string" ||
username.length < 3 ||
username.length > 31 ||
!/^[a-z0-9_-]+$/.test(username)
) {
return {
error: "Invalid username",
success: false,
};
}
if (await prisma.user.findUnique({ where: { username: username } })) {
return {
error: "Username is already taken",
success: false,
};
}
const password = formData.get("password");
if (typeof password !== "string" || password.length < 6 || password.length > 255) {
return {
error: "Invalid password",
success: false,
};
}
const hashedPassword = await new Argon2id().hash(password);
const userId = generateId(15);
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("/");
}
interface ActionResult {
error: string | null;
success: boolean | null;
}

71
src/lib/auth/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import { PrismaAdapter } from "@lucia-auth/adapter-prisma";
import { Lucia, Session, User } from "lucia";
import prisma from "../db";
import { cache } from "react";
import { cookies } from "next/headers";
const adapter = new PrismaAdapter(prisma.session, prisma.user);
export const lucia = new Lucia(adapter, {
sessionCookie: {
// this sets cookies with super long expiration
// since Next.js doesn't allow Lucia to extend cookie expiration when rendering pages
expires: false,
attributes: {
// set to `true` when using HTTPS
secure: process.env.NODE_ENV === "production"
}
},
getUserAttributes: (attributes) => {
return {
username: attributes.username
};
}
});
export const validateRequest = cache(async () => {
const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null
if (!sessionId)
return {
user: null,
session: null,
}
const { user, session } = await lucia.validateSession(sessionId)
try {
if (session && session.fresh) {
const sessionCookie = lucia.createSessionCookie(session.id)
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
)
}
if (!session) {
const sessionCookie = lucia.createBlankSessionCookie()
cookies().set(
sessionCookie.name,
sessionCookie.value,
sessionCookie.attributes
)
}
} catch {
// Next.js throws error attempting to set cookies when rendering page
}
return {
user,
session,
}
})
declare module "lucia" {
interface Register {
Lucia: typeof lucia;
DatabaseUserAttributes: DatabaseUserAttributes;
}
}
interface DatabaseUserAttributes {
username: string;
}

15
src/lib/db/index.ts Normal file
View File

@@ -0,0 +1,15 @@
import { PrismaClient } from '@prisma/client'
const prismaClientSingleton = () => {
return new PrismaClient()
}
declare global {
var prismaGlobal: undefined | ReturnType<typeof prismaClientSingleton>
}
const prisma = globalThis.prismaGlobal ?? prismaClientSingleton()
export default prisma
if (process.env.NODE_ENV !== 'production') globalThis.prismaGlobal = prisma

View File

@@ -0,0 +1,36 @@
// source: https://github.com/ugurkellecioglu/next-14-lucia-auth-postgresql-drizzle-typescript-example/blob/lucia-client-side/providers/Session.provider.tsx
"use client"
import { Session, User } from "lucia"
import { createContext, useContext } from "react"
interface SessionProviderProps {
user: User | null
session: Session | null
}
const SessionContext = createContext<SessionProviderProps>(
{} as SessionProviderProps
)
export const SessionProvider = ({
children,
value,
}: {
children: React.ReactNode
value: SessionProviderProps
}) => {
return (
<SessionContext.Provider value={value}>{children}</SessionContext.Provider>
)
}
export const useSession = () => {
const sessionContext = useContext(SessionContext)
if (!sessionContext) {
throw new Error("useSession must be used within a SessionProvider")
}
return sessionContext
}

View File

@@ -0,0 +1,9 @@
"use client"
import * as React from "react"
import { ThemeProvider as NextThemesProvider } from "next-themes"
import { type ThemeProviderProps } from "next-themes/dist/types"
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}