diff --git a/.firebaserc b/.firebaserc deleted file mode 100644 index 3650b746..00000000 --- a/.firebaserc +++ /dev/null @@ -1,5 +0,0 @@ -{ - "projects": { - "default": "saturday-hack-night" - } -} diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md deleted file mode 100644 index 6816655a..00000000 --- a/.github/pull_request_template.md +++ /dev/null @@ -1,27 +0,0 @@ - - -### What does it do? - -Describe the technical changes you did. - -### Why is it needed? - -Describe the issue you are solving. - -### How to test it? - -Provide information about the environment and the path to verify the behaviour. - -### Related issue(s)/PR(s) - -Let us know if this is related to any issue/pull request - -### Attach Screenshots For Reference - -Kindly attach screenshot of UI changes made. diff --git a/.github/workflows/code_quality.yml b/.github/workflows/code_quality.yml new file mode 100644 index 00000000..17ef9e6c --- /dev/null +++ b/.github/workflows/code_quality.yml @@ -0,0 +1,18 @@ +name: Code quality + +on: + push: + pull_request: + +jobs: + quality: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Biome + uses: biomejs/setup-biome@v2 + with: + version: latest + - name: Run Biome + run: biome ci . \ No newline at end of file diff --git a/.gitignore b/.gitignore index 5acdc4d5..7e437961 100644 --- a/.gitignore +++ b/.gitignore @@ -1,11 +1,37 @@ -node_modules -.turbo -*.log -build -.firebase -.vscode -.idea -out -.next +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz .env -tmp \ No newline at end of file + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..9601faa9 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "biome_lsp.trace.server": "verbose", + "editor.defaultFormatter": "biomejs.biome", + "editor.formatOnPaste": true, + "editor.formatOnSave": true +} diff --git a/LICENSE b/LICENSE deleted file mode 100644 index c802ced4..00000000 --- a/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) 2023 TinkerHub - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/README.md b/README.md index c9a2aaf9..c4033664 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,36 @@ -[![Contributors][contributors-shield]][contributors-url] -[![Forks][forks-shield]][forks-url] -[![Stargazers][stars-shield]][stars-url] -[![Issues][issues-shield]][issues-url] -[![MIT License][license-shield]][license-url] - - -
-
- - Logo - - -

Saturday Hacknight

- -

- Where Hacking Happens -
-

-
- - -
- Table of Contents -
    -
  1. - About The Project - -
  2. -
  3. - Getting Started - -
  4. -
-
- - - -## About The Project - -Saturday Hacknight is a community built Platform for Tinkers to conduct Hacking activities. - -

(back to top)

- -### Built With - -- [NextJs](https://nextjs.org/) -- [NestJs](https://nestjs.com/) -- [Typescript](https://typescript.org/) - -

(back to top)

- - +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). ## Getting Started -### Prerequisites - -You need to install - -1. [Node v16](https://nodejs.org/en/) -2. [Yarn](https://yarnpkg.com/) - -### Installation - -1. Clone the repo - - ```sh - git clone https://github.com/tinkerhub/saturday-hack-night.git - ``` - -2. Install all the NPM packages all the applications. - - > We are using Yarn workspace and turborepo to manage the applications in monorepo. +First, run the development server: - ```sh - yarn install - ``` +```bash +npm run dev +# or +yarn dev +# or +pnpm dev +# or +bun dev +``` -3. Copy the `.env.example` for each applications to `.env` in the same directory and fill the values required +Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. -4. Start the web application dev server and open `http://localhost:3000` +You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. - ```sh - yarn workspace web dev - ``` +This project uses [`next/font`](https://nextjs.org/docs/basic-features/font-optimization) to automatically optimize and load Inter, a custom Google Font. -

(back to top)

+## Learn More -## Contributing +To learn more about Next.js, take a look at the following resources: -Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. +- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. +- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. -If you have a suggestion that would make this better, please fork the repo and create a pull request. You can also simply open an issue with the tag "enhancement". -Don't forget to give the project a star! Thanks again! +You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! -1. Fork the Project -2. Create your Feature Branch (`git checkout -b feature/AmazingFeature`) -3. Commit your Changes (`git commit -m 'Add some AmazingFeature'`) -4. Push to the Branch (`git push origin feature/AmazingFeature`) -5. Open a Pull Request +## Deploy on Vercel -

(back to top)

+The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. -[contributors-shield]: https://img.shields.io/github/contributors/tinkerhub/saturday-hack-night.svg?style=for-the-badge -[contributors-url]: https://github.com/tinkerhub/saturday-hack-night/graphs/contributors -[forks-shield]: https://img.shields.io/github/forks/tinkerhub/saturday-hack-night.svg?style=for-the-badge -[forks-url]: https://github.com/tinkerhub/saturday-hack-night/network/members -[stars-shield]: https://img.shields.io/github/stars/tinkerhub/saturday-hack-night.svg?style=for-the-badge -[stars-url]: https://github.com/tinkerhub/saturday-hack-night/stargazers -[issues-shield]: https://img.shields.io/github/issues/tinkerhub/saturday-hack-night.svg?style=for-the-badge -[issues-url]: https://github.com/tinkerhub/saturday-hack-night/issues -[license-shield]: https://img.shields.io/github/license/tinkerhub/saturday-hack-night.svg?style=for-the-badge -[license-url]: https://github.com/tinkerhub/saturday-hack-night/blob/main/LICENCE +Check out our [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. diff --git a/app/api/colleges/route.ts b/app/api/colleges/route.ts new file mode 100644 index 00000000..31807360 --- /dev/null +++ b/app/api/colleges/route.ts @@ -0,0 +1,35 @@ +import { db } from "@/utils/db"; +import { validateRequest } from "@/utils/lucia"; +import type { NextRequest } from "next/server"; + +export async function GET(request: NextRequest): Promise { + const { session } = await validateRequest(); + + if (!session) { + return new Response(null, { + status: 401, + headers: { + Location: "/auth/login", + }, + }); + } + const params = request.nextUrl.searchParams; + + const searchItem = params.get("search"); + + const colleges = await db.college.findMany({ + where: { + name: { + contains: searchItem ?? "", + mode: "insensitive", + }, + }, + select: { + id: true, + name: true, + }, + take: 10, + }); + + return new Response(JSON.stringify(colleges), {}); +} diff --git a/app/api/users/[githubID]/route.ts b/app/api/users/[githubID]/route.ts new file mode 100644 index 00000000..b950926f --- /dev/null +++ b/app/api/users/[githubID]/route.ts @@ -0,0 +1,31 @@ +import { db } from "@/utils/db"; +import { validateRequest } from "@/utils/lucia"; +import type { NextRequest } from "next/server"; + +export async function GET( + _request: NextRequest, + { params }: { params: { githubID: string } }, +): Promise { + const { session } = await validateRequest(); + + if (!session) { + return new Response(null, { + status: 401, + headers: { + Location: "/auth/login", + }, + }); + } + + const user = await db.user.findUnique({ + where: { + githubId: params.githubID ?? "", + }, + select: { + githubId: true, + }, + }); + return new Response(JSON.stringify(user), { + status: user ? 200 : 404, + }); +} diff --git a/app/auth/github/callback/route.ts b/app/auth/github/callback/route.ts new file mode 100644 index 00000000..bfea89f9 --- /dev/null +++ b/app/auth/github/callback/route.ts @@ -0,0 +1,109 @@ +import { github, lucia } from "@/utils/lucia"; +import { cookies } from "next/headers"; +import { OAuth2RequestError } from "arctic"; +import { generateId } from "lucia"; +import { db } from "@/utils/db"; +import type { GithubOAuthUser } from "@/utils/types"; + +export async function GET(request: Request): Promise { + const url = new URL(request.url); + const state = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + + const storedState = cookies().get("state")?.value; + + if (!code || !state || !storedState || state !== storedState) { + return new Response(null, { + status: 400, + }); + } + + try { + const tokens = await github.validateAuthorizationCode(code); + const githubUser = await fetchGithubUser(tokens.accessToken); + + const existingAccount = await db.account.findUnique({ + where: { + provider: "github", + providerUserId: githubUser.id.toString(), + }, + }); + + if (existingAccount) { + const session = await lucia.createSession(existingAccount.userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + return new Response(null, { + status: 302, + headers: { + Location: "/", + }, + }); + } + + const userId = generateId(15); + + // Replace this with your own DB client. + + await db.user.create({ + data: { + id: userId, + githubId: githubUser.login, + name: githubUser.name, + email: githubUser.email, + avatar: githubUser.avatar_url, + }, + }); + + await db.account.create({ + data: { + id: generateId(15), + userId, + provider: "github", + providerUserId: githubUser.id.toString(), + providerAccessToken: tokens.accessToken, + providerRefreshToken: "", + profileMeta: JSON.stringify(githubUser), + createdAt: new Date(), + updatedAt: new Date(), + }, + }); + + const session = await lucia.createSession(userId, {}); + const sessionCookie = lucia.createSessionCookie(session.id); + cookies().set( + sessionCookie.name, + sessionCookie.value, + sessionCookie.attributes, + ); + return new Response(null, { + status: 302, + headers: { + Location: "/", + }, + }); + } catch (e) { + console.log(e); + if (e instanceof OAuth2RequestError) { + return new Response(null, { + status: 400, + }); + } + return new Response(null, { + status: 500, + }); + } +} + +const fetchGithubUser = async (accessToken: string) => { + const response = await fetch("https://api.github.com/user", { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + }); + return (await response.json()) as GithubOAuthUser; +}; diff --git a/app/auth/github/route.ts b/app/auth/github/route.ts new file mode 100644 index 00000000..f7ecc33e --- /dev/null +++ b/app/auth/github/route.ts @@ -0,0 +1,19 @@ +import { generateState } from "arctic"; +import { github } from "@/utils/lucia"; +import { cookies } from "next/headers"; + +export async function GET(): Promise { + const state = generateState(); + const url = await github.createAuthorizationURL(state, { + scopes: ["user:email"], + }); + + cookies().set("state", state, { + secure: true, + path: "/", + httpOnly: true, + maxAge: 60 * 10, + }); + + return Response.redirect(url); +} diff --git a/app/components/AsyncSelect.tsx b/app/components/AsyncSelect.tsx new file mode 100644 index 00000000..1598a3a6 --- /dev/null +++ b/app/components/AsyncSelect.tsx @@ -0,0 +1,61 @@ +import type { CSSProperties } from "react"; +import type { StylesConfig } from "react-select"; +import AsyncSelect from "react-select/async"; + +const selectStyle = { + control: (styles: CSSProperties) => ({ + ...styles, + ":hover": { + borderColor: "rgba(255, 255, 255, 0.15)", + }, + color: "white", + paddingInline: "10px", + fontFamily: "Clash Display", + fontSize: "16px", + height: "45px", + fontWeight: "regular", + background: "rgba(255,255,255,0.15)", + boxShadow: "none", + outline: "none", + borderRadius: "10px", + border: "none", + }), + menu: (styles: CSSProperties) => ({ + ...styles, + color: "white", + borderRadius: "10px", + fontFamily: "Clash Display", + fontSize: "16px", + }), + menuList: (styles: CSSProperties) => ({ + ...styles, + borderRadius: "10px", + padding: "10px", + backgroundColor: "rgba(0,0,0, 0.75)", + }), + singleValue: (styles: CSSProperties) => ({ + ...styles, + color: "white", + }), + option: (styles: CSSProperties) => ({ + ...styles, + color: "white", + border: "1px solid rgba(255, 255, 255, 0.15)", + backgroundColor: "rgba(255,255,255,0.15)", + }), + input: (styles: CSSProperties) => ({ + ...styles, + width: "100%", + color: "white", + }), +}; + +export const AsyncSelectComponent = ({ ...rest }) => { + return ( + + ); +}; diff --git a/app/components/Button.tsx b/app/components/Button.tsx new file mode 100644 index 00000000..c47efbf6 --- /dev/null +++ b/app/components/Button.tsx @@ -0,0 +1,64 @@ +"use client"; +import type { ButtonHTMLAttributes } from "react"; +import { useFormStatus } from "react-dom"; +import { twMerge } from "tailwind-merge"; + +export const Button = ({ + className, + loading, + type = "button" as const, + children, + wrapperClassName, + ...rest +}: ButtonHTMLAttributes & { + loading?: boolean; + wrapperClassName?: string; +}) => { + const { pending } = useFormStatus(); + + return ( + + ); +}; diff --git a/app/components/Input.tsx b/app/components/Input.tsx new file mode 100644 index 00000000..0353197a --- /dev/null +++ b/app/components/Input.tsx @@ -0,0 +1,18 @@ +import { forwardRef, type InputHTMLAttributes } from "react"; +import { twMerge } from "tailwind-merge"; + +export const Input = forwardRef< + HTMLInputElement, + InputHTMLAttributes +>(({ className, ...rest }, ref) => { + return ( + + ); +}); diff --git a/app/events/page.tsx b/app/events/page.tsx new file mode 100644 index 00000000..678e132c --- /dev/null +++ b/app/events/page.tsx @@ -0,0 +1,58 @@ +import { validateRequest } from "@/utils/lucia"; +import { CurrentEvent } from "./ui/CurrentEvent"; +import { getCurrentEvent, getEvents } from "@/utils/events"; +import { EventCard } from "./ui/EventCard"; +import { ProjectModal } from "./ui/modal/ProjectModal"; + +type SearchParamProps = { + searchParams: Record | null | undefined; +}; + +const EventsPage = async ({ searchParams }: SearchParamProps) => { + const registerModal = searchParams?.register === "true"; + const updateModal = searchParams?.update === "true"; + const results = searchParams?.results === "true"; + const eventID = searchParams?.eventID; + + const { user } = await validateRequest(); + const currentEvent = await getCurrentEvent(user); + + const events = await getEvents(); + + return ( +
+ + {currentEvent.event && ( +
+

+ Ongoing Events 🚀 +

+ +
+ )} + + {events && events.length > 0 && ( +
+

+ Explored Areas 🌟 +

+ +
+ {events.map((event) => ( + + ))} +
+
+ )} +
+ ); +}; + +export default EventsPage; diff --git a/app/events/results/[eventID]/route.ts b/app/events/results/[eventID]/route.ts new file mode 100644 index 00000000..3e98822e --- /dev/null +++ b/app/events/results/[eventID]/route.ts @@ -0,0 +1,70 @@ +import { db } from "@/utils/db"; +import { EventStatus, ProjectStatus } from "@/utils/types"; +import { + getResultsParamsSchema, + validateRequestSchema, +} from "@/utils/validateRequest"; +import type { NextRequest } from "next/server"; + +export async function GET( + _request: NextRequest, + { params }: { params: { eventID: string } }, +) { + const validation = validateRequestSchema( + getResultsParamsSchema, + params, + true, + ); + + if (validation instanceof Response || !validation.success) { + if (validation instanceof Response) { + return validation; + } + return; + } + const data = validation.data; + + const event = await db.event.findUnique({ + where: { + id: data.eventID, + status: EventStatus.RESULTS, + }, + }); + + if (!event) { + return new Response("Event not found", { status: 404 }); + } + + const projects = await db.team.findMany({ + where: { + eventId: data.eventID, + projectStatus: { + in: [ProjectStatus.COMPLETED, ProjectStatus.BEST_PROJECT], + }, + }, + select: { + id: true, + name: true, + projectStatus: true, + repo: true, + members: { + select: { + user: { + select: { + name: true, + avatar: true, + githubId: true, + }, + }, + }, + }, + }, + }); + + return new Response( + JSON.stringify({ + event, + projects, + }), + ); +} diff --git a/app/events/ui/CopyLink.tsx b/app/events/ui/CopyLink.tsx new file mode 100644 index 00000000..648c54f6 --- /dev/null +++ b/app/events/ui/CopyLink.tsx @@ -0,0 +1,25 @@ +"use client"; + +import { toast } from "sonner"; + +const CopyLink = ({ id }: { id: string }) => { + const copyLink = () => { + navigator.clipboard.writeText( + `${window.location.href}?eventID=${id}&results=true`, + ); + toast.success("Copied to clipboard!"); + }; + + return ( + + Copy Link + + ); +}; + +export default CopyLink; diff --git a/app/events/ui/CurrentEvent.tsx b/app/events/ui/CurrentEvent.tsx new file mode 100644 index 00000000..c635a222 --- /dev/null +++ b/app/events/ui/CurrentEvent.tsx @@ -0,0 +1,177 @@ +import Link from "next/link"; +import Image from "next/image"; +import { Calendar } from "lucide-react"; +import type { User } from "lucia"; +import { isProfileComplete as isProfileCompleteFn } from "@/utils/user"; +import dayjs from "dayjs"; +import { redirect } from "next/navigation"; +import { CreateTeamModal } from "./modal/CreateTeamModal"; +import { EventStatus, TeamMemberRole } from "@/utils/types"; +import { UpdateTeamModal } from "./modal/UpdateTeamModal"; + +export const CurrentEvent = ({ + user, + data, +}: { + user: User | null; + data: { + event: { + id: string; + title: string; + description: string; + image: string; + details: string; + status: string | null; + _count: { + teams: number; + }; + imageWhite: string; + date: Date; + location: string; + } | null; + team: { + id: string; + repo: string; + name: string; + eventId: string; + members: { + user: { + githubId: string; + }; + role: string | null; + userId: string; + }[]; + } | null; + }; +}) => { + const isProfileComplete = isProfileCompleteFn(user); + + const { event, team } = data; + + const isEditable = + (team?.members.some( + (member) => + member.userId === user?.id && member.role === TeamMemberRole.LEADER, + ) && + event?.status === EventStatus.REGISTRATION) || + false; + + if (!event) { + redirect("/"); + } + const { + date, + imageWhite, + description, + status, + title, + details, + _count: { teams }, + } = event; + + const url = user + ? team + ? isEditable + ? status === EventStatus.REGISTRATION + ? `/events/?update=true&eventId=${event.id}` + : `/events/?update=true&eventId=${event.id}` + : `/events/?update=true&eventId=${event.id}` + : isProfileComplete + ? status === EventStatus.REGISTRATION + ? `/events/?register=true&eventId=${event.id}` + : "" + : `/events/?register=true&eventId=${event.id}` + : `/events/?register=true&eventId=${event.id}`; + + return ( +
+ {team && user && event && ( + + )} + {user && event?.status === EventStatus.REGISTRATION && ( + + )} + +
+
+
+ + + {dayjs(date).format("ddd MMM DD, YYYY")} + +
+
+ Circle + + {teams} Teams Registered + +
+
+ + Event + +
+ + {team + ? "Registered 🎉" + : status === EventStatus.REGISTRATION + ? "Register Now" + : "Registration Closed"} + +
+
+ +
+

{title}

+

{description}

+ +
+ + + + + + + +
+
+
+ ); +}; diff --git a/app/events/ui/EventCard.tsx b/app/events/ui/EventCard.tsx new file mode 100644 index 00000000..94b861dc --- /dev/null +++ b/app/events/ui/EventCard.tsx @@ -0,0 +1,71 @@ +/* eslint-disable @next/next/no-img-element */ +import Link from "next/link"; +import CopyLink from "./CopyLink"; +import { Button } from "@/app/components/Button"; + +export const EventCard = ({ + event: { + id, + title, + description, + image, + details, + status, + _count: { teams: projectCount }, + }, +}: { + event: { + id: string; + title: string; + description: string; + image: string; + details: string; + date: Date; + status: string | null; + _count: { + teams: number; + }; + }; +}) => { + return ( + <> +
+
+ {title} + +
+ +
+ + ✅ {projectCount || 0} Projects + +

+ {description} +

+
+ + + + + + +
+
+
+ + ); +}; diff --git a/app/events/ui/Member.tsx b/app/events/ui/Member.tsx new file mode 100644 index 00000000..3adbc2e6 --- /dev/null +++ b/app/events/ui/Member.tsx @@ -0,0 +1,74 @@ +"use client"; +import React from "react"; +import { Controller, useFieldArray, useFormContext } from "react-hook-form"; +import { Input } from "@/app/components/Input"; +import { twMerge } from "tailwind-merge"; + +interface MemberProps { + loading: boolean; + isEditable: boolean; +} + +const Member = ({ loading, isEditable }: MemberProps) => { + const { control } = useFormContext(); + const { fields, append, remove } = useFieldArray({ + control, + name: "members", + }); + + return ( +
+
+ + {fields.map((member: Record<"id", string>, index: number) => ( + ( +
+ + !loading && isEditable && remove(index)} + onClick={() => !loading && isEditable && remove(index)} + > + Remove + +
+ )} + /> + ))} +
+ + {fields.length < 3 && ( +
(loading || !isEditable ? null : append(""))} + onKeyDown={() => (loading || !isEditable ? null : append(""))} + > + add + ADD TEAM MEMBER +
+ )} +
+ ); +}; + +export { Member }; diff --git a/app/events/ui/ProjectCard.tsx b/app/events/ui/ProjectCard.tsx new file mode 100644 index 00000000..925d7f5d --- /dev/null +++ b/app/events/ui/ProjectCard.tsx @@ -0,0 +1,54 @@ +import { Button } from "@/app/components/Button"; +import type { ExtractedTeamType } from "@/utils/types"; +import Link from "next/link"; +import { toast } from "sonner"; +export const ProjectCard = ({ team }: { team: ExtractedTeamType }) => { + return ( +
+
+

{team.name}

+
+
+ {team.members.map(({ user }) => ( + + {user.name +

+ {user.name ?? user.githubId} +

+ + ))} +
+ + + + + +
+
+
+ ); +}; diff --git a/app/events/ui/modal/CreateTeamModal.tsx b/app/events/ui/modal/CreateTeamModal.tsx new file mode 100644 index 00000000..f94ba30c --- /dev/null +++ b/app/events/ui/modal/CreateTeamModal.tsx @@ -0,0 +1,168 @@ +"use client"; + +import { useSearchParams, usePathname, useRouter } from "next/navigation"; + +import * as Dialog from "@radix-ui/react-dialog"; +import type { User } from "lucia"; +import { startTransition, useEffect, useState } from "react"; +import { Input } from "@/app/components/Input"; +import { X } from "lucide-react"; +import { Button } from "@/app/components/Button"; +import type { z } from "zod"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import createTeam from "./actions"; +import { createTeamSchema } from "@/utils/validateRequest"; +import { Member } from "../Member"; + +type FormData = z.infer; + +export const CreateTeamModal = ({ + user, +}: { + user: User; +}) => { + const router = useRouter(); + + const [isOpen, setIsOpen] = useState(false); + + const [eventID, setEventID] = useState(null); + + const searchParams = useSearchParams(); + const eventId = searchParams.get("eventId"); + + useEffect(() => { + setEventID(searchParams.get("eventId")); + setIsOpen(!!searchParams.get("register")); + }, [searchParams]); + + const pathName = usePathname(); + const onOpenChange = () => { + router.push(pathName); + }; + + const methods = useForm({ + resolver: zodResolver(createTeamSchema), + }); + + const { + handleSubmit, + register, + formState: { errors, isSubmitting, isDirty, isValid }, + } = methods; + + const createTeamWithBindings = createTeam.bind(null, user.id, eventID); + + const onSubmit = async (data: FormData) => { + const isTeamLeadIncluded = data.members.findIndex( + (member) => member.toLowerCase() === user.githubId.toLowerCase(), + ); + + if (isTeamLeadIncluded !== -1) { + data.members.splice(isTeamLeadIncluded, 1); + } + startTransition(() => { + createTeamWithBindings(data); + }); + }; + + return ( + + + + e.preventDefault()} + > +
+ + + +
+ + +
Register Your Team
+
+ + you'r are currently logged in as{" "} + {user?.email} + + +
+
+
+
+

+ Make sure all the members are registered on the platform +

+

Project repo can't be changed once submitted

+

You can team up with up to 3 people

+

Team should have at least 1 member

+
+
+
+
+ + + {errors.name && ( +

+ Team Name should be Alpha Numeric & should not contain + any special characters +

+ )} +
+
+ + + + {errors.repo && ( +

Enter a valid repo Url

+ )} +
+ + {errors.members && ( +
+ User not found or team should have at least 1 member +
+ )} +
+
+ +
+ +
+
+
+
+
+
+ ); +}; diff --git a/app/events/ui/modal/ProjectModal.tsx b/app/events/ui/modal/ProjectModal.tsx new file mode 100644 index 00000000..fe8fac88 --- /dev/null +++ b/app/events/ui/modal/ProjectModal.tsx @@ -0,0 +1,127 @@ +"use client"; +import { useSearchParams, usePathname, useRouter } from "next/navigation"; + +import * as Dialog from "@radix-ui/react-dialog"; +import { useEffect, useState } from "react"; +import { X } from "lucide-react"; +import useSWR from "swr"; +import { fetcher } from "@/utils/fetcher"; +import { ProjectStatus, type ProjectResults } from "@/utils/types"; +import { twMerge } from "tailwind-merge"; +import { groupBy } from "@/utils/groupBy"; +import { ProjectCard } from "../ProjectCard"; + +export const ProjectModal = () => { + const router = useRouter(); + + const searchParams = useSearchParams(); + const eventId = searchParams.get("eventID"); + const [isOpen, setIsOpen] = useState(false); + + useEffect(() => { + setIsOpen(!!searchParams.get("results") && !!searchParams.get("eventID")); + }, [searchParams]); + + const { data, error, isLoading } = useSWR( + eventId && isOpen ? `/events/results/${eventId}` : null, + fetcher, + ); + + const pathName = usePathname(); + const onOpenChange = () => { + router.push(pathName); + }; + + return ( + + + + e.preventDefault()} + > +
+ + + +
+
+ {isLoading && ( + + Spinning Loading Icon + + + + )} + {error && ( + <> + +
+

Error

+

{error.message}

+
+ + )} + {data && ( + <> +
+ {data.event.title} +
+ {groupBy(data.projects, "projectStatus") + .sort() + .map((group, _index) => { + const statusText = + group[0].projectStatus === ProjectStatus.BEST_PROJECT + ? "Best Projects ⭐" + : "Completed Projects ⭐"; + console.log(group); + return ( +
+

+ {statusText} +

+
+ {group.map((project) => ( + + ))} +
+
+ ); + })} + + )} +
+
+
+
+ ); +}; diff --git a/app/events/ui/modal/UpdateTeamModal.tsx b/app/events/ui/modal/UpdateTeamModal.tsx new file mode 100644 index 00000000..57b67075 --- /dev/null +++ b/app/events/ui/modal/UpdateTeamModal.tsx @@ -0,0 +1,188 @@ +"use client"; + +import { useSearchParams, usePathname, useRouter } from "next/navigation"; + +import * as Dialog from "@radix-ui/react-dialog"; +import type { User } from "lucia"; +import { startTransition, useEffect, useState } from "react"; +import { Input } from "@/app/components/Input"; +import { X } from "lucide-react"; +import { Button } from "@/app/components/Button"; +import type { z } from "zod"; +import { FormProvider, useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import createTeam from "./actions"; +import { updateTeamSchema } from "@/utils/validateRequest"; +import { Member } from "../Member"; + +type FormData = z.infer; + +export const UpdateTeamModal = ({ + user, + team, + isEditable, +}: { + user: User; + team: { + id: string; + name: string; + repo: string; + eventId: string; + members: { + role: string | null; + userId: string; + user: { + githubId: string; + }; + }[]; + }; + isEditable: boolean; +}) => { + const router = useRouter(); + + const [isOpen, setIsOpen] = useState(false); + + const [eventID, setEventID] = useState(null); + + const searchParams = useSearchParams(); + const eventId = searchParams.get("eventId"); + + const methods = useForm({ + resolver: zodResolver(updateTeamSchema), + }); + + const { + handleSubmit, + register, + setValue, + formState: { errors, isSubmitting, isDirty, isValid }, + } = methods; + + useEffect(() => { + setEventID(searchParams.get("eventId")); + + const isOpen = !!searchParams.get("update") && !!team && !!eventID; + setIsOpen(isOpen); + + if (isOpen) { + setValue("name", team.name); + setValue("repo", team.repo); + setValue( + "members", + team.members.map((member) => member.user.githubId), + ); + } + }, [searchParams, eventID, team, setValue]); + + const pathName = usePathname(); + const onOpenChange = () => { + router.push(pathName); + }; + + const createTeamWithBindings = createTeam.bind(null, user.id, eventID); + + const onSubmit = async (data: FormData) => { + const isTeamLeadIncluded = data.members.findIndex( + (member) => member.toLowerCase() === user.githubId.toLowerCase(), + ); + + if (isTeamLeadIncluded !== -1) { + data.members.splice(isTeamLeadIncluded, 1); + } + }; + + return ( + + + + e.preventDefault()} + > +
+ + + +
+ + +
Team Details
+
+ +
+ Your Team Members will appear here once they accept your team + invitation +
+ +
+ Teams should have a leader and atleast 1 member +
+ {errors.members && ( +
+ {errors.members.message} +
+ )} +
+ +
+
+
+
+ + +
+
+ + +
+
+
+ + {errors.members && ( +
+ User not found or team should have at least 1 member +
+ )} +
+
+ +
+ +
+
+
+
+
+
+ ); +}; diff --git a/app/events/ui/modal/actions.ts b/app/events/ui/modal/actions.ts new file mode 100644 index 00000000..8a606cf4 --- /dev/null +++ b/app/events/ui/modal/actions.ts @@ -0,0 +1,154 @@ +"use server"; + +import { sendEmail } from "@/emails"; +import { db } from "@/utils/db"; +import { revalidatePath } from "next/cache"; + +import { + createTeamSchema, + validateRequestSchemaAsync, +} from "@/utils/validateRequest"; +import type { z } from "zod"; + +export type FormData = z.infer; + +export default async function createTeam( + userId: string, + eventId: string | null, + formData: FormData, +) { + if (!eventId) { + return "Invalid Event ID"; + } + + const validation = await validateRequestSchemaAsync( + createTeamSchema, + formData, + false, + ); + + if (validation instanceof Response || !validation.success) { + if (validation instanceof Response) { + return validation; + } + return; + } + + const data = validation.data; + + const admin = await db.user.findUnique({ + where: { + id: userId, + }, + }); + + if (!admin) { + return "User not found!"; + } + + const teamLead = await db.teamMember.findUnique({ + where: { + userId_eventId: { + userId: userId, + eventId: eventId, + }, + }, + }); + + if (teamLead) { + return "You are already in a Team!"; + } + + const userIDs = await db.user.findMany({ + where: { + githubId: { + in: data.members, + }, + }, + select: { + githubId: true, + id: true, + }, + }); + + const [team, members] = await db.$transaction(async (tx) => { + const team = await tx.team.create({ + data: { + name: data.name, + repo: data.repo, + members: { + create: { + userId: userId, + eventId: eventId, + role: "LEADER", + }, + }, + event: { + connect: { + id: eventId, + }, + }, + }, + }); + await tx.invite.createMany({ + data: data.members.map((member) => ({ + userId: + userIDs.find( + (user) => user.githubId.toLowerCase() === member.toLowerCase(), + )?.id || "", + teamId: team.id, + eventId: eventId, + role: "MEMBER", + })), + }); + + const members = await db.invite.findMany({ + where: { + teamId: team.id, + }, + select: { + id: true, + user: { + select: { + email: true, + }, + }, + }, + }); + return [team, members]; + }); + + const mails = []; + + mails.push( + sendEmail( + "CreateTeam", + { + teamID: team.id, + }, + "Welcome to this week's Saturday Hack Night! 🎉", + admin.email, + ), + ); + + for (const member of members) { + mails.push( + sendEmail( + "Invite", + { + inviteCode: member.id, + lead: admin?.name || "", + teamName: team.name, + teamID: team.id, + }, + "You've been added to a team! 🚀", + member.user.email, + ), + ); + } + + await Promise.all(mails); + + revalidatePath("/events"); + return "Team created successfully! 🎉"; +} diff --git a/app/events/ui/modal/updateTeamAction.ts b/app/events/ui/modal/updateTeamAction.ts new file mode 100644 index 00000000..39eccf60 --- /dev/null +++ b/app/events/ui/modal/updateTeamAction.ts @@ -0,0 +1,144 @@ +"use server"; + +import { sendEmail } from "@/emails"; +import { db } from "@/utils/db"; +import { revalidatePath } from "next/cache"; + +import { + updateTeamSchema, + validateRequestSchemaAsync, +} from "@/utils/validateRequest"; +import type { z } from "zod"; + +export type FormData = z.infer; + +export default async function updateTeam( + userId: string, + eventId: string | null, + formData: FormData, +) { + if (!eventId) { + return "Invalid Event ID"; + } + + const validation = await validateRequestSchemaAsync( + updateTeamSchema, + formData, + false, + ); + + if (validation instanceof Response || !validation.success) { + if (validation instanceof Response) { + return validation; + } + return; + } + + const data = validation.data; + + const teamMember = await db.teamMember.findUnique({ + where: { + userId: userId, + eventId: eventId, + }, + }); + + const admin = await db.user.findUnique({ + where: { + id: userId, + }, + }); + + const userIDs = await db.user.findMany({ + where: { + githubId: { + in: data.members, + }, + }, + select: { + githubId: true, + id: true, + }, + }); + + const [team, members] = await db.$transaction(async (tx) => { + const team = await tx.team.create({ + data: { + name: data.name, + repo: data.repo, + members: { + create: { + userId: userId, + eventId: eventId, + role: "LEADER", + }, + }, + event: { + connect: { + id: eventId, + }, + }, + }, + }); + await tx.invite.createMany({ + data: data.members.map((member) => ({ + userId: + userIDs.find( + (user) => user.githubId.toLowerCase() === member.toLowerCase(), + )?.id || "", + teamId: team.id, + eventId: eventId, + role: "MEMBER", + })), + }); + + const members = await db.invite.findMany({ + where: { + teamId: team.id, + }, + select: { + id: true, + user: { + select: { + email: true, + }, + }, + }, + }); + return [team, members]; + }); + + const mails = []; + + mails.push( + sendEmail( + "CreateTeam", + { + teamID: team.id, + }, + "Welcome to this week's Saturday Hack Night! 🎉", + admin?.email || "", + ), + ); + + for (const member of members) { + mails.push( + sendEmail( + "Invite", + { + inviteCode: member.id, + lead: admin?.name || "", + teamName: team.name, + teamID: team.id, + }, + "You've been added to a team! 🚀", + member.user.email, + ), + ); + } + + await Promise.all(mails); + + revalidatePath("/events"); + return "Team created successfully! 🎉"; +} diff --git a/app/global-error.tsx b/app/global-error.tsx new file mode 100644 index 00000000..2cdf1f71 --- /dev/null +++ b/app/global-error.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useEffect } from "react"; + +const GlobalError = ({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) => { + useEffect(() => { + // TODO: Log error to the server + console.error(error); + }, [error]); + + return ( +
+
+

+ Something Went Wrong +

+

Error

+ +
+
+ ); +}; + +export default GlobalError; diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 00000000..ecfa8391 --- /dev/null +++ b/app/globals.css @@ -0,0 +1,23 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +body { + scrollbar-width: none; + @apply bg-secondary; +} +::-webkit-scrollbar { + width: '0px'; + background: transparent; + display: none; +} + +* { + font-family: 'Clash Display', sans-serif; + @apply appearance-none outline-none; + +} + +*:focus { + @apply appearance-none outline-none; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 00000000..b7f9586c --- /dev/null +++ b/app/layout.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import "../public/style/clashDisplay.css"; +import Script from "next/script"; +import { Navbar } from "./ui/Navbar"; +import { validateRequest } from "@/utils/lucia"; +import { Footer } from "./ui/Footer"; +import { Toaster } from "sonner"; +import { SWRProvider } from "@/provider/SwrProvider"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const { user, session } = await validateRequest(); + + return ( + + + + + + + + + + + {children} + +