├── .eslintrc.json ├── postcss.config.js ├── public └── assets │ ├── logo │ └── library.png │ └── images │ └── books │ ├── 6472b0f2db6b9663048dae6f-1685238002664-image.jpg │ ├── 6472c0fcdb6b9663048dae9d-1685242108757-11127.jpg │ ├── 6472be01db6b9663048dae8c-1685241345232-the-shining-1.jpg │ ├── 6472c01cdb6b9663048dae9a-1685241884369-the-great-gatsby-11.jpg │ ├── 64749d3601673c4abc3d191c-1685364133009-Book-cover-page-3-CRC.png │ ├── 647594f31a145e940a7007ed-1685427443963-Book-cover-page-3-CRC.png │ ├── 647596251a145e940a7007f0-1685427749208-Book-cover-page-3-CRC.png │ ├── 6476ab5b9002c060b4aba0f8-1685498755986-Book-cover-page-3-CRC.png │ ├── 647143b499ad08462dfc28d8-1685147201235-Cthulhu_Mythos_Hrairoo.webp │ ├── 6472b17edb6b9663048dae71-1685238142879-the-old-man-and-the-sea-70.jpg │ ├── 6472be4bdb6b9663048dae8e-1685241419580-jane-austen-s-pride-prejudice.jpg │ ├── 6472bee3db6b9663048dae94-1685241571867-murder-on-the-orient-express.jpg │ ├── 6472be94db6b9663048dae91-1685241492139-nineteen-eighty-four-1984-george.jpg │ ├── 6472c083db6b9663048dae9c-1685241987279-the-hobbit-illustrated-by-alan-lee.jpg │ ├── 6471518b99ad08462dfc28dc-1685148043122-programming-concept-illustration_114360-1351.png │ └── 6472bf6ddb6b9663048dae97-1685241709320-book-cover-To-Kill-a-Mockingbird-many-1961.webp ├── next.config.js ├── pages ├── authors │ └── index.tsx ├── books │ └── index.tsx ├── categories │ └── index.tsx ├── api │ ├── users │ │ ├── index.ts │ │ └── user.ts │ ├── index.ts │ ├── authors │ │ ├── index.ts │ │ └── author.ts │ ├── count │ │ └── index.ts │ ├── books │ │ ├── categories │ │ │ ├── index.ts │ │ │ └── category.ts │ │ ├── index.ts │ │ └── borrows │ │ │ ├── index.ts │ │ │ └── borrow.ts │ └── auth │ │ ├── signup.ts │ │ └── signin.ts ├── _app.tsx └── manage │ └── index.tsx ├── server ├── mongo │ ├── authDb.ts │ ├── userDb.ts │ ├── authorDb.ts │ ├── bookDb.ts │ └── db.ts ├── bcrypt.ts └── mongodb.ts ├── utils ├── models │ ├── author.ts │ ├── user.ts │ ├── auth.ts │ └── book.ts ├── site.ts ├── regex.ts ├── error │ └── error.ts ├── functions │ └── date.ts └── types │ └── file.ts ├── redux ├── hooks.ts ├── store.ts └── slice │ └── usersSlice.ts ├── .gitignore ├── tsconfig.json ├── middleware └── formidableMiddleware.ts ├── hooks ├── useUser.tsx ├── useBook.tsx ├── useInput.tsx └── useAuth.tsx ├── .env.example ├── components ├── NavigationBar │ ├── NavigationBar.tsx │ └── NavigationBar │ │ ├── Logo.tsx │ │ └── UserMenu.tsx ├── Input │ ├── SearchBar.tsx │ └── MainSearchBar.tsx ├── Breadcrumb │ └── ManageBreadcrumb.tsx ├── Layout │ └── Layout.tsx ├── Book │ ├── BookCategoryTags.tsx │ └── BookCard.tsx ├── Category │ └── CategoryTagsList.tsx ├── Table │ ├── Pagination.tsx │ ├── Author │ │ └── AuthorItem.tsx │ └── Book │ │ └── BookItem.tsx ├── Pages │ └── AuthPageComponent.tsx └── Borrow │ └── BorrowCard.tsx ├── tailwind.config.js ├── package.json ├── styles └── app.scss └── README.md /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /public/assets/logo/library.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/logo/library.png -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | 3 | const nextConfig = { 4 | reactStrictMode: true, 5 | images: { 6 | domains: [], 7 | }, 8 | }; 9 | 10 | module.exports = nextConfig; 11 | -------------------------------------------------------------------------------- /pages/authors/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type Props = {}; 4 | 5 | const AuthorsPage = (props: Props) => { 6 | return
AuthorsPage
; 7 | }; 8 | 9 | export default AuthorsPage; 10 | -------------------------------------------------------------------------------- /public/assets/images/books/6472b0f2db6b9663048dae6f-1685238002664-image.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472b0f2db6b9663048dae6f-1685238002664-image.jpg -------------------------------------------------------------------------------- /public/assets/images/books/6472c0fcdb6b9663048dae9d-1685242108757-11127.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472c0fcdb6b9663048dae9d-1685242108757-11127.jpg -------------------------------------------------------------------------------- /pages/books/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type BooksPageProps = {}; 4 | 5 | const BooksPage: React.FC = () => { 6 | return
BooksPage
; 7 | }; 8 | 9 | export default BooksPage; 10 | -------------------------------------------------------------------------------- /public/assets/images/books/6472be01db6b9663048dae8c-1685241345232-the-shining-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472be01db6b9663048dae8c-1685241345232-the-shining-1.jpg -------------------------------------------------------------------------------- /public/assets/images/books/6472c01cdb6b9663048dae9a-1685241884369-the-great-gatsby-11.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472c01cdb6b9663048dae9a-1685241884369-the-great-gatsby-11.jpg -------------------------------------------------------------------------------- /pages/categories/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | type CategoriesPageProps = {}; 4 | 5 | const CategoriesPage: React.FC = () => { 6 | return
CategoriesPage
; 7 | }; 8 | 9 | export default CategoriesPage; 10 | -------------------------------------------------------------------------------- /public/assets/images/books/64749d3601673c4abc3d191c-1685364133009-Book-cover-page-3-CRC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/64749d3601673c4abc3d191c-1685364133009-Book-cover-page-3-CRC.png -------------------------------------------------------------------------------- /public/assets/images/books/647594f31a145e940a7007ed-1685427443963-Book-cover-page-3-CRC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/647594f31a145e940a7007ed-1685427443963-Book-cover-page-3-CRC.png -------------------------------------------------------------------------------- /public/assets/images/books/647596251a145e940a7007f0-1685427749208-Book-cover-page-3-CRC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/647596251a145e940a7007f0-1685427749208-Book-cover-page-3-CRC.png -------------------------------------------------------------------------------- /public/assets/images/books/6476ab5b9002c060b4aba0f8-1685498755986-Book-cover-page-3-CRC.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6476ab5b9002c060b4aba0f8-1685498755986-Book-cover-page-3-CRC.png -------------------------------------------------------------------------------- /public/assets/images/books/647143b499ad08462dfc28d8-1685147201235-Cthulhu_Mythos_Hrairoo.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/647143b499ad08462dfc28d8-1685147201235-Cthulhu_Mythos_Hrairoo.webp -------------------------------------------------------------------------------- /public/assets/images/books/6472b17edb6b9663048dae71-1685238142879-the-old-man-and-the-sea-70.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472b17edb6b9663048dae71-1685238142879-the-old-man-and-the-sea-70.jpg -------------------------------------------------------------------------------- /public/assets/images/books/6472be4bdb6b9663048dae8e-1685241419580-jane-austen-s-pride-prejudice.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472be4bdb6b9663048dae8e-1685241419580-jane-austen-s-pride-prejudice.jpg -------------------------------------------------------------------------------- /public/assets/images/books/6472bee3db6b9663048dae94-1685241571867-murder-on-the-orient-express.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472bee3db6b9663048dae94-1685241571867-murder-on-the-orient-express.jpg -------------------------------------------------------------------------------- /public/assets/images/books/6472be94db6b9663048dae91-1685241492139-nineteen-eighty-four-1984-george.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472be94db6b9663048dae91-1685241492139-nineteen-eighty-four-1984-george.jpg -------------------------------------------------------------------------------- /server/mongo/authDb.ts: -------------------------------------------------------------------------------- 1 | import mongoDb from "./db"; 2 | 3 | export default async function authDb() { 4 | const { libraryDb } = await mongoDb(); 5 | 6 | const authCollection = await libraryDb.collection("auth"); 7 | 8 | return { 9 | authCollection, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /server/mongo/userDb.ts: -------------------------------------------------------------------------------- 1 | import mongoDb from "./db"; 2 | 3 | export default async function userDb() { 4 | const { libraryDb } = await mongoDb(); 5 | 6 | const usersCollection = await libraryDb.collection("users"); 7 | 8 | return { 9 | usersCollection, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /public/assets/images/books/6472c083db6b9663048dae9c-1685241987279-the-hobbit-illustrated-by-alan-lee.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472c083db6b9663048dae9c-1685241987279-the-hobbit-illustrated-by-alan-lee.jpg -------------------------------------------------------------------------------- /utils/models/author.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | export interface Author { 4 | _id: ObjectId; 5 | id: string; 6 | name: string; 7 | biography?: string; 8 | birthdate?: Date | string; 9 | updatedAt: Date | string; 10 | createdAt: Date | string; 11 | } 12 | -------------------------------------------------------------------------------- /server/mongo/authorDb.ts: -------------------------------------------------------------------------------- 1 | import mongoDb from "./db"; 2 | 3 | export default async function authorDb() { 4 | const { libraryDb } = await mongoDb(); 5 | 6 | const authorsCollection = await libraryDb.collection("authors"); 7 | 8 | return { 9 | authorsCollection, 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /redux/hooks.ts: -------------------------------------------------------------------------------- 1 | import { useDispatch, useSelector, TypedUseSelectorHook } from "react-redux"; 2 | import type { RootState, AppDispatch } from "./store"; 3 | 4 | export const useAppDispatch = () => useDispatch(); 5 | export const useAppSelector: TypedUseSelectorHook = useSelector; 6 | -------------------------------------------------------------------------------- /public/assets/images/books/6471518b99ad08462dfc28dc-1685148043122-programming-concept-illustration_114360-1351.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6471518b99ad08462dfc28dc-1685148043122-programming-concept-illustration_114360-1351.png -------------------------------------------------------------------------------- /public/assets/images/books/6472bf6ddb6b9663048dae97-1685241709320-book-cover-To-Kill-a-Mockingbird-many-1961.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/godkingjay/library-management-system-next-react/HEAD/public/assets/images/books/6472bf6ddb6b9663048dae97-1685241709320-book-cover-To-Kill-a-Mockingbird-many-1961.webp -------------------------------------------------------------------------------- /utils/site.ts: -------------------------------------------------------------------------------- 1 | export const siteConfig = { 2 | baseUrl: process.env.NEXT_PUBLIC_BASE_URL as string, 3 | }; 4 | 5 | export const apiConfig = { 6 | apiEndpoint: process.env.NEXT_PUBLIC_API_ENDPOINT as string, 7 | }; 8 | 9 | export const jwtConfig = { 10 | secretKey: process.env.NEXT_PUBLIC_JWT_SECRET_KEY as string, 11 | }; 12 | -------------------------------------------------------------------------------- /utils/models/user.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | export interface SiteUser { 4 | _id?: ObjectId; 5 | id: string; 6 | username: string; 7 | email: string; 8 | firstName?: string; 9 | lastName?: string; 10 | birthDate?: Date | string; 11 | roles: ("admin" | "user")[]; 12 | updatedAt: Date | string; 13 | createdAt: Date | string; 14 | } 15 | -------------------------------------------------------------------------------- /redux/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import usersReducer from "./slice/usersSlice"; 3 | 4 | const store = configureStore({ 5 | reducer: { 6 | users: usersReducer, 7 | }, 8 | }); 9 | 10 | export type RootState = ReturnType; 11 | export type AppDispatch = typeof store.dispatch; 12 | 13 | export default store; 14 | -------------------------------------------------------------------------------- /pages/api/users/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | export default async function handler( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) { 7 | try { 8 | } catch (error: any) { 9 | return res.status(500).json({ 10 | statusCode: 500, 11 | error: { 12 | type: "Server Error", 13 | ...error, 14 | }, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pages/api/users/user.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | export default async function handler( 4 | req: NextApiRequest, 5 | res: NextApiResponse 6 | ) { 7 | try { 8 | } catch (error: any) { 9 | return res.status(500).json({ 10 | statusCode: 500, 11 | error: { 12 | type: "Server Error", 13 | ...error, 14 | }, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /utils/regex.ts: -------------------------------------------------------------------------------- 1 | export const EmailRegex = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/; 2 | 3 | export const PasswordRegex = /^(?=.*[A-Za-z\d@$!%*?&])[A-Za-z\d@$!%*?&]{8,256}$/; 4 | 5 | export const NameRegex = 6 | /^(?=.{1,49}$)([A-Z][a-z]*(?:[\s'-]([A-Z][a-z]*|[A-Z]?[a-z]+))*)$/; 7 | 8 | export const ISBNRegex = 9 | /^(?:ISBN(?:-1[03])?:?\s*)?(?=[0-9X]{10}$|(?=(?:[0-9]+\s+){3})[0-9\sX]{13}$|97[89][0-9]{10}$|(?=(?:[0-9]+\s+){4})[0-9\s]{17}$)\d+$/; 10 | -------------------------------------------------------------------------------- /pages/api/index.ts: -------------------------------------------------------------------------------- 1 | import mongoDb from "@/server/mongo/db"; 2 | import { NextApiRequest, NextApiResponse } from "next"; 3 | 4 | export default async function handler( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | try { 9 | const { client, libraryDb } = await mongoDb(); 10 | 11 | res.status(200).json({ statusCode: 200, message: "Connected to DB" }); 12 | } catch (error: any) { 13 | res.status(500).json({ statusCode: 500, message: error }); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /server/mongo/bookDb.ts: -------------------------------------------------------------------------------- 1 | import mongoDb from "./db"; 2 | 3 | export default async function bookDb() { 4 | const { libraryDb } = await mongoDb(); 5 | 6 | const booksCollection = await libraryDb.collection("books"); 7 | const bookCategoriesCollection = await libraryDb.collection("book-categories"); 8 | const bookBorrowsCollection = await libraryDb.collection("book-borrows"); 9 | 10 | return { 11 | booksCollection, 12 | bookCategoriesCollection, 13 | bookBorrowsCollection, 14 | }; 15 | } 16 | -------------------------------------------------------------------------------- /server/bcrypt.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | 3 | export const hashPassword = async (password: string): Promise => { 4 | const saltRounds = 10; 5 | const salt = await bcrypt.genSalt(saltRounds); 6 | const hashedPassword = await bcrypt.hash(password, salt); 7 | return hashedPassword; 8 | }; 9 | 10 | export const comparePasswords = async ( 11 | password: string, 12 | hashedPassword: string 13 | ): Promise => { 14 | return await bcrypt.compare(password, hashedPassword); 15 | }; 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env*.local 29 | 30 | # vercel 31 | .vercel 32 | 33 | # typescript 34 | *.tsbuildinfo 35 | next-env.d.ts 36 | -------------------------------------------------------------------------------- /utils/models/auth.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | export interface UserAuth { 4 | _id: ObjectId; 5 | id: string; 6 | username: string; 7 | email: string; 8 | password: string; 9 | keys: UserAPIKey[]; 10 | lastSignIn: Date | string; 11 | updatedAt: Date | string; 12 | createdAt: Date | string; 13 | session?: { 14 | token: string; 15 | expiresAt: Date | string; 16 | updatedAt: Date | string; 17 | createdAt: Date | string; 18 | }; 19 | } 20 | 21 | export interface UserAPIKey { 22 | key: string; 23 | createdAt: Date | string; 24 | } 25 | -------------------------------------------------------------------------------- /utils/error/error.ts: -------------------------------------------------------------------------------- 1 | const authError = { 2 | "=>Parameter Error: Session token is required": "Invalid Session", 3 | "=>Parameter Error: Email and password are required": "Missing Parameters", 4 | "=>Parameter Error: Email is invalid": "Invalid Email", 5 | "=>Parameter Error: Password is invalid": "Invalid Password", 6 | "=>API: Sign In Failed:\nEmail or password is incorrect": 7 | "Invalid Credentials", 8 | "=>API: Sign Up Failed: User is undefined": "User Not Found", 9 | "=>Parameter Error: Email or username and password are required": 10 | "Missing Parameters", 11 | "=>Parameter Error: Username is required": "Missing Parameters", 12 | }; 13 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ESNext", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./*"] 20 | }, 21 | "types": ["node"] 22 | }, 23 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 24 | "exclude": ["node_modules"] 25 | } 26 | -------------------------------------------------------------------------------- /middleware/formidableMiddleware.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest } from "next"; 2 | import formidable from "formidable"; 3 | 4 | export type FormidableParseReturn = { 5 | fields: formidable.Fields; 6 | files: formidable.Files; 7 | }; 8 | 9 | export async function parseFormAsync( 10 | req: NextApiRequest, 11 | formidableOptions?: formidable.Options 12 | ): Promise { 13 | const form = formidable({ 14 | ...formidableOptions, 15 | }); 16 | 17 | return await new Promise((resolve, reject) => { 18 | form.parse(req, async (err, fields, files) => { 19 | if (err) { 20 | reject(err); 21 | } 22 | 23 | resolve({ fields, files }); 24 | }); 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /hooks/useUser.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useMemo } from "react"; 2 | import { useAppDispatch, useAppSelector } from "@/redux/hooks"; 3 | import { UserState, setUsersState } from "@/redux/slice/usersSlice"; 4 | 5 | const useUser = () => { 6 | const dispatch = useAppDispatch(); 7 | 8 | const usersStateValue = useAppSelector((state) => state.users); 9 | 10 | const usersStateValueMemo = useMemo(() => usersStateValue, [usersStateValue]); 11 | 12 | const setUsersStateValueMemo = useCallback( 13 | (userState: UserState) => { 14 | dispatch(setUsersState(userState)); 15 | }, 16 | [dispatch] 17 | ); 18 | 19 | return { 20 | usersStateValue: usersStateValueMemo, 21 | setUsersStateValue: setUsersStateValueMemo, 22 | }; 23 | }; 24 | 25 | export default useUser; 26 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import type { AppProps } from "next/app"; 2 | import { Provider } from "react-redux"; 3 | import { ChakraProvider } from "@chakra-ui/react"; 4 | 5 | import store from "@/redux/store"; 6 | 7 | import "@/styles/app.scss"; 8 | import Layout from "@/components/Layout/Layout"; 9 | import Head from "next/head"; 10 | 11 | export default function App({ Component, pageProps }: AppProps) { 12 | return ( 13 | <> 14 | 15 | LibMS 16 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | # Rename this file to ".env.local" and fill the variables with your own values 2 | 3 | #NEXT_PUBLIC_BASE_URL is the base url of the website, e.g. http://localhost:3000 4 | NEXT_PUBLIC_BASE_URL=#Website Base URL e.g. http://localhost:3000 5 | 6 | #NEXT_PUBLIC_API_ENDPOINT is the base url of the api, e.g. http://localhost:3000/api 7 | NEXT_PUBLIC_API_ENDPOINT=#API Endpoint Base URL e.g. http://localhost:3000/api 8 | 9 | #NEXT_PUBLIC_JWT_SECRET_KEY is the secret key used to generate JWT tokens, e.g. Hello 10 | NEXT_PUBLIC_JWT_SECRET_KEY=#JWT Generator Secret Key e.g. Hello 11 | 12 | #MONGODB_URI is the endpoint of the MongoDB database, e.g. mongodb://0.0.0.0:27017/library-db 13 | MONGODB_URI=#MongoDB Database Endpoint e.g. mongodb://0.0.0.0:27017/library-db 14 | NEXT_PUBLIC_MONGODB_NAME=#MongoDB Database Name e.g. library-db 15 | -------------------------------------------------------------------------------- /server/mongodb.ts: -------------------------------------------------------------------------------- 1 | import { MongoClient } from "mongodb"; 2 | 3 | if (!process.env.MONGODB_URI) { 4 | throw new Error("Please add your Mongo URI to .env.local"); 5 | } 6 | 7 | const uri: string = process.env.MONGODB_URI; 8 | let client: MongoClient; 9 | let clientPromise: Promise; 10 | 11 | if (process.env.NODE_ENV === "development") { 12 | let globalWithMongoClientPromise = global as typeof globalThis & { 13 | _mongoClientPromise: Promise; 14 | }; 15 | 16 | if (!globalWithMongoClientPromise._mongoClientPromise) { 17 | client = new MongoClient(uri); 18 | globalWithMongoClientPromise._mongoClientPromise = client.connect(); 19 | } 20 | 21 | clientPromise = globalWithMongoClientPromise._mongoClientPromise; 22 | } else { 23 | client = new MongoClient(uri); 24 | clientPromise = client.connect(); 25 | } 26 | 27 | export default clientPromise; 28 | -------------------------------------------------------------------------------- /components/NavigationBar/NavigationBar.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import Logo from "./NavigationBar/Logo"; 3 | import UserMenu from "./NavigationBar/UserMenu"; 4 | 5 | type NavigationBarProps = {}; 6 | 7 | const NavigationBar: React.FC = () => { 8 | return ( 9 | <> 10 |
11 |
12 |
13 | 14 |
15 | {/*
16 | navigation 17 |
*/} 18 |
19 | 20 |
21 |
22 |
23 | 24 | ); 25 | }; 26 | 27 | export default NavigationBar; 28 | -------------------------------------------------------------------------------- /server/mongo/db.ts: -------------------------------------------------------------------------------- 1 | import clientPromise from "../mongodb"; 2 | 3 | export default async function mongoDb() { 4 | const client = await clientPromise; 5 | const libraryDb = await client.db( 6 | process.env.NEXT_PUBLIC_MONGODB_NAME as string 7 | ); 8 | const session = client.startSession(); 9 | 10 | const clientCloseConnection = async () => { 11 | await client.close(); 12 | }; 13 | 14 | // const startTransaction = async () => { 15 | // await session.startTransaction(); 16 | // }; 17 | 18 | // const commitTransaction = async () => { 19 | // await session.commitTransaction(); 20 | // }; 21 | 22 | // const abortTransaction = async () => { 23 | // await session.abortTransaction(); 24 | // }; 25 | 26 | // const endSession = async () => { 27 | // await session.endSession(); 28 | // }; 29 | 30 | return { 31 | client, 32 | libraryDb, 33 | clientCloseConnection, 34 | session, 35 | }; 36 | } 37 | -------------------------------------------------------------------------------- /components/Input/SearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Input, 4 | InputGroup, 5 | InputLeftElement, 6 | IconButton, 7 | Icon, 8 | } from "@chakra-ui/react"; 9 | import { BiSearchAlt } from "react-icons/bi"; 10 | 11 | interface SearchBarProps { 12 | placeholder?: string; 13 | onSearch: (query: string) => void; 14 | } 15 | 16 | const SearchBar: React.FC = ({ 17 | placeholder = "Search...", 18 | onSearch, 19 | }) => { 20 | const handleSearch = (event: React.ChangeEvent) => { 21 | const query = event.target.value; 22 | onSearch(query); 23 | }; 24 | 25 | return ( 26 | 27 | 28 | 32 | 33 | 34 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default SearchBar; 45 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./components/**/*.{js,jsx,ts,tsx,mdx}", 5 | "./pages/**/*.{js,jsx,ts,tsx,mdx}", 6 | "./hooks/**/*.{js,jsx,ts,tsx,mdx}", 7 | ], 8 | theme: { 9 | extend: { 10 | boxShadow: { 11 | "around-2xs": "0 0 1px 1px #0001", 12 | "around-xs": "0 0 2px 1px #0001", 13 | "around-sm": "0 0 4px 0 #0001", 14 | "around-md": "0 0 8px 0 #0001", 15 | "around-lg": "0 0 16px 0 #0001", 16 | "around-xl": "0 0 24px 0 #0002", 17 | "page-box-1": "0 1px 1px #0002", 18 | }, 19 | fontSize: { 20 | "2xs": "0.625rem", 21 | "3xs": "0.5rem", 22 | }, 23 | maxWidth: { 24 | "2xs": "256px", 25 | }, 26 | screens: { 27 | "2xs": "360px", 28 | xs: "480px", 29 | sm2: "640px", 30 | }, 31 | width: { 32 | "2xs": "256px", 33 | xs: "320px", 34 | sm: "384px", 35 | xl: "640px", 36 | "2xl": "764px", 37 | }, 38 | }, 39 | }, 40 | plugins: [], 41 | }; 42 | -------------------------------------------------------------------------------- /redux/slice/usersSlice.ts: -------------------------------------------------------------------------------- 1 | import { createSlice, PayloadAction } from "@reduxjs/toolkit"; 2 | import { SiteUser } from "@/utils/models/user"; 3 | import { UserAuth } from "@/utils/models/auth"; 4 | 5 | export interface UserData { 6 | user: SiteUser | null; 7 | auth: UserAuth | null; 8 | } 9 | 10 | export interface UserState { 11 | currentUser: UserData | null; 12 | users: UserData[]; 13 | } 14 | 15 | const initialState: UserState = { 16 | currentUser: null, 17 | users: [], 18 | }; 19 | 20 | const userSlice = createSlice({ 21 | name: "user", 22 | initialState, 23 | reducers: { 24 | setUsersState: (state, action: PayloadAction) => { 25 | state.currentUser = action.payload.currentUser; 26 | state.users = action.payload.users; 27 | }, 28 | clearUsersState: (state) => { 29 | state.currentUser = null; 30 | state.users = []; 31 | }, 32 | }, 33 | }); 34 | 35 | export const { setUsersState, clearUsersState } = userSlice.actions; 36 | 37 | const usersReducer = userSlice.reducer; 38 | 39 | export default usersReducer; 40 | -------------------------------------------------------------------------------- /components/Input/MainSearchBar.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Input, Icon, FormControl } from "@chakra-ui/react"; 2 | import { BiSearchAlt } from "react-icons/bi"; 3 | 4 | interface MainSearchBarProps { 5 | value?: string; 6 | placeholder?: string; 7 | onSearch: (query: string) => void; 8 | } 9 | 10 | const MainSearchBar: React.FC = ({ 11 | value, 12 | placeholder = "Search...", 13 | onSearch, 14 | }) => { 15 | const handleSearch = (event: React.ChangeEvent) => { 16 | const query = event.target.value; 17 | onSearch(query); 18 | }; 19 | 20 | return ( 21 | 22 | 23 | 27 | 28 | 36 | 37 | ); 38 | }; 39 | 40 | export default MainSearchBar; 41 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "private": true, 3 | "scripts": { 4 | "dev": "next", 5 | "build": "next build", 6 | "start": "next start", 7 | "type-check": "tsc", 8 | "lint": "next lint" 9 | }, 10 | "dependencies": { 11 | "@chakra-ui/react": "^2.6.1", 12 | "@emotion/react": "^11.11.0", 13 | "@emotion/styled": "^11.11.0", 14 | "@reduxjs/toolkit": "^1.9.5", 15 | "@types/bcrypt": "^5.0.0", 16 | "@types/formidable": "^2.0.6", 17 | "@types/jsonwebtoken": "^9.0.2", 18 | "@types/mongodb": "^4.0.7", 19 | "@types/node": "^20.2.1", 20 | "@types/react": "^18.2.6", 21 | "@types/react-dom": "^18.2.4", 22 | "@types/react-icons": "^3.0.0", 23 | "autoprefixer": "^10.4.14", 24 | "axios": "^1.4.0", 25 | "bcrypt": "^5.1.0", 26 | "eslint": "^8.40.0", 27 | "eslint-config-next": "^13.4.2", 28 | "formidable": "^2.1.1", 29 | "framer-motion": "^10.12.12", 30 | "graphql": "^16.6.0", 31 | "graphql-tag": "^2.12.6", 32 | "jsonwebtoken": "^9.0.0", 33 | "moment": "^2.29.4", 34 | "mongodb": "^5.5.0", 35 | "next": "latest", 36 | "postcss": "^8.4.23", 37 | "react": "^18.2.0", 38 | "react-dom": "^18.2.0", 39 | "react-icons": "^4.8.0", 40 | "react-redux": "^8.0.5", 41 | "redux": "^4.2.1", 42 | "safe-json-stringify": "^1.2.0", 43 | "sass": "^1.62.1", 44 | "tailwindcss": "^3.3.2", 45 | "typescript": "^5.0.4" 46 | }, 47 | "devDependencies": {} 48 | } 49 | -------------------------------------------------------------------------------- /styles/app.scss: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @import url("https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"); 6 | @import url("https://fonts.googleapis.com/css2?family=Open+Sans:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap"); 7 | 8 | * { 9 | font-family: "Open Sans", sans-serif; 10 | } 11 | 12 | h1, 13 | h2, 14 | h3, 15 | h4, 16 | h5, 17 | h6 { 18 | font-family: "Poppins", sans-serif; 19 | } 20 | 21 | @layer components { 22 | html { 23 | @apply bg-gray-100; 24 | } 25 | 26 | .limit-width { 27 | max-width: 1536px; 28 | } 29 | 30 | .scroll-y-style::-webkit-scrollbar { 31 | height: 100%; 32 | width: 4px; 33 | background: #0001; 34 | @apply rounded-full; 35 | } 36 | 37 | .scroll-y-style::-webkit-scrollbar-thumb { 38 | background: #0002; 39 | -webkit-border-radius: 1ex; 40 | @apply rounded-full; 41 | } 42 | 43 | .scroll-x-style::-webkit-scrollbar { 44 | height: 2px; 45 | width: 100%; 46 | background: #0001; 47 | @apply rounded-full; 48 | } 49 | 50 | .scroll-x-style::-webkit-scrollbar-thumb { 51 | background: #0004; 52 | -webkit-border-radius: 1ex; 53 | @apply rounded-full; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /utils/functions/date.ts: -------------------------------------------------------------------------------- 1 | export function getTimeDifference( 2 | fromDate: Date | string, 3 | toDate: Date | string, 4 | type: "Y" | "M" | "D" | "H" | "m" | "s" = "D" 5 | ): number { 6 | const from = typeof fromDate === "string" ? new Date(fromDate) : fromDate; 7 | const to = typeof toDate === "string" ? new Date(toDate) : toDate; 8 | 9 | const startDate = new Date(from); 10 | const endDate = new Date(to); 11 | const timeDifference = endDate.getTime() - startDate.getTime(); 12 | 13 | const calculate = () => { 14 | switch (type) { 15 | case "Y": { 16 | return Math.floor(timeDifference / (1000 * 60 * 60 * 24 * 365)); 17 | break; 18 | } 19 | 20 | case "M": { 21 | return Math.floor(timeDifference / (1000 * 60 * 60 * 24 * 30)); 22 | break; 23 | } 24 | 25 | case "D": { 26 | return Math.floor(timeDifference / (1000 * 60 * 60 * 24)); 27 | break; 28 | } 29 | 30 | case "H": { 31 | return Math.floor(timeDifference / (1000 * 60 * 60)); 32 | break; 33 | } 34 | 35 | case "m": { 36 | return Math.floor(timeDifference / (1000 * 60)); 37 | break; 38 | } 39 | 40 | case "s": { 41 | return Math.floor(timeDifference / 1000); 42 | break; 43 | } 44 | 45 | default: { 46 | return Math.floor(timeDifference / (1000 * 60 * 60 * 24)); 47 | break; 48 | } 49 | } 50 | }; 51 | 52 | const daysDifference = calculate(); 53 | 54 | return daysDifference; 55 | } 56 | -------------------------------------------------------------------------------- /components/NavigationBar/NavigationBar/Logo.tsx: -------------------------------------------------------------------------------- 1 | import Link from "next/link"; 2 | import React from "react"; 3 | import { IoLibrary } from "react-icons/io5"; 4 | 5 | type LogoProps = {}; 6 | 7 | const Logo: React.FC = () => { 8 | return ( 9 | <> 10 | 14 |
20 | 21 |
22 |
23 |

29 | LIB 30 |

31 |
37 |
43 |
49 |
50 | 51 | 52 | ); 53 | }; 54 | 55 | export default Logo; 56 | -------------------------------------------------------------------------------- /utils/models/book.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import { Author } from "./author"; 3 | import { SiteUser } from "./user"; 4 | 5 | export interface BookInfo { 6 | book: Book; 7 | author: Author; 8 | borrow: BookBorrow | null; 9 | borrower: SiteUser | null; 10 | } 11 | 12 | export interface Book { 13 | _id: ObjectId; 14 | id: string; 15 | title: string; 16 | description?: string; 17 | author: string; 18 | categories: string[]; 19 | cover?: BookImage; 20 | amount: number; 21 | available: number; 22 | borrows: number; 23 | borrowedTimes: number; 24 | ISBN?: string; 25 | publicationDate?: Date | string; 26 | updatedAt: Date | string; 27 | createdAt: Date | string; 28 | } 29 | 30 | export interface BookImage { 31 | bookId: string; 32 | height: number; 33 | width: number; 34 | filePath: string; 35 | fileName: string; 36 | fileType: string; 37 | fileUrl: string; 38 | fileExtension?: string; 39 | fileSize: number; 40 | createdAt: Date | string; 41 | } 42 | 43 | export interface BookCategory { 44 | _id: ObjectId; 45 | id: string; 46 | name: string; 47 | description?: string; 48 | updatedAt: Date | string; 49 | createdAt: Date | string; 50 | } 51 | 52 | export interface BookBorrow { 53 | _id: ObjectId; 54 | id: string; 55 | userId: string; 56 | bookId: string; 57 | note?: string; 58 | borrowStatus: "borrowed" | "pending" | "returned"; 59 | borrowedAt: Date | string; 60 | requestedAt: Date | string; 61 | returnedAt: Date | string; 62 | createdAt: Date | string; 63 | dueAt: Date | string; 64 | } 65 | -------------------------------------------------------------------------------- /components/Breadcrumb/ManageBreadcrumb.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | Breadcrumb, 4 | BreadcrumbItem, 5 | BreadcrumbLink, 6 | } from "@chakra-ui/react"; 7 | import Link from "next/link"; 8 | import { useRouter } from "next/router"; 9 | import React from "react"; 10 | import { BsChevronRight } from "react-icons/bs"; 11 | 12 | type ManageBreadcrumbProps = {}; 13 | 14 | const ManageBreadcrumb: React.FC = () => { 15 | const router = useRouter(); 16 | const { pathname } = router; 17 | const paths = pathname.split("/").filter((path) => path); 18 | 19 | return ( 20 | <> 21 | 22 | } 25 | > 26 | {paths.map((path, index) => ( 27 | 40 | 48 | {path.substring(0, 1).toUpperCase() + path.substring(1)} 49 | 50 | 51 | ))} 52 | 53 | 54 | 55 | ); 56 | }; 57 | 58 | export default ManageBreadcrumb; 59 | -------------------------------------------------------------------------------- /components/Layout/Layout.tsx: -------------------------------------------------------------------------------- 1 | import useAuth from "@/hooks/useAuth"; 2 | import useUser from "@/hooks/useUser"; 3 | import React, { useEffect, useRef, useState } from "react"; 4 | import AuthPageComponent from "../Pages/AuthPageComponent"; 5 | import NavigationBar from "../NavigationBar/NavigationBar"; 6 | import { useRouter } from "next/router"; 7 | 8 | type LayoutProps = { 9 | children: React.ReactNode; 10 | }; 11 | 12 | const Layout: React.FC = ({ children }) => { 13 | const { loadingUser, userMounted } = useAuth(); 14 | 15 | const { usersStateValue } = useUser(); 16 | 17 | const pageMounted = useRef(false); 18 | 19 | const router = useRouter(); 20 | const { pathname } = router; 21 | const directories = pathname.split("/"); 22 | 23 | useEffect(() => { 24 | if ( 25 | (!userMounted && !loadingUser) || 26 | (userMounted && 27 | !usersStateValue.currentUser?.user?.roles.includes("admin")) 28 | ) { 29 | router.push("/"); 30 | } 31 | }, [ 32 | router.pathname, 33 | userMounted, 34 | loadingUser, 35 | usersStateValue.currentUser?.user?.roles.includes("admin"), 36 | ]); 37 | 38 | return ( 39 | <> 40 |
41 | <> 42 | {userMounted && ( 43 | <> 44 | {usersStateValue.currentUser && ( 45 | <> 46 | {((router.pathname.split("/")[1] === "manage" && 47 | usersStateValue.currentUser.user?.roles.includes("admin")) || 48 | router.pathname.split("/")[1] !== "manage") && ( 49 | <> 50 | 51 | <>{children} 52 | 53 | )} 54 | 55 | )} 56 | 57 | )} 58 | 59 | <> 60 | {!loadingUser && !userMounted && ( 61 | <> 62 | 63 | 64 | )} 65 | 66 |
67 | 68 | ); 69 | }; 70 | 71 | export default Layout; 72 | -------------------------------------------------------------------------------- /components/Book/BookCategoryTags.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { 3 | Tag, 4 | TagCloseButton, 5 | TagLabel, 6 | Input, 7 | HStack, 8 | VStack, 9 | FormLabel, 10 | FormControl, 11 | Flex, 12 | Text, 13 | } from "@chakra-ui/react"; 14 | 15 | interface BookCategoryTagsProps { 16 | categories: string[]; 17 | onAddCategory: (category: string) => void; 18 | onRemoveCategory: (category: string) => void; 19 | } 20 | 21 | const BookCategoryTags: React.FC = ({ 22 | categories, 23 | onAddCategory, 24 | onRemoveCategory, 25 | }) => { 26 | const [newCategory, setNewCategory] = useState(""); 27 | 28 | const handleAddCategory = (event: React.KeyboardEvent) => { 29 | event.preventDefault(); 30 | 31 | if (newCategory.trim() === "") return; 32 | 33 | const formattedInput = newCategory 34 | .toLowerCase() 35 | .replace(/[^\w.,_\-\/\s]/g, "") 36 | .replace(/[^a-zA-Z0-9]+/g, "-") 37 | .replace(/-+/g, "-") 38 | .replace(/(^-|-$)/g, "") 39 | .trim(); 40 | 41 | if ( 42 | formattedInput === "" || 43 | categories.find((category) => category === formattedInput) 44 | ) { 45 | return; 46 | } 47 | 48 | onAddCategory(formattedInput); 49 | setNewCategory(""); 50 | }; 51 | 52 | const handleRemoveCategory = (category: string) => { 53 | onRemoveCategory(category); 54 | }; 55 | 56 | return ( 57 | <> 58 | 62 | 63 | 64 | Categories{" "} 65 | * 66 | 67 | setNewCategory(event.target.value)} 72 | onKeyDown={(event) => 73 | event.key === "Enter" && handleAddCategory(event) 74 | } 75 | /> 76 | 77 | {categories.length > 0 && ( 78 | <> 79 | 84 | {categories.map((category) => ( 85 | 96 | 101 | {category} 102 | 103 | handleRemoveCategory(category)} 105 | /> 106 | 107 | ))} 108 | 109 | 110 | )} 111 | 112 | 113 | ); 114 | }; 115 | 116 | export default BookCategoryTags; 117 | -------------------------------------------------------------------------------- /components/Category/CategoryTagsList.tsx: -------------------------------------------------------------------------------- 1 | import { Tooltip } from "@chakra-ui/react"; 2 | import React, { useState } from "react"; 3 | import { IoRemoveCircleOutline } from "react-icons/io5"; 4 | 5 | type CategoryTagsListProps = { 6 | itemName: string; 7 | items: string[]; 8 | maxItems?: number; 9 | }; 10 | 11 | const CategoryTagsList: React.FC = ({ 12 | itemName, 13 | items, 14 | maxItems = 5, 15 | }) => { 16 | const hiddenCount = items.length - maxItems; 17 | const [isTooltipOpen, setIsTooltipOpen] = useState(false); 18 | 19 | const handleTooltipToggle = (state: boolean) => { 20 | setIsTooltipOpen((prev) => state); 21 | }; 22 | 23 | return ( 24 |
25 | {items.slice(0, maxItems).map((item, index) => ( 26 |
31 | 32 | {item 33 | .split("-") 34 | .map((word) => { 35 | return word.charAt(0).toUpperCase() + word.slice(1); 36 | }) 37 | .join(" ")} 38 | 39 |
40 | ))} 41 | {hiddenCount > 0 && ( 42 |
handleTooltipToggle(true)} 45 | onMouseLeave={() => handleTooltipToggle(false)} 46 | onClick={() => handleTooltipToggle(!isTooltipOpen)} 47 | onFocus={() => handleTooltipToggle(true)} 48 | onBlur={() => handleTooltipToggle(false)} 49 | > 50 | item) 54 | .join(", ") 55 | .concat(items.length > 20 ? "..." : "")}`} 56 | isOpen={isTooltipOpen} 57 | placement="top" 58 | > 59 |
63 | {`+${hiddenCount}`} 64 |
65 |
66 | {/* {isTooltipOpen && ( 67 |
68 | {items.slice(maxItems, 20).map((item, index) => ( 69 |
event.stopPropagation()} 74 | > 75 | 76 | {item.length > 64 ? `${item.slice(0, 64)}...` : item} 77 | 78 |
79 | ))} 80 | {items.length - 20 > 20 && ( 81 |
82 | 83 | {items.length - 20} More 84 | 85 |
86 | )} 87 |
88 | )} */} 89 |
90 | )} 91 |
92 | ); 93 | }; 94 | 95 | export default CategoryTagsList; 96 | -------------------------------------------------------------------------------- /components/Table/Pagination.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Box, Button, ButtonGroup, Text } from "@chakra-ui/react"; 3 | 4 | interface PaginationProps { 5 | currentPage: number; 6 | totalPages: number; 7 | onPageChange: (page: number) => void; 8 | } 9 | 10 | const Pagination: React.FC = ({ 11 | currentPage, 12 | totalPages, 13 | onPageChange, 14 | }) => { 15 | const handlePageChange = (page: number) => { 16 | onPageChange(page); 17 | }; 18 | 19 | const renderPages = () => { 20 | const pages = []; 21 | 22 | pages.push( 23 | <> 24 | 32 | 33 | ); 34 | 35 | if (currentPage > 1 || currentPage < totalPages) { 36 | if (currentPage - 1 > 1) { 37 | if (currentPage - 2 > 1) { 38 | pages.push( 39 | <> 40 | ... 41 | 42 | ); 43 | } 44 | 45 | pages.push( 46 | <> 47 | 55 | 56 | ); 57 | } 58 | 59 | if (currentPage > 1 && currentPage < totalPages) { 60 | pages.push( 61 | <> 62 | 70 | 71 | ); 72 | } 73 | 74 | if (currentPage + 1 < totalPages) { 75 | pages.push( 76 | <> 77 | 85 | 86 | ); 87 | 88 | if (currentPage + 2 < totalPages) { 89 | pages.push( 90 | <> 91 | ... 92 | 93 | ); 94 | } 95 | } 96 | } 97 | 98 | if (totalPages > 1) { 99 | pages.push( 100 | <> 101 | 109 | 110 | ); 111 | } 112 | 113 | return pages; 114 | }; 115 | 116 | return ( 117 | 118 | 122 | 129 | {renderPages()} 130 | 139 | 140 | 141 | ); 142 | }; 143 | 144 | export default Pagination; 145 | -------------------------------------------------------------------------------- /pages/api/authors/index.ts: -------------------------------------------------------------------------------- 1 | import authDb from "@/server/mongo/authDb"; 2 | import authorDb from "@/server/mongo/authorDb"; 3 | import { UserAuth } from "@/utils/models/auth"; 4 | import { CollationOptions } from "mongodb"; 5 | import { NextApiRequest, NextApiResponse } from "next"; 6 | 7 | export interface APIEndpointAuthorsParameters { 8 | apiKey: string; 9 | name?: string; 10 | fromName?: string; 11 | page?: number; 12 | limit?: number; 13 | } 14 | 15 | export default async function handler( 16 | req: NextApiRequest, 17 | res: NextApiResponse 18 | ) { 19 | try { 20 | const { authCollection } = await authDb(); 21 | 22 | const { authorsCollection } = await authorDb(); 23 | 24 | const { 25 | apiKey, 26 | name: rawName = undefined, 27 | fromName = undefined, 28 | page = 1, 29 | limit = 10, 30 | }: APIEndpointAuthorsParameters = req.body || req.query; 31 | 32 | const name: APIEndpointAuthorsParameters["name"] = 33 | typeof rawName === "string" ? rawName.trim() : undefined; 34 | 35 | if (!apiKey) { 36 | return res.status(400).json({ 37 | statusCode: 400, 38 | error: { 39 | type: "Missing API Key", 40 | message: "Please enter API Key", 41 | }, 42 | }); 43 | } 44 | 45 | if (!authCollection) { 46 | return res.status(500).json({ 47 | statusCode: 500, 48 | error: { 49 | type: "Database Connection Error", 50 | message: "Could not connect to authentication database", 51 | }, 52 | }); 53 | } 54 | 55 | if (!authorsCollection) { 56 | return res.status(500).json({ 57 | statusCode: 500, 58 | error: { 59 | type: "Database Connection Error", 60 | message: "Could not connect to author database", 61 | }, 62 | }); 63 | } 64 | 65 | const userAuthData = (await authCollection.findOne({ 66 | "keys.key": apiKey, 67 | })) as unknown as UserAuth; 68 | 69 | if (!userAuthData) { 70 | return res.status(400).json({ 71 | statusCode: 400, 72 | error: { 73 | type: "Invalid API Key", 74 | message: "Invalid API Key", 75 | }, 76 | }); 77 | } 78 | 79 | const requestedAt = new Date(); 80 | 81 | switch (req.method) { 82 | case "GET": { 83 | let query: any = {}; 84 | let countQuery: any = {}; 85 | 86 | if (name) { 87 | countQuery.name = { 88 | $regex: new RegExp(name, "i"), 89 | }; 90 | query.name = { 91 | $regex: new RegExp(name, "i"), 92 | }; 93 | } 94 | 95 | if (fromName) { 96 | query.name = { 97 | ...query.name, 98 | $lt: fromName, 99 | }; 100 | } 101 | 102 | const pageNumber = 103 | typeof page === "number" 104 | ? page 105 | : typeof page === "string" 106 | ? parseInt(page) 107 | : 1; 108 | 109 | const itemsPerPage = 110 | typeof limit === "number" 111 | ? limit 112 | : typeof limit === "string" 113 | ? parseInt(limit) 114 | : 10; 115 | 116 | const skip = (pageNumber - 1) * itemsPerPage; 117 | 118 | const collationOptions: CollationOptions = { 119 | locale: "en", 120 | numericOrdering: true, 121 | }; 122 | 123 | const authorsData = await authorsCollection 124 | .find({ 125 | ...query, 126 | }) 127 | .sort({ 128 | name: 1, 129 | }) 130 | .collation(collationOptions) 131 | .skip(skip) 132 | .limit( 133 | typeof limit === "number" 134 | ? limit 135 | : typeof limit === "string" 136 | ? parseInt(limit) 137 | : 10 138 | ) 139 | .toArray(); 140 | 141 | const totalCount = await authorsCollection.countDocuments({ 142 | ...countQuery, 143 | }); 144 | 145 | return res.status(200).json({ 146 | statusCode: 200, 147 | authors: authorsData, 148 | page: page, 149 | totalPages: Math.ceil(totalCount / itemsPerPage), 150 | totalCount, 151 | }); 152 | 153 | break; 154 | } 155 | 156 | default: { 157 | return res.status(405).json({ 158 | statusCode: 405, 159 | error: { 160 | type: "Method Not Allowed", 161 | message: "Method Not Allowed", 162 | }, 163 | }); 164 | 165 | break; 166 | } 167 | } 168 | } catch (error: any) { 169 | return res.status(500).json({ 170 | statusCode: 500, 171 | error: { 172 | type: "Server Error", 173 | ...error, 174 | }, 175 | }); 176 | } 177 | } 178 | -------------------------------------------------------------------------------- /utils/types/file.ts: -------------------------------------------------------------------------------- 1 | export const validArchiveTypes = { 2 | ext: [ 3 | ".7z", 4 | ".cbr", 5 | ".deb", 6 | ".gz", 7 | ".gzip", 8 | ".pak", 9 | ".pkg", 10 | ".rar", 11 | ".rpm", 12 | ".tar.gz", 13 | ".tgz", 14 | ".xapk", 15 | "application/x-zip-compressed", 16 | ".zipx", 17 | ], 18 | type: "archive", 19 | }; 20 | 21 | export const validConfigTypes = { 22 | ext: [ 23 | "application/json", 24 | ".xml", 25 | ".yaml", 26 | ".ini", 27 | ".conf", 28 | ".config", 29 | ".properties", 30 | ], 31 | type: "config", 32 | }; 33 | 34 | export const validDataTypes = { 35 | ext: [ 36 | ".aae", 37 | ".bin", 38 | ".csv", 39 | ".dat", 40 | ".mpp", 41 | ".obb", 42 | ".rpt", 43 | ".sdf", 44 | ".vcf", 45 | "application/vnd.ms-excel", 46 | ], 47 | type: "data", 48 | }; 49 | 50 | export const validDatabaseTypes = { 51 | ext: [".accdb", ".crypt14", ".db", ".mdb", ".odb", ".pdb", ".sql", ".sqlite"], 52 | type: "database", 53 | }; 54 | 55 | export const validDeveloperFilesTypes = { 56 | ext: [ 57 | ".c", 58 | ".class", 59 | ".cpp", 60 | ".cs", 61 | ".h", 62 | ".java", 63 | ".kt", 64 | ".lua", 65 | ".m", 66 | ".pl", 67 | ".php", 68 | ".py", 69 | ".swift", 70 | ".unity", 71 | ".vb", 72 | ".vcxproj", 73 | ".xcodeproj", 74 | ".yml", 75 | ], 76 | type: "developer-files", 77 | }; 78 | 79 | export const validDiskImageTypes = { 80 | ext: [".dmg", ".img", ".iso", ".mdf", ".rom", ".vcd"], 81 | type: "disk-image", 82 | }; 83 | 84 | export const validDocumentTypes = { 85 | ext: [ 86 | ".doc", 87 | "application/vnd.openxmlformats-officedocument.wordprocessingml.document", 88 | ".odt", 89 | ], 90 | type: "document", 91 | }; 92 | 93 | export const validFontTypes = { 94 | ext: [".fnt", ".otf", ".ttf", ".woff", ".woff2"], 95 | type: "font", 96 | }; 97 | 98 | export const validHTMLTypes = { 99 | ext: [".htm", ".html", ".xhtml"], 100 | type: "html", 101 | }; 102 | 103 | export const validImageTypes = { 104 | ext: ["image/png", "image/jpeg", "image/jpg", "image/webp"], 105 | type: "image", 106 | }; 107 | 108 | export const validMessageTypes = { 109 | ext: [".eml", ".msg", ".oft", ".ost", ".pst", ".vcf"], 110 | type: "message", 111 | }; 112 | 113 | export const validMusicTypes = { 114 | ext: ["audio/mpeg", "audio/mp3", "audio/wav", "audio/ogg", "audio/m4a"], 115 | type: "music", 116 | }; 117 | 118 | export const validPageLayoutTypes = { 119 | ext: [".afpub", ".indd", ".oxps", ".pmd", ".pub", ".qxp", ".xps"], 120 | type: "page-layout", 121 | }; 122 | 123 | export const validPdfType = { 124 | ext: ["application/pdf"], 125 | type: "pdf", 126 | }; 127 | 128 | export const validPresentationTypes = { 129 | ext: [ 130 | ".ppt", 131 | "application/vnd.openxmlformats-officedocument.presentationml.presentation", 132 | ".odp", 133 | ".key", 134 | ], 135 | type: "presentation", 136 | }; 137 | 138 | export const validProgramTypes = { 139 | ext: [ 140 | ".exe", 141 | ".msi", 142 | ".jar", 143 | ".apk", 144 | ".app", 145 | ".ipa", 146 | ".bat", 147 | ".run", 148 | ".sh", 149 | "application/x-msdownload", 150 | ], 151 | type: "program", 152 | }; 153 | 154 | export const validSpreadsheetTypes = { 155 | ext: [ 156 | ".xls", 157 | "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", 158 | ".numbers", 159 | ".ods", 160 | ".xlr", 161 | ], 162 | type: "spreadsheet", 163 | }; 164 | 165 | export const validTextTypes = { 166 | ext: ["text/plain", ".md", ".log", ".rtf", ".tex", ".wpd"], 167 | type: "text", 168 | }; 169 | 170 | export const validThreeDImageTypes = { 171 | ext: [".3dm", ".3ds", ".max", ".obj", ".blend", ".fbx", ".dae"], 172 | type: "3d-image", 173 | }; 174 | 175 | export const validVectorImageTypes = { 176 | ext: [".cdr", ".emf", "application/postscript", ".sketch", ".svg", ".vsdx"], 177 | type: "vector-image", 178 | }; 179 | 180 | export const validVideoTypes = { 181 | ext: ["video/mp4", "video/avi", "video/mov", "video/wmv", "video/m4v"], 182 | type: "video", 183 | }; 184 | 185 | export const validAllTypes = [ 186 | validArchiveTypes, 187 | validConfigTypes, 188 | validDataTypes, 189 | validDatabaseTypes, 190 | validDeveloperFilesTypes, 191 | validDiskImageTypes, 192 | validDocumentTypes, 193 | validFontTypes, 194 | validHTMLTypes, 195 | validImageTypes, 196 | validMessageTypes, 197 | validMusicTypes, 198 | validPageLayoutTypes, 199 | validPdfType, 200 | validPresentationTypes, 201 | validProgramTypes, 202 | validSpreadsheetTypes, 203 | validTextTypes, 204 | validThreeDImageTypes, 205 | validVectorImageTypes, 206 | validVideoTypes, 207 | ]; 208 | -------------------------------------------------------------------------------- /hooks/useBook.tsx: -------------------------------------------------------------------------------- 1 | import { APIEndpointBooksParameters } from "@/pages/api/books"; 2 | import React, { useCallback } from "react"; 3 | import useUser from "./useUser"; 4 | import { BookBorrow, BookInfo } from "@/utils/models/book"; 5 | import axios from "axios"; 6 | import { apiConfig } from "@/utils/site"; 7 | import { APIEndpointBorrowParameters } from "@/pages/api/books/borrows/borrow"; 8 | import { useToast } from "@chakra-ui/react"; 9 | 10 | const useBook = () => { 11 | const { usersStateValue } = useUser(); 12 | 13 | const toast = useToast(); 14 | 15 | const getBooks = useCallback( 16 | async ({ 17 | search, 18 | page, 19 | fromTitle, 20 | limit, 21 | }: Partial) => { 22 | try { 23 | if (usersStateValue.currentUser) { 24 | const { 25 | books, 26 | totalPages, 27 | totalCount, 28 | }: { 29 | books: BookInfo[]; 30 | totalPages: number; 31 | totalCount: number; 32 | } = await axios 33 | .get(apiConfig.apiEndpoint + "/books/", { 34 | params: { 35 | apiKey: usersStateValue.currentUser?.auth?.keys[0].key, 36 | search: search, 37 | page: page, 38 | fromTitle: fromTitle, 39 | limit: limit, 40 | } as APIEndpointBooksParameters, 41 | }) 42 | .then((response) => response.data) 43 | .catch((error) => { 44 | throw error; 45 | }); 46 | 47 | return { 48 | books, 49 | totalPages, 50 | totalCount, 51 | }; 52 | } 53 | } catch (error: any) { 54 | throw error; 55 | } 56 | }, 57 | [usersStateValue.currentUser] 58 | ); 59 | 60 | const sendCancelBorrow = useCallback( 61 | async ({ 62 | bookId, 63 | borrowId, 64 | borrowType, 65 | borrowStatus, 66 | }: Partial & { 67 | borrowStatus?: BookBorrow["borrowStatus"]; 68 | }) => { 69 | try { 70 | if ( 71 | (borrowType === "request" && borrowStatus === "pending") || 72 | (borrowType === "return" && borrowStatus === "returned") 73 | ) { 74 | await axios 75 | .delete(apiConfig.apiEndpoint + "/books/borrows/borrow", { 76 | params: { 77 | apiKey: usersStateValue.currentUser?.auth?.keys[0].key, 78 | borrowId: borrowId, 79 | } as APIEndpointBorrowParameters, 80 | }) 81 | .catch((error) => { 82 | const errorData = error.response.data; 83 | 84 | if (errorData.error.message) { 85 | toast({ 86 | title: "Borrow Book Failed", 87 | description: errorData.error.message, 88 | status: "error", 89 | duration: 5000, 90 | isClosable: true, 91 | position: "top", 92 | }); 93 | } 94 | 95 | throw new Error( 96 | `=>API: Borrow API Call Book Failed:\n${error.response.data.error.message}` 97 | ); 98 | }); 99 | 100 | toast({ 101 | title: "Borrow Book Removed", 102 | description: "You removed your borrow request.", 103 | status: "success", 104 | colorScheme: "red", 105 | duration: 5000, 106 | isClosable: true, 107 | position: "top", 108 | }); 109 | } else if ( 110 | (borrowType === "request" && borrowId) || 111 | (borrowType === "request" && borrowStatus === "returned") 112 | ) { 113 | await axios 114 | .post(apiConfig.apiEndpoint + "/books/borrows/borrow", { 115 | apiKey: usersStateValue.currentUser?.auth?.keys[0].key, 116 | bookId: bookId, 117 | borrowType: borrowType, 118 | }) 119 | .catch((error) => { 120 | const errorData = error.response.data; 121 | 122 | if (errorData.error.message) { 123 | toast({ 124 | title: "Borrow Book Failed", 125 | description: errorData.error.message, 126 | status: "error", 127 | duration: 5000, 128 | isClosable: true, 129 | position: "top", 130 | }); 131 | } 132 | 133 | throw new Error( 134 | `=>API: Borrow API Call Book Failed:\n${error.response.data.error.message}` 135 | ); 136 | }); 137 | 138 | toast({ 139 | title: "Borrow Book Success", 140 | description: "You requested to borrow this book.", 141 | status: "success", 142 | colorScheme: "messenger", 143 | duration: 5000, 144 | isClosable: true, 145 | position: "top", 146 | }); 147 | } 148 | } catch (error) { 149 | throw error; 150 | } 151 | }, 152 | [toast, usersStateValue.currentUser?.auth?.keys] 153 | ); 154 | 155 | return { 156 | getBooks, 157 | sendCancelBorrow, 158 | }; 159 | }; 160 | 161 | export default useBook; 162 | -------------------------------------------------------------------------------- /pages/api/count/index.ts: -------------------------------------------------------------------------------- 1 | import authDb from "@/server/mongo/authDb"; 2 | import authorDb from "@/server/mongo/authorDb"; 3 | import bookDb from "@/server/mongo/bookDb"; 4 | import userDb from "@/server/mongo/userDb"; 5 | import { UserAuth } from "@/utils/models/auth"; 6 | import { SiteUser } from "@/utils/models/user"; 7 | import { NextApiRequest, NextApiResponse } from "next"; 8 | 9 | export interface APIEndpointCountParams { 10 | apiKey: string; 11 | userId?: string; 12 | } 13 | 14 | export default async function handler( 15 | req: NextApiRequest, 16 | res: NextApiResponse 17 | ) { 18 | try { 19 | const { authCollection } = await authDb(); 20 | 21 | const { usersCollection } = await userDb(); 22 | 23 | const { authorsCollection } = await authorDb(); 24 | 25 | const { booksCollection, bookCategoriesCollection, bookBorrowsCollection } = 26 | await bookDb(); 27 | 28 | const { apiKey, userId = undefined }: APIEndpointCountParams = 29 | req.body || req.query; 30 | 31 | if (!apiKey) { 32 | return res.status(400).json({ 33 | statusCode: 400, 34 | error: { 35 | type: "Missing API Key", 36 | message: "Please enter API Key", 37 | }, 38 | }); 39 | } 40 | 41 | if (!authCollection) { 42 | return res.status(500).json({ 43 | statusCode: 500, 44 | error: { 45 | type: "Database Connection Error", 46 | message: "Could not connect to authentication database", 47 | }, 48 | }); 49 | } 50 | 51 | if (!usersCollection) { 52 | return res.status(500).json({ 53 | statusCode: 500, 54 | error: { 55 | type: "Database Connection Error", 56 | message: "Could not connect to user database", 57 | }, 58 | }); 59 | } 60 | 61 | if (!authorsCollection) { 62 | return res.status(500).json({ 63 | statusCode: 500, 64 | error: { 65 | type: "Database Connection Error", 66 | message: "Could not connect to author database", 67 | }, 68 | }); 69 | } 70 | 71 | if ( 72 | !booksCollection || 73 | !bookCategoriesCollection || 74 | !bookBorrowsCollection 75 | ) { 76 | return res.status(500).json({ 77 | statusCode: 500, 78 | error: { 79 | type: "Database Connection Error", 80 | message: "Could not connect to book database", 81 | }, 82 | }); 83 | } 84 | 85 | const userAuthData = (await authCollection.findOne({ 86 | "keys.key": apiKey, 87 | })) as unknown as UserAuth; 88 | 89 | if (!userAuthData) { 90 | return res.status(400).json({ 91 | statusCode: 400, 92 | error: { 93 | type: "Invalid API Key", 94 | message: "Invalid API Key", 95 | }, 96 | }); 97 | } 98 | 99 | const userData = (await usersCollection.findOne({ 100 | email: userAuthData.email, 101 | username: userAuthData.username, 102 | })) as unknown as SiteUser; 103 | 104 | if (!userData) { 105 | return res.status(400).json({ 106 | statusCode: 400, 107 | error: { 108 | type: "Invalid User", 109 | message: "Invalid API Key", 110 | }, 111 | }); 112 | } 113 | 114 | const requestedAt = new Date(); 115 | 116 | switch (req.method) { 117 | case "GET": { 118 | const booksCount = await booksCollection.countDocuments(); 119 | const authorsCount = await authorsCollection.countDocuments(); 120 | 121 | let query: any = {}; 122 | 123 | if (userId) { 124 | query.userId = userId; 125 | } 126 | 127 | const bookCategoriesCount = 128 | await bookCategoriesCollection.countDocuments(); 129 | const bookBorrowsBorrowsCount = 130 | await bookBorrowsCollection.countDocuments({ 131 | borrowStatus: "borrowed", 132 | ...query, 133 | }); 134 | 135 | const bookBorrowsPendingCount = 136 | await bookBorrowsCollection.countDocuments({ 137 | borrowStatus: "pending", 138 | ...query, 139 | }); 140 | 141 | const bookBorrowsReturnsCount = 142 | await bookBorrowsCollection.countDocuments({ 143 | borrowStatus: "returned", 144 | ...query, 145 | }); 146 | 147 | return res.status(200).json({ 148 | statusCode: 200, 149 | success: { 150 | type: "Count", 151 | message: "Database data counted successfully", 152 | }, 153 | count: { 154 | books: booksCount, 155 | authors: authorsCount, 156 | categories: bookCategoriesCount, 157 | borrows: { 158 | borrowed: bookBorrowsBorrowsCount, 159 | pending: bookBorrowsPendingCount, 160 | returned: bookBorrowsReturnsCount, 161 | }, 162 | }, 163 | }); 164 | 165 | break; 166 | } 167 | 168 | default: { 169 | return res.status(405).json({ 170 | statusCode: 405, 171 | error: { 172 | type: "Method Not Allowed", 173 | message: "Method Not Allowed", 174 | }, 175 | }); 176 | 177 | break; 178 | } 179 | } 180 | } catch (error: any) { 181 | return res.status(500).json({ 182 | statusCode: 500, 183 | error: { 184 | type: "Internal Server Error", 185 | message: error.message, 186 | ...error, 187 | }, 188 | }); 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /components/Table/Author/AuthorItem.tsx: -------------------------------------------------------------------------------- 1 | import { Author } from "@/utils/models/author"; 2 | import { 3 | Tr, 4 | Td, 5 | Stack, 6 | Button, 7 | Icon, 8 | Popover, 9 | PopoverArrow, 10 | PopoverBody, 11 | PopoverCloseButton, 12 | PopoverContent, 13 | PopoverHeader, 14 | PopoverTrigger, 15 | Flex, 16 | Text, 17 | useClipboard, 18 | Tooltip, 19 | } from "@chakra-ui/react"; 20 | import moment from "moment"; 21 | import React from "react"; 22 | import { FiEdit } from "react-icons/fi"; 23 | import { MdContentCopy, MdOutlineDeleteOutline } from "react-icons/md"; 24 | 25 | type AuthorItemProps = { 26 | index: number; 27 | author: Author; 28 | onEdit?: (author: Author) => void; 29 | onDelete?: (author: Author) => void; 30 | }; 31 | 32 | const AuthorItem: React.FC = ({ 33 | index, 34 | author, 35 | onEdit, 36 | onDelete, 37 | }) => { 38 | const { onCopy, setValue, hasCopied } = useClipboard(""); 39 | 40 | const handleCopyText = (text: string) => { 41 | setValue(text); 42 | onCopy(); 43 | }; 44 | 45 | return ( 46 | <> 47 | 48 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | Author 60 | 61 | 66 | 70 | ID: 71 | 72 | {author.id} 73 | 78 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | {author.name} 96 | 97 | 98 | {author.biography?.length 99 | ? author.biography.length > 256 100 | ? author.biography.slice(0, 256) + "..." 101 | : author.biography 102 | : "---"} 103 | 104 | 108 | {author.birthdate 109 | ? typeof author.birthdate === "string" 110 | ? moment(author.birthdate).format("DD/MM/YYYY") 111 | : moment(new Date(author.birthdate).toISOString()).format( 112 | "DD/MM/YYYY" 113 | ) 114 | : "---"} 115 | 116 | 120 | {author.updatedAt 121 | ? typeof author.updatedAt === "string" 122 | ? moment(author.updatedAt).format("DD/MM/YYYY") 123 | : moment(new Date(author.updatedAt).toISOString()).format( 124 | "DD/MM/YYYY" 125 | ) 126 | : "---"} 127 | 128 | 132 | {author.createdAt 133 | ? typeof author.createdAt === "string" 134 | ? moment(author.createdAt).format("DD/MM/YYYY") 135 | : moment(new Date(author.createdAt).toISOString()).format( 136 | "DD/MM/YYYY" 137 | ) 138 | : "---"} 139 | 140 | 144 | 151 | 164 | 177 | 178 | 179 | 180 | 181 | ); 182 | }; 183 | 184 | export default AuthorItem; 185 | -------------------------------------------------------------------------------- /pages/api/books/categories/index.ts: -------------------------------------------------------------------------------- 1 | import authDb from "@/server/mongo/authDb"; 2 | import authorDb from "@/server/mongo/authorDb"; 3 | import bookDb from "@/server/mongo/bookDb"; 4 | import { UserAuth } from "@/utils/models/auth"; 5 | import { CollationOptions } from "mongodb"; 6 | import { NextApiRequest, NextApiResponse } from "next"; 7 | 8 | export interface APIEndpointBooksCategoriesParameters { 9 | apiKey: string; 10 | search?: string; 11 | alphabet?: string; 12 | fromCategory?: string; 13 | page?: number; 14 | limit?: number; 15 | } 16 | 17 | export default async function handler( 18 | req: NextApiRequest, 19 | res: NextApiResponse 20 | ) { 21 | try { 22 | const { authCollection } = await authDb(); 23 | 24 | const { authorsCollection } = await authorDb(); 25 | 26 | const { bookCategoriesCollection } = await bookDb(); 27 | 28 | const { 29 | apiKey, 30 | search = undefined, 31 | alphabet = undefined, 32 | fromCategory = undefined, 33 | page: rawPage = 1, 34 | limit: rawLimit = 10, 35 | }: APIEndpointBooksCategoriesParameters = req.body || req.query; 36 | 37 | const page: APIEndpointBooksCategoriesParameters["page"] = 38 | typeof rawPage === "number" 39 | ? rawPage 40 | : typeof rawPage === "string" 41 | ? parseInt(rawPage) 42 | : 1; 43 | 44 | const limit: APIEndpointBooksCategoriesParameters["limit"] = 45 | typeof rawLimit === "number" 46 | ? rawLimit 47 | : typeof rawLimit === "string" 48 | ? parseInt(rawLimit) 49 | : 10; 50 | 51 | if (!apiKey) { 52 | return res.status(400).json({ 53 | statusCode: 400, 54 | error: { 55 | type: "Missing API Key", 56 | message: "Please enter API Key", 57 | }, 58 | }); 59 | } 60 | 61 | if (!authCollection) { 62 | return res.status(500).json({ 63 | statusCode: 500, 64 | error: { 65 | type: "Database Connection Error", 66 | message: "Could not connect to authentication database", 67 | }, 68 | }); 69 | } 70 | 71 | if (!authorsCollection) { 72 | return res.status(500).json({ 73 | statusCode: 500, 74 | error: { 75 | type: "Database Connection Error", 76 | message: "Could not connect to author database", 77 | }, 78 | }); 79 | } 80 | 81 | if (!bookCategoriesCollection) { 82 | return res.status(500).json({ 83 | statusCode: 500, 84 | error: { 85 | type: "Database Connection Error", 86 | message: "Could not connect to book category database", 87 | }, 88 | }); 89 | } 90 | 91 | const userAuthData = (await authCollection.findOne({ 92 | "keys.key": apiKey, 93 | })) as unknown as UserAuth; 94 | 95 | if (!userAuthData) { 96 | return res.status(400).json({ 97 | statusCode: 400, 98 | error: { 99 | type: "Invalid API Key", 100 | message: "Invalid API Key", 101 | }, 102 | }); 103 | } 104 | 105 | const requestedAt = new Date(); 106 | 107 | switch (req.method) { 108 | case "GET": { 109 | let query: any = {}; 110 | let countQuery: any = {}; 111 | 112 | if (search && alphabet) { 113 | countQuery.name = { 114 | $regex: new RegExp(`^(?=^${alphabet}.*)(?=.*${search}.*).*$`, "i"), 115 | }; 116 | query.name = { 117 | $regex: new RegExp(`^(?=^${alphabet}.*)(?=.*${search}.*).*$`, "i"), 118 | }; 119 | } else if (alphabet) { 120 | countQuery.name = { 121 | $regex: new RegExp(`^${alphabet}.*$`, "i"), 122 | }; 123 | query.name = { 124 | $regex: new RegExp(`^${alphabet}.*$`, "i"), 125 | }; 126 | } else if (search) { 127 | countQuery.name = { 128 | $regex: new RegExp(`^.*${search}.*$`, "i"), 129 | }; 130 | query.name = { 131 | $regex: new RegExp(`^.*${search}.*$`, "i"), 132 | }; 133 | } 134 | 135 | if (fromCategory) { 136 | query.name = { 137 | ...query.name, 138 | $lt: fromCategory, 139 | }; 140 | } 141 | 142 | const skip = (page - 1) * limit; 143 | 144 | const collationOptions: CollationOptions = { 145 | locale: "en", 146 | numericOrdering: true, 147 | }; 148 | 149 | const bookCategoriesData = await bookCategoriesCollection 150 | .find(query) 151 | 152 | .sort({ 153 | name: 1, 154 | }) 155 | .collation(collationOptions) 156 | .skip(skip) 157 | .limit(limit) 158 | .toArray(); 159 | 160 | const bookCategoriesCount = 161 | await bookCategoriesCollection.countDocuments(countQuery); 162 | 163 | return res.status(200).json({ 164 | statusCode: 200, 165 | categories: bookCategoriesData, 166 | page, 167 | totalPages: Math.ceil(bookCategoriesCount / limit), 168 | totalCount: bookCategoriesCount, 169 | }); 170 | } 171 | 172 | default: { 173 | return res.status(405).json({ 174 | statusCode: 405, 175 | error: { 176 | type: "Method Not Allowed", 177 | message: "Method Not Allowed", 178 | }, 179 | }); 180 | 181 | break; 182 | } 183 | } 184 | } catch (error: any) { 185 | return res.status(500).json({ 186 | statusCode: 500, 187 | error: { 188 | type: "Server Error", 189 | ...error, 190 | }, 191 | }); 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /components/NavigationBar/NavigationBar/UserMenu.tsx: -------------------------------------------------------------------------------- 1 | import useAuth from "@/hooks/useAuth"; 2 | import useUser from "@/hooks/useUser"; 3 | import { 4 | AlertDialog, 5 | AlertDialogBody, 6 | AlertDialogContent, 7 | AlertDialogFooter, 8 | AlertDialogHeader, 9 | AlertDialogOverlay, 10 | Box, 11 | Button, 12 | Menu, 13 | MenuButton, 14 | MenuItem, 15 | MenuList, 16 | Text, 17 | } from "@chakra-ui/react"; 18 | import Link from "next/link"; 19 | import { useRouter } from "next/router"; 20 | import React, { useRef, useState } from "react"; 21 | import { AiOutlineUser } from "react-icons/ai"; 22 | import { BiChevronDown } from "react-icons/bi"; 23 | import { FaHandHolding } from "react-icons/fa"; 24 | import { IoExitOutline } from "react-icons/io5"; 25 | import { MdSpaceDashboard } from "react-icons/md"; 26 | 27 | type UserMenuProps = {}; 28 | 29 | const UserMenu: React.FC = () => { 30 | const { signOut } = useAuth(); 31 | const { usersStateValue } = useUser(); 32 | 33 | const router = useRouter(); 34 | const { pathname } = router; 35 | const directories = pathname.split("/"); 36 | 37 | const [signOutModalOpen, setSignOutModalOpen] = useState(false); 38 | const [signingOut, setSigningOut] = useState(false); 39 | 40 | const cancelRef = useRef(null); 41 | 42 | const signOutModalClose = () => setSignOutModalOpen(false); 43 | 44 | const handleSignOut = async () => { 45 | try { 46 | if (!signingOut) { 47 | setSigningOut(true); 48 | 49 | await signOut(); 50 | 51 | setSigningOut(false); 52 | signOutModalClose(); 53 | } 54 | } catch (error: any) { 55 | console.error(`=>Hook: Sign Out Failed:\n${error.message}`); 56 | 57 | setSigningOut(false); 58 | } 59 | }; 60 | 61 | return ( 62 | <> 63 | 64 | } 67 | > 68 | 69 | 70 | 71 | 72 | 73 | {usersStateValue.currentUser?.user?.roles.includes("admin") && ( 74 | <> 75 | 84 | 91 | 92 | 93 | 94 | Dashboard 95 | 96 | 97 | 98 | )} 99 | 100 | {/* 109 | 116 | 117 | 118 | 119 | Borrows 120 | 121 | */} 122 | 123 | setSignOutModalOpen(true)} 129 | > 130 | 131 | 132 | 133 | 134 | Sign Out 135 | 136 | 137 | 138 | 139 | 140 | 146 | 147 | 148 | 152 | Sign Out 153 | 154 | 155 | Are you sure you want to sign out? 156 | 157 | 158 | 164 | 172 | 173 | 174 | 175 | 176 | 177 | ); 178 | }; 179 | 180 | export default UserMenu; 181 | -------------------------------------------------------------------------------- /pages/api/auth/signup.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | 3 | import JWT from "jsonwebtoken"; 4 | 5 | import { hashPassword } from "@/server/bcrypt"; 6 | import { EmailRegex, PasswordRegex } from "./../../../utils/regex"; 7 | import authDb from "@/server/mongo/authDb"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | import { UserAuth } from "@/utils/models/auth"; 10 | import { SiteUser } from "@/utils/models/user"; 11 | import userDb from "@/server/mongo/userDb"; 12 | import { jwtConfig } from "@/utils/site"; 13 | 14 | export interface APIEndpointSignUpParameters { 15 | email: string; 16 | password: string; 17 | } 18 | 19 | export default async function handler( 20 | req: NextApiRequest, 21 | res: NextApiResponse 22 | ) { 23 | try { 24 | const { authCollection } = await authDb(); 25 | 26 | const { usersCollection } = await userDb(); 27 | 28 | const { email, password }: APIEndpointSignUpParameters = req.body; 29 | 30 | if (!authCollection) { 31 | return res.status(500).json({ 32 | statusCode: 500, 33 | error: { 34 | type: "Database Connection Error", 35 | message: "Could not connect to authentication database", 36 | }, 37 | }); 38 | } 39 | 40 | const requestDate = new Date(); 41 | 42 | switch (req.method) { 43 | case "POST": { 44 | if (!email || !password) { 45 | return res.status(400).json({ 46 | statusCode: 400, 47 | error: { 48 | type: "Missing Parameters", 49 | message: "Email and password are required", 50 | }, 51 | }); 52 | } 53 | 54 | if (!EmailRegex.test(email) && email) { 55 | return res.status(400).json({ 56 | statusCode: 400, 57 | error: { 58 | type: "Invalid Email", 59 | message: "Email is invalid", 60 | }, 61 | }); 62 | } 63 | 64 | if (!PasswordRegex.test(password)) { 65 | return res.status(400).json({ 66 | statusCode: 400, 67 | error: { 68 | type: "Invalid Password", 69 | message: "Password is invalid", 70 | }, 71 | }); 72 | } 73 | 74 | const existingUser = await authCollection.findOne({ 75 | email, 76 | }); 77 | 78 | if (existingUser) { 79 | return res.status(400).json({ 80 | statusCode: 400, 81 | error: { 82 | type: "User Already Exists", 83 | message: "User with this email already exists", 84 | }, 85 | }); 86 | } 87 | 88 | const authId = new ObjectId(); 89 | 90 | const newUserAuth: UserAuth = { 91 | _id: authId, 92 | id: authId.toHexString(), 93 | username: email.split("@")[0], 94 | email, 95 | password: await hashPassword(password), 96 | keys: [ 97 | { 98 | key: await hashPassword(email.concat(password)), 99 | createdAt: requestDate.toISOString(), 100 | }, 101 | ], 102 | lastSignIn: requestDate.toISOString(), 103 | updatedAt: requestDate.toISOString(), 104 | createdAt: requestDate.toISOString(), 105 | }; 106 | 107 | const userId = new ObjectId(); 108 | 109 | const newUser: SiteUser = { 110 | _id: userId, 111 | id: userId.toHexString(), 112 | username: email.split("@")[0], 113 | email, 114 | roles: ["user"], 115 | updatedAt: requestDate.toISOString(), 116 | createdAt: requestDate.toISOString(), 117 | }; 118 | 119 | const { 120 | ok, 121 | value: { password: excludedPassword, ...newUserAuthData }, 122 | }: { 123 | ok: 0 | 1; 124 | value: any; 125 | } = await authCollection.findOneAndUpdate( 126 | { 127 | email, 128 | }, 129 | { 130 | $set: { 131 | ...newUserAuth, 132 | "session.token": JWT.sign( 133 | { 134 | userId: new ObjectId().toHexString(), 135 | }, 136 | jwtConfig.secretKey, 137 | { 138 | expiresIn: "30d", 139 | } 140 | ), 141 | "session.updatedAt": requestDate.toISOString(), 142 | "session.expiresAt": new Date( 143 | Date.now() + 30 * 24 * 60 * 60 * 1000 144 | ).toISOString(), 145 | "session.createdAt": requestDate.toISOString(), 146 | }, 147 | }, 148 | { 149 | upsert: true, 150 | returnDocument: "after", 151 | } 152 | ); 153 | 154 | const newUserData = await usersCollection.findOneAndUpdate( 155 | { 156 | email, 157 | }, 158 | { 159 | $set: newUser, 160 | }, 161 | { 162 | upsert: true, 163 | returnDocument: "after", 164 | } 165 | ); 166 | 167 | return res.status(201).json({ 168 | statusCode: 201, 169 | success: { 170 | type: "User Created", 171 | message: "User was created successfully", 172 | }, 173 | userAuth: newUserAuthData, 174 | user: newUserData.value, 175 | }); 176 | 177 | break; 178 | } 179 | 180 | default: { 181 | return res.status(405).json({ 182 | statusCode: 405, 183 | error: { 184 | type: "Method Not Allowed", 185 | message: "Only POST requests are allowed", 186 | }, 187 | }); 188 | break; 189 | } 190 | } 191 | } catch (error: any) { 192 | return res.status(500).json({ 193 | statusCode: 500, 194 | error: { 195 | type: "Server Error", 196 | ...error, 197 | }, 198 | }); 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /hooks/useInput.tsx: -------------------------------------------------------------------------------- 1 | import { validImageTypes, validVideoTypes } from "@/utils/types/file"; 2 | import React, { useCallback } from "react"; 3 | 4 | export type ImageOrVideoType = { 5 | name: string; 6 | url: string; 7 | size: number; 8 | type: string; 9 | height: number; 10 | width: number; 11 | }; 12 | 13 | const useInput = () => { 14 | const getImageFile = useCallback( 15 | async ({ image }: { image: ImageOrVideoType | null }) => { 16 | if (image) { 17 | const response = await fetch(image.url); 18 | const blob = await response.blob(); 19 | 20 | return new File([blob], image.name, { 21 | type: image.type, 22 | }); 23 | } else { 24 | return null; 25 | } 26 | }, 27 | [] 28 | ); 29 | 30 | const validateImageOrVideo = useCallback((imageOrVideo: File) => { 31 | if (validImageTypes.ext.includes(imageOrVideo.type)) { 32 | if (imageOrVideo.size > 1024 * 1024 * 2) { 33 | return false; 34 | } 35 | 36 | return true; 37 | } else if (validVideoTypes.ext.includes(imageOrVideo.type)) { 38 | if (imageOrVideo.size > 1024 * 1024 * 20) { 39 | return false; 40 | } 41 | 42 | return true; 43 | } else { 44 | return false; 45 | } 46 | }, []); 47 | 48 | const uploadImageOrVideo = useCallback( 49 | async (file: File): Promise => { 50 | if (!file || !validateImageOrVideo(file)) { 51 | return null; 52 | } 53 | 54 | if (validImageTypes.ext.includes(file.type)) { 55 | return new Promise((resolve) => { 56 | const reader = new FileReader(); 57 | 58 | reader.onload = async (event) => { 59 | const result = event.target?.result as string; 60 | const img = new Image(); 61 | 62 | img.onload = async () => { 63 | const canvas = document.createElement("canvas"); 64 | const ctx = canvas.getContext("2d") as CanvasRenderingContext2D; 65 | 66 | const height = img.height; 67 | const width = img.width; 68 | 69 | canvas.height = height; 70 | canvas.width = width; 71 | 72 | ctx.fillStyle = "#fff"; 73 | ctx.fillRect(0, 0, width, height); 74 | 75 | ctx.drawImage(img, 0, 0, width, height); 76 | 77 | canvas.toBlob( 78 | async (blob) => { 79 | if (blob) { 80 | const imageOrVideo: ImageOrVideoType = { 81 | name: file.name, 82 | url: URL.createObjectURL(blob), 83 | size: blob.size, 84 | type: blob.type, 85 | height, 86 | width, 87 | }; 88 | resolve(imageOrVideo); 89 | } 90 | }, 91 | "image/webp", 92 | 0.8 93 | ); 94 | 95 | img.remove(); 96 | canvas.remove(); 97 | reader.abort(); 98 | }; 99 | 100 | img.src = result; 101 | }; 102 | 103 | reader.readAsDataURL(file); 104 | }); 105 | } else if (validVideoTypes.ext.includes(file.type)) { 106 | return new Promise((resolve) => { 107 | const reader = new FileReader(); 108 | 109 | reader.onload = () => { 110 | const result = reader.result as ArrayBuffer; 111 | const blob = new Blob([result], { type: file.type || "video/mp4" }); 112 | const video = document.createElement("video"); 113 | 114 | video.onloadedmetadata = () => { 115 | const imageOrVideo: ImageOrVideoType = { 116 | name: file.name, 117 | url: URL.createObjectURL(blob), 118 | size: blob.size, 119 | type: blob.type, 120 | height: video.videoHeight, 121 | width: video.videoWidth, 122 | }; 123 | resolve(imageOrVideo); 124 | 125 | video.remove(); 126 | reader.abort(); 127 | }; 128 | 129 | video.src = URL.createObjectURL(blob); 130 | }; 131 | 132 | reader.readAsArrayBuffer(file); 133 | }); 134 | } 135 | 136 | return null; 137 | }, 138 | [validateImageOrVideo] 139 | ); 140 | 141 | const calculateDaysAway = ( 142 | fromDate: Date | string, 143 | toDate: Date | string 144 | ): number => { 145 | const timeDiff = Math.abs( 146 | (typeof fromDate === "string" 147 | ? new Date(fromDate).getTime() 148 | : fromDate.getTime()) - 149 | (typeof toDate === "string" 150 | ? new Date(toDate).getTime() 151 | : toDate.getTime()) 152 | ); 153 | 154 | const daysAway = Math.ceil(timeDiff / (1000 * 3600 * 24)); 155 | 156 | return daysAway; 157 | }; 158 | 159 | const formatNumberWithSuffix = useCallback((number: number) => { 160 | const suffixes = ["", "K", "M", "B"]; 161 | let suffixIndex = 0; 162 | while (number >= 1000 && suffixIndex < suffixes.length - 1) { 163 | number /= 1000; 164 | suffixIndex++; 165 | } 166 | const roundedNumber = Math.floor(number * 100) / 100; 167 | const suffix = suffixes[suffixIndex]; 168 | return `${roundedNumber}${suffix}`; 169 | }, []); 170 | 171 | const formatFileSize = useCallback((size: number) => { 172 | const units = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]; 173 | let i = 0; 174 | let fileSize = size; 175 | while (fileSize >= 1024) { 176 | fileSize /= 1024; 177 | i++; 178 | } 179 | return fileSize.toFixed(2) + " " + units[i]; 180 | }, []); 181 | 182 | return { 183 | getImageFile, 184 | uploadImageOrVideo, 185 | calculateDaysAway, 186 | formatNumberWithSuffix, 187 | formatFileSize, 188 | }; 189 | }; 190 | 191 | export default useInput; 192 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Library Management System with Next, React, Chakra, and Redux 2 | 3 | ## Tech Stack 4 | 5 | | | | 6 | | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | 7 | | Front-End | ![React](https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB) ![Chakra](https://img.shields.io/badge/chakra-%234ED1C5.svg?style=for-the-badge&logo=chakraui&logoColor=white) ![TailwindCSS](https://img.shields.io/badge/tailwindcss-%2338B2AC.svg?style=for-the-badge&logo=tailwind-css&logoColor=white) ![SASS](https://img.shields.io/badge/SASS-hotpink.svg?style=for-the-badge&logo=SASS&logoColor=white) | 8 | | Back-End | ![NodeJS](https://img.shields.io/badge/node.js-6DA55F?style=for-the-badge&logo=node.js&logoColor=white) ![TypeScript](https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white) ![Next JS](https://img.shields.io/badge/Next-black?style=for-the-badge&logo=next.js&logoColor=white) | 9 | | Database | ![MongoDB](https://img.shields.io/badge/MongoDB-%234ea94b.svg?style=for-the-badge&logo=mongodb&logoColor=white) | 10 | | State-Management | ![Redux](https://img.shields.io/badge/redux-%23593d88.svg?style=for-the-badge&logo=redux&logoColor=white) | 11 | | Tools | ![Postman](https://img.shields.io/badge/Postman-FF6C37?style=for-the-badge&logo=postman&logoColor=white) | 12 | | Others | ![JWT](https://img.shields.io/badge/JWT-black?style=for-the-badge&logo=JSON%20web%20tokens) | 13 | 14 | ## Setup 15 | 16 | ### Environment Variables 17 | 18 | 1. Open `.env.example` 19 | 2. Fill-up the variables 20 | 21 | ```bash 22 | #NEXT_PUBLIC_BASE_URL is the base url of the website, e.g. http://localhost:3000 23 | NEXT_PUBLIC_BASE_URL=#Website Base URL e.g. http://localhost:3000 24 | 25 | #NEXT_PUBLIC_API_ENDPOINT is the base url of the api, e.g. http://localhost:3000/api 26 | NEXT_PUBLIC_API_ENDPOINT=#API Endpoint Base URL e.g. http://localhost:3000/api 27 | 28 | #NEXT_PUBLIC_JWT_SECRET_KEY is the secret key used to generate JWT tokens, e.g. Hello 29 | NEXT_PUBLIC_JWT_SECRET_KEY=#JWT Generator Secret Key e.g. Hello 30 | 31 | #MONGODB_URI is the endpoint of the MongoDB database, e.g. mongodb://0.0.0.0:27017/library-db 32 | MONGODB_URI=#MongoDB Database Endpoint e.g. mongodb://0.0.0.0:27017/library-db 33 | NEXT_PUBLIC_MONGODB_NAME=#MongoDB Database Name e.g. library-db 34 | ``` 35 | 36 | 3. Rename `.env.example` to `.env.local` 37 | 38 | ### Running the Software 39 | 40 | 1. Install the dependencies 41 | 42 | ```bash 43 | # Using npm 44 | npm install 45 | 46 | # Using yarn 47 | yarn install 48 | ``` 49 | 50 | 2. Run Development Build 51 | 52 | ```bash 53 | # Using npm 54 | npm run dev 55 | 56 | # Using yarn 57 | yarn dev 58 | ``` 59 | 60 | 3. Go to development build (e.g. [http://localhost:3000/](http://localhost:3000)) 61 | 62 | ### Running Production 63 | 64 | 1. Create Production Build 65 | 66 | ```bash 67 | # Using npm 68 | npm run build 69 | 70 | # Using yarn 71 | yarn build 72 | ``` 73 | 74 | 2. Run Production 75 | 76 | ```bash 77 | # Using npm 78 | npm run start 79 | 80 | # Using yarn 81 | yarn build 82 | ``` 83 | 84 | 3. Go to production build (e.g. [http://localhost:3000/](http://localhost:3000)) 85 | -------------------------------------------------------------------------------- /pages/api/books/index.ts: -------------------------------------------------------------------------------- 1 | import authDb from "@/server/mongo/authDb"; 2 | import authorDb from "@/server/mongo/authorDb"; 3 | import bookDb from "@/server/mongo/bookDb"; 4 | import userDb from "@/server/mongo/userDb"; 5 | import { UserAuth } from "@/utils/models/auth"; 6 | import { Author } from "@/utils/models/author"; 7 | import { Book, BookBorrow, BookInfo } from "@/utils/models/book"; 8 | import { SiteUser } from "@/utils/models/user"; 9 | import { CollationOptions } from "mongodb"; 10 | import { NextApiRequest, NextApiResponse } from "next"; 11 | 12 | export interface APIEndpointBooksParameters { 13 | apiKey: string; 14 | search?: string; 15 | fromTitle?: string; 16 | page?: number; 17 | limit?: number; 18 | } 19 | 20 | export default async function handler( 21 | req: NextApiRequest, 22 | res: NextApiResponse 23 | ) { 24 | try { 25 | const { authCollection } = await authDb(); 26 | 27 | const { usersCollection } = await userDb(); 28 | 29 | const { authorsCollection } = await authorDb(); 30 | 31 | const { booksCollection, bookBorrowsCollection } = await bookDb(); 32 | 33 | const { 34 | apiKey, 35 | search = undefined, 36 | fromTitle = undefined, 37 | page: rawPage = 1, 38 | limit: rawLimit = 10, 39 | }: APIEndpointBooksParameters = req.body || req.query; 40 | 41 | const page: APIEndpointBooksParameters["page"] = 42 | typeof rawPage === "number" 43 | ? rawPage 44 | : typeof rawPage === "string" 45 | ? parseInt(rawPage) 46 | : 1; 47 | 48 | const limit: APIEndpointBooksParameters["limit"] = 49 | typeof rawLimit === "number" 50 | ? rawLimit 51 | : typeof rawLimit === "string" 52 | ? parseInt(rawLimit) 53 | : 10; 54 | 55 | if (!apiKey) { 56 | return res.status(400).json({ 57 | statusCode: 400, 58 | error: { 59 | type: "Missing API Key", 60 | message: "Please enter API Key", 61 | }, 62 | }); 63 | } 64 | 65 | if (!authCollection) { 66 | return res.status(500).json({ 67 | statusCode: 500, 68 | error: { 69 | type: "Database Connection Error", 70 | message: "Could not connect to authentication database", 71 | }, 72 | }); 73 | } 74 | 75 | if (!authorsCollection) { 76 | return res.status(500).json({ 77 | statusCode: 500, 78 | error: { 79 | type: "Database Connection Error", 80 | message: "Could not connect to author database", 81 | }, 82 | }); 83 | } 84 | 85 | if (!booksCollection) { 86 | return res.status(500).json({ 87 | statusCode: 500, 88 | error: { 89 | type: "Database Connection Error", 90 | message: "Could not connect to book database", 91 | }, 92 | }); 93 | } 94 | 95 | const userAuthData = (await authCollection.findOne({ 96 | "keys.key": apiKey, 97 | })) as unknown as UserAuth; 98 | 99 | if (!userAuthData) { 100 | return res.status(400).json({ 101 | statusCode: 400, 102 | error: { 103 | type: "Invalid API Key", 104 | message: "Invalid API Key", 105 | }, 106 | }); 107 | } 108 | 109 | const userData = (await usersCollection.findOne({ 110 | username: userAuthData.username, 111 | email: userAuthData.email, 112 | })) as unknown as SiteUser; 113 | 114 | if (!userData) { 115 | return res.status(400).json({ 116 | statusCode: 400, 117 | error: { 118 | type: "Invalid User Data", 119 | message: "Invalid User Data", 120 | }, 121 | }); 122 | } 123 | 124 | const requestedAt = new Date(); 125 | 126 | switch (req.method) { 127 | case "GET": { 128 | let query: any = {}; 129 | let countQuery: any = {}; 130 | 131 | if (search) { 132 | countQuery = { 133 | $or: [ 134 | { 135 | title: { 136 | $regex: new RegExp(search, "i"), 137 | }, 138 | }, 139 | { 140 | author: { 141 | $regex: new RegExp(search, "i"), 142 | }, 143 | }, 144 | { 145 | ISBN: { 146 | $regex: new RegExp(search, "i"), 147 | }, 148 | }, 149 | { 150 | categories: { 151 | $in: [search.toLowerCase()], 152 | }, 153 | }, 154 | ], 155 | }; 156 | query = { 157 | $or: [ 158 | { 159 | title: { 160 | $regex: new RegExp(search, "i"), 161 | }, 162 | }, 163 | { 164 | author: { 165 | $regex: new RegExp(search, "i"), 166 | }, 167 | }, 168 | { 169 | ISBN: { 170 | $regex: new RegExp(search, "i"), 171 | }, 172 | }, 173 | { 174 | categories: { 175 | $in: [search.toLowerCase()], 176 | }, 177 | }, 178 | ], 179 | }; 180 | } 181 | 182 | if (fromTitle) { 183 | query = { 184 | ...query, 185 | $or: [ 186 | { 187 | search: { 188 | ...query.$or[0].title, 189 | $lt: fromTitle, 190 | }, 191 | }, 192 | { 193 | author: { 194 | ...query.$or[1].author, 195 | $lt: fromTitle, 196 | }, 197 | }, 198 | { 199 | categories: { 200 | ...query.$or[2].categories, 201 | }, 202 | }, 203 | ], 204 | }; 205 | } 206 | 207 | const skip = (page - 1) * limit; 208 | 209 | const collationOptions: CollationOptions = { 210 | locale: "en", 211 | numericOrdering: true, 212 | }; 213 | 214 | const booksData = await booksCollection 215 | .find({ 216 | ...query, 217 | }) 218 | .sort({ 219 | title: 1, 220 | }) 221 | .collation(collationOptions) 222 | .skip(skip) 223 | .limit( 224 | typeof limit === "number" 225 | ? limit 226 | : typeof limit === "string" 227 | ? parseInt(limit) 228 | : 10 229 | ) 230 | .toArray(); 231 | 232 | const totalCount = await booksCollection.countDocuments(countQuery); 233 | 234 | const booksInfo: BookInfo[] = await Promise.all( 235 | booksData.map(async (book) => { 236 | const bookDoc = book as unknown as Book; 237 | 238 | const authorData = (await authorsCollection.findOne({ 239 | name: bookDoc.author, 240 | })) as unknown as Author; 241 | 242 | const borrowData = await bookBorrowsCollection 243 | .find({ userId: userData.id, bookId: bookDoc.id }) 244 | .sort({ createdAt: -1 }) 245 | .limit(1) 246 | .toArray(); 247 | 248 | return { 249 | book: bookDoc, 250 | author: authorData, 251 | borrow: borrowData[0] ? borrowData[0] : null, 252 | } as BookInfo; 253 | }) 254 | ); 255 | 256 | return res.status(200).json({ 257 | statusCode: 200, 258 | books: booksInfo, 259 | page: page, 260 | totalPages: Math.ceil(totalCount / limit), 261 | totalCount, 262 | }); 263 | 264 | break; 265 | } 266 | 267 | default: { 268 | return res.status(405).json({ 269 | statusCode: 405, 270 | error: { 271 | type: "Method Not Allowed", 272 | message: "Method Not Allowed", 273 | }, 274 | }); 275 | 276 | break; 277 | } 278 | } 279 | } catch (error: any) { 280 | return res.status(500).json({ 281 | statusCode: 500, 282 | error: { 283 | type: "Server Error", 284 | ...error, 285 | }, 286 | }); 287 | } 288 | } 289 | -------------------------------------------------------------------------------- /pages/api/books/borrows/index.ts: -------------------------------------------------------------------------------- 1 | import authDb from "@/server/mongo/authDb"; 2 | import authorDb from "@/server/mongo/authorDb"; 3 | import bookDb from "@/server/mongo/bookDb"; 4 | import userDb from "@/server/mongo/userDb"; 5 | import { UserAuth } from "@/utils/models/auth"; 6 | import { Author } from "@/utils/models/author"; 7 | import { Book, BookBorrow, BookInfo } from "@/utils/models/book"; 8 | import { SiteUser } from "@/utils/models/user"; 9 | import { NextApiRequest, NextApiResponse } from "next"; 10 | 11 | export interface APIEndpointBorrowsParameters { 12 | apiKey: string; 13 | userId?: string; 14 | search?: string; 15 | borrowStatus?: BookBorrow["borrowStatus"]; 16 | page?: number; 17 | limit: number; 18 | } 19 | 20 | export default async function handler( 21 | req: NextApiRequest, 22 | res: NextApiResponse 23 | ) { 24 | try { 25 | const { authCollection } = await authDb(); 26 | 27 | const { usersCollection } = await userDb(); 28 | 29 | const { authorsCollection } = await authorDb(); 30 | 31 | const { booksCollection, bookBorrowsCollection } = await bookDb(); 32 | 33 | const { 34 | apiKey, 35 | userId = undefined, 36 | search = undefined, 37 | borrowStatus = "borrowed", 38 | page: rawPage = 1, 39 | limit: rawLimit = 10, 40 | }: APIEndpointBorrowsParameters = req.body || req.query; 41 | 42 | const page = typeof rawPage === "number" ? rawPage : parseInt(rawPage); 43 | const limit = typeof rawLimit === "number" ? rawLimit : parseInt(rawLimit); 44 | 45 | if (!apiKey) { 46 | return res.status(400).json({ 47 | statusCode: 400, 48 | error: { 49 | type: "Missing API Key", 50 | message: "Please enter API Key", 51 | }, 52 | }); 53 | } 54 | 55 | if (!authCollection) { 56 | return res.status(500).json({ 57 | statusCode: 500, 58 | error: { 59 | type: "Database Connection Error", 60 | message: "Could not connect to authentication database", 61 | }, 62 | }); 63 | } 64 | 65 | if (!usersCollection) { 66 | return res.status(500).json({ 67 | statusCode: 500, 68 | error: { 69 | type: "Database Connection Error", 70 | message: "Could not connect to user database", 71 | }, 72 | }); 73 | } 74 | 75 | if (!authorsCollection) { 76 | return res.status(500).json({ 77 | statusCode: 500, 78 | error: { 79 | type: "Database Connection Error", 80 | message: "Could not connect to author database", 81 | }, 82 | }); 83 | } 84 | 85 | if (!booksCollection || !bookBorrowsCollection) { 86 | return res.status(500).json({ 87 | statusCode: 500, 88 | error: { 89 | type: "Database Connection Error", 90 | message: "Could not connect to book database", 91 | }, 92 | }); 93 | } 94 | 95 | const userAuthData = (await authCollection.findOne({ 96 | "keys.key": apiKey, 97 | })) as unknown as UserAuth; 98 | 99 | if (!userAuthData) { 100 | return res.status(400).json({ 101 | statusCode: 400, 102 | error: { 103 | type: "Invalid API Key", 104 | message: "Invalid API Key", 105 | }, 106 | }); 107 | } 108 | 109 | const userData = (await usersCollection.findOne({ 110 | username: userAuthData.username, 111 | email: userAuthData.email, 112 | })) as unknown as SiteUser; 113 | 114 | if (!userData) { 115 | return res.status(400).json({ 116 | statusCode: 400, 117 | error: { 118 | type: "Invalid User Data", 119 | message: "Invalid User Data", 120 | }, 121 | }); 122 | } 123 | 124 | const requestedAt = new Date(); 125 | 126 | switch (req.method) { 127 | case "GET": { 128 | if (!borrowStatus) { 129 | return res.status(400).json({ 130 | statusCode: 400, 131 | error: { 132 | type: "Missing Borrow Status", 133 | message: "Please enter Borrow Status", 134 | }, 135 | }); 136 | } 137 | 138 | if ( 139 | borrowStatus !== "pending" && 140 | borrowStatus !== "borrowed" && 141 | borrowStatus !== "returned" 142 | ) { 143 | return res.status(400).json({ 144 | statusCode: 400, 145 | error: { 146 | type: "Invalid Borrow Status", 147 | message: "Invalid Borrow Status", 148 | }, 149 | }); 150 | } 151 | 152 | let query: any = { 153 | borrowStatus, 154 | }; 155 | 156 | let countQuery: any = { 157 | borrowStatus, 158 | }; 159 | 160 | if (userId) { 161 | query.userId = userId; 162 | countQuery.userId = userId; 163 | } 164 | 165 | if (search) { 166 | query.note = { 167 | $regex: new RegExp(search, "i"), 168 | }; 169 | countQuery.note = { 170 | $regex: new RegExp(search, "i"), 171 | }; 172 | } 173 | 174 | const skip = (page - 1) * limit; 175 | 176 | let sort: Partial> = {}; 177 | 178 | switch (borrowStatus) { 179 | case "pending": { 180 | sort = { 181 | requestedAt: -1, 182 | }; 183 | 184 | break; 185 | } 186 | 187 | case "borrowed": { 188 | sort = { 189 | borrowedAt: -1, 190 | }; 191 | 192 | break; 193 | } 194 | 195 | case "returned": { 196 | sort = { 197 | returnedAt: -1, 198 | }; 199 | 200 | break; 201 | } 202 | 203 | default: { 204 | sort = { 205 | createdAt: -1, 206 | }; 207 | 208 | break; 209 | } 210 | } 211 | 212 | const bookBorrowsData = await bookBorrowsCollection 213 | .find(query) 214 | .sort(sort as any) 215 | .skip(skip) 216 | .limit(limit) 217 | .toArray(); 218 | 219 | const totalCount = await bookBorrowsCollection.countDocuments( 220 | countQuery 221 | ); 222 | 223 | const borrowInfo: BookInfo[] = await Promise.all( 224 | bookBorrowsData.map(async (bookBorrow) => { 225 | const bookData = (await booksCollection.findOne({ 226 | id: bookBorrow.bookId, 227 | })) as unknown as Book; 228 | 229 | const bookBorrowData = bookBorrow as unknown as BookBorrow; 230 | 231 | const authorData = (await authorsCollection.findOne({ 232 | name: bookData.author, 233 | })) as unknown as Author; 234 | 235 | const userData = (await usersCollection.findOne({ 236 | id: bookBorrowData.userId, 237 | })) as unknown as SiteUser; 238 | 239 | return { 240 | book: bookData, 241 | borrow: bookBorrowData, 242 | author: authorData, 243 | borrower: userData, 244 | } as BookInfo; 245 | }) 246 | ); 247 | 248 | return res.status(200).json({ 249 | statusCode: 200, 250 | success: { 251 | type: "Borrows Data", 252 | message: "Fetching borrow book information successful", 253 | }, 254 | borrows: borrowInfo, 255 | page: page, 256 | totalPages: Math.ceil(totalCount / limit), 257 | totalCount: totalCount, 258 | }); 259 | 260 | break; 261 | } 262 | 263 | default: { 264 | return res.status(405).json({ 265 | statusCode: 405, 266 | error: { 267 | type: "Method Not Allowed", 268 | message: "Method Not Allowed", 269 | }, 270 | }); 271 | 272 | break; 273 | } 274 | } 275 | } catch (error: any) { 276 | return res.status(500).json({ 277 | statusCode: 500, 278 | error: { 279 | type: "Internal Server Error", 280 | message: error.message || "Internal Server Error", 281 | }, 282 | }); 283 | } 284 | } 285 | -------------------------------------------------------------------------------- /components/Table/Book/BookItem.tsx: -------------------------------------------------------------------------------- 1 | import { BookInfo } from "@/utils/models/book"; 2 | import { 3 | Tr, 4 | Td, 5 | Stack, 6 | Button, 7 | Icon, 8 | Popover, 9 | PopoverArrow, 10 | PopoverBody, 11 | PopoverCloseButton, 12 | PopoverContent, 13 | PopoverHeader, 14 | PopoverTrigger, 15 | Flex, 16 | Text, 17 | useClipboard, 18 | Tooltip, 19 | Badge, 20 | Box, 21 | } from "@chakra-ui/react"; 22 | import moment from "moment"; 23 | import Image from "next/image"; 24 | import React from "react"; 25 | import { FiEdit } from "react-icons/fi"; 26 | import { MdContentCopy, MdOutlineDeleteOutline } from "react-icons/md"; 27 | 28 | type BookItemProps = { 29 | index: number; 30 | bookInfo: BookInfo; 31 | onEdit?: (bookInfo: BookInfo) => void; 32 | onDelete?: (bookInfo: BookInfo) => void; 33 | }; 34 | 35 | const BookItem: React.FC = ({ 36 | index, 37 | bookInfo, 38 | onEdit, 39 | onDelete, 40 | }) => { 41 | const { onCopy, setValue, hasCopied } = useClipboard(""); 42 | 43 | const handleCopyText = (text: string) => { 44 | setValue(text); 45 | onCopy(); 46 | }; 47 | 48 | return ( 49 | <> 50 | 51 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | Book 63 | 64 | 69 | 73 | ID: 74 | 75 | {bookInfo.book.id} 76 | 81 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | {/** 98 | * Cover 99 | */} 100 | 101 | {bookInfo.book.cover ? ( 102 | <> 103 | 109 | {bookInfo.book.title} 117 | 118 | 119 | ) : ( 120 | <> 121 | None 122 | 123 | )} 124 | 125 | 126 | {bookInfo.book.title} 127 | 128 | 129 | {bookInfo.book.description?.length 130 | ? bookInfo.book.description.length > 256 131 | ? bookInfo.book.description.slice(0, 256) + "..." 132 | : bookInfo.book.description 133 | : "---"} 134 | 135 | {bookInfo.author.name} 136 | 140 | {bookInfo.book.ISBN} 141 | 142 | 143 | 149 | {bookInfo.book.categories.length > 0 ? ( 150 | bookInfo.book.categories.map((category) => ( 151 | <> 152 | 158 | {category} 159 | 160 | 161 | )) 162 | ) : ( 163 | <> 164 | 169 | None 170 | 171 | 172 | )} 173 | 174 | 175 | 179 | {bookInfo.book.amount} 180 | 181 | 185 | {bookInfo.book.available} 186 | 187 | 191 | {bookInfo.book.borrows} 192 | 193 | 197 | {bookInfo.book.borrowedTimes} 198 | 199 | 203 | {bookInfo.book.publicationDate 204 | ? typeof bookInfo.book.publicationDate === "string" 205 | ? moment(bookInfo.book.publicationDate).format("DD/MM/YYYY") 206 | : moment( 207 | new Date(bookInfo.book.publicationDate).toISOString() 208 | ).format("DD/MM/YYYY") 209 | : "---"} 210 | 211 | 215 | {bookInfo.book.updatedAt 216 | ? typeof bookInfo.book.updatedAt === "string" 217 | ? moment(bookInfo.book.updatedAt).format("DD/MM/YYYY") 218 | : moment(new Date(bookInfo.book.updatedAt).toISOString()).format( 219 | "DD/MM/YYYY" 220 | ) 221 | : "---"} 222 | 223 | 227 | {bookInfo.book.createdAt 228 | ? typeof bookInfo.book.createdAt === "string" 229 | ? moment(bookInfo.book.createdAt).format("DD/MM/YYYY") 230 | : moment(new Date(bookInfo.book.createdAt).toISOString()).format( 231 | "DD/MM/YYYY" 232 | ) 233 | : "---"} 234 | 235 | 239 | 246 | 259 | 272 | 273 | 274 | 275 | 276 | ); 277 | }; 278 | 279 | export default BookItem; 280 | -------------------------------------------------------------------------------- /pages/api/auth/signin.ts: -------------------------------------------------------------------------------- 1 | import { comparePasswords } from "@/server/bcrypt"; 2 | import authDb from "@/server/mongo/authDb"; 3 | import { EmailRegex, PasswordRegex } from "@/utils/regex"; 4 | import { NextApiRequest, NextApiResponse } from "next"; 5 | import JWT from "jsonwebtoken"; 6 | import { getTimeDifference } from "@/utils/functions/date"; 7 | import { UserAuth } from "@/utils/models/auth"; 8 | import { jwtConfig } from "@/utils/site"; 9 | import { SiteUser } from "@/utils/models/user"; 10 | import userDb from "@/server/mongo/userDb"; 11 | 12 | export interface APIEndpointSignInParameters { 13 | email: string; 14 | username: string; 15 | password: string; 16 | sessionToken: string; 17 | } 18 | 19 | export default async function handler( 20 | req: NextApiRequest, 21 | res: NextApiResponse 22 | ) { 23 | try { 24 | const { authCollection } = await authDb(); 25 | 26 | const { usersCollection } = await userDb(); 27 | 28 | const { 29 | email, 30 | username, 31 | password, 32 | sessionToken, 33 | }: APIEndpointSignInParameters = req.body; 34 | 35 | if (!authCollection) { 36 | return res.status(500).json({ 37 | statusCode: 500, 38 | error: { 39 | type: "Database Connection Error", 40 | message: "Could not connect to authentication database", 41 | }, 42 | }); 43 | } 44 | 45 | const requestDate = new Date(); 46 | 47 | switch (req.method) { 48 | case "POST": { 49 | if (sessionToken) { 50 | const previousSession = (await authCollection.findOne({ 51 | "session.token": sessionToken, 52 | })) as unknown as UserAuth; 53 | 54 | if (!previousSession) { 55 | return res.status(400).json({ 56 | statusCode: 400, 57 | error: { 58 | type: "Invalid Session", 59 | message: "Session is invalid", 60 | }, 61 | }); 62 | } 63 | 64 | const timeDifference = getTimeDifference( 65 | requestDate.toISOString(), 66 | previousSession.session!.expiresAt 67 | ); 68 | 69 | if (timeDifference <= 0) { 70 | return res.status(400).json({ 71 | statusCode: 400, 72 | error: { 73 | type: "Invalid Session", 74 | message: "Session expired", 75 | }, 76 | }); 77 | } 78 | 79 | const updatedUserAuth = { 80 | lastSignIn: requestDate.toISOString(), 81 | updatedAt: requestDate.toISOString(), 82 | "session.expiresAt": new Date( 83 | Date.now() + 30 * 24 * 60 * 60 * 1000 84 | ).toISOString(), 85 | "session.updatedAt": requestDate.toISOString(), 86 | }; 87 | 88 | const { 89 | ok, 90 | value: { password: excludedPassword, ...userSessionData }, 91 | }: { 92 | ok: 0 | 1; 93 | value: any; 94 | } = await authCollection.findOneAndUpdate( 95 | { 96 | "session.token": sessionToken, 97 | }, 98 | { 99 | $set: updatedUserAuth, 100 | }, 101 | { 102 | returnDocument: "after", 103 | } 104 | ); 105 | 106 | const userData = (await usersCollection.findOne({ 107 | email: userSessionData.email, 108 | })) as unknown as SiteUser; 109 | 110 | return res.status(200).json({ 111 | statusCode: 200, 112 | success: { 113 | status: ok ? 1 : 0, 114 | type: "User Signed In", 115 | message: "Successfully signed in", 116 | }, 117 | userAuth: userSessionData, 118 | user: userData, 119 | }); 120 | } else { 121 | if ((!email && !username) || !password) { 122 | return res.status(400).json({ 123 | statusCode: 400, 124 | error: { 125 | type: "Missing Parameters", 126 | message: "Email and password are required", 127 | }, 128 | }); 129 | } 130 | 131 | if (!EmailRegex.test(email) && email) { 132 | return res.status(400).json({ 133 | statusCode: 400, 134 | error: { 135 | type: "Invalid Email", 136 | message: "Email is invalid", 137 | }, 138 | }); 139 | } 140 | 141 | if (!PasswordRegex.test(password)) { 142 | return res.status(400).json({ 143 | statusCode: 400, 144 | error: { 145 | type: "Invalid Password", 146 | message: "Password is invalid", 147 | }, 148 | }); 149 | } 150 | 151 | const userAuth = (await authCollection.findOne({ 152 | $or: [ 153 | { 154 | email, 155 | }, 156 | { 157 | username, 158 | }, 159 | ], 160 | })) as unknown as UserAuth; 161 | 162 | if (!userAuth) { 163 | return res.status(400).json({ 164 | statusCode: 400, 165 | error: { 166 | type: "Invalid Credentials", 167 | message: "Email or password is incorrect", 168 | }, 169 | }); 170 | } 171 | 172 | const isPasswordValid = await comparePasswords( 173 | password, 174 | userAuth.password 175 | ); 176 | 177 | if (!isPasswordValid) { 178 | return res.status(400).json({ 179 | statusCode: 400, 180 | error: { 181 | type: "Invalid Credentials", 182 | message: "Email or password is incorrect", 183 | }, 184 | }); 185 | } 186 | 187 | const updatedUserAuth = { 188 | lastSignIn: requestDate.toISOString(), 189 | updatedAt: requestDate.toISOString(), 190 | "session.token": 191 | userAuth.session?.token && 192 | getTimeDifference( 193 | requestDate.toISOString(), 194 | userAuth.session.expiresAt 195 | ) > 0 196 | ? userAuth.session.token 197 | : JWT.sign( 198 | { 199 | userId: userAuth._id.toHexString(), 200 | }, 201 | jwtConfig.secretKey, 202 | { 203 | expiresIn: "30d", 204 | } 205 | ), 206 | "session.updatedAt": requestDate.toISOString(), 207 | "session.expiresAt": new Date( 208 | Date.now() + 30 * 24 * 60 * 60 * 1000 209 | ).toISOString(), 210 | "session.createdAt": 211 | userAuth.session?.expiresAt && 212 | getTimeDifference( 213 | requestDate.toISOString(), 214 | userAuth.session.expiresAt 215 | ) > 0 216 | ? userAuth.session.createdAt 217 | : requestDate.toISOString(), 218 | }; 219 | 220 | const userData = (await usersCollection.findOne({ 221 | $or: [ 222 | { 223 | email, 224 | }, 225 | { 226 | username, 227 | }, 228 | ], 229 | })) as unknown as SiteUser; 230 | 231 | const { 232 | ok, 233 | value: { password: excludedPassword, ...userSessionData }, 234 | }: { 235 | ok: 0 | 1; 236 | value: any; 237 | } = await authCollection.findOneAndUpdate( 238 | { 239 | $or: [ 240 | { 241 | email, 242 | }, 243 | { 244 | username, 245 | }, 246 | ], 247 | }, 248 | { 249 | $set: updatedUserAuth, 250 | }, 251 | { 252 | returnDocument: "after", 253 | } 254 | ); 255 | 256 | return res.status(200).json({ 257 | statusCode: 200, 258 | success: { 259 | status: ok ? 1 : 0, 260 | type: "User Signed In", 261 | message: "Successfully signed in", 262 | }, 263 | userAuth: userSessionData, 264 | user: userData, 265 | }); 266 | } 267 | 268 | break; 269 | } 270 | 271 | default: { 272 | return res.status(405).json({ 273 | statusCode: 405, 274 | error: { 275 | type: "Method Not Allowed", 276 | message: "Only POST requests are allowed", 277 | }, 278 | }); 279 | 280 | break; 281 | } 282 | } 283 | } catch (error: any) { 284 | return res.status(500).json({ 285 | statusCode: 500, 286 | error: { 287 | type: "Server Error", 288 | ...error, 289 | }, 290 | }); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /components/Book/BookCard.tsx: -------------------------------------------------------------------------------- 1 | import { BookInfo } from "@/utils/models/book"; 2 | import { Box, Button, Grid, Icon, Text, Tooltip } from "@chakra-ui/react"; 3 | import moment from "moment"; 4 | import Image from "next/image"; 5 | import React from "react"; 6 | import { MdBrokenImage } from "react-icons/md"; 7 | import CategoryTagsList from "../Category/CategoryTagsList"; 8 | import { IoBookSharp } from "react-icons/io5"; 9 | import { FaHandHolding } from "react-icons/fa"; 10 | import { ImBooks } from "react-icons/im"; 11 | import { SiBookstack } from "react-icons/si"; 12 | import { AiOutlineEye } from "react-icons/ai"; 13 | import { HiOutlineClock } from "react-icons/hi"; 14 | 15 | type BookCardProps = { 16 | bookData: BookInfo; 17 | onViewBook: (bookData: BookInfo) => void; 18 | }; 19 | 20 | const BookCard: React.FC = ({ bookData, onViewBook }) => { 21 | return ( 22 | <> 23 | onViewBook(bookData)} 26 | > 27 | 28 | <> 29 | 30 | 31 | {bookData.book.cover ? ( 32 | <> 33 | {bookData.book.title} 41 | 42 | ) : ( 43 | <> 44 | 45 | 46 | 51 | 52 | 53 | No cover image available 54 | 55 | 56 | 57 | )} 58 | 59 | 60 | 66 | 67 | 73 | 74 | {bookData.book.amount} 75 | 76 | 77 | 78 | 84 | 85 | 91 | 92 | {bookData.book.available} 93 | 94 | 95 | 96 | 102 | 103 | 109 | 110 | {bookData.book.borrows} 111 | 112 | 113 | 114 | 120 | 121 | 127 | 128 | {bookData.book.borrowedTimes} 129 | 130 | 131 | 132 | 133 | 134 | 138 | 143 | {bookData.book.title} 144 | 145 | 146 | {bookData.author.name} 147 | | 148 | 149 | {moment(bookData.book.publicationDate).format("MMMM DD, YYYY")} 150 | 151 | 152 | 153 | ISBN: {bookData.book.ISBN} 154 | 155 | 160 | 161 | 181 | 182 | 183 | 184 | 185 | <> 186 | {bookData.borrow && bookData.borrow.borrowStatus !== "returned" && ( 187 | <> 188 | 196 | 208 | 209 | {bookData.borrow.borrowStatus === "borrowed" && "Borrowed"} 210 | {bookData.borrow.borrowStatus === "pending" && 211 | "Pending Borrow"} 212 | 213 | 214 | 215 | )} 216 | 217 | 218 | 219 | ); 220 | }; 221 | 222 | export default BookCard; 223 | -------------------------------------------------------------------------------- /pages/api/authors/author.ts: -------------------------------------------------------------------------------- 1 | import { ObjectId } from "mongodb"; 2 | import authDb from "@/server/mongo/authDb"; 3 | import authorDb from "@/server/mongo/authorDb"; 4 | import userDb from "@/server/mongo/userDb"; 5 | import { UserAuth } from "@/utils/models/auth"; 6 | import { Author } from "@/utils/models/author"; 7 | import { SiteUser } from "@/utils/models/user"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | import bookDb from "@/server/mongo/bookDb"; 10 | import { Book } from "@/utils/models/book"; 11 | 12 | export interface APIEndpointAuthorParameters { 13 | apiKey: string; 14 | authorId?: string; 15 | name: string; 16 | biography: string; 17 | birthdate?: Date | string; 18 | } 19 | 20 | export default async function handler( 21 | req: NextApiRequest, 22 | res: NextApiResponse 23 | ) { 24 | try { 25 | const { authCollection } = await authDb(); 26 | 27 | const { usersCollection } = await userDb(); 28 | 29 | const { authorsCollection } = await authorDb(); 30 | 31 | const { booksCollection, bookBorrowsCollection } = await bookDb(); 32 | 33 | const { 34 | apiKey, 35 | authorId: rawAuthorId = "", 36 | name: rawName = "", 37 | biography: rawBiography = "", 38 | birthdate = undefined, 39 | }: APIEndpointAuthorParameters = req.body || req.query; 40 | 41 | const name = rawName.trim(); 42 | const biography = rawBiography.trim(); 43 | const authorId = rawAuthorId.trim(); 44 | 45 | if (!apiKey) { 46 | return res.status(400).json({ 47 | statusCode: 400, 48 | error: { 49 | type: "Missing API Key", 50 | message: "Please enter API Key", 51 | }, 52 | }); 53 | } 54 | 55 | if (!authCollection) { 56 | return res.status(500).json({ 57 | statusCode: 500, 58 | error: { 59 | type: "Database Connection Error", 60 | message: "Could not connect to authentication database", 61 | }, 62 | }); 63 | } 64 | 65 | if (!authorsCollection) { 66 | return res.status(500).json({ 67 | statusCode: 500, 68 | error: { 69 | type: "Database Connection Error", 70 | message: "Could not connect to author database", 71 | }, 72 | }); 73 | } 74 | 75 | const userAuthData = (await authCollection.findOne({ 76 | "keys.key": apiKey, 77 | })) as unknown as UserAuth; 78 | 79 | if (!userAuthData) { 80 | return res.status(400).json({ 81 | statusCode: 400, 82 | error: { 83 | type: "Invalid API Key", 84 | message: "Invalid API Key", 85 | }, 86 | }); 87 | } 88 | 89 | const userData = (await usersCollection.findOne({ 90 | email: userAuthData.email, 91 | username: userAuthData.username, 92 | })) as unknown as SiteUser; 93 | 94 | if (!userData) { 95 | return res.status(400).json({ 96 | statusCode: 400, 97 | error: { 98 | type: "Invalid User", 99 | message: "Invalid API Key", 100 | }, 101 | }); 102 | } 103 | 104 | const requestedAt = new Date(); 105 | 106 | switch (req.method) { 107 | case "POST": { 108 | if (!userData.roles.includes("admin")) { 109 | return res.status(401).json({ 110 | statusCode: 401, 111 | error: { 112 | type: "Unauthorized", 113 | message: "User is not authorized to create a new author", 114 | }, 115 | }); 116 | } 117 | 118 | if (!name) { 119 | return res.status(400).json({ 120 | statusCode: 400, 121 | error: { 122 | type: "Missing Parameters", 123 | message: "Name is required", 124 | }, 125 | }); 126 | } 127 | 128 | const existingAuthor = (await authorsCollection.findOne({ 129 | name, 130 | })) as unknown as Author; 131 | 132 | if (existingAuthor) { 133 | return res.status(400).json({ 134 | statusCode: 400, 135 | error: { 136 | type: "Author Already Exists", 137 | message: "An author with the same name already exists", 138 | }, 139 | }); 140 | } 141 | 142 | const authorId = new ObjectId(); 143 | 144 | const newAuthor: Author = { 145 | _id: authorId, 146 | id: authorId.toHexString(), 147 | name: name, 148 | biography: biography, 149 | birthdate: birthdate, 150 | updatedAt: requestedAt.toISOString(), 151 | createdAt: requestedAt.toISOString(), 152 | }; 153 | 154 | const newAuthorData = await authorsCollection.findOneAndUpdate( 155 | { 156 | name, 157 | }, 158 | { 159 | $set: newAuthor, 160 | }, 161 | { 162 | upsert: true, 163 | returnDocument: "after", 164 | } 165 | ); 166 | 167 | return res.status(201).json({ 168 | statusCode: 201, 169 | success: { 170 | type: "Author Created", 171 | message: "Author was created successfully", 172 | }, 173 | author: newAuthorData.value, 174 | }); 175 | 176 | break; 177 | } 178 | 179 | case "PUT": { 180 | if (!userData.roles.includes("admin")) { 181 | return res.status(401).json({ 182 | statusCode: 401, 183 | error: { 184 | type: "Unauthorized", 185 | message: "User is not authorized to create a new author", 186 | }, 187 | }); 188 | } 189 | 190 | if (!authorId) { 191 | return res.status(400).json({ 192 | statusCode: 400, 193 | error: { 194 | type: "Missing Parameters", 195 | message: "Author ID is required", 196 | }, 197 | }); 198 | } 199 | 200 | const existingAuthor = (await authorsCollection.findOne({ 201 | name, 202 | })) as unknown as Author; 203 | 204 | if (existingAuthor) { 205 | return res.status(400).json({ 206 | statusCode: 400, 207 | error: { 208 | type: "Author Already Exists", 209 | message: "An author with the same name already exists", 210 | }, 211 | }); 212 | } 213 | 214 | let updatedAuthor: Partial = { 215 | updatedAt: requestedAt.toISOString(), 216 | }; 217 | 218 | if (name) { 219 | updatedAuthor.name = name; 220 | } 221 | 222 | if (biography) { 223 | updatedAuthor.biography = biography; 224 | } 225 | 226 | if (birthdate) { 227 | updatedAuthor.birthdate = birthdate; 228 | } 229 | 230 | const oldAuthorData = (await authorsCollection.findOne({ 231 | id: authorId, 232 | })) as unknown as Author; 233 | 234 | const updatedAuthorData = await authorsCollection.findOneAndUpdate( 235 | { 236 | id: authorId, 237 | }, 238 | { 239 | $set: updatedAuthor, 240 | }, 241 | { 242 | returnDocument: "after", 243 | } 244 | ); 245 | 246 | if (name) { 247 | await booksCollection.updateMany( 248 | { 249 | author: oldAuthorData.name, 250 | }, 251 | { 252 | $set: { 253 | author: name, 254 | }, 255 | } 256 | ); 257 | } 258 | 259 | return res.status(200).json({ 260 | statusCode: 200, 261 | success: { 262 | type: "Author Updated", 263 | message: "Author was updated successfully", 264 | }, 265 | updatedAuthor: updatedAuthorData.value, 266 | }); 267 | 268 | break; 269 | } 270 | 271 | case "DELETE": { 272 | if (!userData.roles.includes("admin")) { 273 | return res.status(401).json({ 274 | statusCode: 401, 275 | error: { 276 | type: "Unauthorized", 277 | message: "User is not authorized to create a new author", 278 | }, 279 | }); 280 | } 281 | 282 | if (!authorId) { 283 | return res.status(400).json({ 284 | statusCode: 400, 285 | error: { 286 | type: "Missing Parameters", 287 | message: "Author ID is required", 288 | }, 289 | }); 290 | } 291 | 292 | const existingAuthor = (await authorsCollection.findOne({ 293 | id: authorId, 294 | })) as unknown as Author; 295 | 296 | if (!existingAuthor) { 297 | return res.status(200).json({ 298 | statusCode: 200, 299 | success: { 300 | type: "Author Does Not Exist", 301 | message: "An author with that name does not exist", 302 | isDeleted: true, 303 | }, 304 | }); 305 | } 306 | 307 | const deletedAuthorData = await authorsCollection.findOneAndDelete({ 308 | id: authorId, 309 | }); 310 | 311 | const authorBooks = (await booksCollection 312 | .find({ 313 | author: existingAuthor.name, 314 | }) 315 | .toArray()) as Book[]; 316 | 317 | await Promise.all( 318 | authorBooks.map(async (book) => { 319 | await bookBorrowsCollection.deleteMany({ 320 | bookId: book.id, 321 | }); 322 | 323 | await booksCollection.deleteOne({ 324 | id: book.id, 325 | }); 326 | }) 327 | ); 328 | 329 | return res.status(200).json({ 330 | statusCode: 200, 331 | success: { 332 | type: "Author Deleted", 333 | message: "Author was deleted successfully", 334 | isDeleted: true, 335 | }, 336 | }); 337 | 338 | break; 339 | } 340 | 341 | default: { 342 | return res.status(405).json({ 343 | statusCode: 405, 344 | error: { 345 | type: "Method Not Allowed", 346 | message: "Method Not Allowed", 347 | }, 348 | }); 349 | 350 | break; 351 | } 352 | } 353 | } catch (error: any) { 354 | return res.status(500).json({ 355 | statusCode: 500, 356 | error: { 357 | type: "Server Error", 358 | ...error, 359 | }, 360 | }); 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /pages/api/books/categories/category.ts: -------------------------------------------------------------------------------- 1 | import authDb from "@/server/mongo/authDb"; 2 | import bookDb from "@/server/mongo/bookDb"; 3 | import userDb from "@/server/mongo/userDb"; 4 | import { UserAuth } from "@/utils/models/auth"; 5 | import { BookCategory } from "@/utils/models/book"; 6 | import { SiteUser } from "@/utils/models/user"; 7 | import { ObjectId } from "mongodb"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | 10 | export interface APIEndpointBooksCategoryParameters { 11 | apiKey: string; 12 | categoryId?: string; 13 | name?: string; 14 | description?: string; 15 | } 16 | 17 | export default async function handler( 18 | req: NextApiRequest, 19 | res: NextApiResponse 20 | ) { 21 | try { 22 | const { authCollection } = await authDb(); 23 | 24 | const { usersCollection } = await userDb(); 25 | 26 | const { booksCollection, bookCategoriesCollection } = await bookDb(); 27 | 28 | const { 29 | apiKey, 30 | categoryId = undefined, 31 | name: rawName = undefined, 32 | description: rawDescription = undefined, 33 | }: APIEndpointBooksCategoryParameters = req.body || req.query; 34 | 35 | const name: APIEndpointBooksCategoryParameters["name"] = 36 | typeof rawName === "string" 37 | ? rawName 38 | .toLowerCase() 39 | .replace(/[^\w.,_\-\/\s]/g, "") 40 | .replace(/[^a-zA-Z0-9]+/g, "-") 41 | .replace(/-+/g, "-") 42 | .replace(/(^-|-$)/g, "") 43 | .trim() 44 | : undefined; 45 | 46 | const description: APIEndpointBooksCategoryParameters["description"] = 47 | typeof rawDescription === "string" ? rawDescription.trim() : undefined; 48 | 49 | if (!apiKey) { 50 | return res.status(400).json({ 51 | statusCode: 400, 52 | error: { 53 | type: "Missing API Key", 54 | message: "Please enter API Key", 55 | }, 56 | }); 57 | } 58 | 59 | if (!authCollection) { 60 | return res.status(500).json({ 61 | statusCode: 500, 62 | error: { 63 | type: "Database Connection Error", 64 | message: "Could not connect to authentication database", 65 | }, 66 | }); 67 | } 68 | 69 | if (!usersCollection) { 70 | return res.status(500).json({ 71 | statusCode: 500, 72 | error: { 73 | type: "Database Connection Error", 74 | message: "Could not connect to user database", 75 | }, 76 | }); 77 | } 78 | 79 | if (!booksCollection || !bookCategoriesCollection) { 80 | return res.status(500).json({ 81 | statusCode: 500, 82 | error: { 83 | type: "Database Connection Error", 84 | message: "Could not connect to book category database", 85 | }, 86 | }); 87 | } 88 | 89 | const userAuthData = (await authCollection.findOne({ 90 | "keys.key": apiKey, 91 | })) as unknown as UserAuth; 92 | 93 | if (!userAuthData) { 94 | return res.status(400).json({ 95 | statusCode: 400, 96 | error: { 97 | type: "Invalid API Key", 98 | message: "Invalid API Key", 99 | }, 100 | }); 101 | } 102 | 103 | const userData = (await usersCollection.findOne({ 104 | email: userAuthData.email, 105 | username: userAuthData.username, 106 | })) as unknown as SiteUser; 107 | 108 | if (!userData) { 109 | return res.status(400).json({ 110 | statusCode: 400, 111 | error: { 112 | type: "Invalid User", 113 | message: "Invalid API Key", 114 | }, 115 | }); 116 | } 117 | 118 | const requestedAt = new Date(); 119 | 120 | switch (req.method) { 121 | case "POST": { 122 | if (!userData.roles.includes("admin")) { 123 | return res.status(401).json({ 124 | statusCode: 401, 125 | error: { 126 | type: "Unauthorized", 127 | message: "You are not authorized to create a book category", 128 | }, 129 | }); 130 | } 131 | 132 | if (!name) { 133 | return res.status(400).json({ 134 | statusCode: 400, 135 | error: { 136 | type: "Missing Category Name", 137 | message: "Please enter category name", 138 | }, 139 | }); 140 | } 141 | 142 | const existingCategory = (await bookCategoriesCollection.findOne({ 143 | name, 144 | })) as unknown as BookCategory; 145 | 146 | if (existingCategory) { 147 | return res.status(400).json({ 148 | statusCode: 400, 149 | error: { 150 | type: "Category Already Exists", 151 | message: "Category already exists", 152 | }, 153 | }); 154 | } 155 | 156 | const categoryId = new ObjectId(); 157 | 158 | const newCategory: BookCategory = { 159 | _id: categoryId, 160 | id: categoryId.toHexString(), 161 | name, 162 | description, 163 | updatedAt: requestedAt.toISOString(), 164 | createdAt: requestedAt.toISOString(), 165 | }; 166 | 167 | const newCategoryData = await bookCategoriesCollection.findOneAndUpdate( 168 | { 169 | name, 170 | }, 171 | { 172 | $set: newCategory, 173 | }, 174 | { 175 | upsert: true, 176 | returnDocument: "after", 177 | } 178 | ); 179 | 180 | return res.status(201).json({ 181 | statusCode: 201, 182 | success: { 183 | type: "Category Created", 184 | message: "Category created successfully", 185 | }, 186 | category: newCategoryData.value, 187 | }); 188 | 189 | break; 190 | } 191 | 192 | case "PUT": { 193 | if (!userData.roles.includes("admin")) { 194 | return res.status(401).json({ 195 | statusCode: 401, 196 | error: { 197 | type: "Unauthorized", 198 | message: "You are not authorized to update categories", 199 | }, 200 | }); 201 | } 202 | 203 | if (!categoryId) { 204 | return res.status(400).json({ 205 | statusCode: 400, 206 | error: { 207 | type: "Missing Category ID", 208 | message: "Please enter category ID", 209 | }, 210 | }); 211 | } 212 | 213 | const existingCategory = (await bookCategoriesCollection.findOne({ 214 | id: categoryId, 215 | })) as unknown as BookCategory; 216 | 217 | if (!existingCategory) { 218 | return res.status(404).json({ 219 | statusCode: 404, 220 | error: { 221 | type: "Category Not Found", 222 | message: "Category not found", 223 | }, 224 | }); 225 | } 226 | 227 | let updatedCategory: Partial = {}; 228 | 229 | if (name) { 230 | updatedCategory.name = name; 231 | } 232 | 233 | if (description) { 234 | updatedCategory.description = description; 235 | } 236 | 237 | updatedCategory.updatedAt = requestedAt.toISOString(); 238 | 239 | const updatedCategoryData = 240 | await bookCategoriesCollection.findOneAndUpdate( 241 | { 242 | id: categoryId, 243 | }, 244 | { 245 | $set: updatedCategory, 246 | }, 247 | { 248 | returnDocument: "after", 249 | } 250 | ); 251 | 252 | if (updatedCategoryData.ok && updatedCategory.name) { 253 | await booksCollection.updateMany( 254 | { 255 | categories: { 256 | $in: [existingCategory.name], 257 | }, 258 | }, 259 | { 260 | $set: { 261 | "categories.$": updatedCategory.name, 262 | }, 263 | } 264 | ); 265 | } 266 | 267 | return res.status(200).json({ 268 | statusCode: 200, 269 | success: { 270 | type: "Category Updated", 271 | message: "Category updated successfully", 272 | }, 273 | category: updatedCategoryData.value, 274 | }); 275 | 276 | break; 277 | } 278 | 279 | case "DELETE": { 280 | if (!userData.roles.includes("admin")) { 281 | return res.status(401).json({ 282 | statusCode: 401, 283 | error: { 284 | type: "Unauthorized", 285 | message: "You are not authorized to delete categories", 286 | }, 287 | }); 288 | } 289 | 290 | if (!categoryId) { 291 | return res.status(400).json({ 292 | statusCode: 400, 293 | error: { 294 | type: "Missing Category ID", 295 | message: "Please enter category ID", 296 | }, 297 | }); 298 | } 299 | 300 | const existingCategory = (await bookCategoriesCollection.findOne({ 301 | id: categoryId, 302 | })) as unknown as BookCategory; 303 | 304 | if (!existingCategory) { 305 | return res.status(404).json({ 306 | statusCode: 404, 307 | error: { 308 | type: "Category Not Found", 309 | message: "Category not found", 310 | }, 311 | }); 312 | } 313 | 314 | const deletedCategory = await bookCategoriesCollection.deleteOne({ 315 | id: categoryId, 316 | }); 317 | 318 | if (deletedCategory.acknowledged) { 319 | await booksCollection.updateMany( 320 | { 321 | categories: { 322 | $in: [existingCategory.name], 323 | }, 324 | }, 325 | { 326 | $pull: { 327 | categories: existingCategory.name, 328 | } as any, 329 | } 330 | ); 331 | } 332 | 333 | return res.status(200).json({ 334 | statusCode: 200, 335 | success: { 336 | type: "Category Deleted", 337 | message: "Category deleted successfully", 338 | }, 339 | isDeleted: deletedCategory.acknowledged, 340 | }); 341 | 342 | break; 343 | } 344 | 345 | default: { 346 | return res.status(405).json({ 347 | statusCode: 405, 348 | error: { 349 | type: "Method Not Allowed", 350 | message: "Method Not Allowed", 351 | }, 352 | }); 353 | 354 | break; 355 | } 356 | } 357 | } catch (error: any) { 358 | return res.status(500).json({ 359 | statusCode: 500, 360 | error: { 361 | type: "Server Error", 362 | ...error, 363 | }, 364 | }); 365 | } 366 | } 367 | -------------------------------------------------------------------------------- /pages/manage/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Box, 3 | Breadcrumb, 4 | BreadcrumbItem, 5 | BreadcrumbLink, 6 | Flex, 7 | Grid, 8 | GridItem, 9 | Icon, 10 | Text, 11 | } from "@chakra-ui/react"; 12 | import React, { useEffect, useRef, useState } from "react"; 13 | import { IoBookSharp, IoLibraryOutline } from "react-icons/io5"; 14 | import { BsChevronRight, BsVectorPen } from "react-icons/bs"; 15 | import { BiCategory } from "react-icons/bi"; 16 | import Link from "next/link"; 17 | import ManageBreadcrumb from "@/components/Breadcrumb/ManageBreadcrumb"; 18 | import Head from "next/head"; 19 | import { MdPendingActions } from "react-icons/md"; 20 | import { FaHandHolding } from "react-icons/fa"; 21 | import { RiContactsBookUploadLine } from "react-icons/ri"; 22 | import axios from "axios"; 23 | import { apiConfig } from "@/utils/site"; 24 | import { APIEndpointCountParams } from "../api/count"; 25 | import useUser from "@/hooks/useUser"; 26 | 27 | type ManagePageProps = {}; 28 | 29 | const ManagePage: React.FC = () => { 30 | const { usersStateValue } = useUser(); 31 | const dashboardMounted = useRef(false); 32 | 33 | const [dashboardData, setDashboardData] = useState({ 34 | books: 0, 35 | authors: 0, 36 | categories: 0, 37 | borrows: { 38 | borrowed: 0, 39 | pending: 0, 40 | returned: 0, 41 | }, 42 | }); 43 | 44 | const fetchDatabaseDataCount = async () => { 45 | try { 46 | const { statusCode, count } = await axios 47 | .get(apiConfig.apiEndpoint + "/count/", { 48 | params: { 49 | apiKey: usersStateValue.currentUser?.auth?.keys[0].key, 50 | } as APIEndpointCountParams, 51 | }) 52 | .then((response) => response.data) 53 | .catch((error) => { 54 | throw new Error( 55 | `=>API: Fetching database data count failed with error:\n${error.message}` 56 | ); 57 | }); 58 | 59 | if (statusCode === 200) { 60 | setDashboardData(count); 61 | } 62 | } catch (error: any) { 63 | console.log( 64 | `=>API: Fetching database data count failed with error:\n${error.message}` 65 | ); 66 | } 67 | }; 68 | 69 | useEffect(() => { 70 | if (!dashboardMounted.current && usersStateValue.currentUser?.auth) { 71 | dashboardMounted.current = true; 72 | 73 | fetchDatabaseDataCount(); 74 | } 75 | }, [dashboardMounted.current]); 76 | 77 | // console.log({ 78 | // dashboardMounted: dashboardMounted.current, 79 | // usersStateValue, 80 | // }); 81 | 82 | return ( 83 | <> 84 | 85 | Dashboard | LibMS 86 | 87 | 93 | 101 | 102 | 107 | Dashboard 108 | 109 |
110 | 111 |
112 | 117 | 125 | 130 | 131 | 136 | 137 | 141 | 142 | {dashboardData.books} 143 | 144 | 145 | Books 146 | 147 | 148 | 149 | 150 | 158 | 163 | 164 | 169 | 170 | 174 | 175 | {dashboardData.authors} 176 | 177 | 178 | Authors 179 | 180 | 181 | 182 | 183 | 191 | 196 | 197 | 202 | 203 | 207 | 208 | {dashboardData.categories} 209 | 210 | 211 | Categories 212 | 213 | 214 | 215 | 216 |
217 | 225 | 230 | 231 | 236 | 237 | 241 | 242 | {dashboardData.borrows.borrowed} 243 | 244 | 245 | Borrows 246 | 247 | 248 | 249 | 250 | 258 | 263 | 264 | 269 | 270 | 274 | 275 | {dashboardData.borrows.pending} 276 | 277 | 278 | Pending 279 | 280 | 281 | 282 | 283 | 291 | 296 | 297 | 302 | 303 | 307 | 308 | {dashboardData.borrows.returned} 309 | 310 | 311 | Returned 312 | 313 | 314 | 315 | 316 |
317 |
318 |
319 | 320 | ); 321 | }; 322 | 323 | export default ManagePage; 324 | -------------------------------------------------------------------------------- /hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useCallback, useEffect, useMemo, useRef, useState } from "react"; 2 | import axios from "axios"; 3 | import { apiConfig } from "@/utils/site"; 4 | import useUser from "./useUser"; 5 | import { APIEndpointSignUpParameters } from "@/pages/api/auth/signup"; 6 | import { EmailRegex, PasswordRegex } from "@/utils/regex"; 7 | import { SiteUser } from "@/utils/models/user"; 8 | import { APIEndpointSignInParameters } from "@/pages/api/auth/signin"; 9 | import { UserAuth } from "@/utils/models/auth"; 10 | import { useAppDispatch } from "@/redux/hooks"; 11 | import { clearUsersState } from "@/redux/slice/usersSlice"; 12 | import { useToast } from "@chakra-ui/react"; 13 | 14 | const useAuth = () => { 15 | const { usersStateValue, setUsersStateValue } = useUser(); 16 | 17 | const toast = useToast(); 18 | 19 | const dispatch = useAppDispatch(); 20 | 21 | const [loadingUser, setLoadingUser] = useState(true); 22 | const [error, setError] = useState(null); 23 | 24 | const [loadingSession, setLoadingSession] = useState(false); 25 | 26 | const [loadingUserMemo, setLoadingUserMemo] = useMemo( 27 | () => [loadingUser, setLoadingUser], 28 | [loadingUser, setLoadingUser] 29 | ); 30 | 31 | const [errorMemo, setErrorMemo] = useMemo( 32 | () => [error, setError], 33 | [error, setError] 34 | ); 35 | 36 | const authCheck = useRef(false); 37 | const userMounted = useRef(false); 38 | 39 | const getSession = useCallback( 40 | async ({ 41 | sessionToken, 42 | }: Pick) => { 43 | try { 44 | if (!usersStateValue.currentUser?.user && !loadingSession) { 45 | setLoadingUserMemo(true); 46 | setLoadingSession(true); 47 | 48 | if (!sessionToken) { 49 | localStorage.removeItem("sessionToken"); 50 | throw new Error("=>Parameter Error: Session token is required"); 51 | } 52 | 53 | const { user, userAuth } = await axios 54 | .post(apiConfig.apiEndpoint + "/auth/signin", { 55 | sessionToken, 56 | } as Pick) 57 | .then((response) => response.data) 58 | .catch((error) => { 59 | const errorData = error.response.data; 60 | 61 | if (errorData.error.message) { 62 | toast({ 63 | title: "Error", 64 | description: errorData.error.message, 65 | status: "error", 66 | duration: 5000, 67 | isClosable: true, 68 | position: "top", 69 | }); 70 | } 71 | 72 | throw new Error( 73 | `=>API: Sign In Failed:\n${error.response.data.error.message}` 74 | ); 75 | }); 76 | 77 | if (user) { 78 | setUsersStateValue({ 79 | ...usersStateValue, 80 | currentUser: { 81 | ...usersStateValue.currentUser, 82 | user, 83 | auth: userAuth, 84 | }, 85 | }); 86 | 87 | setLoadingUserMemo(false); 88 | setLoadingSession(false); 89 | localStorage.setItem("sessionToken", userAuth.session.token); 90 | } else { 91 | throw new Error("=>API: Sign In Failed: User is undefined"); 92 | } 93 | } 94 | } catch (error) { 95 | console.log(`=>Mongo: Get Session Failed:\n${error}`); 96 | setErrorMemo(error); 97 | setLoadingUserMemo(false); 98 | setLoadingSession(false); 99 | } 100 | }, 101 | [ 102 | usersStateValue.currentUser?.user, 103 | usersStateValue.currentUser?.auth, 104 | loadingSession, 105 | setUsersStateValue, 106 | setLoadingSession, 107 | setLoadingUserMemo, 108 | setErrorMemo, 109 | ] 110 | ); 111 | 112 | const signUp = useCallback( 113 | async ({ 114 | email, 115 | password, 116 | }: Pick) => { 117 | try { 118 | if (!usersStateValue.currentUser?.user && !loadingSession) { 119 | setLoadingUserMemo(true); 120 | setLoadingSession(true); 121 | 122 | if (!email || !password) { 123 | throw new Error( 124 | "=>Parameter Error: Email and password are required" 125 | ); 126 | } 127 | 128 | if (!EmailRegex.test(email)) { 129 | throw new Error("=>Parameter Error: Email is invalid"); 130 | } 131 | 132 | if (!PasswordRegex.test(password)) { 133 | throw new Error("=>Parameter Error: Password is invalid"); 134 | } 135 | 136 | const { user, userAuth }: { user: SiteUser; userAuth: UserAuth } = 137 | await axios 138 | .post(apiConfig.apiEndpoint + "/auth/signup", { 139 | email, 140 | password, 141 | } as Pick) 142 | .then((response) => response.data) 143 | .catch((error: any) => { 144 | const errorData = error.response.data; 145 | 146 | if (errorData.error.message) { 147 | toast({ 148 | title: "Error", 149 | description: errorData.error.message, 150 | status: "error", 151 | duration: 5000, 152 | isClosable: true, 153 | position: "top", 154 | }); 155 | } 156 | 157 | throw new Error( 158 | `=>API: Sign Up Failed:\n${error.response.data.error.message}` 159 | ); 160 | }); 161 | 162 | if (user) { 163 | setUsersStateValue({ 164 | ...usersStateValue, 165 | currentUser: { 166 | ...usersStateValue.currentUser, 167 | user, 168 | auth: userAuth, 169 | }, 170 | }); 171 | 172 | setLoadingUserMemo(false); 173 | setLoadingSession(false); 174 | localStorage.setItem("sessionToken", userAuth.session!.token); 175 | } else { 176 | throw new Error("=>API: Sign Up Failed: User is undefined"); 177 | } 178 | } 179 | } catch (error: any) { 180 | console.log(`=>Mongo: Sign Up Failed:\n${error}`); 181 | setErrorMemo(error); 182 | setLoadingUserMemo(false); 183 | setLoadingSession(false); 184 | } 185 | }, 186 | [ 187 | usersStateValue.currentUser?.user, 188 | usersStateValue.currentUser?.auth, 189 | loadingSession, 190 | setUsersStateValue, 191 | setLoadingSession, 192 | setLoadingUserMemo, 193 | setErrorMemo, 194 | ] 195 | ); 196 | 197 | const signInWithPassword = useCallback( 198 | async ({ 199 | email, 200 | username, 201 | password, 202 | }: Pick) => { 203 | try { 204 | if (!usersStateValue.currentUser?.user && !loadingSession) { 205 | setLoadingUserMemo(true); 206 | setLoadingSession(true); 207 | 208 | if ((!email && !username) || !password) { 209 | throw new Error( 210 | "=>Parameter Error: Email or username and password are required" 211 | ); 212 | } 213 | 214 | if (!EmailRegex.test(email) && email) { 215 | throw new Error("=>Parameter Error: Email is invalid"); 216 | } else if (!username && username) { 217 | throw new Error("=>Parameter Error: Username is required"); 218 | } 219 | 220 | if (!PasswordRegex.test(password)) { 221 | throw new Error("=>Parameter Error: Password is invalid"); 222 | } 223 | 224 | const { user, userAuth } = await axios 225 | .post(apiConfig.apiEndpoint + "/auth/signin", { 226 | email, 227 | username, 228 | password, 229 | } as Pick) 230 | .then((response) => response.data) 231 | .catch((error) => { 232 | const errorData = error.response.data; 233 | 234 | if (errorData.error.message) { 235 | toast({ 236 | title: "Error", 237 | description: errorData.error.message, 238 | status: "error", 239 | duration: 5000, 240 | isClosable: true, 241 | position: "top", 242 | }); 243 | } 244 | 245 | throw new Error( 246 | `=>API: Sign In Failed:\n${error.response.data.error.message}` 247 | ); 248 | }); 249 | 250 | if (user) { 251 | setUsersStateValue({ 252 | ...usersStateValue, 253 | currentUser: { 254 | ...usersStateValue.currentUser, 255 | user, 256 | auth: userAuth, 257 | }, 258 | }); 259 | 260 | setLoadingUserMemo(false); 261 | setLoadingSession(false); 262 | localStorage.setItem("sessionToken", userAuth.session.token); 263 | } else { 264 | throw new Error("=>API: Sign In Failed: User is undefined"); 265 | } 266 | } 267 | } catch (error: any) { 268 | console.log(`=>Mongo: Sign In Failed:\n${error.message}`); 269 | setErrorMemo(error); 270 | setLoadingUserMemo(false); 271 | setLoadingSession(false); 272 | } 273 | }, 274 | [ 275 | usersStateValue.currentUser?.user, 276 | usersStateValue.currentUser?.auth, 277 | loadingSession, 278 | setUsersStateValue, 279 | setLoadingSession, 280 | setLoadingUserMemo, 281 | setErrorMemo, 282 | ] 283 | ); 284 | 285 | const signOut = useCallback(async () => { 286 | try { 287 | localStorage.removeItem("sessionToken"); 288 | dispatch(clearUsersState()); 289 | } catch (error: any) { 290 | console.log(`=>Mongo: Sign Out Failed:\n${error.message}`); 291 | setErrorMemo(error); 292 | } 293 | }, [dispatch, setErrorMemo, userMounted]); 294 | 295 | useEffect(() => { 296 | const sessionToken = localStorage.getItem("sessionToken"); 297 | 298 | if (!authCheck.current && !userMounted.current && loadingUserMemo) { 299 | authCheck.current = true; 300 | 301 | if (sessionToken && !usersStateValue.currentUser?.auth) { 302 | getSession({ 303 | sessionToken, 304 | }); 305 | } else { 306 | setLoadingUserMemo(false); 307 | } 308 | } 309 | }, [authCheck.current, loadingUserMemo]); 310 | 311 | useEffect(() => { 312 | if (usersStateValue.currentUser?.auth && !userMounted.current) { 313 | userMounted.current = true; 314 | } else { 315 | userMounted.current = false; 316 | } 317 | }, [userMounted, usersStateValue.currentUser]); 318 | 319 | return { 320 | loadingUser: loadingUserMemo, 321 | error: errorMemo, 322 | signInWithPassword, 323 | signUp, 324 | signOut, 325 | userMounted: userMounted.current, 326 | }; 327 | }; 328 | 329 | export default useAuth; 330 | -------------------------------------------------------------------------------- /components/Pages/AuthPageComponent.tsx: -------------------------------------------------------------------------------- 1 | import useAuth from "@/hooks/useAuth"; 2 | import { APIEndpointSignInParameters } from "@/pages/api/auth/signin"; 3 | import { EmailRegex, PasswordRegex } from "@/utils/regex"; 4 | import { 5 | Box, 6 | Button, 7 | Divider, 8 | Highlight, 9 | Modal, 10 | ModalBody, 11 | ModalCloseButton, 12 | ModalContent, 13 | ModalFooter, 14 | ModalHeader, 15 | ModalOverlay, 16 | Text, 17 | } from "@chakra-ui/react"; 18 | import React, { useState } from "react"; 19 | import { IoLibrary } from "react-icons/io5"; 20 | 21 | type AuthPageComponentProps = {}; 22 | 23 | export type AuthModalTypes = "" | "signin" | "signup"; 24 | 25 | const AuthPageComponent: React.FC = () => { 26 | const { loadingUser, signInWithPassword, signUp, error } = useAuth(); 27 | 28 | const [authenticating, setAuthenticating] = useState(false); 29 | 30 | const [signInForm, setSignInForm] = useState< 31 | Pick 32 | >({ 33 | email: "", 34 | username: "", 35 | password: "", 36 | }); 37 | 38 | const [signUpForm, setSignUpForm] = useState< 39 | Pick & { 40 | repeatPassword: string; 41 | } 42 | >({ 43 | email: "", 44 | password: "", 45 | repeatPassword: "", 46 | }); 47 | 48 | const [authModalOpen, setAuthModalOpen] = useState(""); 49 | 50 | const handleAuthModalOpen = (type: AuthModalTypes) => { 51 | setAuthModalOpen(type); 52 | }; 53 | 54 | const handleSignInFormChange = (e: React.ChangeEvent) => { 55 | setSignInForm((prev) => ({ 56 | ...prev, 57 | [e.target.name]: e.target.value, 58 | })); 59 | }; 60 | 61 | const handleSignUpFormChange = (e: React.ChangeEvent) => { 62 | setSignUpForm((prev) => ({ 63 | ...prev, 64 | [e.target.name]: e.target.value, 65 | })); 66 | }; 67 | 68 | const handleSubmitSignInForm = async ( 69 | event: React.FormEvent 70 | ) => { 71 | event.preventDefault(); 72 | 73 | try { 74 | if (!authenticating) { 75 | setAuthenticating(true); 76 | await signInWithPassword({ 77 | email: signInForm.email, 78 | username: signInForm.username, 79 | password: signInForm.password, 80 | }); 81 | setAuthenticating(false); 82 | } 83 | } catch (error: any) { 84 | console.log(error); 85 | setAuthenticating(false); 86 | } 87 | }; 88 | 89 | const handleSubmitSignUpForm = async ( 90 | event: React.FormEvent 91 | ) => { 92 | event.preventDefault(); 93 | 94 | try { 95 | if (!authenticating) { 96 | setAuthenticating(true); 97 | await signUp({ 98 | email: signUpForm.email, 99 | password: signUpForm.password, 100 | }); 101 | setAuthenticating(false); 102 | } 103 | } catch (error: any) { 104 | console.log(error); 105 | setAuthenticating(false); 106 | } 107 | }; 108 | 109 | return ( 110 | <> 111 | <> 112 | 113 | 114 | 115 | 116 | 121 | 122 | 123 | 124 | 129 | LIB 130 | 131 | 136 | 141 | 146 | 147 | 148 | 149 | 150 | 151 | 159 | Welcome to LibMS! The Library Management System 160 | 161 | 162 | 163 | 170 | 177 | 178 | 179 | 180 | 181 | handleAuthModalOpen("")} 184 | > 185 | 186 | 187 | Sign In 188 | 189 | 190 |
193 | !authenticating && 194 | !loadingUser && 195 | handleSubmitSignInForm(event) 196 | } 197 | data-error-email={ 198 | !EmailRegex.test(signInForm.email) && signInForm.email 199 | ? "true" 200 | : "false" 201 | } 202 | data-error-password={ 203 | !PasswordRegex.test(signInForm.password) && signInForm.password 204 | ? "true" 205 | : "false" 206 | } 207 | > 208 | 209 | 215 | 230 | 231 | 232 | 238 | 253 | 254 | 255 | 263 |
264 |
265 | 266 |
267 |
268 | handleAuthModalOpen("")} 271 | > 272 | 273 | 274 | Sign Up 275 | 276 | 277 |
280 | !authenticating && 281 | !loadingUser && 282 | handleSubmitSignUpForm(event) 283 | } 284 | data-error-email={ 285 | !EmailRegex.test(signUpForm.email) && signUpForm.email 286 | ? "true" 287 | : "false" 288 | } 289 | data-error-password={ 290 | !PasswordRegex.test(signUpForm.password) && signUpForm.password 291 | ? "true" 292 | : "false" 293 | } 294 | data-error-repeat-password={ 295 | !PasswordRegex.test(signUpForm.repeatPassword) && 296 | signUpForm.repeatPassword && 297 | signUpForm.repeatPassword != signUpForm.password 298 | ? "true" 299 | : "false" 300 | } 301 | > 302 | 303 | 309 | 324 | 325 | 326 | 327 | 333 | 348 | 349 | 350 | 351 | 357 | 372 | 373 | 374 | 382 |
383 |
384 | 385 |
386 |
387 | 388 | <> 389 | 390 | ); 391 | }; 392 | 393 | export default AuthPageComponent; 394 | -------------------------------------------------------------------------------- /components/Borrow/BorrowCard.tsx: -------------------------------------------------------------------------------- 1 | import { BookBorrow, BookInfo } from "@/utils/models/book"; 2 | import { 3 | Box, 4 | Button, 5 | Icon, 6 | Menu, 7 | MenuButton, 8 | MenuItem, 9 | MenuList, 10 | MenuGroup, 11 | Text, 12 | MenuDivider, 13 | Divider, 14 | } from "@chakra-ui/react"; 15 | import Image from "next/image"; 16 | import React from "react"; 17 | import { 18 | MdBrokenImage, 19 | MdDeleteOutline, 20 | MdOutlineNoteAdd, 21 | } from "react-icons/md"; 22 | import { BiCheckDouble, BiChevronUp } from "react-icons/bi"; 23 | import { IoSettingsOutline } from "react-icons/io5"; 24 | import { HiCheck, HiOutlineClock, HiOutlineX, HiX } from "react-icons/hi"; 25 | import { BsCheck2All, BsCheckAll } from "react-icons/bs"; 26 | import { FaHandHolding } from "react-icons/fa"; 27 | import { APIEndpointBorrowParameters } from "@/pages/api/books/borrows/borrow"; 28 | import moment from "moment"; 29 | 30 | type BorrowCardProps = { 31 | borrowData: BookInfo; 32 | onNote?: (borrowData: BookInfo) => void; 33 | onReturn?: (borrowData: BookInfo) => void; 34 | onAcceptReject?: ( 35 | borrowData: BookInfo, 36 | borrowType: APIEndpointBorrowParameters["borrowType"] 37 | ) => void; 38 | onRemove?: (borrowData: BookInfo) => void; 39 | }; 40 | 41 | const BorrowCard: React.FC = ({ 42 | borrowData, 43 | onNote, 44 | onAcceptReject, 45 | onReturn, 46 | onRemove, 47 | }) => { 48 | const renderBorrowMenu = (borrowStatus?: BookBorrow["borrowStatus"]) => { 49 | switch (borrowStatus) { 50 | case "pending": { 51 | return ( 52 | <> 53 | } 56 | rightIcon={} 57 | colorScheme="messenger" 58 | fontSize={"sm"} 59 | > 60 | Pending Menu 61 | 62 | 63 | ); 64 | 65 | break; 66 | } 67 | 68 | case "borrowed": { 69 | return ( 70 | <> 71 | } 74 | rightIcon={} 75 | colorScheme="whatsapp" 76 | fontSize={"sm"} 77 | > 78 | Borrow Menu 79 | 80 | 81 | ); 82 | 83 | break; 84 | } 85 | 86 | case "returned": { 87 | return ( 88 | <> 89 | } 92 | rightIcon={} 93 | colorScheme="whatsapp" 94 | variant={"outline"} 95 | fontSize={"sm"} 96 | > 97 | Returned Menu 98 | 99 | 100 | ); 101 | 102 | break; 103 | } 104 | 105 | default: { 106 | return <>; 107 | 108 | break; 109 | } 110 | } 111 | }; 112 | 113 | return ( 114 | <> 115 | 116 | 117 | 118 | 119 | {borrowData.book.cover ? ( 120 | <> 121 | 126 | {borrowData.book.title} 134 | 135 | 136 | ) : ( 137 | <> 138 | 139 | 140 | 145 | 146 | 147 | No cover image available 148 | 149 | 150 | 151 | )} 152 | 153 | 154 | 158 | 159 | 160 | 161 | {borrowData.book.title} 162 | 163 | 164 | by {borrowData.author.name} 165 | 166 | 167 | 168 | 169 | 170 | {borrowData.borrow?.borrowStatus === "pending" && "Requested"} 171 | {borrowData.borrow?.borrowStatus === "borrowed" && "Borrowed"} 172 | {borrowData.borrow?.borrowStatus === "returned" && 173 | "Returned"}{" "} 174 | By: 175 | 176 | 177 | {borrowData.borrower?.firstName 178 | ? `${borrowData.borrower.firstName} ${borrowData.borrower.lastName}` 179 | : `${ 180 | borrowData.borrower?.username || 181 | borrowData.borrower?.email 182 | }`} 183 | 184 | 185 | {borrowData.borrow?.requestedAt && ( 186 | <> 187 | 188 | 189 | Requested At: 190 | 191 | 192 | {moment(borrowData.borrow.requestedAt).format( 193 | "MMMM DD, YYYY, h:mm:ss a" 194 | )} 195 | 196 | 197 | 198 | )} 199 | {borrowData.borrow?.borrowedAt && ( 200 | <> 201 | 202 | 203 | Borrowed At: 204 | 205 | 206 | {moment(borrowData.borrow.borrowedAt).format( 207 | "MMMM DD, YYYY, h:mm:ss a" 208 | )} 209 | 210 | 211 | 212 | )} 213 | {borrowData.borrow?.returnedAt && ( 214 | <> 215 | 216 | 217 | Returned At: 218 | 219 | 220 | {moment(borrowData.borrow.returnedAt).format( 221 | "MMMM DD, YYYY, h:mm:ss a" 222 | )} 223 | 224 | 225 | 226 | )} 227 | 228 | 229 | 230 | 231 | Note 232 | 233 | 234 | {borrowData.borrow?.note 235 | ? borrowData.borrow.note 236 | : "No note found."} 237 | 238 | 239 | {borrowData.borrow?.dueAt && ( 240 | <> 241 | 242 | 243 | Due At: 244 | 245 | 246 | {moment(borrowData.borrow.dueAt).format("MMMM DD, YYYY")} 247 | 248 | 249 | 250 | )} 251 | 252 | 253 | 254 | 255 | {renderBorrowMenu(borrowData.borrow?.borrowStatus)} 256 | 257 | {borrowData.borrow?.borrowStatus === "pending" && ( 258 | <> 259 | 263 | 264 | onNote && onNote(borrowData)} 267 | > 268 | 269 | Add Note 270 | 271 | 274 | onAcceptReject && 275 | onAcceptReject(borrowData, "accept") 276 | } 277 | > 278 | 279 | Borrow 280 | 281 | 284 | onAcceptReject && 285 | onAcceptReject(borrowData, "request") 286 | } 287 | > 288 | 289 | Reject 290 | 291 | 292 | 293 | )} 294 | {borrowData.borrow?.borrowStatus === "borrowed" && ( 295 | <> 296 | 300 | 301 | onNote && onNote(borrowData)} 304 | > 305 | 306 | Add Note 307 | 308 | onReturn && onReturn(borrowData)} 311 | > 312 | 313 | Return 314 | 315 | 316 | 317 | )} 318 | {borrowData.borrow?.borrowStatus === "returned" && ( 319 | <> 320 | 324 | 325 | onNote && onNote(borrowData)} 328 | > 329 | 330 | Add Note 331 | 332 | onRemove && onRemove(borrowData)} 335 | > 336 | 337 | Remove 338 | 339 | 340 | 341 | )} 342 | 343 | 344 | 345 | 346 | 347 | 348 | 349 | ); 350 | }; 351 | 352 | export default BorrowCard; 353 | -------------------------------------------------------------------------------- /pages/api/books/borrows/borrow.ts: -------------------------------------------------------------------------------- 1 | import authDb from "@/server/mongo/authDb"; 2 | import bookDb from "@/server/mongo/bookDb"; 3 | import userDb from "@/server/mongo/userDb"; 4 | import { UserAuth } from "@/utils/models/auth"; 5 | import { Book, BookBorrow } from "@/utils/models/book"; 6 | import { SiteUser } from "@/utils/models/user"; 7 | import { ObjectId } from "mongodb"; 8 | import { NextApiRequest, NextApiResponse } from "next"; 9 | 10 | export interface APIEndpointBorrowParameters { 11 | apiKey: string; 12 | borrowId?: string; 13 | bookId?: string; 14 | note?: string; 15 | dueAt?: Date | string; 16 | borrowType?: "request" | "accept" | "return"; 17 | } 18 | 19 | export default async function handler( 20 | req: NextApiRequest, 21 | res: NextApiResponse 22 | ) { 23 | try { 24 | const { authCollection } = await authDb(); 25 | 26 | const { usersCollection } = await userDb(); 27 | 28 | const { booksCollection, bookBorrowsCollection } = await bookDb(); 29 | 30 | const { 31 | apiKey, 32 | borrowId = undefined, 33 | bookId = undefined, 34 | note: rawNote = "", 35 | dueAt = "", 36 | borrowType = "request", 37 | }: APIEndpointBorrowParameters = req.body || req.query; 38 | 39 | const note = 40 | typeof rawNote === "string" ? rawNote.trim() : rawNote || undefined; 41 | 42 | if (!apiKey) { 43 | return res.status(400).json({ 44 | statusCode: 400, 45 | error: { 46 | type: "Missing API Key", 47 | message: "Please enter API Key", 48 | }, 49 | }); 50 | } 51 | 52 | if (!authCollection) { 53 | return res.status(500).json({ 54 | statusCode: 500, 55 | error: { 56 | type: "Server Error", 57 | message: "Authentication database not found", 58 | }, 59 | }); 60 | } 61 | 62 | if (!usersCollection) { 63 | return res.status(500).json({ 64 | statusCode: 500, 65 | error: { 66 | type: "Server Error", 67 | message: "User database not found", 68 | }, 69 | }); 70 | } 71 | 72 | if (!booksCollection || !bookBorrowsCollection) { 73 | return res.status(500).json({ 74 | statusCode: 500, 75 | error: { 76 | type: "Server Error", 77 | message: "Book database not found", 78 | }, 79 | }); 80 | } 81 | 82 | const userAuthData = (await authCollection.findOne({ 83 | "keys.key": apiKey, 84 | })) as unknown as UserAuth; 85 | 86 | if (!userAuthData) { 87 | return res.status(400).json({ 88 | statusCode: 400, 89 | error: { 90 | type: "Invalid API Key", 91 | message: "Invalid API Key", 92 | }, 93 | }); 94 | } 95 | 96 | const userData = (await usersCollection.findOne({ 97 | email: userAuthData.email, 98 | username: userAuthData.username, 99 | })) as unknown as SiteUser; 100 | 101 | if (!userData) { 102 | return res.status(400).json({ 103 | statusCode: 400, 104 | error: { 105 | type: "Invalid User", 106 | message: "Invalid API Key", 107 | }, 108 | }); 109 | } 110 | 111 | const requestedAt = new Date(); 112 | 113 | switch (req.method) { 114 | case "POST": { 115 | if (!bookId) { 116 | return res.status(400).json({ 117 | statusCode: 400, 118 | error: { 119 | type: "Missing Book ID", 120 | message: "Please enter Book ID", 121 | }, 122 | }); 123 | } 124 | 125 | if (borrowType !== "request") { 126 | return res.status(400).json({ 127 | statusCode: 400, 128 | error: { 129 | type: "Invalid Borrow Status", 130 | message: "Invalid Borrow Status", 131 | }, 132 | }); 133 | } 134 | 135 | const existingBorrow = (await bookBorrowsCollection 136 | .find({ 137 | userId: userData.id, 138 | bookId: bookId, 139 | $or: [ 140 | { 141 | borrowStatus: "pending", 142 | }, 143 | { 144 | borrowStatus: "borrowed", 145 | }, 146 | ], 147 | }) 148 | .sort({ 149 | createdAt: -1, 150 | }) 151 | .limit(1) 152 | .toArray()) as BookBorrow[]; 153 | 154 | if (existingBorrow.length > 0) { 155 | return res.status(400).json({ 156 | statusCode: 400, 157 | error: { 158 | type: "Existing Borrow", 159 | message: "Existing Borrow", 160 | }, 161 | }); 162 | } 163 | 164 | const bookData = (await booksCollection.findOne({ 165 | id: bookId, 166 | })) as unknown as Book; 167 | 168 | if (bookData.available <= 0) { 169 | return res.status(400).json({ 170 | statusCode: 400, 171 | error: { 172 | type: "Book Unavailable", 173 | message: "Book Unavailable", 174 | }, 175 | }); 176 | } 177 | 178 | const borrowId = new ObjectId(); 179 | 180 | const newBookBorrow: Partial = { 181 | _id: borrowId, 182 | id: borrowId.toHexString(), 183 | userId: userData.id, 184 | bookId: bookId, 185 | note: note, 186 | borrowStatus: "pending", 187 | requestedAt: requestedAt.toISOString(), 188 | createdAt: requestedAt.toISOString(), 189 | }; 190 | 191 | const borrowData = await bookBorrowsCollection.findOneAndUpdate( 192 | { 193 | bookId: bookId, 194 | userId: userData.id, 195 | borrowStatus: "pending", 196 | }, 197 | { 198 | $set: newBookBorrow, 199 | }, 200 | { 201 | upsert: true, 202 | returnDocument: "after", 203 | } 204 | ); 205 | 206 | return res.status(201).json({ 207 | statusCode: 201, 208 | success: { 209 | type: "Book Borrow", 210 | message: "Book Borrow request sent successfully", 211 | }, 212 | borrow: borrowData.value, 213 | }); 214 | 215 | break; 216 | } 217 | 218 | case "PUT": { 219 | if (!borrowId) { 220 | return res.status(400).json({ 221 | statusCode: 400, 222 | error: { 223 | type: "Missing Borrow ID", 224 | message: "Please enter Borrow ID", 225 | }, 226 | }); 227 | } 228 | 229 | // if (!bookId) { 230 | // return res.status(400).json({ 231 | // statusCode: 400, 232 | // error: { 233 | // type: "Missing Book ID", 234 | // message: "Please enter Book ID", 235 | // }, 236 | // }); 237 | // } 238 | 239 | if ( 240 | borrowType !== "accept" && 241 | borrowType !== "return" && 242 | borrowType !== "request" 243 | ) { 244 | return res.status(400).json({ 245 | statusCode: 400, 246 | error: { 247 | type: "Invalid Borrow Status", 248 | message: "Invalid Borrow Status", 249 | }, 250 | }); 251 | } 252 | 253 | // const existingBorrow = (await bookBorrowsCollection.findOne({ 254 | // // id: borrowId, 255 | // bookId: bookId, 256 | // userId: userData.id, 257 | // $or: [ 258 | // { 259 | // borrowStatus: "pending", 260 | // }, 261 | // { 262 | // borrowStatus: "borrowed", 263 | // }, 264 | // ], 265 | // })) as unknown as BookBorrow; 266 | 267 | // let query: any = { 268 | // bookId: bookId, 269 | // userId: userData.id, 270 | // }; 271 | 272 | // if (borrowType === "accept") { 273 | // query = { 274 | // ...query, 275 | // borrowStatus: "pending", 276 | // }; 277 | // } else if (borrowType === "return") { 278 | // query = { 279 | // ...query, 280 | // borrowStatus: "borrowed", 281 | // }; 282 | // } 283 | 284 | const existingBorrow = (await bookBorrowsCollection 285 | .find({ 286 | id: borrowId, 287 | }) 288 | .sort({ 289 | createdAt: -1, 290 | }) 291 | .limit(1) 292 | .toArray()) as BookBorrow[]; 293 | 294 | if (!existingBorrow) { 295 | return res.status(400).json({ 296 | statusCode: 400, 297 | error: { 298 | type: "No Borrow", 299 | message: "No Borrow", 300 | }, 301 | }); 302 | } 303 | 304 | if (!existingBorrow || existingBorrow.length === 0) { 305 | return res.status(400).json({ 306 | statusCode: 400, 307 | error: { 308 | type: "No Borrow", 309 | message: "No Borrow", 310 | }, 311 | borrowType, 312 | }); 313 | } 314 | 315 | const bookData = (await booksCollection.findOne({ 316 | id: existingBorrow[0].bookId, 317 | })) as unknown as Book; 318 | 319 | if (!bookData) { 320 | return res.status(400).json({ 321 | statusCode: 400, 322 | error: { 323 | type: "No Book", 324 | message: "No Book", 325 | }, 326 | }); 327 | } 328 | 329 | // if ( 330 | // existingBorrow[0].borrowStatus === "borrowed" && 331 | // borrowType === "accept" 332 | // ) { 333 | // return res.status(400).json({ 334 | // statusCode: 400, 335 | // error: { 336 | // type: "Book Already Borrowed", 337 | // message: "Book Already Borrowed", 338 | // }, 339 | // }); 340 | // } 341 | 342 | if ( 343 | existingBorrow[0].borrowStatus === "pending" && 344 | borrowType === "return" 345 | ) { 346 | return res.status(400).json({ 347 | statusCode: 400, 348 | error: { 349 | type: "Book Not Borrowed", 350 | message: "Book Not Borrowed", 351 | }, 352 | }); 353 | } 354 | 355 | // if (existingBorrow[0].borrowStatus === "returned") { 356 | // return res.status(400).json({ 357 | // statusCode: 400, 358 | // error: { 359 | // type: "Book Already Returned", 360 | // message: "Book Already Returned", 361 | // }, 362 | // }); 363 | // } 364 | 365 | const updatedBookBorrow: Partial = {}; 366 | 367 | if (note) { 368 | updatedBookBorrow.note = note; 369 | } 370 | 371 | if (dueAt) { 372 | updatedBookBorrow.dueAt = dueAt; 373 | } 374 | 375 | if ( 376 | borrowType === "accept" && 377 | existingBorrow[0].borrowStatus === "pending" 378 | ) { 379 | if (bookData.available <= 0) { 380 | return res.status(400).json({ 381 | statusCode: 400, 382 | error: { 383 | type: "Book Not Available", 384 | message: "Book Not Available", 385 | }, 386 | }); 387 | } 388 | 389 | updatedBookBorrow.borrowStatus = "borrowed"; 390 | updatedBookBorrow.borrowedAt = requestedAt.toISOString(); 391 | 392 | await booksCollection.updateOne( 393 | { 394 | id: existingBorrow[0].bookId, 395 | }, 396 | { 397 | $inc: { 398 | available: -1, 399 | borrows: 1, 400 | borrowedTimes: 1, 401 | }, 402 | } 403 | ); 404 | } 405 | 406 | if ( 407 | borrowType === "return" && 408 | existingBorrow[0].borrowStatus === "borrowed" 409 | ) { 410 | await booksCollection.updateOne( 411 | { 412 | id: existingBorrow[0].bookId, 413 | }, 414 | { 415 | $inc: { 416 | available: 1, 417 | borrows: -1, 418 | }, 419 | } 420 | ); 421 | 422 | updatedBookBorrow.borrowStatus = "returned"; 423 | updatedBookBorrow.returnedAt = requestedAt.toISOString(); 424 | } 425 | 426 | const borrowData = await bookBorrowsCollection.findOneAndUpdate( 427 | { 428 | // id: existingBorrow.id, 429 | id: existingBorrow[0].id, 430 | // bookId: bookId, 431 | // userId: userData.id, 432 | // $or: [ 433 | // { 434 | // borrowStatus: "pending", 435 | // }, 436 | // { 437 | // borrowStatus: "borrowed", 438 | // }, 439 | // ], 440 | }, 441 | { 442 | $set: { 443 | ...updatedBookBorrow, 444 | }, 445 | }, 446 | { 447 | returnDocument: "after", 448 | } 449 | ); 450 | 451 | return res.status(200).json({ 452 | statusCode: 200, 453 | success: { 454 | type: "Book Borrow", 455 | message: "Book Borrow updated successfully", 456 | }, 457 | borrow: borrowData.value, 458 | }); 459 | 460 | break; 461 | } 462 | 463 | case "GET": { 464 | if (!bookId) { 465 | return res.status(400).json({ 466 | statusCode: 400, 467 | error: { 468 | type: "Missing Book ID", 469 | message: "Please enter Book ID", 470 | }, 471 | }); 472 | } 473 | 474 | const existingBookBorrow = (await bookBorrowsCollection 475 | .find({ 476 | bookId: bookId, 477 | userId: userData.id, 478 | }) 479 | .sort({ 480 | createdAt: -1, 481 | }) 482 | .limit(1) 483 | .toArray()) as BookBorrow[]; 484 | 485 | return res.status(200).json({ 486 | statusCode: 200, 487 | success: { 488 | type: "Book Borrow", 489 | message: "Book Borrow fetched successfully", 490 | }, 491 | }); 492 | 493 | break; 494 | } 495 | 496 | case "DELETE": { 497 | if (!borrowId) { 498 | return res.status(400).json({ 499 | statusCode: 400, 500 | error: { 501 | type: "Missing Borrow ID", 502 | message: "Please enter Borrow ID", 503 | }, 504 | }); 505 | } 506 | 507 | const existingBorrow = (await bookBorrowsCollection.findOne({ 508 | id: borrowId, 509 | })) as unknown as BookBorrow; 510 | 511 | if (!existingBorrow) { 512 | return res.status(400).json({ 513 | statusCode: 400, 514 | error: { 515 | type: "No Borrow", 516 | message: "No Borrow", 517 | }, 518 | }); 519 | } 520 | 521 | if (existingBorrow.borrowStatus === "borrowed") { 522 | return res.status(400).json({ 523 | statusCode: 400, 524 | error: { 525 | type: "Book Not Returned", 526 | message: "Book Not Returned", 527 | }, 528 | }); 529 | } 530 | 531 | const deletedBorrow = await bookBorrowsCollection.findOneAndDelete({ 532 | id: borrowId, 533 | }); 534 | 535 | return res.status(200).json({ 536 | statusCode: 200, 537 | success: { 538 | type: "Book Borrow", 539 | message: "Book Borrow deleted successfully", 540 | }, 541 | isDeleted: deletedBorrow.ok === 1 ? true : false, 542 | }); 543 | 544 | break; 545 | } 546 | 547 | default: { 548 | return res.status(405).json({ 549 | statusCode: 405, 550 | error: { 551 | type: "Method Not Allowed", 552 | message: "Method Not Allowed", 553 | }, 554 | }); 555 | 556 | break; 557 | } 558 | } 559 | } catch (error: any) { 560 | return res.status(500).json({ 561 | statusCode: 500, 562 | error: { 563 | type: "Server Error", 564 | ...error, 565 | }, 566 | }); 567 | } 568 | } 569 | --------------------------------------------------------------------------------