diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e09e65 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +NEXT_PUBLIC_API_BASE_URL= \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5ef6a52..7b8da95 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ yarn-error.log* # env files (can opt-in for committing if needed) .env* +!.env.example # vercel .vercel diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx new file mode 100644 index 0000000..7944d2f --- /dev/null +++ b/app/dashboard/page.tsx @@ -0,0 +1,44 @@ +"use client"; + +import { useAuth } from "@/context/AuthContext"; +import ProtectedRoute from "@/components/ProtectedRoute"; + +export default function DashboardPage() { + const { authState, logout } = useAuth(); + + return ( + +
+ +
+
+
+

+ Your protected dashboard content goes here +

+
+
+
+
+
+ ); +} diff --git a/app/layout.tsx b/app/layout.tsx index e237c03..ec4bb3e 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import { AuthProvider } from "@/context/AuthContext"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "NextJS template", - description: "With typescript, tailwindCSS and shadcnUI", + title: "ExamJam App", + description: "Authentication system for ExamJam", }; export default function RootLayout({ @@ -27,7 +28,7 @@ export default function RootLayout({ - {children} + {children} ); diff --git a/app/login/page.tsx b/app/login/page.tsx new file mode 100644 index 0000000..5efe679 --- /dev/null +++ b/app/login/page.tsx @@ -0,0 +1,11 @@ +import { LoginForm } from "@/components/login-form"; + +export default function Page() { + return ( +
+
+ +
+
+ ); +} diff --git a/app/page.tsx b/app/page.tsx index a9b078e..a74cb27 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,31 +1,5 @@ -import Image from "next/image"; +import { redirect } from "next/navigation"; export default function Home() { - return ( -
-
- Next.js logo{" "} - template with typescript, tailwindCSS and shadcnUI -
    -
  1. - Get started by editing{" "} - - app/page.tsx - - . -
  2. -
  3. - Save and see your changes instantly. -
  4. -
-
-
- ); + redirect("/dashboard"); } diff --git a/components/ProtectedRoute.tsx b/components/ProtectedRoute.tsx new file mode 100644 index 0000000..87cc62a --- /dev/null +++ b/components/ProtectedRoute.tsx @@ -0,0 +1,27 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect } from "react"; +import { useAuth } from "@/context/AuthContext"; +import LoadingSpinner from "./LoadingSpinner"; + +export default function ProtectedRoute({ + children, +}: { + children: React.ReactNode; +}) { + const { authState } = useAuth(); + const router = useRouter(); + + useEffect(() => { + if (!authState.isLoading && !authState.isAuthenticated) { + router.push("/login"); + } + }, [authState.isAuthenticated, authState.isLoading, router]); + + if (authState.isLoading) { + return ; + } + + return authState.isAuthenticated ? children : null; +} diff --git a/components/login-form.tsx b/components/login-form.tsx new file mode 100644 index 0000000..37ea1c5 --- /dev/null +++ b/components/login-form.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useState } from "react"; +import Link from "next/link"; +import { useAuth } from "@/context/AuthContext"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; +import { Terminal, CheckCircle } from "lucide-react"; +import Image from "next/image"; + +import Logo from "../public/logo.png"; + +export function LoginForm({ + className, + ...props +}: React.ComponentProps<"div">) { + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [successMessage, setSuccessMessage] = useState(null); + const { login, isLoading, error } = useAuth(); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setSuccessMessage(null); // Clear success message on new attempt + await login(email, password); + }; + + return ( +
+ + + ExamaJam Logo + Welcome to ExamJam + + +
+
+ {error && ( + + + Login Error + {error} + + )} + {successMessage && ( + + + Success + {successMessage} + + )} +
+ + setEmail(e.target.value)} + disabled={isLoading} + /> +
+
+
+ +
+ setPassword(e.target.value)} + disabled={isLoading} + /> +
+
+ +
+
+
+ Don't have an account?{" "} + + Sign up + +
+
+
+
+
+ ); +} diff --git a/components/ui/alert.tsx b/components/ui/alert.tsx new file mode 100644 index 0000000..1421354 --- /dev/null +++ b/components/ui/alert.tsx @@ -0,0 +1,66 @@ +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" + +import { cn } from "@/lib/utils" + +const alertVariants = cva( + "relative w-full rounded-lg border px-4 py-3 text-sm grid has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current", + { + variants: { + variant: { + default: "bg-card text-card-foreground", + destructive: + "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +function Alert({ + className, + variant, + ...props +}: React.ComponentProps<"div"> & VariantProps) { + return ( +
+ ) +} + +function AlertTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function AlertDescription({ + className, + ...props +}: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { Alert, AlertTitle, AlertDescription } diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..a2df8dc --- /dev/null +++ b/components/ui/button.tsx @@ -0,0 +1,59 @@ +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 gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive", + { + variants: { + variant: { + default: + "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90", + destructive: + "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60", + outline: + "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50", + secondary: + "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80", + ghost: + "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50", + link: "text-primary underline-offset-4 hover:underline", + }, + size: { + default: "h-9 px-4 py-2 has-[>svg]:px-3", + sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5", + lg: "h-10 rounded-md px-6 has-[>svg]:px-4", + icon: "size-9", + }, + }, + defaultVariants: { + variant: "default", + size: "default", + }, + } +) + +function Button({ + className, + variant, + size, + asChild = false, + ...props +}: React.ComponentProps<"button"> & + VariantProps & { + asChild?: boolean + }) { + const Comp = asChild ? Slot : "button" + + return ( + + ) +} + +export { Button, buttonVariants } diff --git a/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..d05bbc6 --- /dev/null +++ b/components/ui/card.tsx @@ -0,0 +1,92 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Card({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardTitle({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardDescription({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardAction({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardContent({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function CardFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +export { + Card, + CardHeader, + CardFooter, + CardTitle, + CardAction, + CardDescription, + CardContent, +} diff --git a/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..03295ca --- /dev/null +++ b/components/ui/input.tsx @@ -0,0 +1,21 @@ +import * as React from "react" + +import { cn } from "@/lib/utils" + +function Input({ className, type, ...props }: React.ComponentProps<"input">) { + return ( + + ) +} + +export { Input } diff --git a/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..fb5fbc3 --- /dev/null +++ b/components/ui/label.tsx @@ -0,0 +1,24 @@ +"use client" + +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib/utils" + +function Label({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { Label } diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx new file mode 100644 index 0000000..e474fac --- /dev/null +++ b/context/AuthContext.tsx @@ -0,0 +1,95 @@ +"use client"; + +import React, { createContext, useState, useContext, ReactNode } from "react"; +import { useRouter } from "next/navigation"; + +interface AuthResponse { + accessToken: string; + user: { + id: string; + email: string; + }; +} + +interface AuthContextType { + user: AuthResponse["user"] | null; + token: string | null; + isLoading: boolean; + error: string | null; + login: (email: string, password: string) => Promise; + logout: () => void; +} + +const AuthContext = createContext(undefined); + +const LOGIN_URL = `${process.env.NEXT_PUBLIC_API_BASE_URL}/login`; + +export function AuthProvider({ children }: { children: ReactNode }) { + const [user, setUser] = useState(null); + const [token, setToken] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const router = useRouter(); + + // // On initial load, check for a token in localStorage + // useEffect(() => { + // try { + // const storedToken = window.localStorage.getItem("authToken"); + // if (storedToken) { + // setToken(storedToken); + // } + // } catch (error) { + // console.error("Could not access localStorage:", error); + // } + // }, []); + + const login = async (email: string, password: string) => { + setIsLoading(true); + setError(null); + + try { + const response = await fetch(LOGIN_URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + + const data: AuthResponse = await response.json(); + + if (!response.ok) { + throw new Error((data as any).message || "Login failed."); + } + + // setToken(data.accessToken); + // setUser(data.user); + + // window.localStorage.setItem("authToken", data.accessToken); + + router.push("/dashboard"); // Redirect on successful login + } catch (err: any) { + setError(err.message); + } finally { + setIsLoading(false); + } + }; + + const logout = () => { + setUser(null); + setToken(null); + window.localStorage.removeItem("authToken"); + router.push("/login"); // Redirect to login page after logout + }; + + const value = { user, token, isLoading, error, login, logout }; + + return {children}; +} + +// Custom hook to use the AuthContext +export const useAuth = (): AuthContextType => { + const context = useContext(AuthContext); + if (context === undefined) { + throw new Error("useAuth must be used within an AuthProvider"); + } + return context; +}; diff --git a/middleware.ts b/middleware.ts new file mode 100644 index 0000000..fb21926 --- /dev/null +++ b/middleware.ts @@ -0,0 +1,25 @@ +import { NextResponse } from "next/server"; +import type { NextRequest } from "next/server"; + +const protectedRoutes = ["/dashboard"]; +const authRoutes = ["/login", "/register"]; + +export function middleware(request: NextRequest) { + const token = request.cookies.get("token")?.value; + + if ( + protectedRoutes.some((route) => request.nextUrl.pathname.startsWith(route)) + ) { + if (!token) { + return NextResponse.redirect(new URL("/login", request.url)); + } + } + + if (authRoutes.includes(request.nextUrl.pathname)) { + if (token) { + return NextResponse.redirect(new URL("/dashboard", request.url)); + } + } + + return NextResponse.next(); +} diff --git a/next.config.ts b/next.config.ts index e9ffa30..ea8c006 100644 --- a/next.config.ts +++ b/next.config.ts @@ -1,7 +1,9 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { - /* config options here */ + experimental: { + // serverActions: true, + }, }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index cd1f800..952fb4d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,14 +8,18 @@ "name": "nextjs-template-shadcn", "version": "0.1.0", "dependencies": { + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.525.0", "next": "15.3.4", + "next-auth": "^5.0.0-beta.29", "postcss": "^8.5.6", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zod": "^3.25.73" }, "devDependencies": { "@eslint/eslintrc": "^3", @@ -57,6 +61,35 @@ "node": ">=6.0.0" } }, + "node_modules/@auth/core": { + "version": "0.40.0", + "resolved": "https://registry.npmjs.org/@auth/core/-/core-0.40.0.tgz", + "integrity": "sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==", + "license": "ISC", + "dependencies": { + "@panva/hkdf": "^1.2.1", + "jose": "^6.0.6", + "oauth4webapi": "^3.3.0", + "preact": "10.24.3", + "preact-render-to-string": "6.5.11" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "nodemailer": "^6.8.0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/@emnapi/core": { "version": "1.4.3", "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.4.3.tgz", @@ -963,6 +996,94 @@ "node": ">=12.4.0" } }, + "node_modules/@panva/hkdf": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@panva/hkdf/-/hkdf-1.2.1.tgz", + "integrity": "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.7.tgz", + "integrity": "sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@rtsao/scc": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", @@ -1314,7 +1435,7 @@ "version": "19.1.8", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz", "integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.0.2" @@ -1324,7 +1445,7 @@ "version": "19.1.6", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz", "integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==", - "dev": true, + "devOptional": true, "license": "MIT", "peerDependencies": { "@types/react": "^19.0.0" @@ -2419,7 +2540,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -4137,6 +4258,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.0.11", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.0.11.tgz", + "integrity": "sha512-QxG7EaliDARm1O1S8BGakqncGT9s25bKL1WSf6/oa17Tkqwi8D2ZNglqCF+DsYF88/rV66Q/Q2mFAy697E1DUg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -4743,6 +4873,33 @@ } } }, + "node_modules/next-auth": { + "version": "5.0.0-beta.29", + "resolved": "https://registry.npmjs.org/next-auth/-/next-auth-5.0.0-beta.29.tgz", + "integrity": "sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==", + "license": "ISC", + "dependencies": { + "@auth/core": "0.40.0" + }, + "peerDependencies": { + "@simplewebauthn/browser": "^9.0.1", + "@simplewebauthn/server": "^9.0.2", + "next": "^14.0.0-0 || ^15.0.0-0", + "nodemailer": "^6.6.5", + "react": "^18.2.0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@simplewebauthn/browser": { + "optional": true + }, + "@simplewebauthn/server": { + "optional": true + }, + "nodemailer": { + "optional": true + } + } + }, "node_modules/next/node_modules/postcss": { "version": "8.4.31", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", @@ -4771,6 +4928,15 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/oauth4webapi": { + "version": "3.5.5", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.5.5.tgz", + "integrity": "sha512-1K88D2GiAydGblHo39NBro5TebGXa+7tYoyIbxvqv3+haDDry7CBE1eSYuNbOSsYCCU6y0gdynVZAkm4YPw4hg==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -5059,6 +5225,25 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/preact": { + "version": "10.24.3", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.24.3.tgz", + "integrity": "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, + "node_modules/preact-render-to-string": { + "version": "6.5.11", + "resolved": "https://registry.npmjs.org/preact-render-to-string/-/preact-render-to-string-6.5.11.tgz", + "integrity": "sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==", + "license": "MIT", + "peerDependencies": { + "preact": ">=10" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -6215,6 +6400,15 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "3.25.73", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.73.tgz", + "integrity": "sha512-fuIKbQAWQl22Ba5d1quwEETQYjqnpKVyZIWAhbnnHgnDd3a+z4YgEfkI5SZ2xMELnLAXo/Flk2uXgysZNf0uaA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 6a18b5f..fbfedb2 100644 --- a/package.json +++ b/package.json @@ -9,14 +9,18 @@ "lint": "next lint" }, "dependencies": { + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-slot": "^1.2.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "lucide-react": "^0.525.0", "next": "15.3.4", + "next-auth": "^5.0.0-beta.29", "postcss": "^8.5.6", "react": "^19.0.0", "react-dom": "^19.0.0", - "tailwind-merge": "^3.3.1" + "tailwind-merge": "^3.3.1", + "zod": "^3.25.73" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/public/logo.png b/public/logo.png new file mode 100644 index 0000000..3827dc9 Binary files /dev/null and b/public/logo.png differ