diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..bffb357 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..fda3ae2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts +bun.lockb diff --git a/README.md b/README.md new file mode 100644 index 0000000..169ceca --- /dev/null +++ b/README.md @@ -0,0 +1,49 @@ + +# GatePYQ 📚💻 + +[Live Link](https://gate-pyq-web.vercel.app) + +GatePYQ is a dynamic web application designed to help in preparing for competitive exam Graduate Aptitude Test in Engineering (GATE). It provides a centralized platform for accessing and practicing previous year questions. + +## Features 🚀 + +- 📝 Access to extensive collections of previous year questions for GATE exams. +- 📚 Detailed explanations provided for each question and its correct answer, powered by Google's Gemini Pro Generative AI. +- 🔒 Secure authentication using NextAuth with Google and Credentials Providers. +- 🌐 Built with Next.js 14, MongoDB, Prisma, and React components for a seamless user experience. +- 💪 Written in Typescript. +- 〽️ MongoDB data api as an alternative data source. + +## Screenshots 📸 + +   +   +   +   +   + +## Installation 🛠️ + +To run GatePYQ locally, follow these steps: + +1. Clone the repository: + ``` + git clone https://github.com/anujd64/GatePYQ.git + ``` + +2. Navigate to the project directory: + ``` + cd GatePYQ + ``` + +3. Install dependencies: + ``` + npm install + ``` + +4. Start the development server: + ``` + npm run dev + ``` + +5. Access GatePYQ in your browser at `http://localhost:3000`. \ No newline at end of file diff --git a/Screenshots/404.png b/Screenshots/404.png new file mode 100644 index 0000000..c660420 Binary files /dev/null and b/Screenshots/404.png differ diff --git a/Screenshots/Homepage Signed in.png b/Screenshots/Homepage Signed in.png new file mode 100644 index 0000000..5ec08e1 Binary files /dev/null and b/Screenshots/Homepage Signed in.png differ diff --git a/Screenshots/Homepage.png b/Screenshots/Homepage.png new file mode 100644 index 0000000..8db4372 Binary files /dev/null and b/Screenshots/Homepage.png differ diff --git a/Screenshots/Profile Page.png b/Screenshots/Profile Page.png new file mode 100644 index 0000000..e9e6842 Binary files /dev/null and b/Screenshots/Profile Page.png differ diff --git a/Screenshots/Questions - Signed In - Gemini Response.png b/Screenshots/Questions - Signed In - Gemini Response.png new file mode 100644 index 0000000..d5b35d8 Binary files /dev/null and b/Screenshots/Questions - Signed In - Gemini Response.png differ diff --git a/Screenshots/Sign In Page.png b/Screenshots/Sign In Page.png new file mode 100644 index 0000000..96b320f Binary files /dev/null and b/Screenshots/Sign In Page.png differ diff --git a/app/_lib/authOptions.ts b/app/_lib/authOptions.ts new file mode 100644 index 0000000..3e964ac --- /dev/null +++ b/app/_lib/authOptions.ts @@ -0,0 +1,90 @@ +import GoogleProvider from 'next-auth/providers/google'; +import CredentialsProvider from 'next-auth/providers/credentials' +import { AuthOptions } from "next-auth"; +export const authOptions: AuthOptions = { + // adapter: PrismaAdapter(PrismaClient), + providers: [ + GoogleProvider({ + clientId: process.env.GOOGLE_CLIENT_ID ?? "", + clientSecret: process.env.GOOGLE_CLIENT_SECRET ?? "", + }), + + CredentialsProvider({ + type: "credentials", + credentials: { + email: { + label: "Email", + type: "email", + }, + password: { label: "Password", type: "password" }, + }, + async authorize(credentials) { + + const credentialDetails = { + email: credentials?.email, + password: credentials?.password, + }; + + const resp = await fetch(process.env.NEXTAUTH_URL+"/api/auth/login", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(credentialDetails), + }); + const user = await resp.json(); + console.log("user request ",credentials?.email,credentials?.password,"user response", user) + if (resp.ok) { + console.log("nextauth user: " + user.user); + + return user.user; + } + if(!resp.ok){ + console.log("check your credentials"); + throw new Error(user.message) + } + return null; + }, + }), + ], + + + callbacks: { + jwt: async ({ token, user }) => { + + if (user) { + token.email = user.email ; + token.username = user.name; + token.name = user.name; + token.image = user.image; + } + + + // if (user) { + // const { user: userData } = user; + + // console.log("jwt callback",user, userData) + // token.email = userData?.email ?? user.email; + // token.username = userData?.name ?? user.name; + // token.name = userData?.name ?? user.name; + // token.image = userData?.image ?? user.image; + // // token.accessToken = userData?.token; // Uncomment if needed + // } + return token; + }, + session: ({ session, token }) => { + if (token) { + session.user!.email = token.email; + session.user!.name = token.name; + session.user!.image = token.image as string; + // session.user.accessToken = token.accessToken; + } + return session; + }, + }, + + pages: { + signIn: '/profile/login', + }, + }; diff --git a/app/_lib/fonts.ts b/app/_lib/fonts.ts new file mode 100644 index 0000000..f49f0e0 --- /dev/null +++ b/app/_lib/fonts.ts @@ -0,0 +1,3 @@ +import { Outfit } from 'next/font/google'; + +export const outfit = Outfit({ subsets: ['latin'] }); \ No newline at end of file diff --git a/app/_lib/mongodb.ts b/app/_lib/mongodb.ts new file mode 100644 index 0000000..bd1ffb4 --- /dev/null +++ b/app/_lib/mongodb.ts @@ -0,0 +1,40 @@ +import { MongoClient } from 'mongodb'; + +// Replace with your MongoDB connection string +const uri = process.env.MONGODB_URI as string; +const options = {}; + +declare global { + var _mongoClientPromise: Promise; +} + +class Singleton { + private static _instance: Singleton; + private client: MongoClient; + private clientPromise: Promise; + + private constructor() { + this.client = new MongoClient(uri, options); + this.clientPromise = this.client.connect(); + + if (process.env.NODE_ENV === 'development') { + // In development mode, use a global variable to preserve the value + // across module reloads caused by HMR (Hot Module Replacement). + global._mongoClientPromise = this.clientPromise; + } + } + + public static get instance() { + if (!this._instance) { + this._instance = new Singleton(); + } + return this._instance.clientPromise; + } +} + +const clientPromise = Singleton.instance; + +// Export a module-scoped MongoClient promise. +// By doing this in a separate module, +// the client can be shared across functions. +export default clientPromise; \ No newline at end of file diff --git a/app/_lib/utils.ts b/app/_lib/utils.ts new file mode 100644 index 0000000..4643a3f --- /dev/null +++ b/app/_lib/utils.ts @@ -0,0 +1,94 @@ + +import { type ClassValue, clsx } from "clsx" +import { twMerge } from "tailwind-merge" +import { findUser, createUser } from "../_server/actions/UserAction"; +import { IUser } from "../_server/UserService/IUserService"; +import { json } from "stream/consumers"; + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} + +//https://stackoverflow.com/questions/2450954/how-to-randomize-shuffle-a-javascript-array +/* Randomize array in-place using Durstenfeld shuffle algorithm */ + +export function shuffleArray(array: string[]) { + for (var i = array.length - 1; i > 0; i--) { + var j = Math.floor(Math.random() * (i + 1)); + var temp = array[i]; + array[i] = array[j]; + array[j] = temp; + } + return array; +} + + +// export async function getTags() { +// const res = await fetch('http://localhost:2020/tags') +// // The return value is *not* serialized +// // You can return Date, Map, Set, etc. + +// if (!res.ok) { +// throw new Error('Failed to fetch data') +// } + +// return res.json() +// } + + +export function validateRegisterForm(values: {email:string,password:string,passwordC:string, username:string}) { + const errors: any = {}; + + if (!values.email) { + errors.email = "Required"; + } else if ( + !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email) + ) { + errors.email = "Invalid email address"; + } + + if (values.password !== values.passwordC) { + const errMsg = "Passwords don't match"; + errors.password = errMsg; + errors.passwordC = errMsg; + } + + return errors; +} + +export async function onRegisterSubmit(values:{email:string,password:string, passwordC:string,username:string}) { + + const userObj = (values: { email: string; password: string; username?: string; })=> { + return { + email:values.email, + password:values.password, + username:values.username + } +} + const data = findUser({email: values.email}) + let user = await data.then(json => json.data); + if(user === null){ + user = await createUser(userObj(values)).then(json => json.data) + } + return user; +} + + +export function validateLoginForm(values: {email:string,password:string}) { + const errors: any = {}; + + if (!values.email) { + errors.email = "Required"; + } else if ( + !/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(values.email) + ) { + errors.email = "Invalid email address"; + } + + if (values.password == "") { + const errMsg = "Password is too short"; + errors.password = errMsg; + } + + return errors; +} \ No newline at end of file diff --git a/app/_server/IRepositoryService.ts b/app/_server/IRepositoryService.ts new file mode 100644 index 0000000..ce1edb5 --- /dev/null +++ b/app/_server/IRepositoryService.ts @@ -0,0 +1,8 @@ +interface IRepository { + find( + filter: Partial, + page: number, + limit: number, + projection?: Partial>, + ): Promise<{ data: T[], totalCount: number }>; + } \ No newline at end of file diff --git a/app/_server/QuestionService/IQuestionService.ts b/app/_server/QuestionService/IQuestionService.ts new file mode 100644 index 0000000..1c71784 --- /dev/null +++ b/app/_server/QuestionService/IQuestionService.ts @@ -0,0 +1,16 @@ +export type IQuestion = { + _id:string, + question: string, + options: string[], + correct_options:string[], + image_links:string[], + explanation_link:string, + tags: string[] | any, +}; + + +export interface IQuestionService { + getQuestions( + filter: Partial + ): Promise<{ data: IQuestion[]; totalCount: number }>; +} diff --git a/app/_server/QuestionService/QuestionService.ts b/app/_server/QuestionService/QuestionService.ts new file mode 100644 index 0000000..cae12d0 --- /dev/null +++ b/app/_server/QuestionService/QuestionService.ts @@ -0,0 +1,20 @@ +import { Repository } from "@/app/_server/RepositoryService"; +import { IQuestion, IQuestionService } from "./IQuestionService"; + +export class QuestionService implements IQuestionService { + private repository: Repository; + + constructor() { + this.repository = new Repository("questions"); + } + + async getQuestions( + filter: Partial, + page: number = 1, + limit: number = 10 + ): Promise<{ data: IQuestion[], totalCount: number }> { + + + return this.repository.find(filter, page, limit); + } +} \ No newline at end of file diff --git a/app/_server/RepositoryService.ts b/app/_server/RepositoryService.ts new file mode 100644 index 0000000..ffbf9a0 --- /dev/null +++ b/app/_server/RepositoryService.ts @@ -0,0 +1,111 @@ +//@ts-nocheck +import clientPromise from "../_lib/mongodb"; +import { MongoClient, OptionalId } from "mongodb"; +import { IUser } from "./UserService/IUserService"; + +export class Repository implements IRepository { + private collection: string; + + constructor(collection: string) { + this.collection = collection; + } + + async find( + filter: Partial, + page: number = 1, + limit: number = 10, + projection?: Partial>, + ): Promise<{ data: T[], totalCount: number }> { + try { + // Await the client promise to get an instance of MongoClient + const client: MongoClient = await clientPromise; + + // Calculate how many documents to skip + const skip = (page - 1) * limit; + + // Access the database and the collection + const collection = client.db().collection(this.collection); + + // Get the total count of all items + const totalCount = await collection.countDocuments(filter); + + // Access the database and the collection, then find documents matching the filter + // If a projection is provided, apply it to the query + // Convert the result to an array and return it + + const data = await collection + .find(filter, { projection }) + .skip(skip) + .limit(limit) + .toArray(); + + return { data: data as unknown as T[], totalCount }; + } catch (error: unknown) { + // Catch and log any connection errors + if (error instanceof Error) { + if (error.message.includes("ECONNREFUSED")) { + console.error("Failed to connect to MongoDB. Connection refused."); + } else { + console.error("An error occurred:", error.message); + } + } + return { data: [], totalCount: 0 }; + } + } + + async createUser( + user:Partial, + ): Promise<{ data: IUser }> { + try { + // Await the client promise to get an instance of MongoClient + const client: MongoClient = await clientPromise; + + // Access the database and the collection + const collection = client.db().collection(this.collection); + + const data = await collection + .insertOne(user) + + return { data: data as unknown as IUser }; + } catch (error: unknown) { + // Catch and log any connection errors + if (error instanceof Error) { + if (error.message.includes("ECONNREFUSED")) { + console.error("Failed to connect to MongoDB. Connection refused."); + } else { + console.error("An error occurred:", error.message); + } + } + return { data: { + _id: "", + username: "", + password: "", + email: "" + }}; + } + } + + async findUser( + filter: Partial, + ): Promise<{ data: IUser }> { + try { + const client: MongoClient = await clientPromise; + + const collection = client.db().collection(this.collection); + + const data = await collection + .findOne(filter) + + return { data: data as unknown as IUser }; + } catch (error: unknown) { + if (error instanceof Error) { + if (error.message.includes("ECONNREFUSED")) { + console.error("Failed to connect to MongoDB. Connection refused."); + } else { + console.error("An error occurred:", error.message); + } + } + return { data: undefined }; + } + } +} \ No newline at end of file diff --git a/app/_server/TagService/ITagService.ts b/app/_server/TagService/ITagService.ts new file mode 100644 index 0000000..2d9f09e --- /dev/null +++ b/app/_server/TagService/ITagService.ts @@ -0,0 +1,13 @@ +export type ITag = { + _id:string, + questionIds: string[], + count:number + }; + + + export interface ITagService { + getTags( + filter: Partial + ): Promise<{ data: ITag[]; totalCount: number }>; + } + \ No newline at end of file diff --git a/app/_server/TagService/TagService.ts b/app/_server/TagService/TagService.ts new file mode 100644 index 0000000..6f37332 --- /dev/null +++ b/app/_server/TagService/TagService.ts @@ -0,0 +1,18 @@ +import { Repository } from "@/app/_server/RepositoryService"; +import { ITag, ITagService } from "./ITagService"; + +export class TagService implements ITagService { + private repository: Repository; + + constructor() { + this.repository = new Repository("tags"); + } + + async getTags( + filter: Partial, + page: number = 1, + limit: number = 30 + ): Promise<{ data: ITag[], totalCount: number }> { + return this.repository.find(filter, page, limit); + } +} \ No newline at end of file diff --git a/app/_server/UserService/IUserService.ts b/app/_server/UserService/IUserService.ts new file mode 100644 index 0000000..d2d53b1 --- /dev/null +++ b/app/_server/UserService/IUserService.ts @@ -0,0 +1,18 @@ +export type IUser = { + _id:string, + username: string | undefined, + password: string, + email:string, + }; + + + export interface IUserService { + createUser( + filter: Partial + ): Promise<{ data: IUser;}>; + + findUser( + filter: Partial + ): Promise<{ data: IUser;}>; + } + \ No newline at end of file diff --git a/app/_server/UserService/UserService.ts b/app/_server/UserService/UserService.ts new file mode 100644 index 0000000..78adcb4 --- /dev/null +++ b/app/_server/UserService/UserService.ts @@ -0,0 +1,17 @@ +import { Repository } from "@/app/_server/RepositoryService"; +import { IUser, IUserService } from "./IUserService"; + +export class UserService implements IUserService { + private repository: Repository; + + constructor() { + this.repository = new Repository("users"); + } + async createUser(user:Partial): Promise<{ data: IUser }> { + return this.repository.createUser(user); + } + + async findUser(filter: Partial): Promise<{ data: IUser }> { + return this.repository.findUser(filter); + } +} diff --git a/app/_server/actions/GeminiActions.ts b/app/_server/actions/GeminiActions.ts new file mode 100644 index 0000000..b89166a --- /dev/null +++ b/app/_server/actions/GeminiActions.ts @@ -0,0 +1,18 @@ +'use server' + +import { GoogleGenerativeAI } from "@google/generative-ai"; +export async function askGemini(question: string,answer:string) { + + const genAI = new GoogleGenerativeAI(process.env.GEMINI_API_KEY as string); + + console.log("key is",process.env.GEMINI_API_KEY) + // For text-only input, use the gemini-pro model + const model = genAI.getGenerativeModel({ model: "gemini-pro"}); + + const prompt = `Explain the question and answer, why is it that way ? you do not need to repeat the question or answer only the reason for it, question: ${question} answer: ${answer} ` + + const result = await model.generateContent(prompt); + const response = result.response; + const text = response.text(); + return text; +} \ No newline at end of file diff --git a/app/_server/actions/QuestionActions.ts b/app/_server/actions/QuestionActions.ts new file mode 100644 index 0000000..2496ac9 --- /dev/null +++ b/app/_server/actions/QuestionActions.ts @@ -0,0 +1,17 @@ +'use server'; +import { IQuestion } from "../QuestionService/IQuestionService"; +import { QuestionService } from "../QuestionService/QuestionService" + +export const fetchQuestions = async (tag:string, pageNumber: number,limit:number) => { + const questionService = new QuestionService(); + + console.log(tag); + const arr = [tag] + const filterPredicate: Partial = { + tags: { $in: arr } + }; + return await questionService.getQuestions( + // { tags: $all:[tag] } + filterPredicate + , pageNumber, limit); +}; \ No newline at end of file diff --git a/app/_server/actions/TagActions.ts b/app/_server/actions/TagActions.ts new file mode 100644 index 0000000..c62a169 --- /dev/null +++ b/app/_server/actions/TagActions.ts @@ -0,0 +1,8 @@ +'use server'; + +import { TagService } from "../TagService/TagService" + +export const fetchTags = async (pageNumber: number,limit:number) => { + const tagService = new TagService(); + return await tagService.getTags({}, pageNumber, limit); +}; \ No newline at end of file diff --git a/app/_server/actions/UserAction.ts b/app/_server/actions/UserAction.ts new file mode 100644 index 0000000..25dcd11 --- /dev/null +++ b/app/_server/actions/UserAction.ts @@ -0,0 +1,15 @@ +'use server'; + +import { IUser } from "../UserService/IUserService"; +import { UserService } from "../UserService/UserService" + +export async function findUser(user:Partial){ + const userService = new UserService(); + return await userService.findUser(user); +}; + + +export const createUser = async (user:Partial) => { + const userService = new UserService(); + return await userService.createUser(user); + }; \ No newline at end of file diff --git a/app/_server/db-helper.ts b/app/_server/db-helper.ts new file mode 100644 index 0000000..f7e8996 --- /dev/null +++ b/app/_server/db-helper.ts @@ -0,0 +1,10 @@ +import prisma from "@/prisma" + +export const connectToDatabase = async () => { + try { + await prisma.$connect(); + } catch (error) { + console.log(error); + throw new Error("Unable to connect to the db"); + } +} \ No newline at end of file diff --git a/app/_server/mongo-data-api/data-api.ts b/app/_server/mongo-data-api/data-api.ts new file mode 100644 index 0000000..7726f4f --- /dev/null +++ b/app/_server/mongo-data-api/data-api.ts @@ -0,0 +1,132 @@ + +type QuestionsQueryParams = { + arr: string[]; + skip: number; + limit: number; +} +export async function getQuestions({arr,skip,limit}: QuestionsQueryParams) { + + // { + // "collection":"questions", + // "database":"test", + // "dataSource":"db", + // "filter": { + // "tags": {"$in": ["Computer Network"]} + // }, + // "skip":10, + // "limit":5 + // } + + const filterPredicate = { + tags: { $in: arr } + }; + const data = { + collection: "questions", + database:"test", + dataSource:"db", + filter:filterPredicate, + skip:skip, + limit:limit + } + + const response = await fetch(process.env.MONGODB_DATA_API_URL+"find", + { + method: "POST", + body: JSON.stringify(data), + headers: { + 'api-key': process.env.MONGODB_DATA_API_KEY as string + } + } + ) + + // "pipeline": [ + // { + // "$match": { + // "tags": { "$in": ["Computer Network"] } + // } + // }, + // { + // "$group": { + // "_id": null, + // "count": { "$sum": 1 } + // } + // } + // ] + const data2 = { + collection: "questions", + database:"test", + dataSource:"db", + pipeline:[ + {$match: filterPredicate}, + { + $group: { + _id: null, + count: { $sum: 1 } + } + } + ] + } + + const totalCountResp = await fetch(process.env.MONGODB_DATA_API_URL+"aggregate", + { + method: "POST", + body: JSON.stringify(data2), + headers: { + 'api-key': process.env.MONGODB_DATA_API_KEY as string + } + } + ) + + const resData = await response.json().then(data => data) + const totalCount =await totalCountResp.json().then(data =>data); + + return { data: resData.documents, totalCount: totalCount.documents.at(0).count }; + // return resData.documents; + +} +//test +// const filter = { tags: {$in: ["Computer Network"] } } +// const skip = 10 +// const limit = 10 +// getQuestions({filter,skip,limit}); + + +export async function getTags() { + + const data = { + collection: "tags", + database:"test", + dataSource:"db", + } + + const response = await fetch(process.env.MONGODB_DATA_API_URL+"find", + { + method: "POST", + body: JSON.stringify(data), + headers: { + 'api-key': process.env.MONGODB_DATA_API_KEY as string + } + } + ) + + const data2 = { + collection: "questions", + database:"test", + dataSource:"db", + pipeline:[ + {$match: {}}, + { + $group: { + _id: null, + count: { $sum: 1 } + } + } + ] + } + + const resData = await response.json().then(data => data) + + console.log(resData.documents.length) + + return { data: resData.documents, totalCount: resData.documents.length }; +} \ No newline at end of file diff --git a/app/api/auth/[...nextauth]/route.ts b/app/api/auth/[...nextauth]/route.ts new file mode 100644 index 0000000..8b069f4 --- /dev/null +++ b/app/api/auth/[...nextauth]/route.ts @@ -0,0 +1,11 @@ +import NextAuth from 'next-auth'; +import { authOptions } from '@/app/_lib/authOptions' +// import { PrismaClient } from "@prisma/client" +// import { PrismaAdapter } from "@auth/prisma-adapter" +//TODO configure adapter +// If the adapter is not configured the new accounts created via oauth will not be saved to the db + + +const handler = NextAuth(authOptions); + +export { handler as GET, handler as POST} \ No newline at end of file diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..5c50357 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,38 @@ +import { connectToDatabase } from "@/app/_server/db-helper"; +import prisma from "@/prisma"; +import { NextResponse } from "next/server"; +import bcrypt from "bcrypt"; + +export const POST = async (req: Request) => { + try { + const { email, password } = await req.json(); + + if (!email || !password) { + return NextResponse.json({ message: "Invalid data" }, { status: 422 }); + } + + await connectToDatabase(); + + const user = await prisma.users.findFirst({ + where: { email: email } + }); + + console.log(email, password, "records received", user) + + if(!user){ + return NextResponse.json({message:"Not found"}, {status:404}) + } + const result =await bcrypt.compare(password, user.hashedPassword as string); + + if(!result){ + return NextResponse.json({message:"Wrong Password"}, {status:401}) + } + + return NextResponse.json({ user }, { status: 200 }); + } catch (error) { + console.log(error); + return NextResponse.json({ message: "Server Error" }, { status: 500 }); + } finally { + await prisma.$disconnect(); + } +}; diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts new file mode 100644 index 0000000..31a10e3 --- /dev/null +++ b/app/api/auth/register/route.ts @@ -0,0 +1,36 @@ +import { connectToDatabase } from "@/app/_server/db-helper"; +import prisma from "@/prisma"; +import { NextResponse } from "next/server" +import bcrypt from 'bcrypt' + +export const POST = async (req: Request) => { + try { + const {name, email, password} = await req.json() + + if(!email || !password){ + return NextResponse.json({message: "Invalid data"}, {status: 422}); + } + let userName = ""; + if(!name){ + userName = await fetch("https://random-data-api.com/api/v2/users").then(data => data.json()).then(json=> json.username); + // console.log(userName); + }else{ + userName = name; + } + + await connectToDatabase(); + const hashedPassword = await bcrypt.hash(password,12); + const alreadyUser = await prisma.users.findFirst({where: {email: email}}) + if(alreadyUser){ + return NextResponse.json({message:"Already registered!"}, {status:403}) + } + const user = await prisma.users.create({data:{email,name:userName,hashedPassword}}); + return NextResponse.json({user},{status:201}); + } catch (error) { + console.log(error); + return NextResponse.json({message: "Server Error"}, {status: 500}); + }finally{ + await prisma.$disconnect(); + } + +} \ No newline at end of file diff --git a/app/api/tags/route.ts b/app/api/tags/route.ts new file mode 100644 index 0000000..244ba86 --- /dev/null +++ b/app/api/tags/route.ts @@ -0,0 +1,16 @@ +import { Repository } from "@/app/_server/RepositoryService"; +import { ITag } from "@/app/_server/TagService/ITagService"; +import { NextResponse } from "next/server"; + +export async function GET() { + const repository: Repository = new Repository("tags"); + + async function getTags(): Promise<{ data: ITag[], totalCount: number }> { + const filter = {} + return repository.find(filter,1,9999); + } + + const {data}= await getTags() + + return NextResponse.json(data); +} diff --git a/app/favicon.ico b/app/favicon.ico new file mode 100644 index 0000000..718d6fe Binary files /dev/null and b/app/favicon.ico differ diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..fd81e88 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,27 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..108de25 --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,35 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./ui/globals.css"; +import Header from "../components/ui/Header"; +import { cn } from "@/app/_lib/utils"; +import { getServerSession } from "next-auth"; +import SessionProvider from "@/components/SessionProvider"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "GATE PYQ", + description: "A website to practice gate pyqs.", +}; + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const session = await getServerSession(); + + return ( + + + +
+
+ {children} +
+
+ + + ); +} diff --git a/app/not-found.tsx b/app/not-found.tsx new file mode 100644 index 0000000..0fda6e0 --- /dev/null +++ b/app/not-found.tsx @@ -0,0 +1,22 @@ +import Image from 'next/image' +import notfound from '@/public/notfound.svg' + +export default async function NotFound() { + + // const data = fetchImage() + // console.log(data) + + return( +
+

Page not found

+ +
+ ) + +} + +// async function fetchImage() { +// const response = await fetch("https://random.dog/woof.json") +// const json = await response.json(); +// return json.url; +// } diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..8b45e3f --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,64 @@ +import React, { Suspense } from "react"; +import HeroSection from "@/components/ui/HeroSection"; +import TagCard from "@/components/ui/TagCard"; +import { fetchTags } from "./_server/actions/TagActions"; +import { getServerSession } from "next-auth"; +import { getTags } from "./_server/mongo-data-api/data-api"; +import { ITag } from "./_server/TagService/ITagService"; +import SearchBar from "@/components/ui/SearchBar"; + +type ISearchQuery = { + page: string; +}; + +type HomeProps = { + searchParams?: { [key: string]: string | string[] | undefined }; +}; + +export default async function Home(params: HomeProps) { + // get the current page number + const { page } = params as ISearchQuery; + let pageNumber = page && !isNaN(Number(page)) ? Number(page) : 1; + + let tags; + let totalCount; + try{ + const result = await getTags(); + tags = result.data; + totalCount = result.totalCount; + }catch(error){ + console.log("Error in first query",error); + try{ + const result = await fetchTags(pageNumber, 999); + tags = result.data; + totalCount = result.totalCount; + + }catch(error){ + console.log("Error in second query",error); + + } + } + + + return ( +
+ + {/*
*/} + {/*
*/} + +
+ + {tags?.length ? ( + <> + {tags.map((tag:ITag, index:string) => ( + + ))} + + + ) : ( +

No tags found

+ )} +
+
+ ); +} diff --git a/app/profile/[user]/page.tsx b/app/profile/[user]/page.tsx new file mode 100644 index 0000000..de4ad10 --- /dev/null +++ b/app/profile/[user]/page.tsx @@ -0,0 +1,42 @@ +"use client"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { Button } from "@/components/ui/button"; +import { signOut, useSession } from "next-auth/react"; +import { useState } from "react"; + +type ProfileProps = { + params: any; +}; + +export default function Profile() { + const session = useSession(); + const [buttonText, setButtonText] = useState("Log out"); + + const name = session.data?.user?.name || "You are not signed in"; + const img = session.data?.user?.image as string; + const email = session.data?.user?.email; + + const logout = () => { + session.status === 'authenticated' ? signOut() : ("/login"); + }; + + console.log("name:", name); + + return ( +
+ + + {`${name.at(0)?.toUpperCase()}${name.at(1)?.toUpperCase()}`} + +
+

{name}

+

+ {" "}{email}{" "} +

+
+ +
+ ); +} diff --git a/app/profile/login/loading.tsx b/app/profile/login/loading.tsx new file mode 100644 index 0000000..d2c2f75 --- /dev/null +++ b/app/profile/login/loading.tsx @@ -0,0 +1,25 @@ +export default function Loading() { + + return( +
+

Loading...

+
+ + + + +
+
) + } \ No newline at end of file diff --git a/app/profile/login/page.tsx b/app/profile/login/page.tsx new file mode 100644 index 0000000..e467c2d --- /dev/null +++ b/app/profile/login/page.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { RiLockPasswordLine } from "react-icons/ri"; +import google from "@/public/google-logo.svg"; +import Image from "next/image"; +import Link from "next/link"; +import { ErrorMessage, Field, Form, Formik } from "formik"; +import { MdEmail } from "react-icons/md"; +import { validateLoginForm } from "@/app/_lib/utils"; +import { signIn } from "next-auth/react"; + +type ValuesTypeLoginForm = { + email: string; + password: string; +}; + +export default function LoginPage() { + const onLoginSubmit = async (values: ValuesTypeLoginForm,setFieldError: any) => { + const res = await signIn("credentials", { + email: values.email, + password: values.password, + callbackUrl:process.env.BASE_URL, + redirect: true + }); + + if(res?.status !== 200) { + console.log("inside if ",res) + const errorMessage = res?.error; + setFieldError("password", errorMessage ) + } + }; + + const handleGoogleSignIn = async () => { + signIn("google", { callbackUrl: process.env.BASE_URL }); + }; + + return ( +
+
+

+ Sign In +

+
+ { + onLoginSubmit(values,setFieldError); + }} + > + +{({ isSubmitting }) => ( +
+
+ + + + +
+ + +
+ + + + +
+ + + + + + +

or

+ + )} +
+ +

+ Don't have an account ? + + Register here. + +

+
+ ); +} diff --git a/app/profile/register/loading.tsx b/app/profile/register/loading.tsx new file mode 100644 index 0000000..ee8ae04 --- /dev/null +++ b/app/profile/register/loading.tsx @@ -0,0 +1,25 @@ +export default function Loading() { + + return( +
+

Loading...

+
+ + + + +
+
) + } \ No newline at end of file diff --git a/app/profile/register/page.tsx b/app/profile/register/page.tsx new file mode 100644 index 0000000..dacecfc --- /dev/null +++ b/app/profile/register/page.tsx @@ -0,0 +1,164 @@ +"use client"; +import { HiAtSymbol } from "react-icons/hi"; +import { RiLockPasswordLine } from "react-icons/ri"; +import { MdEmail } from "react-icons/md"; +import google from "@/public/google-logo.svg"; +import Image from "next/image"; +import Link from "next/link"; +import { ErrorMessage, Field, Form, Formik } from "formik"; +import { validateRegisterForm } from "@/app/_lib/utils"; +import { signIn } from "next-auth/react"; + +type valuesType = { + email: string; + username?: string; + password: string; + passwordC?: string; +}; + +export default function RegisterForm() { + + const onRegSubmit = async (values: valuesType, setFieldError: any) => { + const data = { + email: values.email, + password: values.password, + name: values.username, + }; + + const handleGoogleSignUp = async () => { + // TODO signUp() + }; + + const user = await fetch(`/api/auth/register/`, { + method: "POST", + body: JSON.stringify(data), + }); + if (user.ok) { + signIn("credentials", { + email: values.email, + password: values.password, + callbackUrl: process.env.BASE_URL, + }); + console.log("user registered", user); + // setRedirectH(true); + } else { + console.log("inside else ",user) + const data = await user.json(); + const errorMessage = data.message; + + setFieldError("email", errorMessage ) + + // const errorMessage = + // user?.status === 403 ? "Already registered" : "Server Error"; + // setFieldError("email", errorMessage); + console.log("error registering", user, errorMessage); + } + }; + + return ( +
+
+

+ Sign Up +

+
+ + onRegSubmit(values, setFieldError) + } + > +
+
+ + + + +
+ + + +
+ + + + +
+ + +
+ + + + +
+ + + +
+ + + + +
+ + + + {/* +

or

+ //TODO + */} + +
+ +

+ Already have an account ? + + Login here. + +

+
+ ); +} diff --git a/app/questions/[tag]/[pageNumber]/page.tsx b/app/questions/[tag]/[pageNumber]/page.tsx new file mode 100644 index 0000000..4f674ec --- /dev/null +++ b/app/questions/[tag]/[pageNumber]/page.tsx @@ -0,0 +1,128 @@ +"use server"; +import React from "react"; +// import { BookCard } from '@/Client/Components/Home/BookCard'; +import { fetchQuestions } from "@/app/_server/actions/QuestionActions"; +import QuestionCard from "@/components/ui/QuestionCard"; +import { + PaginationContent, + PaginationItem, + PaginationPrevious, + PaginationLink, + PaginationNext, + Pagination, +} from "@/components/ui/pagination"; +import { getQuestions } from "@/app/_server/mongo-data-api/data-api"; +import { IQuestion } from "@/app/_server/QuestionService/IQuestionService"; + +type ISearchQuery = { + page: string; +}; + +type HomeProps = { + params: any; + searchParams?: { [key: string]: string | string[] | undefined }; +}; +export default async function Home(searchParams: HomeProps) { + // get the current page number + + const page = searchParams.params.pageNumber; + const text = decodeURI(searchParams.params.tag); + + const pageNumber = page && !isNaN(Number(page)) ? Number(page) : 1; + + const limit = 10; + const skip = pageNumber === 1 ? 0 : 10; + + let questions: IQuestion[]; + let totalCount; + try { + const result = await getQuestions({ + arr: [text], + skip: skip, + limit: limit, + }); + questions = result.data; + totalCount = result.totalCount; + } catch (error) { + // If the first query fails, run the second one + console.error("Error with the first query:", error); + + try { + const result = await fetchQuestions( + text, + pageNumber, + limit + ); + questions = result.data; + totalCount = result.totalCount; + } catch (secondError) { + // Handle the second query error + console.error("Error with the second query:", secondError); + } + } + + console.log("page #", pageNumber, " tag", text); + /* begin:: feetch book list */ + // const limit = 10; + // // const { data: questions, totalCount } = await fetchQuestions( + // // text, + // // pageNumber, + // // limit + // // ); + // const skip = pageNumber == 1 ? 0 : 10; + + // const { data: questions, totalCount } = await getQuestions({ + // arr: [text], + // skip: skip, + // limit: limit, + // }); + + return ( +
+

+ {text} +

+
+
+ {questions!.length ? ( + <> + {questions!.map((question: IQuestion, index: number) => ( + + ))} + + + {pageNumber > 1 && ( + + + + )} + + {pageNumber} + + {totalCount - limit * page + 10 > 10 && ( + + + + )} + + + + ) : ( +

No books found

+ )} +
+
+ ); +} diff --git a/app/ui/globals.css b/app/ui/globals.css new file mode 100644 index 0000000..9ce444f --- /dev/null +++ b/app/ui/globals.css @@ -0,0 +1,60 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + + +@layer base { + :root { + --background: 0 0% 100%; + --foreground: 222.2 84% 4.9%; + --card: 0 0% 100%; + --card-foreground: 222.2 84% 4.9%; + --popover: 0 0% 100%; + --popover-foreground: 222.2 84% 4.9%; + --primary: 222.2 47.4% 11.2%; + --primary-foreground: 210 40% 98%; + --secondary: 210 40% 96.1%; + --secondary-foreground: 222.2 47.4% 11.2%; + --muted: 210 40% 96.1%; + --muted-foreground: 215.4 16.3% 46.9%; + --accent: 210 40% 96.1%; + --accent-foreground: 222.2 47.4% 11.2%; + --destructive: 0 84.2% 60.2%; + --destructive-foreground: 210 40% 98%; + --border: 214.3 31.8% 91.4%; + --input: 214.3 31.8% 91.4%; + --ring: 222.2 84% 4.9%; + --radius: 0.75rem; + } + + .dark { + --background: 222.2 84% 4.9%; + --foreground: 210 40% 98%; + --card: 222.2 84% 4.9%; + --card-foreground: 210 40% 98%; + --popover: 222.2 84% 4.9%; + --popover-foreground: 210 40% 98%; + --primary: 210 40% 98%; + --primary-foreground: 222.2 47.4% 11.2%; + --secondary: 217.2 32.6% 17.5%; + --secondary-foreground: 210 40% 98%; + --muted: 217.2 32.6% 17.5%; + --muted-foreground: 215 20.2% 65.1%; + --accent: 217.2 32.6% 17.5%; + --accent-foreground: 210 40% 98%; + --destructive: 0 62.8% 30.6%; + --destructive-foreground: 210 40% 98%; + --border: 217.2 32.6% 17.5%; + --input: 217.2 32.6% 17.5%; + --ring: 212.7 26.8% 83.9; + } +} + +@layer base { + * { + @apply border-border; + } + body { + @apply bg-background text-foreground; + } +} \ No newline at end of file diff --git a/components.json b/components.json new file mode 100644 index 0000000..9247b1e --- /dev/null +++ b/components.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "default", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "tailwind.config.ts", + "css": "app/ui/globals.css", + "baseColor": "zinc", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils" + } +} \ No newline at end of file diff --git a/components/SessionProvider.tsx b/components/SessionProvider.tsx new file mode 100644 index 0000000..1374150 --- /dev/null +++ b/components/SessionProvider.tsx @@ -0,0 +1,3 @@ +'use client' +import { SessionProvider } from "next-auth/react"; +export default SessionProvider; \ No newline at end of file diff --git a/components/ui/Header.tsx b/components/ui/Header.tsx new file mode 100644 index 0000000..bb57e80 --- /dev/null +++ b/components/ui/Header.tsx @@ -0,0 +1,53 @@ +"use client"; +import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { useSession } from "next-auth/react"; +import Link from "next/link"; + +type HeaderProps = { + text: string; +}; +export default function Header({ text }: HeaderProps) { + return ( + + ); +} +type AuthButtonProps = { + className:string +} +export function AuthButton({className}: AuthButtonProps) { + const { data: session } = useSession(); + console.log("session present ?", session); + const name = session?.user?.name || "AB"; + const img = session?.user?.image as string; + + + + return ( +
+ {!session && ( + +
+ +
+ + + )} + + {session && ( + + + + {`${name.at(0)?.toUpperCase()}${name.at(1)?.toUpperCase()}`} + + + )} +
+ ); +} diff --git a/components/ui/HeroSection.tsx b/components/ui/HeroSection.tsx new file mode 100644 index 0000000..68437a8 --- /dev/null +++ b/components/ui/HeroSection.tsx @@ -0,0 +1,43 @@ +import { Button } from "@/components/ui/button"; +import heroImage from "@/public/hero-image.svg" +import Link from "next/link"; +import Image from 'next/image' +import sparkle from '@/public/sparkle.svg' +type HearoSectionProps = { + buttonText: string | undefined; +} +export default function HeroSection({buttonText}:HearoSectionProps) { + + return( +
+ +
+

+ Practice GATE CSE Previous year questions on the go! +

+

+ Your one stop destination to practice GATE previous year questions.
+ Get expalanations from Google's Gemini Pro + sparkle +

+
+ + + + + +
+
+ +
+ hero image +
+
+ ) +} \ No newline at end of file diff --git a/components/ui/LoadingButton.tsx b/components/ui/LoadingButton.tsx new file mode 100644 index 0000000..e4322a6 --- /dev/null +++ b/components/ui/LoadingButton.tsx @@ -0,0 +1,68 @@ +'use client' +import { cn } from "@/app/_lib/utils"; +import sparkle from "@/public/sparkle.svg"; +import { useSession } from "next-auth/react"; +import Image from "next/image"; +import { useEffect, useState } from "react"; + + +type LoadingButtonProps = { + text: string; + onClick: () => void; + loading: boolean; + }; + export function LoadingButton({ + text, + onClick, + loading = false + }: LoadingButtonProps) { + const [disabled, setDisabled] = useState(true); + const session = useSession(); + + useEffect( + ()=> { + if(session.status==='authenticated') setDisabled(false); if(session.status==='unauthenticated') setDisabled(true); + }, [] + ) + + return ( + + + ); + } + \ No newline at end of file diff --git a/components/ui/QuestionCard.tsx b/components/ui/QuestionCard.tsx new file mode 100644 index 0000000..b794446 --- /dev/null +++ b/components/ui/QuestionCard.tsx @@ -0,0 +1,128 @@ +//@ts-nocheck +"use client"; +import React, { useState, useEffect, Suspense } from "react"; +import { cn, shuffleArray } from "@/app/_lib/utils"; +import { askGemini } from "@/app/_server/actions/GeminiActions"; +import { IQuestion } from "@/app/_server/QuestionService/IQuestionService"; +import { LoadingButton } from "./LoadingButton"; +import Image from "next/image"; +import correct from "@/public/correct.svg"; +import wrong from "@/public/wrong.svg"; +import Link from "next/link"; +import { useSession } from "next-auth/react"; +import SmallTag from "./SmallTag"; +type QuestionCardProps = { + question: IQuestion; + questionNumber: number; +}; +export default function QuestionCard({ + question, + questionNumber, +}: QuestionCardProps) { + const [optionsArr, setOptionsArr] = useState([]); + const [answers, setAnswers] = useState([]); + const [geminiResponse, setGeminiResponse] = useState(""); + const [loading, setLoading] = useState(false); + + const correct_options = question.correct_options; + const [selectedAnswers, setSelectedAnswers] = useState([]); + const answerCheck = (option: string) => { + setSelectedAnswers([...selectedAnswers, option]); + selectedAnswers.push(option); + if (correct_options.length === selectedAnswers.length) { + setAnswers(correct_options); + } + }; + + useEffect(() => { + const arr = shuffleArray(question.options); + setOptionsArr(arr); + }, []); + + let image_link = + question.image_links.length > 0 ? question.image_links[1] : ""; + + const askGeminiClicked = () => { + setLoading(true); + setTimeout(() => { + const response = askGemini(question.question, correct_options.toString()); + + if (response != null) setLoading(false); + setGeminiResponse(response); + }, 500); + }; + + return ( +
+
+

Q{`${questionNumber}. `}

+ {`${question.question}`} +
+ +
{ + question.tags.map((tag,index) => { + return + }) + }
+ + + {/* {image_link && ( + + )} */} + + {optionsArr.map((option, index) => ( +
+
answerCheck(option)} + className={cn( + "flex flex-row font-outfit font-medium mx-2 my-2.5 rounded-3xl bg-zinc-800 lg:px-20 px-5 py-5 active:scale-90 transition-transform", + { + "bg-green-900": selectedAnswers.includes(option), + "bg-blue-900": answers.length != 0 && answers.includes(option), + "bg-red-900": answers.length != 0 && !answers.includes(option), + "text-gray-200": answers, + } + )} + > + + {option} +
+
+ ))} +
+ + GateOverflow + + + + {/* */} +

+ Gemini : {geminiResponse} +

+
+
+ ); +} diff --git a/components/ui/SearchBar.tsx b/components/ui/SearchBar.tsx new file mode 100644 index 0000000..12b5dfa --- /dev/null +++ b/components/ui/SearchBar.tsx @@ -0,0 +1,13 @@ +import { useState } from "react"; + +type SearchBarProps = { +} +export default function SearchBar() { + const [value, setValue] = useState(''); + return ( + { setValue(e.currentTarget.value); }} + placeholder="Search a topic"/> + ); +} diff --git a/components/ui/SmallTag.tsx b/components/ui/SmallTag.tsx new file mode 100644 index 0000000..1e23ae0 --- /dev/null +++ b/components/ui/SmallTag.tsx @@ -0,0 +1,12 @@ +import Link from "next/link"; + +type SmallTagProps = { + text : string; +} +export default function SmallTag({text}:SmallTagProps) { + return ( + + + + ); +} diff --git a/components/ui/TagCard.tsx b/components/ui/TagCard.tsx new file mode 100644 index 0000000..d496188 --- /dev/null +++ b/components/ui/TagCard.tsx @@ -0,0 +1,16 @@ +import Link from "next/link"; + +type TagCardProps = { + text : string; +} +export default async function TagCard({text}:TagCardProps) { + return ( + + + + + ); +} + + diff --git a/components/ui/avatar.tsx b/components/ui/avatar.tsx new file mode 100644 index 0000000..87ea84c --- /dev/null +++ b/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 "@/app/_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/components/ui/button.tsx b/components/ui/button.tsx new file mode 100644 index 0000000..0a54f73 --- /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 "@/app/_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/pagination.tsx b/components/ui/pagination.tsx new file mode 100644 index 0000000..58ba15b --- /dev/null +++ b/components/ui/pagination.tsx @@ -0,0 +1,117 @@ +import * as React from "react" +import { ChevronLeft, ChevronRight, MoreHorizontal } from "lucide-react" + +import { cn } from "@/app/_lib/utils" +import { ButtonProps, buttonVariants } from "@/components/ui/button" + +const Pagination = ({ className, ...props }: React.ComponentProps<"nav">) => ( +