diff --git a/README.md b/README.md index 5c29a5a8..35d1bfe0 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ DurHack's 'Guilds' platform, built in-house by the DurHack team (2023-present). ## Nginx Configuration ``` server { - server_name megateams.durhack.com www.megateams.durhack.com; + server_name guilds.durhack.com www.guilds.durhack.com; location /api { proxy_pass http://127.0.0.1:3101$request_uri; @@ -58,26 +58,3 @@ server { listen 80; } ``` - -## mysql server - -Make sure you are running a [mysql server](https://dev.mysql.com/doc/refman/8.1/en/installing.html) -on your machine before trying to run the project otherwise you will get connection refused errors etc. -as the javascript can't find a server to connect to on your localhost 🥲. - -If your computer's `hosts` file contains `::1 localhost` (IPv6 local loopback) then you will need to use -`127.0.0.1` as the database host, as the IPv6 spec clashes with the port spec (`::1:3306` is recognised as an IPv6 address, -not an address followed by a port). - -This server should also have an appropriate user with permissions to manage the database, e.g. -```sql -CREATE USER 'durhack'@'localhost' IDENTIFIED BY 'durhack'; -GRANT ALL PRIVILEGES ON durhack2023megateams.* TO 'durhack'@'localhost' -``` -_This follows the naming used in the `.env.local` file given above_ - - -## environment options -- `MEGATEAMS_SKIP_EMAIL_VERIFICATION`: set to `true` to allow users to set their password without verifying email -- `MEGATEAMS_NO_MITIGATE_CSRF`: set to `true` to allow `POST/PATCH/DELETE` (etc.) requests -_without_ a CSRF hash cookie and token header. diff --git a/client/package.json b/client/package.json index 6a6399e4..77a719fb 100644 --- a/client/package.json +++ b/client/package.json @@ -37,6 +37,7 @@ "chart.js": "^4.3.0", "chartjs-plugin-annotation": "^3.0.1", "chartjs-plugin-datalabels": "^2.2.0", + "clsx": "^2.1.1", "dateformat": "^5.0.3", "eslint-config-next": "13.4.5", "next": "^14.2.7", @@ -44,7 +45,6 @@ "qrcode-scanner-react": "^0.2.3", "react": "18.2.0", "react-chartjs-2": "^5.2.0", - "react-component-export-image": "^1.0.6", "react-dom": "18.2.0", "react-hooks-use-form-state": "^0.0.1", "react-paginate": "^8.2.0", @@ -55,6 +55,7 @@ "sass": "^1.63.4", "socket.io-client": "^4.7.5", "swr": "^2.2.2", + "tailwind-merge": "^2.5.4", "tailwindcss": "3.3.2", "typescript": "5.1.3" }, diff --git a/client/public/Atlantis/icon.png b/client/public/Atlantis/icon.png new file mode 100644 index 00000000..b0d63985 Binary files /dev/null and b/client/public/Atlantis/icon.png differ diff --git a/client/public/Atlantis/icon.svg b/client/public/Atlantis/icon.svg new file mode 100644 index 00000000..e8dc4a09 --- /dev/null +++ b/client/public/Atlantis/icon.svg @@ -0,0 +1,6 @@ + + Atlantis + + + + \ No newline at end of file diff --git a/client/public/Centre of the Earth/icon.png b/client/public/Centre of the Earth/icon.png new file mode 100644 index 00000000..6b8f1ea5 Binary files /dev/null and b/client/public/Centre of the Earth/icon.png differ diff --git a/client/public/Centre of the Earth/icon.svg b/client/public/Centre of the Earth/icon.svg new file mode 100644 index 00000000..f123e5b4 --- /dev/null +++ b/client/public/Centre of the Earth/icon.svg @@ -0,0 +1,6 @@ + + Centre of the Earth + + + + \ No newline at end of file diff --git a/client/public/Cygnus/constellation.svg b/client/public/Cygnus/constellation.svg deleted file mode 100644 index fdd256e3..00000000 --- a/client/public/Cygnus/constellation.svg +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - - - diff --git a/client/public/Cygnus/icon.png b/client/public/Cygnus/icon.png deleted file mode 100644 index ee76c094..00000000 Binary files a/client/public/Cygnus/icon.png and /dev/null differ diff --git a/client/public/Cygnus/icon.svg b/client/public/Cygnus/icon.svg deleted file mode 100644 index 77391790..00000000 --- a/client/public/Cygnus/icon.svg +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/Lyra/constellation.svg b/client/public/Lyra/constellation.svg deleted file mode 100644 index 5d65f9c0..00000000 --- a/client/public/Lyra/constellation.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/client/public/Lyra/icon.png b/client/public/Lyra/icon.png deleted file mode 100644 index c759cfc0..00000000 Binary files a/client/public/Lyra/icon.png and /dev/null differ diff --git a/client/public/Lyra/icon.svg b/client/public/Lyra/icon.svg deleted file mode 100644 index fd03bef1..00000000 --- a/client/public/Lyra/icon.svg +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/Moon/icon.png b/client/public/Moon/icon.png new file mode 100644 index 00000000..97b77ab6 Binary files /dev/null and b/client/public/Moon/icon.png differ diff --git a/client/public/Moon/icon.svg b/client/public/Moon/icon.svg new file mode 100644 index 00000000..e26714a1 --- /dev/null +++ b/client/public/Moon/icon.svg @@ -0,0 +1,6 @@ + + Moon + + + + \ No newline at end of file diff --git a/client/public/Mysterious Island/icon.png b/client/public/Mysterious Island/icon.png new file mode 100644 index 00000000..1a126ba9 Binary files /dev/null and b/client/public/Mysterious Island/icon.png differ diff --git a/client/public/Mysterious Island/icon.svg b/client/public/Mysterious Island/icon.svg new file mode 100644 index 00000000..9218a794 --- /dev/null +++ b/client/public/Mysterious Island/icon.svg @@ -0,0 +1,6 @@ + + Mysterious Island + + + + \ No newline at end of file diff --git a/client/public/Orion/constellation.svg b/client/public/Orion/constellation.svg deleted file mode 100644 index e97df206..00000000 --- a/client/public/Orion/constellation.svg +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/Orion/icon.png b/client/public/Orion/icon.png deleted file mode 100644 index 9da248dd..00000000 Binary files a/client/public/Orion/icon.png and /dev/null differ diff --git a/client/public/Orion/icon.svg b/client/public/Orion/icon.svg deleted file mode 100644 index d0b4d11a..00000000 --- a/client/public/Orion/icon.svg +++ /dev/null @@ -1,287 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/Pegasus/constellation.svg b/client/public/Pegasus/constellation.svg deleted file mode 100644 index a79c8130..00000000 --- a/client/public/Pegasus/constellation.svg +++ /dev/null @@ -1,23 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/Pegasus/icon.png b/client/public/Pegasus/icon.png deleted file mode 100644 index c8074ae4..00000000 Binary files a/client/public/Pegasus/icon.png and /dev/null differ diff --git a/client/public/Pegasus/icon.svg b/client/public/Pegasus/icon.svg deleted file mode 100644 index 4f370165..00000000 --- a/client/public/Pegasus/icon.svg +++ /dev/null @@ -1,284 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/guild-icons.svg b/client/public/guild-icons.svg new file mode 100644 index 00000000..c1e57c1a --- /dev/null +++ b/client/public/guild-icons.svg @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/icon/android-chrome-192x192.png b/client/public/icon/android-chrome-192x192.png new file mode 100644 index 00000000..ed0f0791 Binary files /dev/null and b/client/public/icon/android-chrome-192x192.png differ diff --git a/client/public/icon/android-chrome-512x512.png b/client/public/icon/android-chrome-512x512.png new file mode 100644 index 00000000..272b8ff9 Binary files /dev/null and b/client/public/icon/android-chrome-512x512.png differ diff --git a/client/public/icon/apple-touch-icon.png b/client/public/icon/apple-touch-icon.png new file mode 100644 index 00000000..38f0c83b Binary files /dev/null and b/client/public/icon/apple-touch-icon.png differ diff --git a/client/public/icon/favicon-16x16.png b/client/public/icon/favicon-16x16.png new file mode 100644 index 00000000..50fa1e11 Binary files /dev/null and b/client/public/icon/favicon-16x16.png differ diff --git a/client/public/icon/favicon-32x32.png b/client/public/icon/favicon-32x32.png new file mode 100644 index 00000000..308bb16f Binary files /dev/null and b/client/public/icon/favicon-32x32.png differ diff --git a/client/public/icon/favicon-mono.svg b/client/public/icon/favicon-mono.svg new file mode 100644 index 00000000..a9ba1558 --- /dev/null +++ b/client/public/icon/favicon-mono.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/icon/favicon.ico b/client/public/icon/favicon.ico new file mode 100644 index 00000000..7f586a04 Binary files /dev/null and b/client/public/icon/favicon.ico differ diff --git a/client/public/icon/favicon.svg b/client/public/icon/favicon.svg new file mode 100644 index 00000000..331bc374 --- /dev/null +++ b/client/public/icon/favicon.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/icon/mstile-144x144.png b/client/public/icon/mstile-144x144.png new file mode 100644 index 00000000..d6eef13a Binary files /dev/null and b/client/public/icon/mstile-144x144.png differ diff --git a/client/public/icon/mstile-150x150.png b/client/public/icon/mstile-150x150.png new file mode 100644 index 00000000..cf2d002c Binary files /dev/null and b/client/public/icon/mstile-150x150.png differ diff --git a/client/public/icon/mstile-310x150.png b/client/public/icon/mstile-310x150.png new file mode 100644 index 00000000..cdddc508 Binary files /dev/null and b/client/public/icon/mstile-310x150.png differ diff --git a/client/public/icon/mstile-310x310.png b/client/public/icon/mstile-310x310.png new file mode 100644 index 00000000..8f649509 Binary files /dev/null and b/client/public/icon/mstile-310x310.png differ diff --git a/client/public/icon/mstile-70x70.png b/client/public/icon/mstile-70x70.png new file mode 100644 index 00000000..5c4baff6 Binary files /dev/null and b/client/public/icon/mstile-70x70.png differ diff --git a/client/public/icon/safari-pinned-tab.svg b/client/public/icon/safari-pinned-tab.svg new file mode 100644 index 00000000..a9ba1558 --- /dev/null +++ b/client/public/icon/safari-pinned-tab.svg @@ -0,0 +1,3 @@ + + + diff --git a/client/public/logo.png b/client/public/logo.png deleted file mode 100644 index 1ced6270..00000000 Binary files a/client/public/logo.png and /dev/null differ diff --git a/client/public/logo.svg b/client/public/logo.svg index f7a83648..ac403050 100644 --- a/client/public/logo.svg +++ b/client/public/logo.svg @@ -1,38 +1,84 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + DurHack 2024 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/client/public/opengraph-image.png b/client/public/opengraph-image.png new file mode 100644 index 00000000..692b09b3 Binary files /dev/null and b/client/public/opengraph-image.png differ diff --git a/client/public/stars-overlay.jpg b/client/public/stars-overlay.jpg deleted file mode 100644 index 2684e9d2..00000000 Binary files a/client/public/stars-overlay.jpg and /dev/null differ diff --git a/client/src/app/hacker/(main)/layout.tsx b/client/src/app/(authorized)/hacker/(main)/layout.tsx similarity index 54% rename from client/src/app/hacker/(main)/layout.tsx rename to client/src/app/(authorized)/hacker/(main)/layout.tsx index b5927f4e..421649bb 100644 --- a/client/src/app/hacker/(main)/layout.tsx +++ b/client/src/app/(authorized)/hacker/(main)/layout.tsx @@ -1,19 +1,16 @@ "use client"; -import useSWR from "swr"; import { redirect } from "next/navigation"; -import { fetchMegateamsApi } from "@/lib/api"; import { isHacker } from "@/lib/is-role"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; export default function HackerLayout({ children, }: { children: React.ReactNode; }) { - const { user, userIsLoading } = useMegateamsContext(); - const { isLoading: teamIsLoading } = useSWR("", fetchMegateamsApi); + const { user, userIsLoading, teamIsLoading } = useGuildsContext(); if (userIsLoading || teamIsLoading) return <>; if (user == null || !isHacker(user)) return redirect("/"); diff --git a/client/src/app/hacker/(main)/leaderboard/page.tsx b/client/src/app/(authorized)/hacker/(main)/leaderboard/page.tsx similarity index 76% rename from client/src/app/hacker/(main)/leaderboard/page.tsx rename to client/src/app/(authorized)/hacker/(main)/leaderboard/page.tsx index 72c71571..e78d3e87 100644 --- a/client/src/app/hacker/(main)/leaderboard/page.tsx +++ b/client/src/app/(authorized)/hacker/(main)/leaderboard/page.tsx @@ -3,7 +3,7 @@ import dynamic from "next/dynamic"; const Leaderboard = dynamic( - () => import("@/components/leaderboard/leaderboard"), + () => import("@/components/leaderboard"), { ssr: false, } diff --git a/client/src/app/hacker/(main)/page.tsx b/client/src/app/(authorized)/hacker/(main)/page.tsx similarity index 67% rename from client/src/app/hacker/(main)/page.tsx rename to client/src/app/(authorized)/hacker/(main)/page.tsx index e7dc20f4..ec7562e0 100644 --- a/client/src/app/hacker/(main)/page.tsx +++ b/client/src/app/(authorized)/hacker/(main)/page.tsx @@ -9,12 +9,13 @@ import * as React from "react"; import dynamic from "next/dynamic"; import { useRouter } from "next/navigation"; -import { getHackerEmoji, getPositionMedal } from "@/lib/rankEmojis"; +import { getHackerEmoji, getPositionMedal } from "@/lib/rank-emojis"; import { ButtonModal } from "@/components/button-modal"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; import { TeamBox } from "./team/team-box"; import { TeamSetup } from "./team-setup"; +import { QuestList } from "./quest-list"; const Scanner = dynamic(() => import("qrcode-scanner-react"), { ssr: false, @@ -23,24 +24,23 @@ const Scanner = dynamic(() => import("qrcode-scanner-react"), { export default function HackerHome() { const [scanning, setScanning] = React.useState(false); const router = useRouter(); - const { user, team } = useMegateamsContext(); - const { data: { challenges } = { challenges: null } } = useSWR("/qr_codes/challenges"); - const { data: { megateams } = { megateams: null } } = useSWR("/megateams"); + const { user, team } = useGuildsContext(); + const { data: { guilds } = { guilds: null } } = useSWR("/guilds"); const { data: { teams } = { teams: null } } = useSWR("/teams"); const hasTeam = team !== null; - const hasMegateam = team?.megateam_name !== null; + const hasGuild = team?.guild_name !== null; - let megateam_points = 0; - let megateam_rank = 0; + let guild_points = 0; + let guild_rank = 0; - megateams?.sort((a: any, b: any) => { + guilds?.sort((a: any, b: any) => { return b.points - a.points; }); - megateams?.forEach((megateam: any, index: number) => { - if (megateam.megateam_name === team?.megateam_name) { - megateam_points = megateam.points; - megateam_rank = index; + guilds?.forEach((guild: any, index: number) => { + if (guild.guild_name === team?.guild_name) { + guild_points = guild.points; + guild_rank = index; } }); @@ -77,27 +77,27 @@ export default function HackerHome() {

Hello {user?.preferred_name},

- - {hasMegateam && ( + + {hasGuild && (
-

Megateam

+

Guild

{`${team?.megateam_name} -

{team?.megateam_name}

+

{team?.guild_name}

)}
- {hasMegateam ? ( + {hasGuild ? ( <>
@@ -115,34 +115,20 @@ export default function HackerHome() {
-

Megateam Points

+

Guild Points

- {megateam_points} {getPositionMedal(megateam_rank)} + {guild_points} {getPositionMedal(guild_rank)}

-
-

Challenges

- {!challenges ? ( -

No challenges have been published yet. Check back soon!

- ) : ( -
- {challenges?.map((challenge: any, i: number) => ( - -

{i + 1}.

-

{challenge.title}

-

{challenge.points} points

-
- ))} -
- )} -
+

Quests

+ ) : (

- No Megateam Assigned + No Guild Assigned

Please speak to a volunteer to ensure your team's points diff --git a/client/src/app/(authorized)/hacker/(main)/quest-list.tsx b/client/src/app/(authorized)/hacker/(main)/quest-list.tsx new file mode 100644 index 00000000..006b17f1 --- /dev/null +++ b/client/src/app/(authorized)/hacker/(main)/quest-list.tsx @@ -0,0 +1,41 @@ +"use client"; + +import useSWR from "swr"; +import * as React from "react"; +import { ClockIcon, CheckIcon } from "@heroicons/react/24/outline"; + +export function QuestList() { + const { data: { quests } = { quests: [] } } = useSWR("/quests"); + + return ( +

+ {quests.length === 0 ? ( +

No quests have been published yet. Check back soon!

+ ) : ( + quests.map((quest: any, i: number) => ( +
+ + { quest.completed ? : } +

{ quest.name }

+ { quest.value > 0 &&

{ quest.value } pts

} +
+

{ quest.description }

+

{ quest.dependency_mode === "AND" ? "Complete all below:" : "Complete one below:" }

+
+ { quest.challenges.map((challenge: any, i: number) => ( +
+ + { challenge.completed ? : } +

{ challenge.name }

+

{ challenge.value } pts

+
+

{ challenge.description }

+
+ )) } +
+
+ )) + )} +
+ ); +} diff --git a/client/src/app/hacker/(main)/team-setup.tsx b/client/src/app/(authorized)/hacker/(main)/team-setup.tsx similarity index 65% rename from client/src/app/hacker/(main)/team-setup.tsx rename to client/src/app/(authorized)/hacker/(main)/team-setup.tsx index 1faa1656..bb07db85 100644 --- a/client/src/app/hacker/(main)/team-setup.tsx +++ b/client/src/app/(authorized)/hacker/(main)/team-setup.tsx @@ -1,34 +1,36 @@ +"use client"; + import { ArrowPathRoundedSquareIcon } from "@heroicons/react/24/outline"; -import { useEffect, useState } from "react"; -import { mutate } from "swr"; +import { useState } from "react"; -import { fetchMegateamsApi } from "@/lib/api"; -import { abortForRerender } from "@/lib/symbols"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { fetchGuildsApi } from "@/lib/api"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; +import useSWRImmutable from "swr"; export function TeamSetup() { - const { user, mutateTeam } = useMegateamsContext(); - const [name, setName] = useState(null); + const { user, mutateTeam } = useGuildsContext(); + const { + data: { name } = { name: "" }, + mutate: mutateName, + isLoading: nameIsLoading, + } = useSWRImmutable("/teams/generate-name"); const [createError, setCreateError] = useState(""); const [joinError, setJoinError] = useState(""); const [joinCode, setJoinCode] = useState(""); - async function getTeamName(refresh: boolean, signal?: AbortSignal) { - const params = new URLSearchParams(); - if (refresh) params.set("refresh", "yes"); + async function refreshTeamName() { + const params = new URLSearchParams({ refresh: "yes" }); try { - const { name } = await fetchMegateamsApi(`/teams/generate-name?${params}`, { signal }); - setName(name); - setCreateError(""); + await fetchGuildsApi(`/teams/generate-name?${params}`); + mutateName(); } catch (error) { - if (error === abortForRerender) return setCreateError("Failed to fetch team name!"); } } async function joinTeam() { try { - const response = await fetchMegateamsApi("/user/team", { + await fetchGuildsApi("/user/team", { method: "POST", body: JSON.stringify({ join_code: joinCode }), headers: { "Content-Type": "application/json" }, @@ -42,7 +44,7 @@ export function TeamSetup() { async function createTeam() { try { - await fetchMegateamsApi("/teams", { method: "POST" }); + await fetchGuildsApi("/teams", { method: "POST" }); setCreateError(""); await mutateTeam(); } catch { @@ -50,12 +52,6 @@ export function TeamSetup() { } } - useEffect(() => { - const controller = new AbortController(); - void getTeamName(false, controller.signal); - return () => controller.abort(abortForRerender) - }, []); - return (

Hello {user?.preferred_name},

@@ -65,10 +61,10 @@ export function TeamSetup() {

Name:
- {name == null ? "..." : name} + {nameIsLoading ? "..." : name}

-
diff --git a/client/src/app/hacker/(main)/team/layout.tsx b/client/src/app/(authorized)/hacker/(main)/team/layout.tsx similarity index 69% rename from client/src/app/hacker/(main)/team/layout.tsx rename to client/src/app/(authorized)/hacker/(main)/team/layout.tsx index 29109765..c8224143 100644 --- a/client/src/app/hacker/(main)/team/layout.tsx +++ b/client/src/app/(authorized)/hacker/(main)/team/layout.tsx @@ -3,14 +3,14 @@ import * as React from "react" import { redirect } from "next/navigation"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; export default function HackerTeamLayout({ children, }: { children: React.ReactNode; }) { - const { team, teamIsLoading } = useMegateamsContext(); + const { team, teamIsLoading } = useGuildsContext(); if (teamIsLoading) return <>; if (team == null) return redirect("/hacker"); diff --git a/client/src/app/hacker/(main)/team/page.tsx b/client/src/app/(authorized)/hacker/(main)/team/page.tsx similarity index 89% rename from client/src/app/hacker/(main)/team/page.tsx rename to client/src/app/(authorized)/hacker/(main)/team/page.tsx index 25003113..c738f0f2 100644 --- a/client/src/app/hacker/(main)/team/page.tsx +++ b/client/src/app/(authorized)/hacker/(main)/team/page.tsx @@ -5,22 +5,22 @@ import { ExclamationTriangleIcon, UserIcon } from "@heroicons/react/24/outline"; import { Dialog } from "@headlessui/react"; import { ButtonModal } from "@/components/button-modal"; -import { fetchMegateamsApi } from "@/lib/api"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { fetchGuildsApi } from "@/lib/api"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; import { TeamBox } from "./team-box"; export default function Team() { const [open, setOpen] = useState(false); const [error, setError] = useState(""); - const { team, mutateTeam } = useMegateamsContext() + const { team, mutateTeam } = useGuildsContext() - const members: { preferredNames: string; points: number }[] = team?.members ?? []; + const members = team?.members ?? []; members.sort((a, b) => b.points - a.points); async function leaveTeam() { try { - await fetchMegateamsApi("/user/team", { method: "DELETE" }); + await fetchGuildsApi("/user/team", { method: "DELETE" }); setError(""); await mutateTeam(null); } catch { @@ -32,12 +32,12 @@ export default function Team() { return ( <>
- +

Team Members

- {members.map(({ preferredNames, points }, i) => ( - + {members.map(({ preferredNames, points, id }) => ( +

{preferredNames}

diff --git a/client/src/app/hacker/(main)/team/team-box.tsx b/client/src/app/(authorized)/hacker/(main)/team/team-box.tsx similarity index 79% rename from client/src/app/hacker/(main)/team/team-box.tsx rename to client/src/app/(authorized)/hacker/(main)/team/team-box.tsx index 9f52d2f9..caec57bc 100644 --- a/client/src/app/hacker/(main)/team/team-box.tsx +++ b/client/src/app/(authorized)/hacker/(main)/team/team-box.tsx @@ -4,11 +4,12 @@ import { ShareIcon, XMarkIcon } from "@heroicons/react/24/outline"; import { useState } from "react"; import TeamName from "@/components/team-name"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; +import { cn } from "@/lib/utils"; -export function TeamBox({ grow = true }: { grow?: boolean }) { +export function TeamBox({ className }: { className?: string }) { const [showTeamCode, setShowTeamCode] = useState(false); - const { team } = useMegateamsContext() + const { team } = useGuildsContext() function toggleTeamCode() { setShowTeamCode(!showTeamCode); @@ -16,9 +17,7 @@ export function TeamBox({ grow = true }: { grow?: boolean }) { return (

Team

diff --git a/client/src/app/hacker/layout.tsx b/client/src/app/(authorized)/hacker/layout.tsx similarity index 83% rename from client/src/app/hacker/layout.tsx rename to client/src/app/(authorized)/hacker/layout.tsx index 62f69a5b..a042ea46 100644 --- a/client/src/app/hacker/layout.tsx +++ b/client/src/app/(authorized)/hacker/layout.tsx @@ -8,14 +8,14 @@ import { } from "@heroicons/react/24/outline"; import { TabbedPage } from "@/components/tabbed-page"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; export default function HackerLayout({ children, }: { children: React.ReactNode; }) { - const { team, userIsLoading, teamIsLoading } = useMegateamsContext(); + const { team, userIsLoading, teamIsLoading } = useGuildsContext(); if (userIsLoading || teamIsLoading) return <>; const hasTeam = team !== null diff --git a/client/src/app/hacker/redeem/page.tsx b/client/src/app/(authorized)/hacker/redeem/page.tsx similarity index 93% rename from client/src/app/hacker/redeem/page.tsx rename to client/src/app/(authorized)/hacker/redeem/page.tsx index 895d49a2..8cf2495c 100644 --- a/client/src/app/hacker/redeem/page.tsx +++ b/client/src/app/(authorized)/hacker/redeem/page.tsx @@ -9,16 +9,16 @@ import Link from "next/link"; import { redirect, useSearchParams } from "next/navigation"; import { ReactNode, useEffect, useState } from "react"; -import { fetchMegateamsApi } from "@/lib/api"; +import { fetchGuildsApi } from "@/lib/api"; import { isHacker } from "@/lib/is-role"; import { abortForRerender } from "@/lib/symbols"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; export default function RedeemPage() { const [qrPoints, setQrPoints] = useState(null); const [qrChecked, setQrChecked] = useState(false); const searchParams = useSearchParams(); - const { user, userIsLoading } = useMegateamsContext(); + const { user, userIsLoading } = useGuildsContext(); function makeSearchParams(params: Record) { return new URLSearchParams(params).toString(); @@ -29,7 +29,7 @@ export default function RedeemPage() { const uuid = searchParams.get("qr_id"); if (uuid && uuid !== "invalid") { try { - const result = await fetchMegateamsApi("/qr_codes/redeem", { + const result = await fetchGuildsApi("/qr_codes/redeem", { method: "POST", body: JSON.stringify({ uuid }), headers: { "Content-Type": "application/json" }, diff --git a/client/src/app/(authorized)/layout.tsx b/client/src/app/(authorized)/layout.tsx new file mode 100644 index 00000000..c0994aa9 --- /dev/null +++ b/client/src/app/(authorized)/layout.tsx @@ -0,0 +1,13 @@ +import { GuildsContextProvider } from "@/components/guilds-context-provider"; + +export default function AuthorizedLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + {children} + + ); +} diff --git a/client/src/app/(authorized)/volunteer/(volunteer)/custom.tsx b/client/src/app/(authorized)/volunteer/(volunteer)/custom.tsx new file mode 100644 index 00000000..539ae429 --- /dev/null +++ b/client/src/app/(authorized)/volunteer/(volunteer)/custom.tsx @@ -0,0 +1,89 @@ +import { useState } from "react"; + +import { fetchGuildsApi } from "@/lib/api"; + +export default function Custom({ + displayQR, +}: { + displayQR: (id: number) => void; +}) { + const qrTypes = ["Challenge", "Sponsor", "Workshop"]; + + const [name, setName] = useState(""); + const [category, setCategory] = useState(qrTypes[0]); + const [points, setPoints] = useState(5); + const [claimLimit, setClaimLimit] = useState(false); + + const [error, setError] = useState(""); + + async function submitForm() { + if (!name) return setError("Please set a name!"); + try { + const { data: qr } = await fetchGuildsApi("/qr_codes", { + method: "POST", + body: JSON.stringify({ + name, + category: category.toLowerCase(), + pointsValue: points, + claimLimit, + state: true, + }), + headers: { "Content-Type": "application/json" }, + }); + setName(""); + setCategory(qrTypes[0]); + setPoints(5); + setClaimLimit(false); + setError(""); + displayQR(qr.qrCodeId); + } catch { + setError("Failed to create QR code!"); + } + } + + return ( +
+

Generate Custom QR

+ setName(e.target.value)} + /> + +
+ setPoints(parseInt(e.target.value))} + /> +

points

+
+
+

Per user claim limit?

+ setClaimLimit(e.target.checked)} + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/client/src/app/volunteer/(volunteer)/manage.tsx b/client/src/app/(authorized)/volunteer/(volunteer)/manage.tsx similarity index 81% rename from client/src/app/volunteer/(volunteer)/manage.tsx rename to client/src/app/(authorized)/volunteer/(volunteer)/manage.tsx index af2b5c2d..ec0d32b8 100644 --- a/client/src/app/volunteer/(volunteer)/manage.tsx +++ b/client/src/app/(authorized)/volunteer/(volunteer)/manage.tsx @@ -14,12 +14,13 @@ import useSWR from "swr"; import { Dialog } from "@headlessui/react"; import { ButtonModal } from "@/components/button-modal"; -import { fetchMegateamsApi } from "@/lib/api"; +import { fetchGuildsApi } from "@/lib/api"; +import { getQRState, qrClasses, capitalizeFirstLetter } from "./qr-display-helpers" export default function Manage({ displayQR, }: { - displayQR: (name: string, url: string, category: string) => void; + displayQR: (id: number) => void; }) { const { mutate: mutateCodes, data: codesData = { codes: [] } } = useSWR<{ codes: any[]; @@ -33,7 +34,7 @@ export default function Manage({ try { let body: Record = {}; body[field] = value; - await fetchMegateamsApi("/qr_codes/" + encodeURIComponent(id), { + await fetchGuildsApi("/qr_codes/" + encodeURIComponent(id), { method: "PATCH", body: JSON.stringify(body), headers: { @@ -60,45 +61,6 @@ export default function Manage({ ); } - function getQRState( - start: string, - end: string, - enabled: boolean - ): { checked: boolean; disabled: boolean; preStart: boolean } { - let state = { checked: enabled, disabled: false, preStart: false }; - let now = new Date(); - let startDate = new Date(start); - let endDate = new Date(end); - if (now > endDate) { - state.checked = false; - state.disabled = true; - } else if (now < startDate) { - state.disabled = true; - state.preStart = true; - } - return state; - } - - function qrClasses(state: { - checked: boolean; - disabled: boolean; - preStart: boolean; - }) { - const { checked, disabled, preStart } = state; - let bgClass = "dark:bg-neutral-700 bg-gray-200"; - if (preStart || (!disabled && !checked)) { - bgClass = - "pattern-diagonal-lines pattern-transparent pattern-bg-gray-200 dark:pattern-bg-neutral-700 pattern-size-16 pattern-opacity-100"; - } else if (disabled) { - bgClass = "bg-red-100 opacity-100 dark:bg-red-400/50"; - } - return `${bgClass} drop-shadow-lg p-4 rounded mb-4`; - } - - function capitalizeFirstLetter(text: string) { - return text.charAt(0).toUpperCase() + text.slice(1); - } - return ( <>
@@ -158,7 +120,7 @@ export default function Manage({ className="dh-btn disabled:bg-gray-300 dark:disabled:bg-neutral-500" disabled={!code.enabled || qrState.disabled} onClick={() => - displayQR(code.name, code.redemption_url, code.category) + displayQR(code.id) } > View diff --git a/client/src/app/(authorized)/volunteer/(volunteer)/preset.tsx b/client/src/app/(authorized)/volunteer/(volunteer)/preset.tsx new file mode 100644 index 00000000..7a455602 --- /dev/null +++ b/client/src/app/(authorized)/volunteer/(volunteer)/preset.tsx @@ -0,0 +1,122 @@ +import { ButtonModal } from "@/components/button-modal"; +import { CameraIcon, ClockIcon, GiftIcon, MagnifyingGlassIcon, TagIcon, UserIcon } from "@heroicons/react/24/outline"; +import { useState } from "react"; +import useSWR from "swr"; +import dateFormat from "dateformat"; +import { useFormState } from "react-hooks-use-form-state"; + +import { getQRState, qrClasses, capitalizeFirstLetter } from "./qr-display-helpers"; +import { fetchGuildsApi } from "@/lib/api"; + +export default function Preset({ displayQR }: { displayQR: (id: number) => void }) { + const { data: challengeData = { challenges: [] } } = useSWR<{ challenges: any[] }>("/qr_codes/challenges"); + const [error, setError] = useState(null); + const [challenges, setChallenges, resetForm] = useFormState(challengeData.challenges); + + const filteredChallenges = challenges.filter((challenge) => !challenge.hidden); + + function filterChallenges(searchText: string) { + const lowerSearch = searchText.toLowerCase(); + setChallenges( + challenges.map((challenge) => { + challenge.hidden = true; + if (challenge.name.toLowerCase().includes(lowerSearch)) challenge.hidden = false; + return challenge; + }) + ); + } + + async function generateQR(id: number) { + try { + const { data: qr } = await fetchGuildsApi( + "/qr_codes/challenges/" + encodeURIComponent(id), + { method: "POST" } + ); + setError(null); + displayQR(qr.qrCodeId); + } catch { + setError("Failed to generate QR!"); + } + } + + return ( + <> +
+ filterChallenges(e.target.value)} + /> + +
+
+ {challenges.length === 0 ? ( +

No challenges have been published yet. Check back soon!

+ ) : ( + filteredChallenges.map((challenge, i) => { + const qrState = getQRState(challenge.start_time, challenge.expiry_time, true); + return ( +
+

{challenge.name}

+

{challenge.description}

+
+
+

+ + {capitalizeFirstLetter(challenge.category)} +

+
+
+

+ + {challenge.claim_limit ? "1 per hacker" : "Unlimited"} +

+
+
+

+ + {challenge.value} points +

+
+
+

+ + { !challenge.start_time && !challenge.expiry_time && No time limit } + {challenge.start_time && Starts: {dateFormat(challenge.start_time, "hh:MM dd/mm")}} + {challenge.expiry_time && Expires: {dateFormat(challenge.expiry_time, "hh:MM dd/mm")}} +

+
+
+
+ +
+
+ ); + }) + )} +
+ setError(null)} + content={

Failed to generate QR Code for Challenge!

} + itemsClass="items-center" + buttons={ + + } + /> + + ); +} diff --git a/client/src/app/(authorized)/volunteer/(volunteer)/qr-display-helpers.ts b/client/src/app/(authorized)/volunteer/(volunteer)/qr-display-helpers.ts new file mode 100644 index 00000000..859b80bf --- /dev/null +++ b/client/src/app/(authorized)/volunteer/(volunteer)/qr-display-helpers.ts @@ -0,0 +1,39 @@ +export function getQRState( + start: string, + end: string, + enabled: boolean +): { checked: boolean; disabled: boolean; preStart: boolean } { + let state = { checked: enabled, disabled: false, preStart: false }; + let now = new Date(); + let startDate = new Date(start); + let endDate = new Date(end); + if (now > endDate && end != null) { + state.checked = false; + state.disabled = true; + } + if (now < startDate && start != null) { + state.disabled = true; + state.preStart = true; + } + return state; +} + +export function qrClasses(state: { + checked: boolean; + disabled: boolean; + preStart: boolean; +}) { + const { checked, disabled, preStart } = state; + let bgClass = "dark:bg-neutral-700 bg-gray-200"; + if (preStart || (!disabled && !checked)) { + bgClass = + "pattern-diagonal-lines pattern-transparent pattern-bg-gray-200 dark:pattern-bg-neutral-700 pattern-size-16 pattern-opacity-100"; + } else if (disabled) { + bgClass = "bg-red-100 opacity-100 dark:bg-red-400/50"; + } + return `${bgClass} drop-shadow-lg p-4 rounded mb-4`; +} + +export function capitalizeFirstLetter(text: string) { + return text.charAt(0).toUpperCase() + text.slice(1); +} \ No newline at end of file diff --git a/client/src/app/volunteer/(volunteer)/volunteer-page.tsx b/client/src/app/(authorized)/volunteer/(volunteer)/volunteer-page.tsx similarity index 56% rename from client/src/app/volunteer/(volunteer)/volunteer-page.tsx rename to client/src/app/(authorized)/volunteer/(volunteer)/volunteer-page.tsx index 21788688..94e0668d 100644 --- a/client/src/app/volunteer/(volunteer)/volunteer-page.tsx +++ b/client/src/app/(authorized)/volunteer/(volunteer)/volunteer-page.tsx @@ -3,28 +3,29 @@ import { Dialog, Transition } from "@headlessui/react"; import { Fragment, useRef, useState } from "react"; import QRCode from "react-qr-code"; -import { exportComponentAsJPEG } from "react-component-export-image"; -import dateFormat from "dateformat"; import { useMediaQuery } from "react-responsive"; import resolveConfig from "tailwindcss/resolveConfig"; import tailwindConfig from "tailwindcss/defaultConfig"; +import { CheckCircleIcon } from "@heroicons/react/24/outline"; +import type { QR } from "@durhack/guilds-common/types/index"; import { socketManager } from "@/lib/socket"; -import { isAdmin, isVolunteer } from "@/lib/is-role"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { isAdmin } from "@/lib/is-role"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; import Preset from "./preset"; import Custom from "./custom"; -import Manage from "./manage"; +import { cn } from "@/lib/utils"; export default function Volunteer() { const [current, setCurrent] = useState("Preset"); const [open, setOpen] = useState(false); + const [qrTimeout, setQrTimeout] = useState(false); + const firstQrLoad = useRef(true); const [qr, setQR] = useState({ name: "", url: "", category: "", - preset: false, }); const renderedQR = useRef(null); @@ -33,42 +34,54 @@ export default function Volunteer() { }); const { theme } = resolveConfig(tailwindConfig); - const { user } = useMegateamsContext(); + const { user } = useGuildsContext(); const userIsAdmin = user != null && isAdmin(user); - const userIsVolunteer = user != null && isVolunteer(user) function getClasses(name: string) { - let classes = - "font-semibold pb-4 px-4 inline-flex items-center border-b-[3px] text-sm"; - if (name === current) { - classes += " border-accent text-accent"; - } else { - classes += - " border-gray-200 text-gray-500 hover:text-accent hover:border-accent dark:border-neutral-400 dark:text-neutral-400"; - } - return classes; + return cn( + "font-semibold pb-4 px-4 inline-flex items-center border-b-[3px] text-sm", + (name === current && "border-accent text-accent") || "", + (name !== current && + "border-gray-200 text-gray-500 hover:text-accent hover:border-accent dark:border-neutral-400 dark:text-neutral-400") || + "" + ); } - async function downloadQR() { - const date = new Date(); - const datetimeString = dateFormat(date, "yyyymmdd_hhMMss"); + async function displayQR(id: number) { + if (!(await socketManager.ensureConnected())) return console.error("Failed to get socket"); + firstQrLoad.current = true; - await exportComponentAsJPEG(renderedQR, { - fileName: qr.preset - ? `${qr.name}_PRESET_${datetimeString}.jpg` - : `${qr.name}_${qr.category}_${datetimeString}.jpg`, + socketManager.onQRChange((qr: QR) => { + if (qr == null) return closeQR(); + if (!firstQrLoad.current) { + setQrTimeout(true); + setTimeout(() => setQrTimeout(false), 5000); + } + firstQrLoad.current = false; + + setQR({ + name: qr.name, + url: qr.redemptionUrl, + category: qr.category, + }); + setOpen(true); }); + socketManager.listenForQR(id); + } + + function closeQR() { + socketManager.stopListeningForQR(); + setOpen(false); } - async function displayQR(name: string, url: string, category: string) { - // Should use socket to subscribe to QR updates - if (await socketManager.ensureConnected()) { - console.log("Socket is authenticated") - // setQR({ name, url, category, preset: category === "preset" }); - // setOpen(true); - } else { - console.log("Failed to get socket") - } + function getAdminTabs() { + if (!userIsAdmin) return []; + return [ + { + name: "Custom", + content: , + }, + ]; } const tabs = [ @@ -76,49 +89,30 @@ export default function Volunteer() { name: "Preset", content: , }, - ...(userIsVolunteer - ? [ - { - name: "Custom", - content: , - }, - ] - : []), - ...(userIsAdmin - ? [ - { - name: "Manage", - content: , - }, - ] - : []), + ...getAdminTabs(), ]; + function CurrentTab() { + const tab = tabs.find((tab) => tab.name === current); + return tab && {tab.content}; + } + return ( <>
- {tabs.map( - (tab) => - tab.name === current && ( - {tab.content} - ) - )} +
- +
- + {qrTimeout ? ( + + ) : ( + + )}

{qr.name}

- diff --git a/client/src/app/volunteer/admin/admin-page.tsx b/client/src/app/(authorized)/volunteer/admin/admin-page.tsx similarity index 94% rename from client/src/app/volunteer/admin/admin-page.tsx rename to client/src/app/(authorized)/volunteer/admin/admin-page.tsx index 3cee32a6..beac0044 100644 --- a/client/src/app/volunteer/admin/admin-page.tsx +++ b/client/src/app/(authorized)/volunteer/admin/admin-page.tsx @@ -8,7 +8,7 @@ import Select from "react-select"; import useSWR from "swr"; import { ButtonModal } from "@/components/button-modal"; -import { fetchMegateamsApi } from "@/lib/api"; +import { fetchGuildsApi } from "@/lib/api"; export function AdminPage() { const itemsPerPage = 10; @@ -63,7 +63,7 @@ export function AdminPage() { )?.value; if (points && !Number.isNaN(points)) { try { - await fetchMegateamsApi("/points", { + await fetchGuildsApi("/points", { method: "POST", body: JSON.stringify({ value: parseInt(points), redeemerUserId: id }), headers: { "Content-Type": "application/json" }, @@ -82,7 +82,7 @@ export function AdminPage() { async function changeTeam(id: number, team_id: number) { try { - await fetchMegateamsApi("/users/" + id, { + await fetchGuildsApi("/users/" + id, { method: "PATCH", body: JSON.stringify({ team_id }), headers: { "Content-Type": "application/json" }, @@ -132,19 +132,18 @@ export function AdminPage() { preferred_name, email, points, - megateam_name, + guild_name, team_name, id, team_id, - }, - i + } ) => ( -
+

{preferred_name} - {email}

- {points} points | {megateam_name || "No megateam assigned!"} + {points} points | {guild_name || "No guild assigned!"}

setName(e.target.value)} + /> + setDescription(e.target.value)} + /> +
+ + setPoints(parseInt(e.target.value))} + /> +

points

+
+

Start time (optional)

+
+ setStartDate(e.target.value)} + /> + +
+

End time (optional)

+
+ setEndDate(e.target.value)} + /> + +
+
+

Per hacker claim limit?

+ setClaimLimit(e.target.checked)} + /> + +
+ {error &&

{error}

} +
+ ); +} diff --git a/client/src/app/(authorized)/volunteer/quests/new-quest.tsx b/client/src/app/(authorized)/volunteer/quests/new-quest.tsx new file mode 100644 index 00000000..ac60108a --- /dev/null +++ b/client/src/app/(authorized)/volunteer/quests/new-quest.tsx @@ -0,0 +1,108 @@ +import { useState } from "react"; +import Select from "react-select"; +import useSWR from "swr"; + +import { fetchGuildsApi } from "@/lib/api"; + +export function NewQuest({ save }: { save: () => void }) { + const dependencyModes = ["AND", "OR"]; + + const { data: challengeData = { challenges: [] } } = useSWR<{ challenges: any[] }>( + "/qr_codes/challenges?noFilter=true" + ); + + const [name, setName] = useState(""); + const [description, setDescription] = useState(""); + const [dependencyMode, setDependencyMode] = useState(dependencyModes[0]); + const [points, setPoints] = useState(0); + const [challenges, setChallenges] = useState([]); + const [disabled, setDisabled] = useState(false); + + const [error, setError] = useState(""); + + async function submitForm() { + if (!name) return setError("Please set a name!"); + if (!description) return setError("Please set a description!"); + if (challenges.length === 0) return setError("Please include at least one challenge!"); + setDisabled(true); + try { + await fetchGuildsApi("/quests", { + method: "POST", + body: JSON.stringify({ + name, + description, + dependencyMode: dependencyMode, + points: points, + challenges: challenges.map((challenge) => challenge.id), + }), + headers: { "Content-Type": "application/json" }, + }); + setName(""); + setDescription(""); + setDependencyMode(dependencyModes[0]); + setPoints(0); + setError(""); + save(); + } catch { + setError("Failed to create quest!"); + } + setDisabled(false); + } + + return ( +
+

New Quest

+ setName(e.target.value)} + /> + setDescription(e.target.value)} + /> +
+ + setPoints(parseInt(e.target.value))} + /> +

points

+
+ filterQuests(e.target.value)} + /> + +
+ { filteredQuests.map((quest) => ( +
+

{ quest.name }

+

{ quest.description }

+
+ )) } +
+ setError(null)} + content={

Failed to generate QR Code for Challenge!

} + itemsClass="items-center" + buttons={ + + } + /> + setShowNewQuest(false)} + content={} + itemsClass="items-center" + buttons={ + + } + /> + setShowNewChallenge(false)} + content={} + itemsClass="items-center" + buttons={ + + } + /> + + ); +} diff --git a/client/src/app/volunteer/teams/page.tsx b/client/src/app/(authorized)/volunteer/teams/page.tsx similarity index 76% rename from client/src/app/volunteer/teams/page.tsx rename to client/src/app/(authorized)/volunteer/teams/page.tsx index 7308a236..6de79167 100644 --- a/client/src/app/volunteer/teams/page.tsx +++ b/client/src/app/(authorized)/volunteer/teams/page.tsx @@ -3,7 +3,7 @@ import dynamic from "next/dynamic"; import { redirect } from "next/navigation"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { useGuildsContext } from "@/hooks/use-guilds-context"; import { isVolunteer } from "@/lib/is-role"; const TeamsPage = dynamic( @@ -12,7 +12,7 @@ const TeamsPage = dynamic( ); export default function Teams() { - const { user, userIsLoading } = useMegateamsContext(); + const { user, userIsLoading } = useGuildsContext(); if (userIsLoading) return <>; if (user == null || !isVolunteer(user)) return redirect("/"); diff --git a/client/src/app/volunteer/teams/teams-page.tsx b/client/src/app/(authorized)/volunteer/teams/teams-page.tsx similarity index 76% rename from client/src/app/volunteer/teams/teams-page.tsx rename to client/src/app/(authorized)/volunteer/teams/teams-page.tsx index bfb6a62f..a8b2ea2d 100644 --- a/client/src/app/volunteer/teams/teams-page.tsx +++ b/client/src/app/(authorized)/volunteer/teams/teams-page.tsx @@ -5,18 +5,19 @@ import { useFormState } from "react-hooks-use-form-state"; import Select from "react-select"; import useSWR from "swr"; import ReactPaginate from "react-paginate"; +import type { Guild, Team, Area } from "@durhack/guilds-common/types/index"; import { ButtonModal } from "@/components/button-modal"; -import { fetchMegateamsApi } from "@/lib/api"; +import { fetchGuildsApi } from "@/lib/api"; import { MagnifyingGlassIcon } from "@heroicons/react/24/outline"; export function TeamsPage() { const { mutate: mutateTeams, data: teamsData = { teams: [] } } = useSWR<{ - teams: any[]; + teams: Team[]; }>("/teams"); const [teams, setTeams, resetForm] = useFormState(teamsData.teams); - const { data: { megateams } = { megateams: [] } } = useSWR<{ - megateams: any[]; + const { data: { guilds } = { guilds: [] } } = useSWR<{ + guilds: Guild[]; }>("/areas"); const [message, setMessage] = React.useState(""); const [messageOpen, setMessageOpen] = React.useState(false); @@ -56,35 +57,35 @@ export function TeamsPage() { setPageNumber(0); } - function changeMegateam(team: any, name: string) { + function changeGuild(team: Team, name: string) { const newTeams = [...teams]; - team.area = { megateam: { megateam_name: name } }; + team.area = { guild: { guild_name: name } }; setTeams(newTeams); } - function changeArea(team: any, area: any) { + function changeArea(team: Team, area?: Area) { const newTeams = [...teams]; - team.area.area_id = area.areaId; + team.area.area_id = area?.area_id; setTeams(newTeams); } - function getMegateam(megateam_name: string) { - const filteredMegateams = megateams.filter( - ({ megateamName }) => megateamName === megateam_name + function getGuild(guildName?: string) { + const filteredGuilds = guilds.filter( + ({ guild_name }) => guild_name === guildName ); - return filteredMegateams.length ? filteredMegateams[0] : null; + return filteredGuilds.length ? filteredGuilds[0] : undefined; } - function getArea(megateam: any, area_id: number) { + function getArea(guild?: Guild, area_id?: number) { const filteredAreas = - megateam?.areas?.filter((area: any) => area.areaId === area_id) ?? []; + guild?.areas?.filter((area: Area) => area.area_id === area_id) ?? []; return filteredAreas.length ? filteredAreas[0] : null; } - async function saveTeam(team: any) { + async function saveTeam(team: Team) { if (Number.isInteger(team?.area?.area_id)) { try { - await fetchMegateamsApi("/teams/" + team.team_id, { + await fetchGuildsApi("/teams/" + team.team_id, { method: "PATCH", body: JSON.stringify({ area_code: team.area.area_id }), headers: { "Content-Type": "application/json" }, @@ -96,7 +97,7 @@ export function TeamsPage() { setMessage("Failed to update team!"); } } else { - setMessage("Please select a Megateam and Area!"); + setMessage("Please select a Guild and Area!"); } setMessageOpen(true); } @@ -132,8 +133,8 @@ export function TeamsPage() { className="dh-paginate my-6" /> {currentItems.map((team) => { - const megateam = getMegateam(team.area?.megateam?.megateam_name); - const area = getArea(megateam, team.area?.area_id); + const guild = getGuild(team.area?.guild?.guild_name); + const area = getArea(guild, team.area?.area_id); return (

{team.name}

@@ -142,28 +143,28 @@ export function TeamsPage() {

setName(e.target.value)} - /> - -
- setPoints(parseInt(e.target.value))} - /> -

points

- setUses(parseInt(e.target.value))} - /> -

uses

-
-

Start time

-
- setStartDate(e.target.value)} - /> - -
-

End time

-
- setEndDate(e.target.value)} - /> - -
-
-

Publicised:

- setPublicised(e.target.checked)} - /> - -
- {error &&

{error}

} -
- ); -} diff --git a/client/src/app/volunteer/(volunteer)/preset.tsx b/client/src/app/volunteer/(volunteer)/preset.tsx deleted file mode 100644 index 85cf6c05..00000000 --- a/client/src/app/volunteer/(volunteer)/preset.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { fetchMegateamsApi } from "@/lib/api"; -import { ClockIcon, InformationCircleIcon } from "@heroicons/react/24/outline"; -import { useEffect, useState } from "react"; -import useSWR from "swr"; - -export default function Preset({ - displayQR, -}: { - displayQR: (name: string, url: string, category: string) => void; -}) { - const { data: { presets } = { presets: {} }, isLoading } = - useSWR("/qr_codes/presets"); - const [selected, setSelected] = useState(null); - const [error, setError] = useState(null); - - let presetsList: any[] = Object.entries(presets).map( - ([id, preset]: [string, any]) => { - preset.id = id; - return preset; - } - ); - presetsList.sort((a, b) => b.name.localeCompare(a.name)); - - useEffect(() => { - if (presetsList.length && !selected) setSelected(presetsList[0]); - }, [presets]); - - function getExpiryDate(minutesValid: number) { - let now = new Date(); - now.setMinutes(now.getMinutes() + minutesValid); - return now.toLocaleString("en-GB", { - timeStyle: "short", - dateStyle: "short", - }); - } - - async function generateQR() { - try { - const { data: qr } = await fetchMegateamsApi( - "/qr_codes/presets/" + encodeURIComponent(selected.id), - { - method: "POST", - body: JSON.stringify({ - name: selected.name, - publicised: false, - }), - headers: { "Content-Type": "application/json" }, - } - ); - setError(null); - displayQR(qr.name, qr.redemption_url, qr.category); - } catch { - setError("Failed to generate QR!"); - } - } - - if (isLoading || !selected) return <>; - - return ( -
- -

{selected.description}

-
- -

{getExpiryDate(selected.minutesValid)}

-
-
- -

- {selected.points} point{selected.points !== 1 ? "s" : ""} - ,  - {selected.uses} use{selected.uses !== 1 ? "s" : ""} -

-
- - {error &&

{error}

} -
- ); -} diff --git a/client/src/app/volunteer/challenges/page.tsx b/client/src/app/volunteer/challenges/page.tsx deleted file mode 100644 index e0379032..00000000 --- a/client/src/app/volunteer/challenges/page.tsx +++ /dev/null @@ -1,136 +0,0 @@ -"use client"; - -import { useAutoAnimate } from "@formkit/auto-animate/react"; -import { redirect } from "next/navigation"; -import useSWR from "swr"; -import { useFormState } from "react-hooks-use-form-state"; -import { useState } from "react"; - -import { ButtonModal } from "@/components/button-modal"; -import { fetchMegateamsApi } from "@/lib/api"; -import { isAdmin } from "@/lib/is-role"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; - -export default function Challenges() { - const { user, userIsLoading } = useMegateamsContext(); - const { data = { challenges: [] }, mutate: mutateChallenges } = useSWR<{ - challenges: any[]; - }>("/qr_codes/challenges"); - - const [animationParent] = useAutoAnimate(); - - const [challenges, setChallenges, resetForm] = useFormState(data.challenges); - const [message, setMessage] = useState(""); - const [messageOpen, setMessageOpen] = useState(false); - - if (userIsLoading) return <>; - if (!user) return redirect("/") - if (!isAdmin(user)) return redirect("/volunteer") - - async function updatePosition(oldPos: number, newPos: number) { - try { - let newList = [...challenges]; - if (newPos > oldPos) { - for (let challenge of newList) { - if (challenge.rank === oldPos) { - challenge.rank = newPos; - } else if (challenge.rank > oldPos && challenge.rank <= newPos) { - challenge.rank--; - } - } - } - if (newPos < oldPos) { - for (let challenge of newList) { - if (challenge.rank === oldPos) { - challenge.rank = newPos; - } else if (challenge.rank >= newPos && challenge.rank < oldPos) { - challenge.rank++; - } - } - } - newList.sort((a, b) => a.rank - b.rank); - setChallenges(newList); - await fetchMegateamsApi("/qr_codes/challenges/reorder", { - method: "POST", - body: JSON.stringify({ - challenges: challenges.map(({ id, rank }) => ({ id, rank })), - }), - headers: { - "Content-Type": "application/json", - }, - }); - await mutateChallenges(); - } catch { - setMessage("Failed to reorder challenges!"); - setMessageOpen(true); - } - resetForm(); - } - - async function unpublicise(id: number) { - try { - await fetchMegateamsApi("/qr_codes/" + encodeURIComponent(id), { - method: "PATCH", - body: JSON.stringify({ publicised: false }), - headers: { - "Content-Type": "application/json", - }, - }); - await mutateChallenges(); - } catch { - setMessage("Failed to unpublicise challenge!"); - setMessageOpen(true); - } - resetForm(); - } - - return ( - <> -
-

Manage Challenge List

-
    - {challenges.map(({ title, points, rank, id }) => ( -
  • -

    {title}

    -

    {points} points

    -
    -

    Position:

    - - -
    -
  • - ))} -
-
- setMessageOpen(bool)} - content={

{message}

} - itemsClass="items-center" - buttons={ - - } - /> - - ); -} diff --git a/client/src/components/megateams-context-provider.tsx b/client/src/components/guilds-context-provider.tsx similarity index 63% rename from client/src/components/megateams-context-provider.tsx rename to client/src/components/guilds-context-provider.tsx index a0186da7..89599152 100644 --- a/client/src/components/megateams-context-provider.tsx +++ b/client/src/components/guilds-context-provider.tsx @@ -6,39 +6,39 @@ import type { KeyedMutator } from "swr"; import { type User, useUser } from "@/hooks/use-user"; import { type Team, useTeam } from "@/hooks/use-team"; -type MegateamContextProps = { +type GuildContextProps = { user: User | null | undefined - userError: unknown | undefined mutateUser: KeyedMutator userIsLoading: boolean team: Team | null | undefined - teamError: unknown | undefined mutateTeam: KeyedMutator teamIsLoading: boolean } -export const MegateamsContextContext = React.createContext(null) +export const GuildsContextContext = React.createContext(null) -export function MegateamsContextProvider({ children }: { children?: React.ReactNode }) { +export function GuildsContextProvider({ children }: { children?: React.ReactNode }) { const { data: user, error: userError, mutate: mutateUser, isLoading: userIsLoading } = useUser() const { data: team, error: teamError, mutate: mutateTeam, isLoading: teamIsLoading } = useTeam() + // throw the error to the nearest error boundary (error.tsx in app directory) + if (userError != null) throw userError + if (teamError != null) throw teamError + return ( - {children} - + ) } diff --git a/client/src/components/leaderboard/mega-chart.tsx b/client/src/components/leaderboard/guild-chart.tsx similarity index 74% rename from client/src/components/leaderboard/mega-chart.tsx rename to client/src/components/leaderboard/guild-chart.tsx index 10df6335..ded84534 100644 --- a/client/src/components/leaderboard/mega-chart.tsx +++ b/client/src/components/leaderboard/guild-chart.tsx @@ -14,7 +14,7 @@ import resolveConfig from "tailwindcss/resolveConfig"; import tailwindConfig from "tailwindcss/defaultConfig"; import useSWR from "swr"; -import { fetchMegateamsApi } from "@/lib/api"; +import { fetchGuildsApi } from "@/lib/api"; ChartJS.register( CategoryScale, @@ -25,14 +25,14 @@ ChartJS.register( ); export default function MegaChart() { - const { data: { megateams } = { megateams: null } } = useSWR( - "/megateams", - fetchMegateamsApi, + const { data: { guilds } = { guilds: null } } = useSWR( + "/guilds", + fetchGuildsApi, { refreshInterval: 1000 } ); - megateams?.sort((a: any, b: any) => { - return a.megateam_name.localeCompare(b.megateam_name); + guilds?.sort((a: any, b: any) => { + return a.guild_name.localeCompare(b.guild_name); }); const isDark = useMediaQuery({ @@ -43,20 +43,27 @@ export default function MegaChart() { let largestPoints = 0; - megateams?.forEach((megateam: any) => { - megateam.image = new Image(); - megateam.image.src = `/${megateam.megateam_name}/icon.png`; - if (megateam.points > largestPoints) { - largestPoints = megateam.points; + guilds?.forEach((guild: any) => { + guild.image = new Image(); + guild.image.src = `/${guild.guild_name}/icon.png`; + if (guild.points > largestPoints) { + largestPoints = guild.points; } }); + const guildDisplayNames = new Map([ + ["Centre of the Earth", "CotE"], + ["Atlantis", "Atlantis"], + ["Moon", "Moon"], + ["Mysterious Island", "MI"], + ]) + const dataset = { - labels: megateams?.map((team: any) => team.megateam_name), + labels: guilds?.map((team: any) => guildDisplayNames.get(team.guild_name)), datasets: [ { label: "Points", - data: megateams?.map((team: any) => team.points), + data: guilds?.map((team: any) => team.points), ...(isDark ? { // @ts-ignore @@ -94,7 +101,7 @@ export default function MegaChart() { plugins: { datalabels, annotation: { - annotations: megateams?.map((team: any, i: number) => { + annotations: guilds?.map((team: any, i: number) => { const options: AnnotationOptions = { type: "box", yMin: Math.max(largestPoints * 0.6, team.points * 0.75), diff --git a/client/src/components/leaderboard/leaderboard.tsx b/client/src/components/leaderboard/index.tsx similarity index 86% rename from client/src/components/leaderboard/leaderboard.tsx rename to client/src/components/leaderboard/index.tsx index eb3eab61..cbd3adfc 100644 --- a/client/src/components/leaderboard/leaderboard.tsx +++ b/client/src/components/leaderboard/index.tsx @@ -3,16 +3,16 @@ import * as React from "react"; import useSWR from "swr"; -import { getPositionMedal } from "@/lib/rankEmojis"; +import { getPositionMedal } from "@/lib/rank-emojis"; import TeamName from "@/components/team-name"; -import { fetchMegateamsApi } from "@/lib/api"; +import { fetchGuildsApi } from "@/lib/api"; -import MegaChart from "./mega-chart"; +import MegaChart from "./guild-chart"; export default function Leaderboard() { const { data: { teams } = { teams: null } } = useSWR( "/teams", - fetchMegateamsApi, + fetchGuildsApi, { refreshInterval: 1000 } ); @@ -23,7 +23,7 @@ export default function Leaderboard() { return (
-

Megateams Leaderboard

+

Guilds Leaderboard

diff --git a/client/src/components/tabbed-page.tsx b/client/src/components/tabbed-page.tsx index d12c8f5f..248f52a6 100644 --- a/client/src/components/tabbed-page.tsx +++ b/client/src/components/tabbed-page.tsx @@ -7,8 +7,7 @@ import { Dialog } from "@headlessui/react"; import * as React from "react"; import { SWRConfig } from "swr"; -import { fetchMegateamsApi } from "@/lib/api"; -import { useMegateamsContext } from "@/hooks/use-megateams-context"; +import { fetchGuildsApi } from "@/lib/api"; import { ButtonModal } from "./button-modal"; @@ -34,21 +33,13 @@ export function TabbedPage({ }) { const path = usePathname(); const [open, setOpen] = React.useState(false); - const { mutateUser } = useMegateamsContext(); - - async function signOut() { - await fetchMegateamsApi("/auth/logout", { method: "POST" }); - await mutateUser(null); - } return ( - +
- - DurHack Logo - -

DURHACK

+ DurHack Logo +

DurHack

@@ -109,13 +100,12 @@ export function TabbedPage({ } buttons={ <> - +