From e29862436bd874352adffa21596abb75b196e419 Mon Sep 17 00:00:00 2001 From: Indu Date: Tue, 2 Jun 2026 16:41:25 +0530 Subject: [PATCH] UI added and Login page added --- .idea/.gitignore | 10 + .idea/CIS-GPACAL.iml | 8 + .idea/modules.xml | 8 + .idea/vcs.xml | 6 + app/admin/login/page.tsx | 185 +++++++++++ app/api/auth/admin/login/route.ts | 72 +++++ app/api/auth/logout/route.ts | 15 + app/api/auth/student/change-password/route.ts | 89 ++++++ app/api/auth/student/login/route.ts | 79 +++++ app/layout.tsx | 6 +- app/page.tsx | 62 +++- app/student/change-password/page.tsx | 299 ++++++++++++++++++ app/student/login/page.tsx | 199 ++++++++++++ components.json | 16 + components/ui/button.tsx | 56 ++++ components/ui/card.tsx | 79 +++++ components/ui/input.tsx | 25 ++ components/ui/label.tsx | 26 ++ components/ui/toast.tsx | 129 ++++++++ components/ui/toaster.tsx | 35 ++ components/ui/use-toast.ts | 189 +++++++++++ lib/db.ts | 23 +- package-lock.json | 26 -- 23 files changed, 1605 insertions(+), 37 deletions(-) create mode 100644 .idea/.gitignore create mode 100644 .idea/CIS-GPACAL.iml create mode 100644 .idea/modules.xml create mode 100644 .idea/vcs.xml create mode 100644 app/admin/login/page.tsx create mode 100644 app/api/auth/admin/login/route.ts create mode 100644 app/api/auth/logout/route.ts create mode 100644 app/api/auth/student/change-password/route.ts create mode 100644 app/api/auth/student/login/route.ts create mode 100644 app/student/change-password/page.tsx create mode 100644 app/student/login/page.tsx create mode 100644 components.json create mode 100644 components/ui/button.tsx create mode 100644 components/ui/card.tsx create mode 100644 components/ui/input.tsx create mode 100644 components/ui/label.tsx create mode 100644 components/ui/toast.tsx create mode 100644 components/ui/toaster.tsx create mode 100644 components/ui/use-toast.ts diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/CIS-GPACAL.iml b/.idea/CIS-GPACAL.iml new file mode 100644 index 0000000..c956989 --- /dev/null +++ b/.idea/CIS-GPACAL.iml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..ef90e35 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..ee0c6ce --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,185 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useToast } from "@/components/ui/use-toast"; +import { ShieldCheck, Loader2, Eye, EyeOff } from "lucide-react"; + +export default function AdminLoginPage() { + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const res = await fetch("/api/auth/admin/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ username, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + toast({ + variant: "destructive", + title: "Login Failed", + description: data.error || "Invalid credentials", + }); + return; + } + + toast({ + title: "Welcome back!", + description: "Redirecting to dashboard…", + }); + + router.push(data.redirect); + } catch { + toast({ + variant: "destructive", + title: "Error", + description: "Something went wrong. Please try again.", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* ── Branding Header ─────────────────────────────────────────────── */} +
+
+
+ C +
+
+

+ Department of Computing +

+

+ GPA Portal +

+
+
+
+ + {/* ── Login Card ──────────────────────────────────────────────────── */} +
+ + +
+ +
+ + Admin Login + + + Sign in to manage the GPA portal + +
+ + +
+ {/* Username */} +
+ + setUsername(e.target.value)} + required + autoComplete="username" + disabled={isLoading} + className="h-11 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-blue-500/50 focus-visible:border-blue-500/50 transition-colors" + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + disabled={isLoading} + className="h-11 pr-10 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-blue-500/50 focus-visible:border-blue-500/50 transition-colors" + /> + +
+
+ + {/* Submit */} + +
+
+
+
+ + {/* ── Footer ──────────────────────────────────────────────────────── */} +
+ Department of Computing & Information Systems +
+
+ ); +} diff --git a/app/api/auth/admin/login/route.ts b/app/api/auth/admin/login/route.ts new file mode 100644 index 0000000..a655766 --- /dev/null +++ b/app/api/auth/admin/login/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { admins } from "@/lib/schema"; +import { comparePassword, signToken } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { username, password } = body; + + // ── Validate input ────────────────────────────────────────────────── + if (!username || !password) { + return NextResponse.json( + { error: "Username and password are required" }, + { status: 400 } + ); + } + + // ── Find admin ────────────────────────────────────────────────────── + const [admin] = await db + .select() + .from(admins) + .where(eq(admins.username, username)) + .limit(1); + + if (!admin) { + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 } + ); + } + + // ── Verify password ───────────────────────────────────────────────── + const isValid = await comparePassword(password, admin.passwordHash); + if (!isValid) { + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 } + ); + } + + // ── Sign JWT ──────────────────────────────────────────────────────── + const token = signToken({ + id: admin.id, + role: "admin", + identifier: admin.username, + }); + + // ── Set httpOnly cookie and respond ───────────────────────────────── + const response = NextResponse.json({ + success: true, + redirect: "/admin/dashboard", + }); + + response.cookies.set("auth-token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24, // 24 hours + }); + + return response; + } catch (error) { + console.error("Admin login error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..631941c --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,15 @@ +import { NextResponse } from "next/server"; + +export async function POST() { + const response = NextResponse.json({ success: true }); + + response.cookies.set("auth-token", "", { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 0, // Expire immediately + }); + + return response; +} diff --git a/app/api/auth/student/change-password/route.ts b/app/api/auth/student/change-password/route.ts new file mode 100644 index 0000000..e48eeac --- /dev/null +++ b/app/api/auth/student/change-password/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { students } from "@/lib/schema"; +import { comparePassword, hashPassword, verifyToken } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + // ── Verify student JWT ────────────────────────────────────────────── + const token = request.cookies.get("auth-token")?.value; + if (!token) { + return NextResponse.json( + { error: "Authentication required" }, + { status: 401 } + ); + } + + const payload = verifyToken(token); + if (!payload || payload.role !== "student") { + return NextResponse.json( + { error: "Unauthorized — student access only" }, + { status: 403 } + ); + } + + // ── Parse body ────────────────────────────────────────────────────── + const body = await request.json(); + const { currentPassword, newPassword } = body; + + if (!currentPassword || !newPassword) { + return NextResponse.json( + { error: "Current password and new password are required" }, + { status: 400 } + ); + } + + if (newPassword.length < 8) { + return NextResponse.json( + { error: "New password must be at least 8 characters" }, + { status: 400 } + ); + } + + // ── Find student ──────────────────────────────────────────────────── + const [student] = await db + .select() + .from(students) + .where(eq(students.indexNumber, payload.identifier)) + .limit(1); + + if (!student) { + return NextResponse.json( + { error: "Student not found" }, + { status: 404 } + ); + } + + // ── Verify current password ───────────────────────────────────────── + const isValid = await comparePassword( + currentPassword, + student.passwordHash + ); + if (!isValid) { + return NextResponse.json( + { error: "Current password is incorrect" }, + { status: 401 } + ); + } + + // ── Hash new password and update ──────────────────────────────────── + const newHash = await hashPassword(newPassword); + + await db + .update(students) + .set({ + passwordHash: newHash, + isFirstLogin: false, + }) + .where(eq(students.indexNumber, payload.identifier)); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error("Change password error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/api/auth/student/login/route.ts b/app/api/auth/student/login/route.ts new file mode 100644 index 0000000..c51fac9 --- /dev/null +++ b/app/api/auth/student/login/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { students } from "@/lib/schema"; +import { comparePassword, signToken } from "@/lib/auth"; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { indexNumber, password } = body; + + // ── Validate input ────────────────────────────────────────────────── + if (!indexNumber || !password) { + return NextResponse.json( + { error: "Index number and password are required" }, + { status: 400 } + ); + } + + // ── Find student ──────────────────────────────────────────────────── + const [student] = await db + .select() + .from(students) + .where(eq(students.indexNumber, indexNumber)) + .limit(1); + + if (!student) { + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 } + ); + } + + // ── Verify password ───────────────────────────────────────────────── + const isValid = await comparePassword(password, student.passwordHash); + if (!isValid) { + return NextResponse.json( + { error: "Invalid credentials" }, + { status: 401 } + ); + } + + // ── Sign JWT ──────────────────────────────────────────────────────── + const token = signToken({ + id: student.id, + role: "student", + identifier: student.indexNumber, + }); + + // ── Determine redirect based on first login ───────────────────────── + const isFirstLogin = student.isFirstLogin; + const redirect = isFirstLogin + ? "/student/change-password" + : "/student/dashboard"; + + // ── Set httpOnly cookie and respond ───────────────────────────────── + const response = NextResponse.json({ + success: true, + isFirstLogin, + redirect, + }); + + response.cookies.set("auth-token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 60 * 60 * 24, // 24 hours + }); + + return response; + } catch (error) { + console.error("Student login error:", error); + return NextResponse.json( + { error: "Internal server error" }, + { status: 500 } + ); + } +} diff --git a/app/layout.tsx b/app/layout.tsx index 0675638..54c404a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; +import { Toaster } from "@/components/ui/toaster"; import "./globals.css"; const inter = Inter({ subsets: ["latin"] }); @@ -17,7 +18,10 @@ export default function RootLayout({ }) { return ( - {children} + + {children} + + ); } diff --git a/app/page.tsx b/app/page.tsx index 202836e..3eaf61d 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,10 +1,60 @@ +import Link from "next/link"; +import { GraduationCap, ShieldCheck } from "lucide-react"; + export default function Home() { return ( -
-

CIS GPA Calculator

-

- Department of Computing & Information Systems -

-
+
+ {/* ── Hero Section ──────────────────────────────────────────────────── */} +
+ {/* Logo */} +
+ C +
+ + {/* Heading */} +

+ GPA Calculator +

+

+ Department of Computing & Information Systems +

+ + {/* Divider */} +
+ + {/* Login Cards */} +
+ {/* Student Login */} + +
+ +
+ Student + View your GPA & results + + + {/* Admin Login */} + +
+ +
+ Admin + Manage portal & results + +
+
+ + {/* ── Footer ────────────────────────────────────────────────────────── */} +
+ © {new Date().getFullYear()} Department of Computing & Information + Systems +
+
); } diff --git a/app/student/change-password/page.tsx b/app/student/change-password/page.tsx new file mode 100644 index 0000000..1f5c90c --- /dev/null +++ b/app/student/change-password/page.tsx @@ -0,0 +1,299 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useToast } from "@/components/ui/use-toast"; +import { KeyRound, Loader2, Eye, EyeOff, Check, X } from "lucide-react"; + +export default function ChangePasswordPage() { + const [currentPassword, setCurrentPassword] = useState(""); + const [newPassword, setNewPassword] = useState(""); + const [confirmPassword, setConfirmPassword] = useState(""); + const [showCurrent, setShowCurrent] = useState(false); + const [showNew, setShowNew] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + // ── Validation state ──────────────────────────────────────────────── + const isMinLength = newPassword.length >= 8; + const passwordsMatch = + newPassword.length > 0 && + confirmPassword.length > 0 && + newPassword === confirmPassword; + const canSubmit = + currentPassword.length > 0 && isMinLength && passwordsMatch && !isLoading; + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + + if (!isMinLength) { + toast({ + variant: "destructive", + title: "Password too short", + description: "New password must be at least 8 characters.", + }); + return; + } + + if (!passwordsMatch) { + toast({ + variant: "destructive", + title: "Passwords don't match", + description: "Please make sure both passwords match.", + }); + return; + } + + setIsLoading(true); + + try { + const res = await fetch("/api/auth/student/change-password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ currentPassword, newPassword }), + }); + + const data = await res.json(); + + if (!res.ok) { + toast({ + variant: "destructive", + title: "Failed", + description: data.error || "Could not change password.", + }); + return; + } + + toast({ + title: "Password Changed", + description: "Your password has been updated successfully.", + }); + + router.push("/student/dashboard"); + } catch { + toast({ + variant: "destructive", + title: "Error", + description: "Something went wrong. Please try again.", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* ── Branding Header ─────────────────────────────────────────────── */} +
+
+
+ C +
+
+

+ Department of Computing +

+

+ GPA Portal +

+
+
+
+ + {/* ── Change Password Card ────────────────────────────────────────── */} +
+ + +
+ +
+ + Change Password + + + Please set a new password for your account + +
+ + +
+ {/* Current Password */} +
+ +
+ setCurrentPassword(e.target.value)} + required + autoComplete="current-password" + disabled={isLoading} + className="h-11 pr-10 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-amber-500/50 focus-visible:border-amber-500/50 transition-colors" + /> + +
+
+ + {/* New Password */} +
+ +
+ setNewPassword(e.target.value)} + required + autoComplete="new-password" + disabled={isLoading} + className="h-11 pr-10 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-amber-500/50 focus-visible:border-amber-500/50 transition-colors" + /> + +
+
+ + {/* Confirm Password */} +
+ +
+ setConfirmPassword(e.target.value)} + required + autoComplete="new-password" + disabled={isLoading} + className="h-11 pr-10 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-amber-500/50 focus-visible:border-amber-500/50 transition-colors" + /> + +
+
+ + {/* Validation indicators */} +
+
+ {isMinLength ? ( + + ) : ( + + )} + + At least 8 characters + +
+
+ {passwordsMatch ? ( + + ) : ( + + )} + + Passwords match + +
+
+ + {/* Submit */} + +
+
+
+
+ + {/* ── Footer ──────────────────────────────────────────────────────── */} +
+ Department of Computing & Information Systems +
+
+ ); +} diff --git a/app/student/login/page.tsx b/app/student/login/page.tsx new file mode 100644 index 0000000..e7dd27a --- /dev/null +++ b/app/student/login/page.tsx @@ -0,0 +1,199 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { useToast } from "@/components/ui/use-toast"; +import { GraduationCap, Loader2, Eye, EyeOff, Info } from "lucide-react"; + +export default function StudentLoginPage() { + const [indexNumber, setIndexNumber] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const router = useRouter(); + const { toast } = useToast(); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setIsLoading(true); + + try { + const res = await fetch("/api/auth/student/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ indexNumber, password }), + }); + + const data = await res.json(); + + if (!res.ok) { + toast({ + variant: "destructive", + title: "Login Failed", + description: data.error || "Invalid credentials", + }); + return; + } + + toast({ + title: "Welcome!", + description: data.isFirstLogin + ? "Please change your password to continue." + : "Redirecting to your dashboard…", + }); + + router.push(data.redirect); + } catch { + toast({ + variant: "destructive", + title: "Error", + description: "Something went wrong. Please try again.", + }); + } finally { + setIsLoading(false); + } + }; + + return ( +
+ {/* ── Branding Header ─────────────────────────────────────────────── */} +
+
+
+ C +
+
+

+ Department of Computing +

+

+ GPA Portal +

+
+
+
+ + {/* ── Login Card ──────────────────────────────────────────────────── */} +
+ + +
+ +
+ + Student Login + + + Sign in to view your GPA results + +
+ + +
+ {/* Index Number */} +
+ + setIndexNumber(e.target.value)} + required + autoComplete="username" + disabled={isLoading} + className="h-11 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-emerald-500/50 focus-visible:border-emerald-500/50 transition-colors" + /> +
+ + {/* Password */} +
+ +
+ setPassword(e.target.value)} + required + autoComplete="current-password" + disabled={isLoading} + className="h-11 pr-10 bg-slate-700/50 border-slate-600/50 text-white placeholder:text-slate-500 focus-visible:ring-emerald-500/50 focus-visible:border-emerald-500/50 transition-colors" + /> + +
+
+ + {/* First-time login hint */} +
+ +

+ First-time login? Your + default password is{" "} + + 123456789 + +

+
+ + {/* Submit */} + +
+
+
+
+ + {/* ── Footer ──────────────────────────────────────────────────────── */} +
+ Department of Computing & Information Systems +
+
+ ); +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..fd5076f --- /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.js", + "css": "app/globals.css", + "baseColor": "slate", + "cssVariables": true + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} diff --git a/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..0ba4277 --- /dev/null +++ b/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/components/ui/card.tsx b/components/ui/card.tsx new file mode 100644 index 0000000..afa13ec --- /dev/null +++ b/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/components/ui/input.tsx b/components/ui/input.tsx new file mode 100644 index 0000000..677d05f --- /dev/null +++ b/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/components/ui/label.tsx b/components/ui/label.tsx new file mode 100644 index 0000000..5341821 --- /dev/null +++ b/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/components/ui/toast.tsx b/components/ui/toast.tsx new file mode 100644 index 0000000..521b94b --- /dev/null +++ b/components/ui/toast.tsx @@ -0,0 +1,129 @@ +"use client" + +import * as React from "react" +import * as ToastPrimitives from "@radix-ui/react-toast" +import { cva, type VariantProps } from "class-variance-authority" +import { X } from "lucide-react" + +import { cn } from "@/lib/utils" + +const ToastProvider = ToastPrimitives.Provider + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastViewport.displayName = ToastPrimitives.Viewport.displayName + +const toastVariants = cva( + "group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full", + { + variants: { + variant: { + default: "border bg-background text-foreground", + destructive: + "destructive group border-destructive bg-destructive text-destructive-foreground", + }, + }, + defaultVariants: { + variant: "default", + }, + } +) + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ) +}) +Toast.displayName = ToastPrimitives.Root.displayName + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastAction.displayName = ToastPrimitives.Action.displayName + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +ToastClose.displayName = ToastPrimitives.Close.displayName + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastTitle.displayName = ToastPrimitives.Title.displayName + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +ToastDescription.displayName = ToastPrimitives.Description.displayName + +type ToastProps = React.ComponentPropsWithoutRef + +type ToastActionElement = React.ReactElement + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, +} diff --git a/components/ui/toaster.tsx b/components/ui/toaster.tsx new file mode 100644 index 0000000..e223385 --- /dev/null +++ b/components/ui/toaster.tsx @@ -0,0 +1,35 @@ +"use client" + +import { + Toast, + ToastClose, + ToastDescription, + ToastProvider, + ToastTitle, + ToastViewport, +} from "@/components/ui/toast" +import { useToast } from "@/components/ui/use-toast" + +export function Toaster() { + const { toasts } = useToast() + + return ( + + {toasts.map(function ({ id, title, description, action, ...props }) { + return ( + +
+ {title && {title}} + {description && ( + {description} + )} +
+ {action} + +
+ ) + })} + +
+ ) +} diff --git a/components/ui/use-toast.ts b/components/ui/use-toast.ts new file mode 100644 index 0000000..60d91ab --- /dev/null +++ b/components/ui/use-toast.ts @@ -0,0 +1,189 @@ +"use client" + +// Inspired by react-hot-toast library +import * as React from "react" + +import type { ToastActionElement, ToastProps } from "@/components/ui/toast" + +const TOAST_LIMIT = 1 +const TOAST_REMOVE_DELAY = 1000000 + +type ToasterToast = ToastProps & { + id: string + title?: React.ReactNode + description?: React.ReactNode + action?: ToastActionElement +} + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const + +let count = 0 + +function genId() { + count = (count + 1) % Number.MAX_SAFE_INTEGER + return count.toString() +} + +type ActionType = typeof actionTypes + +type Action = + | { + type: ActionType["ADD_TOAST"] + toast: ToasterToast + } + | { + type: ActionType["UPDATE_TOAST"] + toast: Partial + } + | { + type: ActionType["DISMISS_TOAST"] + toastId?: ToasterToast["id"] + } + | { + type: ActionType["REMOVE_TOAST"] + toastId?: ToasterToast["id"] + } + +interface State { + toasts: ToasterToast[] +} + +const toastTimeouts = new Map>() + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId) + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }) + }, TOAST_REMOVE_DELAY) + + toastTimeouts.set(toastId, timeout) +} + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + } + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t + ), + } + + case "DISMISS_TOAST": { + const { toastId } = action + + if (toastId) { + addToRemoveQueue(toastId) + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id) + }) + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t + ), + } + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + } + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + } + } +} + +const listeners: Array<(state: State) => void> = [] + +let memoryState: State = { toasts: [] } + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action) + listeners.forEach((listener) => { + listener(memoryState) + }) +} + +type Toast = Omit + +function toast({ ...props }: Toast) { + const id = genId() + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }) + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }) + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss() + }, + }, + }) + + return { + id: id, + dismiss, + update, + } +} + +function useToast() { + const [state, setState] = React.useState(memoryState) + + React.useEffect(() => { + listeners.push(setState) + return () => { + const index = listeners.indexOf(setState) + if (index > -1) { + listeners.splice(index, 1) + } + } + }, [state]) + + return { + ...state, + toast, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + } +} + +export { useToast, toast } diff --git a/lib/db.ts b/lib/db.ts index 4ea824a..7a103ca 100644 --- a/lib/db.ts +++ b/lib/db.ts @@ -2,9 +2,24 @@ import { neon } from "@neondatabase/serverless"; import { drizzle } from "drizzle-orm/neon-http"; import * as schema from "./schema"; -if (!process.env.DATABASE_URL) { - throw new Error("DATABASE_URL environment variable is not set"); +// Lazy initialization — avoids throwing at module-load time during `next build` +// when DATABASE_URL isn't available in the build environment. +let _db: ReturnType | null = null; + +export function getDb() { + if (!_db) { + if (!process.env.DATABASE_URL) { + throw new Error("DATABASE_URL environment variable is not set"); + } + const sql = neon(process.env.DATABASE_URL); + _db = drizzle(sql, { schema }); + } + return _db; } -const sql = neon(process.env.DATABASE_URL); -export const db = drizzle(sql, { schema }); +// Keep the named export for backward-compat, but as a getter +export const db = new Proxy({} as ReturnType, { + get(_target, prop) { + return (getDb() as any)[prop]; + }, +}); diff --git a/package-lock.json b/package-lock.json index 9382d85..9f1c903 100644 --- a/package-lock.json +++ b/package-lock.json @@ -827,7 +827,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -861,7 +860,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -895,7 +893,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4238,7 +4235,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4255,7 +4251,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4272,7 +4267,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4289,7 +4283,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4306,7 +4299,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4323,7 +4315,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4340,7 +4331,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4357,7 +4347,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4374,7 +4363,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4391,7 +4379,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4408,7 +4395,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4425,7 +4411,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4442,7 +4427,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4459,7 +4443,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4476,7 +4459,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4493,7 +4475,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4510,7 +4491,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4527,7 +4507,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4544,7 +4523,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4561,7 +4539,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4578,7 +4555,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4595,7 +4571,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -4612,7 +4587,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [