diff --git a/.env b/.env new file mode 100644 index 0000000..498ab17 --- /dev/null +++ b/.env @@ -0,0 +1 @@ +DATABASE_URL=postgres://postgres:postgres@localhost:5432/{DB_NAME} \ No newline at end of file diff --git a/bun.lockb b/bun.lockb new file mode 100755 index 0000000..bc0fcb0 Binary files /dev/null and b/bun.lockb differ diff --git a/components.json b/components.json new file mode 100644 index 0000000..33fb800 --- /dev/null +++ b/components.json @@ -0,0 +1,16 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "src/app/globals.css", + "baseColor": "neutral", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/drizzle.config.ts b/drizzle.config.ts new file mode 100644 index 0000000..2175e39 --- /dev/null +++ b/drizzle.config.ts @@ -0,0 +1,11 @@ +import type { Config } from "drizzle-kit"; +import { env } from "@/lib/env.mjs"; + +export default { + schema: "./src/lib/db/schema", + out: "./src/lib/db/migrations", + driver: "pg", + dbCredentials: { + connectionString: env.DATABASE_URL, + } +} satisfies Config; \ No newline at end of file diff --git a/kirimase.config.json b/kirimase.config.json new file mode 100644 index 0000000..c2baa9a --- /dev/null +++ b/kirimase.config.json @@ -0,0 +1,18 @@ +{ + "hasSrc": true, + "packages": [ + "shadcn-ui", + "drizzle", + "lucia" + ], + "preferredPackageManager": "bun", + "t3": false, + "alias": "@", + "analytics": true, + "rootPath": "src/", + "componentLib": "shadcn-ui", + "driver": "pg", + "provider": "postgresjs", + "orm": "drizzle", + "auth": "lucia" +} \ No newline at end of file diff --git a/next.config.mjs b/next.config.mjs index 4678774..558df26 100644 --- a/next.config.mjs +++ b/next.config.mjs @@ -1,4 +1,10 @@ /** @type {import('next').NextConfig} */ -const nextConfig = {}; +const nextConfig = { + webpack: (config) => { + config.externals.push("@node-rs/argon2", "@node-rs/bcrypt"); + return config; + }, +}; + export default nextConfig; diff --git a/package.json b/package.json index 233f80e..1385473 100644 --- a/package.json +++ b/package.json @@ -6,22 +6,54 @@ "dev": "next dev", "build": "next build", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "db:generate": "drizzle-kit generate:pg", + "db:migrate": "tsx src/lib/db/migrate.ts", + "db:drop": "drizzle-kit drop", + "db:pull": "drizzle-kit introspect:pg", + "db:studio": "drizzle-kit studio", + "db:check": "drizzle-kit check:pg" }, "dependencies": { + "@lucia-auth/adapter-drizzle": "^1.0.4", + "@node-rs/argon2": "^1.8.0", + "@node-rs/bcrypt": "^1.10.1", + "@radix-ui/react-avatar": "^1.0.4", + "@radix-ui/react-dropdown-menu": "^2.0.6", + "@radix-ui/react-label": "^2.0.2", + "@radix-ui/react-slot": "^1.0.2", + "@t3-oss/env-nextjs": "^0.9.2", + "class-variance-authority": "^0.7.0", + "clsx": "^2.1.0", + "drizzle-orm": "^0.30.2", + "drizzle-zod": "^0.5.1", + "lucia": "^3.1.1", + "lucide-react": "^0.358.0", + "nanoid": "^5.0.6", + "next": "14.1.3", + "next-themes": "^0.3.0", + "oslo": "^1.1.3", + "postgres": "^3.4.3", "react": "^18", "react-dom": "^18", - "next": "14.1.3" + "sonner": "^1.4.3", + "tailwind-merge": "^2.2.2", + "tailwindcss-animate": "^1.0.7", + "zod": "^3.22.4" }, "devDependencies": { - "typescript": "^5", "@types/node": "^20", "@types/react": "^18", "@types/react-dom": "^18", "autoprefixer": "^10.0.1", + "dotenv": "^16.4.5", + "drizzle-kit": "^0.20.14", + "eslint": "^8", + "eslint-config-next": "14.1.3", + "pg": "^8.11.3", "postcss": "^8", "tailwindcss": "^3.3.0", - "eslint": "^8", - "eslint-config-next": "14.1.3" + "tsx": "^4.7.1", + "typescript": "^5" } -} +} \ No newline at end of file diff --git a/src/app/(app)/account/AccountCard.tsx b/src/app/(app)/account/AccountCard.tsx new file mode 100644 index 0000000..2e37f10 --- /dev/null +++ b/src/app/(app)/account/AccountCard.tsx @@ -0,0 +1,45 @@ +import { Card } from "@/components/ui/card"; + +interface AccountCardProps { + params: { + header: string; + description: string; + price?: number; + }; + children: React.ReactNode; +} + +export function AccountCard({ params, children }: AccountCardProps) { + const { header, description } = params; + return ( + +
+

{header}

+

{description}

+
+ {children} +
+ ); +} + +export function AccountCardBody({ children }: { children: React.ReactNode }) { + return
{children}
; +} + +export function AccountCardFooter({ + description, + children, +}: { + children: React.ReactNode; + description: string; +}) { + return ( + + ); +} diff --git a/src/app/(app)/account/UpdateEmailCard.tsx b/src/app/(app)/account/UpdateEmailCard.tsx new file mode 100644 index 0000000..193b545 --- /dev/null +++ b/src/app/(app)/account/UpdateEmailCard.tsx @@ -0,0 +1,47 @@ +"use client"; + +import { useEffect } from "react"; +import { useFormState, useFormStatus } from "react-dom"; + +import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard"; +import { updateUser } from "@/lib/actions/users"; + +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +export default function UpdateEmailCard({ email }: { email: string }) { + const [state, formAction] = useFormState(updateUser, { + error: "", + }); + + useEffect(() => { + if (state.success == true) toast.success("Updated Email"); + if (state.error) toast.error("Error", { description: state.error }); + }, [state]); + + return ( + +
+ + + + + + +
+
+ ); +} + +const Submit = () => { + const { pending } = useFormStatus(); + return ; +}; + diff --git a/src/app/(app)/account/UpdateNameCard.tsx b/src/app/(app)/account/UpdateNameCard.tsx new file mode 100644 index 0000000..687962e --- /dev/null +++ b/src/app/(app)/account/UpdateNameCard.tsx @@ -0,0 +1,46 @@ +"use client"; + +import { useEffect } from "react"; +import { useFormState, useFormStatus } from "react-dom"; + +import { AccountCard, AccountCardFooter, AccountCardBody } from "./AccountCard"; +import { updateUser } from "@/lib/actions/users"; + +import { toast } from "sonner"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; + +export default function UpdateNameCard({ name }: { name: string }) { + const [state, formAction] = useFormState(updateUser, { + error: "", + }); + + useEffect(() => { + if (state.success == true) toast.success("Updated User"); + if (state.error) toast.error("Error", { description: state.error }); + }, [state]); + + return ( + +
+ + + + + + +
+
+ ); +} + +const Submit = () => { + const { pending } = useFormStatus(); + return ; +}; diff --git a/src/app/(app)/account/UserSettings.tsx b/src/app/(app)/account/UserSettings.tsx new file mode 100644 index 0000000..9d38a40 --- /dev/null +++ b/src/app/(app)/account/UserSettings.tsx @@ -0,0 +1,17 @@ +"use client"; +import UpdateNameCard from "./UpdateNameCard"; +import UpdateEmailCard from "./UpdateEmailCard"; +import { AuthSession } from "@/lib/auth/utils"; + +export default function UserSettings({ + session, +}: { + session: AuthSession["session"]; +}) { + return ( + <> + + + + ); +} diff --git a/src/app/(app)/account/page.tsx b/src/app/(app)/account/page.tsx new file mode 100644 index 0000000..0588287 --- /dev/null +++ b/src/app/(app)/account/page.tsx @@ -0,0 +1,16 @@ +import UserSettings from "./UserSettings"; +import { checkAuth, getUserAuth } from "@/lib/auth/utils"; + +export default async function Account() { + await checkAuth(); + const { session } = await getUserAuth(); + + return ( +
+

Account

+
+ +
+
+ ); +} diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx new file mode 100644 index 0000000..2db90a4 --- /dev/null +++ b/src/app/(app)/dashboard/page.tsx @@ -0,0 +1,15 @@ +import SignOutBtn from "@/components/auth/SignOutBtn"; +import { getUserAuth } from "@/lib/auth/utils"; + +export default async function Home() { + const { session } = await getUserAuth(); + return ( +
+

Profile

+
+        {JSON.stringify(session, null, 2)}
+      
+ +
+ ); +} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx new file mode 100644 index 0000000..b5d0ce8 --- /dev/null +++ b/src/app/(app)/layout.tsx @@ -0,0 +1,20 @@ +import { checkAuth } from "@/lib/auth/utils"; +import { Toaster } from "@/components/ui/sonner"; +import Navbar from "@/components/Navbar"; +import Sidebar from "@/components/Sidebar"; +export default async function AppLayout({ + children, +}: { + children: React.ReactNode; +}) { + await checkAuth(); + return (
+ +
+ +{children} +
+
+ +
) +} \ No newline at end of file diff --git a/src/app/(app)/settings/page.tsx b/src/app/(app)/settings/page.tsx new file mode 100644 index 0000000..a801d9e --- /dev/null +++ b/src/app/(app)/settings/page.tsx @@ -0,0 +1,106 @@ +"use client"; + +import { Button } from "@/components/ui/button"; +import { useTheme } from "next-themes"; + +export default function Page() { + const { setTheme } = useTheme(); + return ( +
+

Settings

+
+
+

Appearance

+

+ Customize the appearance of the app. Automatically switch between + day and night themes. +

+
+ + + +
+
+ ); +} diff --git a/src/app/(auth)/layout.tsx b/src/app/(auth)/layout.tsx new file mode 100644 index 0000000..1feed78 --- /dev/null +++ b/src/app/(auth)/layout.tsx @@ -0,0 +1,13 @@ +import { getUserAuth } from "@/lib/auth/utils"; +import { redirect } from "next/navigation"; + +export default async function AuthLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getUserAuth(); + if (session?.session) redirect("/dashboard"); + + return (
{children}
); +} diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx new file mode 100644 index 0000000..ad15a22 --- /dev/null +++ b/src/app/(auth)/sign-in/page.tsx @@ -0,0 +1,58 @@ +"use client"; + +import Link from "next/link"; +import { useFormState } from "react-dom"; +import { useFormStatus } from "react-dom"; + +import { signInAction } from "@/lib/actions/users"; + +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import AuthFormError from "@/components/auth/AuthFormError"; + +export default function SignInPage() { + const [state, formAction] = useFormState(signInAction, { + error: "", + }); + + return ( +
+

+ Sign in to your account +

+ +
+ + +
+ + +
+ + +
+ Don't have an account yet?{" "} + + Create an account + +
+
+ ); +} + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ( + + ); +}; \ No newline at end of file diff --git a/src/app/(auth)/sign-up/page.tsx b/src/app/(auth)/sign-up/page.tsx new file mode 100644 index 0000000..0ea7ce1 --- /dev/null +++ b/src/app/(auth)/sign-up/page.tsx @@ -0,0 +1,54 @@ +"use client"; + +import Link from "next/link"; +import { useFormState } from "react-dom"; +import { useFormStatus } from "react-dom"; + +import { signUpAction } from "@/lib/actions/users"; + +import { Label } from "@/components/ui/label"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import AuthFormError from "@/components/auth/AuthFormError"; + + +export default function SignUpPage() { + const [state, formAction] = useFormState(signUpAction, { + error: "", + }); + + return ( +
+

Create an account

+ +
+ + +
+ + +
+ + +
+ Already have an account?{" "} + + Sign in + +
+
+ ); +} + +const SubmitButton = () => { + const { pending } = useFormStatus(); + return ( + + ); +}; diff --git a/src/app/api/account/route.ts b/src/app/api/account/route.ts new file mode 100644 index 0000000..cce581a --- /dev/null +++ b/src/app/api/account/route.ts @@ -0,0 +1,15 @@ +import { getUserAuth } from "@/lib/auth/utils"; +import { db } from "@/lib/db/index"; +import { users } from "@/lib/db/schema/auth"; +import { eq } from "drizzle-orm"; +import { revalidatePath } from "next/cache"; + +export async function PUT(request: Request) { + const { session } = await getUserAuth(); + if (!session) return new Response("Error", { status: 400 }); + const body = (await request.json()) as { name?: string; email?: string }; + + await db.update(users).set({ ...body }).where(eq(users.id, session.user.id)); + revalidatePath("/account"); + return new Response(JSON.stringify({ message: "ok" }), { status: 200 }); +} diff --git a/src/app/globals.css b/src/app/globals.css index 875c01e..77d3522 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,33 +1,76 @@ @tailwind base; @tailwind components; @tailwind utilities; + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 0 0% 3.9%; -:root { - --foreground-rgb: 0, 0, 0; - --background-start-rgb: 214, 219, 220; - --background-end-rgb: 255, 255, 255; -} + --card: 0 0% 100%; + --card-foreground: 0 0% 3.9%; + + --popover: 0 0% 100%; + --popover-foreground: 0 0% 3.9%; + + --primary: 0 0% 9%; + --primary-foreground: 0 0% 98%; + + --secondary: 0 0% 96.1%; + --secondary-foreground: 0 0% 9%; + + --muted: 0 0% 96.1%; + --muted-foreground: 0 0% 45.1%; + + --accent: 0 0% 96.1%; + --accent-foreground: 0 0% 9%; + + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 0 0% 98%; -@media (prefers-color-scheme: dark) { - :root { - --foreground-rgb: 255, 255, 255; - --background-start-rgb: 0, 0, 0; - --background-end-rgb: 0, 0, 0; + --border: 0 0% 89.8%; + --input: 0 0% 89.8%; + --ring: 0 0% 3.9%; + + --radius: 0.5rem; + } + + .dark { + --background: 0 0% 3.9%; + --foreground: 0 0% 98%; + + --card: 0 0% 3.9%; + --card-foreground: 0 0% 98%; + + --popover: 0 0% 3.9%; + --popover-foreground: 0 0% 98%; + + --primary: 0 0% 98%; + --primary-foreground: 0 0% 9%; + + --secondary: 0 0% 14.9%; + --secondary-foreground: 0 0% 98%; + + --muted: 0 0% 14.9%; + --muted-foreground: 0 0% 63.9%; + + --accent: 0 0% 14.9%; + --accent-foreground: 0 0% 98%; + + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 0 0% 98%; + + --border: 0 0% 14.9%; + --input: 0 0% 14.9%; + --ring: 0 0% 83.1%; } } - -body { - color: rgb(var(--foreground-rgb)); - background: linear-gradient( - to bottom, - transparent, - rgb(var(--background-end-rgb)) - ) - rgb(var(--background-start-rgb)); -} - -@layer utilities { - .text-balance { - text-wrap: balance; + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; } } diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 3314e47..03f7b34 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { ThemeProvider } from "@/components/ThemeProvider"; const inter = Inter({ subsets: ["latin"] }); @@ -16,7 +17,9 @@ export default function RootLayout({ }>) { return ( - {children} + +{children} + ); } diff --git a/src/app/loading.tsx b/src/app/loading.tsx new file mode 100644 index 0000000..5df6350 --- /dev/null +++ b/src/app/loading.tsx @@ -0,0 +1,25 @@ +export default function Loading() { + return ( +
+
+ + Loading... +
+
+ ); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index b81507d..0a69b41 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,113 +1,183 @@ -import Image from "next/image"; +/** + * v0 by Vercel. + * @see https://v0.dev/t/PmwTvNfrVgf + * Documentation: https://v0.dev/docs#integrating-generated-code-into-your-nextjs-app + */ +import Link from "next/link"; -export default function Home() { +export default function LandingPage() { return ( -
-
-

- Get started by editing  - src/app/page.tsx -

-
- +
+ + + Acme Inc + +
-
- -
- Next.js Logo -
- -
- -

- Docs{" "} - - -> - -

-

- Find in-depth information about Next.js features and API. -

-
- - -

- Learn{" "} - - -> - -

-

- Learn about Next.js in an interactive course with quizzes! -

-
+ Features + + + Sign In + + + +
+
+
+
+
+
+
+

+ The complete platform
+ for building the Web +

+

+ Give your team the toolkit to stop configuring and start + innovating. Securely build, deploy, and scale the best web + experiences. +

+
+
+ + Get Started + + + Contact Sales + +
+
+
+
+
+
+
+
+
+
+ Key Features +
+

+ Faster iteration. More innovation. +

+

+ The platform for rapid progress. Let your team focus on + shipping features instead of managing infrastructure with + automated CI/CD. +

+
+
+
+
+
+
    +
  • +
    +

    Collaboration

    +

    + Make collaboration seamless with built-in code review + tools. +

    +
    +
  • +
  • +
    +

    Automation

    +

    + Automate your workflow with continuous integration. +

    +
    +
  • +
  • +
    +

    Scale

    +

    + Deploy to the cloud with a single click and scale with + ease. +

    +
    +
  • +
+
+
+
+
- -

- Templates{" "} - - -> - -

-

- Explore starter templates for Next.js. -

-
+
+
+
+
+

+ Sign Up for Updates +

+

+ Stay updated with the latest product news and updates. +

+
+
+
+ + +
+
+
+
+
+
+
+

+ © 2024 Acme Inc. All rights reserved. +

+ +
+
+ ); +} - -

- Deploy{" "} - - -> - -

-

- Instantly deploy your Next.js site to a shareable URL with Vercel. -

-
- -
+function MountainIcon(props: any) { + return ( + + + ); } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx new file mode 100644 index 0000000..26b5257 --- /dev/null +++ b/src/components/Navbar.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; +import { usePathname } from "next/navigation"; + +import { Button } from "@/components/ui/button"; + +import { AlignRight } from "lucide-react"; +import { defaultLinks } from "@/config/nav"; + +export default function Navbar() { + const [open, setOpen] = useState(false); + const pathname = usePathname(); + return ( +
+ + {open ? ( +
+
    + {defaultLinks.map((link) => ( +
  • setOpen(false)} className=""> + + {link.title} + +
  • + ))} +
+
+ ) : null} +
+ ); +} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx new file mode 100644 index 0000000..8d1d63f --- /dev/null +++ b/src/components/Sidebar.tsx @@ -0,0 +1,55 @@ +import Link from "next/link"; + +import SidebarItems from "./SidebarItems"; +import { Avatar, AvatarFallback } from "./ui/avatar"; + +import { AuthSession, getUserAuth } from "@/lib/auth/utils"; + +const Sidebar = async () => { + const session = await getUserAuth(); + if (session.session === null) return null; + + return ( + + ); +}; + +export default Sidebar; + +const UserDetails = ({ session }: { session: AuthSession }) => { + if (session.session === null) return null; + const { user } = session.session; + + if (!user?.name || user.name.length == 0) return null; + + return ( + +
+
+

{user.name ?? "John Doe"}

+

+ {user.email ?? "john@doe.com"} +

+
+ + + {user.name + ? user.name + ?.split(" ") + .map((word) => word[0].toUpperCase()) + .join("") + : "~"} + + +
+ + ); +}; diff --git a/src/components/SidebarItems.tsx b/src/components/SidebarItems.tsx new file mode 100644 index 0000000..7dea48b --- /dev/null +++ b/src/components/SidebarItems.tsx @@ -0,0 +1,91 @@ +"use client"; + +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +import { LucideIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; +import { defaultLinks, additionalLinks } from "@/config/nav"; + +export interface SidebarLink { + title: string; + href: string; + icon: LucideIcon; +} + +const SidebarItems = () => { + return ( + <> + + {additionalLinks.length > 0 + ? additionalLinks.map((l) => ( + + )) + : null} + + ); +}; +export default SidebarItems; + +const SidebarLinkGroup = ({ + links, + title, + border, +}: { + links: SidebarLink[]; + title?: string; + border?: boolean; +}) => { + const fullPathname = usePathname(); + const pathname = "/" + fullPathname.split("/")[1]; + + return ( +
+ {title ? ( +

+ {title} +

+ ) : null} +
    + {links.map((link) => ( +
  • + +
  • + ))} +
+
+ ); +}; +const SidebarLink = ({ + link, + active, +}: { + link: SidebarLink; + active: boolean; +}) => { + return ( + +
+
+ + {link.title} +
+ + ); +}; diff --git a/src/components/ThemeProvider.tsx b/src/components/ThemeProvider.tsx new file mode 100644 index 0000000..b0ff266 --- /dev/null +++ b/src/components/ThemeProvider.tsx @@ -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 {children}; +} diff --git a/src/components/auth/AuthFormError.tsx b/src/components/auth/AuthFormError.tsx new file mode 100644 index 0000000..afc706e --- /dev/null +++ b/src/components/auth/AuthFormError.tsx @@ -0,0 +1,10 @@ +export default function AuthFormError({ state }: { state: { error: string } }) { + if (state.error) + return ( +
+

Error

+

{state.error}

+
+ ); + return null; +} diff --git a/src/components/auth/SignOutBtn.tsx b/src/components/auth/SignOutBtn.tsx new file mode 100644 index 0000000..ba0d225 --- /dev/null +++ b/src/components/auth/SignOutBtn.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { Button } from "../ui/button"; +import { useFormStatus } from "react-dom"; +import { signOutAction } from "@/lib/actions/users"; + +export default function SignOutBtn() { + return ( +
+ + + ); +} + +const Btn = () => { + const { pending } = useFormStatus(); + return ( + + ); +}; diff --git a/src/components/ui/ThemeToggle.tsx b/src/components/ui/ThemeToggle.tsx new file mode 100644 index 0000000..63ff415 --- /dev/null +++ b/src/components/ui/ThemeToggle.tsx @@ -0,0 +1,40 @@ +"use client"; + +import * as React from "react"; +import { MoonIcon, SunIcon } 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 ModeToggle() { + const { setTheme } = useTheme(); + + return ( + + + + + + setTheme("light")}> + Light + + setTheme("dark")}> + Dark + + setTheme("system")}> + System + + + + ); +} diff --git a/src/components/ui/avatar.tsx b/src/components/ui/avatar.tsx new file mode 100644 index 0000000..51e507b --- /dev/null +++ b/src/components/ui/avatar.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Avatar.displayName = AvatarPrimitive.Root.displayName + +const AvatarImage = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarImage.displayName = AvatarPrimitive.Image.displayName + +const AvatarFallback = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName + +export { Avatar, AvatarImage, AvatarFallback } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx new file mode 100644 index 0000000..0ba4277 --- /dev/null +++ b/src/components/ui/button.tsx @@ -0,0 +1,56 @@ +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" + +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", + 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, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : "button" + return ( + + ) + } +) +Button.displayName = "Button" + +export { Button, buttonVariants } diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/src/components/ui/card.tsx @@ -0,0 +1,79 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +const Card = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +Card.displayName = "Card" + +const CardHeader = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardHeader.displayName = "CardHeader" + +const CardTitle = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardTitle.displayName = "CardTitle" + +const CardDescription = React.forwardRef< + HTMLParagraphElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardDescription.displayName = "CardDescription" + +const CardContent = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +

+)) +CardContent.displayName = "CardContent" + +const CardFooter = React.forwardRef< + HTMLDivElement, + React.HTMLAttributes +>(({ className, ...props }, ref) => ( +
+)) +CardFooter.displayName = "CardFooter" + +export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent } diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..f69a0d6 --- /dev/null +++ b/src/components/ui/dropdown-menu.tsx @@ -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, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, children, ...props }, ref) => ( + + {children} + + +)) +DropdownMenuSubTrigger.displayName = + DropdownMenuPrimitive.SubTrigger.displayName + +const DropdownMenuSubContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSubContent.displayName = + DropdownMenuPrimitive.SubContent.displayName + +const DropdownMenuContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, sideOffset = 4, ...props }, ref) => ( + + + +)) +DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName + +const DropdownMenuItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName + +const DropdownMenuCheckboxItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, checked, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuCheckboxItem.displayName = + DropdownMenuPrimitive.CheckboxItem.displayName + +const DropdownMenuRadioItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + + + + {children} + +)) +DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName + +const DropdownMenuLabel = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & { + inset?: boolean + } +>(({ className, inset, ...props }, ref) => ( + +)) +DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName + +const DropdownMenuSeparator = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName + +const DropdownMenuShortcut = ({ + className, + ...props +}: React.HTMLAttributes) => { + return ( + + ) +} +DropdownMenuShortcut.displayName = "DropdownMenuShortcut" + +export { + DropdownMenu, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuGroup, + DropdownMenuPortal, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuRadioGroup, +} diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx new file mode 100644 index 0000000..677d05f --- /dev/null +++ b/src/components/ui/input.tsx @@ -0,0 +1,25 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +export interface InputProps + extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = "Input" + +export { Input } diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/src/components/ui/label.tsx @@ -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, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/src/components/ui/sonner.tsx b/src/components/ui/sonner.tsx new file mode 100644 index 0000000..452f4d9 --- /dev/null +++ b/src/components/ui/sonner.tsx @@ -0,0 +1,31 @@ +"use client" + +import { useTheme } from "next-themes" +import { Toaster as Sonner } from "sonner" + +type ToasterProps = React.ComponentProps + +const Toaster = ({ ...props }: ToasterProps) => { + const { theme = "system" } = useTheme() + + return ( + + ) +} + +export { Toaster } diff --git a/src/config/nav.ts b/src/config/nav.ts new file mode 100644 index 0000000..e909bcf --- /dev/null +++ b/src/config/nav.ts @@ -0,0 +1,15 @@ +import { SidebarLink } from "@/components/SidebarItems"; +import { Cog, Globe, HomeIcon } from "lucide-react"; + +type AdditionalLinks = { + title: string; + links: SidebarLink[]; +}; + +export const defaultLinks: SidebarLink[] = [ + { href: "/dashboard", title: "Home", icon: HomeIcon }, + { href: "/account", title: "Account", icon: Cog }, + { href: "/settings", title: "Settings", icon: Cog }, +]; + +export const additionalLinks: AdditionalLinks[] = []; diff --git a/src/lib/actions/users.ts b/src/lib/actions/users.ts new file mode 100644 index 0000000..af2a4da --- /dev/null +++ b/src/lib/actions/users.ts @@ -0,0 +1,134 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; + +import { Argon2id } from "oslo/password"; +import { lucia, validateRequest } from "../auth/lucia"; +import { generateId } from "lucia"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db/index"; + +import { + genericError, + setAuthCookie, + validateAuthFormData, + getUserAuth, +} from "../auth/utils"; +import { users, updateUserSchema } from "../db/schema/auth"; + +interface ActionResult { + error: string; +} + +export async function signInAction( + _: ActionResult, + formData: FormData, +): Promise { + const { data, error } = validateAuthFormData(formData); + if (error !== null) return { error }; + + try { + const [existingUser] = await db + .select() + .from(users) + .where(eq(users.email, data.email.toLowerCase())); + if (!existingUser) { + return { + error: "Incorrect username or password", + }; + } + + const validPassword = await new Argon2id().verify( + existingUser.hashedPassword, + data.password, + ); + if (!validPassword) { + return { + error: "Incorrect username or password", + }; + } + + const session = await lucia.createSession(existingUser.id, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + setAuthCookie(sessionCookie); + + return redirect("/dashboard"); + } catch (e) { + return genericError; + } +} + +export async function signUpAction( + _: ActionResult, + formData: FormData, +): Promise { + const { data, error } = validateAuthFormData(formData); + + if (error !== null) return { error }; + + const hashedPassword = await new Argon2id().hash(data.password); + const userId = generateId(15); + + try { + await db.insert(users).values({ + id: userId, + email: data.email, + hashedPassword, + }); + } catch (e) { + return genericError; + } + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + setAuthCookie(sessionCookie); + return redirect("/dashboard"); +} + +export async function signOutAction(): Promise { + const { session } = await validateRequest(); + if (!session) { + return { + error: "Unauthorized", + }; + } + + await lucia.invalidateSession(session.id); + + const sessionCookie = lucia.createBlankSessionCookie(); + setAuthCookie(sessionCookie); + redirect("/sign-in"); +} + +export async function updateUser( + _: any, + formData: FormData, +): Promise { + const { session } = await getUserAuth(); + if (!session) return { error: "Unauthorised" }; + + const name = formData.get("name") ?? undefined; + const email = formData.get("email") ?? undefined; + + const result = updateUserSchema.safeParse({ name, email }); + + if (!result.success) { + const error = result.error.flatten().fieldErrors; + if (error.name) return { error: "Invalid name - " + error.name[0] }; + if (error.email) return { error: "Invalid email - " + error.email[0] }; + return genericError; + } + + try { + await db + .update(users) + .set({ ...result.data }) + .where(eq(users.id, session.user.id)); + revalidatePath("/account"); + return { success: true, error: "" }; + } catch (e) { + return genericError; + } +} + diff --git a/src/lib/auth/lucia.ts b/src/lib/auth/lucia.ts new file mode 100644 index 0000000..f37c6db --- /dev/null +++ b/src/lib/auth/lucia.ts @@ -0,0 +1,75 @@ +import { cookies } from 'next/headers' +import { cache } from 'react' + +import { type Session, type User, Lucia } from 'lucia' +import { db } from "@/lib/db/index"; + +import { DrizzlePostgreSQLAdapter } from "@lucia-auth/adapter-drizzle"; +import { sessions, users } from "../db/schema/auth"; + + +export const adapter = new DrizzlePostgreSQLAdapter(db, sessions, users); + +export const lucia = new Lucia(adapter, { + sessionCookie: { + expires: false, + attributes: { + secure: process.env.NODE_ENV === 'production', + }, + }, + getUserAttributes: (attributes) => { + return { + // attributes has the type of DatabaseUserAttributes + email: attributes.email, + name: attributes.name, + } + }, +}) + +declare module 'lucia' { + interface Register { + Lucia: typeof lucia + DatabaseUserAttributes: DatabaseUserAttributes + } +} + +interface DatabaseUserAttributes { + email: string + name: string; +} + +export const validateRequest = cache( + async (): Promise< + { user: User; session: Session } | { user: null; session: null } + > => { + const sessionId = cookies().get(lucia.sessionCookieName)?.value ?? null + if (!sessionId) { + return { + user: null, + session: null, + } + } + + const result = await lucia.validateSession(sessionId) + // next.js throws when you attempt to set cookie when rendering page + try { + if (result.session && result.session.fresh) { + const sessionCookie = lucia.createSessionCookie(result.session.id) + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ) + } + if (!result.session) { + const sessionCookie = lucia.createBlankSessionCookie() + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes + ) + } + } catch {} + return result + } +) diff --git a/src/lib/auth/utils.ts b/src/lib/auth/utils.ts new file mode 100644 index 0000000..96d3206 --- /dev/null +++ b/src/lib/auth/utils.ts @@ -0,0 +1,69 @@ +import { redirect } from 'next/navigation' +import { cookies } from 'next/headers' + +import { type Cookie } from 'lucia' + +import { validateRequest } from './lucia' +import { UsernameAndPassword, authenticationSchema } from '../db/schema/auth' + +export type AuthSession = { + session: { + user: { + id: string + name?: string + email?: string + username?: string + } + } | null +} +export const getUserAuth = async (): Promise => { + const { session, user } = await validateRequest() + if (!session) return { session: null } + return { + session: { + user: { + id: user.id, + email: user.email, + name: user.name, + }, + }, + } +} + +export const checkAuth = async () => { + const { session } = await validateRequest() + if (!session) redirect('/sign-in') +} + +export const genericError = { error: 'Error, please try again.' } + +export const setAuthCookie = (cookie: Cookie) => { + // cookies().set(cookie.name, cookie.value, cookie.attributes); // <- suggested approach from the docs, but does not work with `next build` locally + cookies().set(cookie); +} + +const getErrorMessage = (errors: any): string => { + if (errors.email) return 'Invalid Email' + if (errors.password) return 'Invalid Password - ' + errors.password[0] + return '' // return a default error message or an empty string +} + +export const validateAuthFormData = ( + formData: FormData +): + | { data: UsernameAndPassword; error: null } + | { data: null; error: string } => { + const email = formData.get('email') + const password = formData.get('password') + const result = authenticationSchema.safeParse({ email, password }) + + if (!result.success) { + return { + data: null, + error: getErrorMessage(result.error.flatten().fieldErrors), + } + } + + return { data: result.data, error: null } +} + diff --git a/src/lib/db/index.ts b/src/lib/db/index.ts new file mode 100644 index 0000000..d0c05ff --- /dev/null +++ b/src/lib/db/index.ts @@ -0,0 +1,6 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import { env } from "@/lib/env.mjs"; + +export const client = postgres(env.DATABASE_URL); +export const db = drizzle(client); \ No newline at end of file diff --git a/src/lib/db/migrate.ts b/src/lib/db/migrate.ts new file mode 100644 index 0000000..03b3bb6 --- /dev/null +++ b/src/lib/db/migrate.ts @@ -0,0 +1,36 @@ +import { env } from "@/lib/env.mjs"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import { migrate } from "drizzle-orm/postgres-js/migrator"; +import postgres from "postgres"; + + +const runMigrate = async () => { + if (!env.DATABASE_URL) { + throw new Error("DATABASE_URL is not defined"); + } + + +const connection = postgres(env.DATABASE_URL, { max: 1 }); + +const db = drizzle(connection); + + + console.log("⏳ Running migrations..."); + + const start = Date.now(); + + await migrate(db, { migrationsFolder: 'src/lib/db/migrations' }); + + const end = Date.now(); + + console.log("✅ Migrations completed in", end - start, "ms"); + + process.exit(0); +}; + +runMigrate().catch((err) => { + console.error("❌ Migration failed"); + console.error(err); + process.exit(1); +}); \ No newline at end of file diff --git a/src/lib/db/schema/auth.ts b/src/lib/db/schema/auth.ts new file mode 100644 index 0000000..5654a96 --- /dev/null +++ b/src/lib/db/schema/auth.ts @@ -0,0 +1,36 @@ +import { z } from "zod"; +import { pgTable, timestamp, text } from "drizzle-orm/pg-core"; + +export const users = pgTable("user", { + id: text("id").primaryKey(), + email: text("email").notNull().unique(), + hashedPassword: text("hashed_password").notNull(), + name: text("name"), +}); + +export const sessions = pgTable("session", { + id: text("id").primaryKey(), + userId: text("user_id") + .notNull() + .references(() => users.id), + expiresAt: timestamp("expires_at", { + withTimezone: true, + mode: "date" + }).notNull() +}); + + +export const authenticationSchema = z.object({ + email: z.string().email().min(5).max(31), + password: z + .string() + .min(4, { message: "must be at least 4 characters long" }) + .max(15, { message: "cannot be more than 15 characters long" }), +}); + +export const updateUserSchema = z.object({ + name: z.string().min(3).optional(), + email: z.string().min(4).optional(), +}); + +export type UsernameAndPassword = z.infer; diff --git a/src/lib/env.mjs b/src/lib/env.mjs new file mode 100644 index 0000000..6a0a110 --- /dev/null +++ b/src/lib/env.mjs @@ -0,0 +1,24 @@ +import { createEnv } from "@t3-oss/env-nextjs"; +import { z } from "zod"; + +export const env = createEnv({ + server: { + NODE_ENV: z + .enum(["development", "test", "production"]) + .default("development"), + DATABASE_URL: z.string().min(1), + + }, + client: { + // NEXT_PUBLIC_PUBLISHABLE_KEY: z.string().min(1), + }, + // If you're using Next.js < 13.4.4, you'll need to specify the runtimeEnv manually + // runtimeEnv: { + // DATABASE_URL: process.env.DATABASE_URL, + // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY, + // }, + // For Next.js >= 13.4.4, you only need to destructure client variables: + experimental__runtimeEnv: { + // NEXT_PUBLIC_PUBLISHABLE_KEY: process.env.NEXT_PUBLIC_PUBLISHABLE_KEY, + }, +}); diff --git a/src/lib/utils.ts b/src/lib/utils.ts new file mode 100644 index 0000000..199deca --- /dev/null +++ b/src/lib/utils.ts @@ -0,0 +1,9 @@ +import { customAlphabet } from "nanoid"; +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +export const nanoid = customAlphabet("abcdefghijklmnopqrstuvwxyz0123456789"); diff --git a/tailwind.config.ts b/tailwind.config.ts index e9a0944..061375e 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -1,20 +1,76 @@ -import type { Config } from "tailwindcss"; +const { fontFamily } = require("tailwindcss/defaultTheme") -const config: Config = { - content: [ - "./src/pages/**/*.{js,ts,jsx,tsx,mdx}", - "./src/components/**/*.{js,ts,jsx,tsx,mdx}", - "./src/app/**/*.{js,ts,jsx,tsx,mdx}", - ], +/** @type {import('tailwindcss').Config} */ +module.exports = { + darkMode: ["class"], + content: ["src/app/**/*.{ts,tsx}", "src/components/**/*.{ts,tsx}"], theme: { + container: { + center: true, + padding: "2rem", + screens: { + "2xl": "1400px", + }, + }, extend: { - backgroundImage: { - "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", - "gradient-conic": - "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + colors: { + border: "hsl(var(--border))", + input: "hsl(var(--input))", + ring: "hsl(var(--ring))", + background: "hsl(var(--background))", + foreground: "hsl(var(--foreground))", + primary: { + DEFAULT: "hsl(var(--primary))", + foreground: "hsl(var(--primary-foreground))", + }, + secondary: { + DEFAULT: "hsl(var(--secondary))", + foreground: "hsl(var(--secondary-foreground))", + }, + destructive: { + DEFAULT: "hsl(var(--destructive))", + foreground: "hsl(var(--destructive-foreground))", + }, + muted: { + DEFAULT: "hsl(var(--muted))", + foreground: "hsl(var(--muted-foreground))", + }, + accent: { + DEFAULT: "hsl(var(--accent))", + foreground: "hsl(var(--accent-foreground))", + }, + popover: { + DEFAULT: "hsl(var(--popover))", + foreground: "hsl(var(--popover-foreground))", + }, + card: { + DEFAULT: "hsl(var(--card))", + foreground: "hsl(var(--card-foreground))", + }, + }, + borderRadius: { + lg: `var(--radius)`, + md: `calc(var(--radius) - 2px)`, + sm: "calc(var(--radius) - 4px)", + }, + fontFamily: { + sans: ["var(--font-sans)", ...fontFamily.sans], + }, + keyframes: { + "accordion-down": { + from: { height: 0 }, + to: { height: "var(--radix-accordion-content-height)" }, + }, + "accordion-up": { + from: { height: "var(--radix-accordion-content-height)" }, + to: { height: 0 }, + }, + }, + animation: { + "accordion-down": "accordion-down 0.2s ease-out", + "accordion-up": "accordion-up 0.2s ease-out", }, }, }, - plugins: [], -}; -export default config; + plugins: [require("tailwindcss-animate")], +} diff --git a/tsconfig.json b/tsconfig.json index 7b28589..c5a7b07 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,10 @@ { "compilerOptions": { - "lib": ["dom", "dom.iterable", "esnext"], + "lib": [ + "dom", + "dom.iterable", + "esnext" + ], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -18,9 +22,20 @@ } ], "paths": { - "@/*": ["./src/*"] - } + "@/*": [ + "./src/*" + ] + }, + "target": "esnext", + "baseUrl": "./" }, - "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], - "exclude": ["node_modules"] -} + "include": [ + "next-env.d.ts", + "**/*.ts", + "**/*.tsx", + ".next/types/**/*.ts" + ], + "exclude": [ + "node_modules" + ] +} \ No newline at end of file