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 (
+
+ );
+}
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
-
-
-
-
-
-
-
-
-
+ );
+}
-
-
- 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 (
+
+
+
+ );
+};
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