diff --git a/.github/release.yml b/.github/release.yml index 377283f..c8b6841 100644 --- a/.github/release.yml +++ b/.github/release.yml @@ -14,4 +14,4 @@ changelog: - 'bug' - title: Misc. Fixes 🚧 labels: - - '*' \ No newline at end of file + - '*' diff --git a/.github/workflows/prod-deploy.yml b/.github/workflows/prod-deploy.yml index 0124f2e..79bebf8 100644 --- a/.github/workflows/prod-deploy.yml +++ b/.github/workflows/prod-deploy.yml @@ -10,16 +10,16 @@ jobs: runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v4 + - name: Checkout Code + uses: actions/checkout@v4 - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 - - name: Install - run: npm ci + - name: Install + run: npm ci publish: needs: build @@ -53,8 +53,8 @@ jobs: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - source: ".env" - target: "~/otr-web/" + source: '.env' + target: '~/otr-web/' - name: Deploy uses: appleboy/ssh-action@master with: @@ -65,4 +65,4 @@ jobs: docker pull stagecodes/otr-web-prod:latest docker stop otr-web-prod || true docker rm otr-web-prod || true - docker run -d -p 3000:3000 --restart always --name otr-web-prod --env-file ~/otr-web/.env stagecodes/otr-web-prod:latest \ No newline at end of file + docker run -d -p 3000:3000 --restart always --name otr-web-prod --env-file ~/otr-web/.env stagecodes/otr-web-prod:latest diff --git a/.github/workflows/staging-deploy.yml b/.github/workflows/staging-deploy.yml index 3e0180f..58c996e 100644 --- a/.github/workflows/staging-deploy.yml +++ b/.github/workflows/staging-deploy.yml @@ -2,25 +2,25 @@ name: Web deploy on: push: - branches: [ master ] + branches: [master] jobs: build: runs-on: ubuntu-latest steps: - - name: Checkout Code - uses: actions/checkout@v4 - with: - ref: master + - name: Checkout Code + uses: actions/checkout@v4 + with: + ref: master - - name: Setup Node - uses: actions/setup-node@v4 - with: - node-version: 20 + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 - - name: Install - run: npm ci + - name: Install + run: npm ci publish: needs: build @@ -58,8 +58,8 @@ jobs: host: ${{ secrets.SSH_HOST }} username: ${{ secrets.SSH_USER }} key: ${{ secrets.SSH_KEY }} - source: ".env" - target: "~/otr-web/" + source: '.env' + target: '~/otr-web/' - name: Deploy uses: appleboy/ssh-action@master with: @@ -70,4 +70,4 @@ jobs: docker pull stagecodes/otr-web-staging:latest docker stop otr-web-staging || true docker rm otr-web-staging || true - docker run -d -p 3000:3000 --restart always --name otr-web-staging --env-file ~/otr-web/.env stagecodes/otr-web-staging:latest \ No newline at end of file + docker run -d -p 3000:3000 --restart always --name otr-web-staging --env-file ~/otr-web/.env stagecodes/otr-web-staging:latest diff --git a/.husky/.gitignore b/.husky/.gitignore new file mode 100644 index 0000000..31354ec --- /dev/null +++ b/.husky/.gitignore @@ -0,0 +1 @@ +_ diff --git a/.husky/pre-commit b/.husky/pre-commit new file mode 100644 index 0000000..36af219 --- /dev/null +++ b/.husky/pre-commit @@ -0,0 +1,4 @@ +#!/bin/sh +. "$(dirname "$0")/_/husky.sh" + +npx lint-staged diff --git a/.prettierrc b/.prettierrc index 544138b..9daff03 100644 --- a/.prettierrc +++ b/.prettierrc @@ -1,3 +1,23 @@ { - "singleQuote": true + "singleQuote": true, + "arrowParens": "always", + "bracketSameLine": false, + "bracketSpacing": true, + "embeddedLanguageFormatting": "auto", + "endOfLine": "lf", + "experimentalTernaries": false, + "htmlWhitespaceSensitivity": "css", + "insertPragma": false, + "jsxBracketSameLine": false, + "jsxSingleQuote": false, + "printWidth": 80, + "proseWrap": "preserve", + "quoteProps": "as-needed", + "requirePragma": false, + "semi": true, + "singleAttributePerLine": false, + "tabWidth": 2, + "trailingComma": "es5", + "useTabs": false, + "vueIndentScriptAndStyle": false } diff --git a/app/actions.ts b/app/actions.ts index a94c811..cc06c97 100644 --- a/app/actions.ts +++ b/app/actions.ts @@ -1,13 +1,20 @@ 'use server'; +import { getSessionData } from '@/app/actions/session'; +import { apiWrapperConfiguration } from '@/lib/api'; import { LeaderboardsQuerySchema, TournamentsQuerySchema, - UserpageQuerySchema + UserpageQuerySchema, } from '@/lib/types'; +import { + MatchesWrapper, + PlayersWrapper, + TournamentsWrapper, + Ruleset, +} from '@osu-tournament-rating/otr-api-client'; import { cookies } from 'next/headers'; import { notFound, redirect } from 'next/navigation'; -import { getSessionData } from '@/app/actions/session'; export async function resetLeaderboardFilters(string: string) { return redirect(string); @@ -131,13 +138,13 @@ export async function fetchLeaderboard(params: {}) { queryCheck.data.type === 'global' ? (backendObject.chartType = 0) : queryCheck.data.type === 'country' - ? (backendObject.chartType = 1) - : null; + ? (backendObject.chartType = 1) + : null; } /* Check page number */ if (queryCheck.data.page) { - backendObject.page = queryCheck.data.page - 1; + backendObject.page = queryCheck.data.page; } /* Assign page size */ @@ -253,6 +260,11 @@ export async function fetchDashboard(params: {}) { } export async function fetchTournamentsPage(params: {}) { + const session = await getSessionData(); + + /* IF USER IS UNAUTHORIZED REDIRECT TO HOMEPAGE */ + if (!session.id) return redirect('/'); + const { page } = params; const queryCheck = await TournamentsQuerySchema.safeParse({ @@ -263,116 +275,64 @@ export async function fetchTournamentsPage(params: {}) { return console.log('error'); } - let data = await fetch(`${process.env.REACT_APP_API_URL}/tournaments`, { - headers: { - 'Content-Type': 'application/json', - }, - }); + const wrapper = new TournamentsWrapper(apiWrapperConfiguration); - data = await data.json(); + let data = await wrapper.list({ + page: 1, + pageSize: 30, + verified: false, + }); - return data; + return data.result; } -export async function fetchTournamentPage(tournament: string | number) { - let data = await fetch( - `${process.env.REACT_APP_API_URL}/tournaments/${tournament}?unfiltered=true`, //! to remove unfiltered - { - headers: { - 'Content-Type': 'application/json', - }, - } - ); +export async function fetchTournamentPage(tournamentId: number | string) { + const session = await getSessionData(); - data = await data.json(); + /* IF USER IS UNAUTHORIZED REDIRECT TO HOMEPAGE */ + if (!session.id) return redirect('/'); - return data; -} + const wrapper = new TournamentsWrapper(apiWrapperConfiguration); -export async function fetchMatchPage(match: string | number) { - let data = await fetch(`${process.env.REACT_APP_API_URL}/matches/${match}`, { - headers: { - 'Content-Type': 'application/json', - }, + let data = await wrapper.get({ + id: tournamentId as number, + verified: false, }); - data = await data.json(); - - return data; + return data.result; } -export async function fetchUserPageTitle(player: string | number) { +export async function fetchMatchPage(matchId: string | number) { const session = await getSessionData(); - let res = await fetch( - `${process.env.REACT_APP_API_URL}/players/${player}`, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${session.accessToken}`, - }, - } - ); + /* IF USER IS UNAUTHORIZED REDIRECT TO HOMEPAGE */ + if (!session.id) return redirect('/'); - if (res?.ok) { - res = await res.json(); - return res; - } + const wrapper = new MatchesWrapper(apiWrapperConfiguration); - return null; + let data = await wrapper.get({ + id: matchId as number, + }); + + return data.result; } -export async function fetchUserPage(player: string | number, params) { +export async function fetchUserPageTitle(player: string | number) { const session = await getSessionData(); - const osuMode = - (await cookies().get('OTR-user-selected-osu-mode')?.value) ?? '0'; - - let urlStringObject = { - ruleset: osuMode, - }; - - if (session?.playerId) { - urlStringObject.comparerId = session?.playerId; - } - - const queryCheck = await UserpageQuerySchema.safeParse({ - time: params?.time, + let res = await fetch(`${process.env.REACT_APP_API_URL}/players/${player}`, { + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessToken}`, + }, }); - // put time on the url only if the value is not undefined and without errors - if (queryCheck.success && queryCheck.data.time) { - let minDate = new Date(); - minDate.setDate(minDate.getDate() - params?.time); - let year = minDate.getFullYear(); - let month = String(minDate.getMonth() + 1).padStart(2, '0'); - let day = String(minDate.getDate()).padStart(2, '0'); - minDate = `${year}-${month}-${day}`; - - urlStringObject.dateMin = minDate; - } - - const urlParams = decodeURIComponent( - new URLSearchParams(urlStringObject).toString() - ); - - let res = await fetch( - `${process.env.REACT_APP_API_URL}/stats/${player}?${urlParams}`, - { - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${session?.accessToken}`, - }, - } - ); - - if (!res?.ok) { - return res?.status === 404 ? notFound() : redirect('/'); + if (res?.ok) { + res = await res.json(); + return res; } - res = await res.json(); - - return res; + return null; } export async function paginationParamsToURL(params: {}) { @@ -443,3 +403,62 @@ export async function fetchSearchData(prevState: any, formData: FormData) { search: searchData, }; } + +export async function fetchTournamentsForAdminPage(params: {}) { + const session = await getSessionData(); + + /* IF USER IS UNAUTHORIZED REDIRECT TO HOMEPAGE */ + if (!session.id) return redirect('/'); + + const wrapper = new TournamentsWrapper(apiWrapperConfiguration); + + let data = await wrapper.list({ + page: 1, + pageSize: 30, + verified: false, + }); + + return data.result; +} + +export async function adminPanelSaveVerified(params) { + const session = await getSessionData(); + + /* IF USER IS UNAUTHORIZED REDIRECT TO HOMEPAGE */ + if (!session.id) return redirect('/'); + + const body = [ + { + path: '/verificationStatus', + op: 'replace', + value: params.status, + }, + { + path: '/rejectionReason', + op: 'replace', + value: 0, + }, + ]; + + let data = await fetch( + `${process.env.REACT_APP_API_URL}/${params.path}/${params.id}`, + { + method: 'PATCH', + body: JSON.stringify(body), + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${session.accessToken}`, + }, + } + ); + + if (!data.ok) { + return { + error: { statusText: data.statusText, status: data.status }, + }; + } + + data = await data.json(); + + return data; +} diff --git a/app/actions/games.ts b/app/actions/games.ts new file mode 100644 index 0000000..e1500ca --- /dev/null +++ b/app/actions/games.ts @@ -0,0 +1,13 @@ +import { GamesGetRequestParams, GamesWrapper } from '@osu-tournament-rating/otr-api-client'; +import { apiWrapperConfiguration } from '@/lib/api'; + +/** + * Get a single game + * @param params see {@link GamesGetRequestParams} + */ +export async function getGame (params: GamesGetRequestParams) { + const wrapper = new GamesWrapper(apiWrapperConfiguration); + const { result } = await wrapper.get(params); + + return result; +} \ No newline at end of file diff --git a/app/actions/login.ts b/app/actions/login.ts index 9bd3095..32d6c62 100644 --- a/app/actions/login.ts +++ b/app/actions/login.ts @@ -1,11 +1,11 @@ 'use server'; -import { apiWrapperConfiguration } from '@/lib/auth'; -import { AccessCredentialsDTO, MeWrapper, OAuthWrapper } from '@osu-tournament-rating/otr-api-client'; +import { MeWrapper, OAuthWrapper } from '@osu-tournament-rating/otr-api-client'; import { redirect } from 'next/navigation'; import { NextResponse } from 'next/server'; -import { clearCookies, getSession, populateSessionUserData } from './session'; +import { clearCookies, getSession } from './session'; import { GetSessionParams } from '@/lib/types'; +import { apiWrapperConfiguration } from '@/lib/api'; /** * Prepares the login flow and redirects to the osu! oauth portal @@ -23,8 +23,14 @@ export async function prepareLogin() { await session.save(); const url = new URL('https://osu.ppy.sh/oauth/authorize'); - url.searchParams.set('client_id', process.env.REACT_APP_OSU_CLIENT_ID as string); - url.searchParams.set('redirect_uri', process.env.REACT_APP_OSU_CALLBACK_URL as string); + url.searchParams.set( + 'client_id', + process.env.REACT_APP_OSU_CLIENT_ID as string + ); + url.searchParams.set( + 'redirect_uri', + process.env.REACT_APP_OSU_CALLBACK_URL as string + ); url.searchParams.set('response_type', 'code'); url.searchParams.set('scope', 'public friends.read'); url.searchParams.set('state', state); @@ -46,28 +52,27 @@ export async function login(code: string) { // Exchange the osu! auth code for o!TR credentials const oauthWrapper = new OAuthWrapper(apiWrapperConfiguration); - - let accessCredentials: AccessCredentialsDTO; try { - accessCredentials = (await oauthWrapper.authorize({ code })).result; + const { result } = await oauthWrapper.authorize({ code }); + + session.accessToken = result.accessToken; + session.refreshToken = result.refreshToken; + session.isLogged = true; + await session.save(); } catch (err) { console.log(err); - return NextResponse.redirect(new URL('/', process.env.REACT_APP_ORIGIN_URL)); + return NextResponse.redirect( + new URL('/', process.env.REACT_APP_ORIGIN_URL) + ); } - // This is technically all that's required for login - session.accessToken = accessCredentials.accessToken; - session.refreshToken = accessCredentials.refreshToken; - session.isLogged = true; - await session.save(); - // Try to get the user and populate the rest of the session const meWrapper = new MeWrapper(apiWrapperConfiguration); - try { const { result } = await meWrapper.get(); - await populateSessionUserData(result); - } catch (err) { + session.user = result; + await session.save(); + } catch (err) { console.log(err); } @@ -86,7 +91,9 @@ export async function logout(getSessionParams?: GetSessionParams) { await clearCookies(getSessionParams?.res?.cookies); if (!getSessionParams) { - return redirect(new URL('/unauthorized', process.env.REACT_APP_ORIGIN_URL).toString()); + return redirect( + new URL('/unauthorized', process.env.REACT_APP_ORIGIN_URL).toString() + ); } } @@ -95,13 +102,16 @@ export async function logout(getSessionParams?: GetSessionParams) { * If the refresh token has expired, the current session is destroyed / user is logged out. * @returns A redirect based on the validity of the access credentials */ -export async function validateAccessCredentials(getSessionParams?: GetSessionParams) { +export async function validateAccessCredentials( + getSessionParams?: GetSessionParams +) { const session = await getSession(getSessionParams); if (!session.isLogged || !session.accessToken || !session.refreshToken) { return; } - const { accessTokenExpired, refreshTokenExpired } = await checkAccessCredentialsValidity(session); + const { accessTokenExpired, refreshTokenExpired } = + await checkAccessCredentialsValidity(session); // If the access token is still valid no action is needed if (!accessTokenExpired) { @@ -115,7 +125,9 @@ export async function validateAccessCredentials(getSessionParams?: GetSessionPar // Refresh the access token const oauthWrapper = new OAuthWrapper(apiWrapperConfiguration); - const { result } = await oauthWrapper.refresh({ refreshToken: session.refreshToken }); + const { result } = await oauthWrapper.refresh({ + refreshToken: session.refreshToken, + }); session.accessToken = result.accessToken; await session.save(); @@ -126,12 +138,16 @@ export async function validateAccessCredentials(getSessionParams?: GetSessionPar * @param accessCredentials Access credentials to check. If not given, they will be retrieved from the session * @returns Whether each token has expired */ -async function checkAccessCredentialsValidity(accessCredentials?: { accessToken?: string, refreshToken?: string }) { - const { accessToken, refreshToken } = (accessCredentials ?? await getSession()); +async function checkAccessCredentialsValidity(accessCredentials?: { + accessToken?: string; + refreshToken?: string; +}) { + const { accessToken, refreshToken } = + accessCredentials ?? (await getSession()); return { - accessTokenExpired: accessToken ? isTokenExpired(accessToken) : true, - refreshTokenExpired: refreshToken ? isTokenExpired(refreshToken) : true + accessTokenExpired: accessToken ? isTokenExpired(accessToken) : true, + refreshTokenExpired: refreshToken ? isTokenExpired(refreshToken) : true, }; } diff --git a/app/actions/matches.ts b/app/actions/matches.ts new file mode 100644 index 0000000..4917d24 --- /dev/null +++ b/app/actions/matches.ts @@ -0,0 +1,42 @@ +import { apiWrapperConfiguration } from '@/lib/api'; +import { + MatchDTO, + MatchesGetRequestParams, + MatchesWrapper, + OperationType, +} from '@osu-tournament-rating/otr-api-client'; + +export async function getMatch(params: MatchesGetRequestParams) { + const wrapper = new MatchesWrapper(apiWrapperConfiguration); + + const { result } = await wrapper.get(params); + return result; +} + +/** + * Updates a match + * @param id Match id + * @param prop Name of the property to update + * @param value New value for the property + * @returns The updated match + */ +export async function patchMatchData< + K extends keyof Omit, +>({ id, path, value }: { id: number; path: K; value: MatchDTO[K] }) { + const wrapper = new MatchesWrapper(apiWrapperConfiguration); + const { result } = await wrapper.update({ + id, + body: [ + { + // Client code requires supplying the operation type, but it has no effect + // 'op' however is required and needs to be a valid operation type + operationType: OperationType.Replace, + op: 'replace', + path, + value, + }, + ], + }); + + return result; +} diff --git a/app/actions/players.ts b/app/actions/players.ts new file mode 100644 index 0000000..6e9cb66 --- /dev/null +++ b/app/actions/players.ts @@ -0,0 +1,36 @@ +import { apiWrapperConfiguration } from '@/lib/auth'; +import { UserpageQuerySchema } from '@/lib/types'; +import { PlayersWrapper, Ruleset } from '@osu-tournament-rating/otr-api-client'; +import { cookies } from 'next/headers'; +import { notFound, redirect } from 'next/navigation'; + +export async function fetchPlayerStats(player: string | number, params) { + const osuMode = + ((await cookies().get('OTR-user-selected-osu-mode')?.value) as + | Ruleset + | undefined) ?? Ruleset.Osu; + + const queryCheck = await UserpageQuerySchema.safeParse({ + time: params?.time, + }); + + const wrapper = new PlayersWrapper(apiWrapperConfiguration); + + let minDate = undefined; + if (queryCheck.success && queryCheck.data.time) { + minDate = new Date(); + minDate.setDate(minDate.getDate() - params?.time); + } + + const statsResponse = await wrapper.getStats({ + key: player as string, + ruleset: osuMode, + dateMin: minDate, + }); + + if (statsResponse.status != 200) { + return statsResponse.status === 404 ? notFound() : redirect('/'); + } + + return statsResponse.result; +} diff --git a/app/actions/session.ts b/app/actions/session.ts index 17afff9..724e3a8 100644 --- a/app/actions/session.ts +++ b/app/actions/session.ts @@ -1,11 +1,13 @@ 'use server'; -import { CookieNames, GetSessionParams, SessionUser } from '@/lib/types'; +import { CookieNames, GetSessionParams, SessionData } from '@/lib/types'; import { getIronSession } from 'iron-session'; import { ironSessionOptions } from '@/lib/auth'; import { cookies } from 'next/headers'; -import { Ruleset, UserDTO } from '@osu-tournament-rating/otr-api-client'; -import { ResponseCookie, ResponseCookies } from 'next/dist/compiled/@edge-runtime/cookies'; +import { + ResponseCookie, + ResponseCookies, +} from 'next/dist/compiled/@edge-runtime/cookies'; /** * Gets the current session @@ -15,37 +17,18 @@ import { ResponseCookie, ResponseCookies } from 'next/dist/compiled/@edge-runtim export async function getSession(params?: GetSessionParams) { if (params) { const { req, res } = params; - return await getIronSession(req, res, ironSessionOptions); + return await getIronSession(req, res, ironSessionOptions); } - return await getIronSession(cookies(), ironSessionOptions); + return await getIronSession(cookies(), ironSessionOptions); } /** * Gets the raw data for the current session * @param params Optionally pass a request and response to use as a cookie store instead of {@link cookies} - * @returns A {@link SessionUser} serialized as a plain object + * @returns A {@link SessionData} serialized as a plain object */ export async function getSessionData(params?: GetSessionParams) { - return JSON.parse(JSON.stringify((await getSession(params) as SessionUser))); -} - -/** - * Populates the current session with data from the given user - * @param user The user data to populate the session with - */ -export async function populateSessionUserData(user: UserDTO) { - const session = await getSession(); - - session.id = user.id; - session.playerId = user.player?.id; - session.osuId = user.player?.osuId; - session.osuCountry = user.player?.country; - session.osuPlayMode = user.settings?.ruleset ?? Ruleset.Osu; - session.username = user.player?.username; - session.scopes = user.scopes; - - await setCookieValue(CookieNames.SelectedRuleset, session.osuPlayMode); - await session.save(); + return JSON.parse(JSON.stringify((await getSession(params)) as SessionData)); } /** @@ -54,7 +37,7 @@ export async function populateSessionUserData(user: UserDTO) { * @returns The value of the cookie */ export async function getCookieValue(cookie: CookieNames) { - return (cookies().get(cookie))?.value; + return cookies().get(cookie)?.value; } /** @@ -83,4 +66,4 @@ const cookieOptions: Partial = { sameSite: 'strict', secure: process.env.NODE_ENV === 'production', maxAge: 1209600, -}; \ No newline at end of file +}; diff --git a/app/actions/tournaments.ts b/app/actions/tournaments.ts index 4577817..a6f7e7c 100644 --- a/app/actions/tournaments.ts +++ b/app/actions/tournaments.ts @@ -1,16 +1,28 @@ 'use server'; -import { isHttpValidationProblemDetails } from "@/lib/api"; -import { apiWrapperConfiguration } from "@/lib/auth"; -import { BeatmapLinkPattern, MatchLinkPattern } from "@/lib/regex"; -import { TournamentSubmissionFormSchema } from "@/lib/schemas"; -import { FormState } from "@/lib/types"; -import { extractFormData } from "@/util/forms"; -import { TournamentSubmissionDTO, TournamentsWrapper } from "@osu-tournament-rating/otr-api-client"; -import { ZodError } from "zod"; +import { + apiWrapperConfiguration, + isHttpValidationProblemDetails, +} from '@/lib/api'; +import { BeatmapLinkPattern, MatchLinkPattern } from '@/lib/regex'; +import { + TournamentsListFilterSchema, + TournamentSubmissionFormSchema, +} from '@/lib/schemas'; +import { FormState, TournamentListFilter } from '@/lib/types'; +import { extractFormData } from '@/util/forms'; +import { + OperationType, + TournamentDTO, + TournamentsGetRequestParams, + TournamentsListRequestParams, + TournamentSubmissionDTO, + TournamentsWrapper, +} from '@osu-tournament-rating/otr-api-client'; +import { ZodError } from 'zod'; /** - * Handles parsing, submiting, and handling errors for tournament submission data + * Handles parsing, submitting, and handling errors for tournament submission data * @param _previousState Previous form state * @param formData Form data * @returns The state of the form after performing the action @@ -21,8 +33,8 @@ export async function tournamentSubmissionFormAction( ): Promise> { const result: FormState = { success: false, - message: "", - errors: {} + message: '', + errors: {}, }; try { @@ -65,24 +77,89 @@ export async function tournamentSubmissionFormAction( const wrapper = new TournamentsWrapper(apiWrapperConfiguration); await wrapper.create({ body: parsedForm }); - result.message = "Successfully processed your submission. Thank you for contributing!"; + result.message = + 'Successfully processed your submission. Thank you for contributing!'; result.success = true; } catch (err) { - result.message = "Submission was not successful."; + result.message = 'Submission was not successful.'; result.success = false; // Handle parsing errors if (err instanceof ZodError) { Object.assign(result.errors, err.flatten().fieldErrors); - result.message += " There was a problem processing your submission."; + result.message += ' There was a problem processing your submission.'; } // Handle API errors if (isHttpValidationProblemDetails(err)) { Object.assign(result.errors, err.errors); - result.message += " The server rejected your submission."; + result.message += ' The server rejected your submission.'; } } return result; -} \ No newline at end of file +} + +/** + * Get a single tournament with complete data + * @param params see {@link TournamentsGetRequestParams} + */ +export async function getTournament(params: TournamentsGetRequestParams) { + const wrapper = new TournamentsWrapper(apiWrapperConfiguration); + const { result } = await wrapper.get(params); + + return result; +} + +export async function buildTournamentListFilter( + queryParams: object, + defaultFilter?: TournamentListFilter +) { + const parsed = TournamentsListFilterSchema.safeParse( + Object.assign({}, defaultFilter, queryParams) + ); + + return parsed.success + ? (parsed.data as TournamentListFilter) + : (defaultFilter ?? {}); +} + +export async function getTournamentList(params: TournamentsListRequestParams) { + const wrapper = new TournamentsWrapper(apiWrapperConfiguration); + const { result } = await wrapper.list(params); + + return result; +} + +/** + * Updates a tournament + * @param id Tournament id + * @param prop Property to update + * @param value New value for the property + */ +export async function patchTournamentData({ + id, + path, + value, +}: { + id: number; + path: K; + value: TournamentDTO[K]; +}) { + const wrapper = new TournamentsWrapper(apiWrapperConfiguration); + const { result } = await wrapper.update({ + id, + body: [ + { + // Client code requires supplying the operation type, but it has no effect + // 'op' however is required and needs to be a valid operation type + operationType: OperationType.Replace, + op: 'replace', + path, + value, + }, + ], + }); + + return result; +} diff --git a/app/admin/@players/page.tsx b/app/admin/@players/page.tsx new file mode 100644 index 0000000..54519ff --- /dev/null +++ b/app/admin/@players/page.tsx @@ -0,0 +1,3 @@ +export default async function Page() { + return

PLAYERS

; +} diff --git a/app/admin/@tournaments/page.tsx b/app/admin/@tournaments/page.tsx new file mode 100644 index 0000000..0d7442a --- /dev/null +++ b/app/admin/@tournaments/page.tsx @@ -0,0 +1,14 @@ +import TournamentsPage from '@/app/tournaments/page'; + +export default async function Page({ + searchParams, +}: { + searchParams: Promise<{}>; +}) { + return ( +
+

Tournaments

+ +
+ ); +} \ No newline at end of file diff --git a/app/admin/layout.module.css b/app/admin/layout.module.css new file mode 100644 index 0000000..6af889d --- /dev/null +++ b/app/admin/layout.module.css @@ -0,0 +1,14 @@ +.adminLayout { + display: grid; + grid-template-columns: auto 1fr; + grid-template-areas: 'nav content'; + gap: var(--main-padding); +} + +.navigationContainer { + width: fit-content; + height: min-content; + grid-area: nav; + position: sticky; + top: var(--main-padding); +} diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx new file mode 100644 index 0000000..b0b327d --- /dev/null +++ b/app/admin/layout.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import styles from './layout.module.css'; +import AdminNavigation from '@/components/AdminNavigation/AdminNavigation'; +import { getSession } from '@/app/actions/session'; +import { Roles } from '@osu-tournament-rating/otr-api-client'; +import { redirect } from 'next/navigation'; +import clsx from 'clsx'; + +export default async function Layout({ + children, + players, + tournaments +}: { + children: React.ReactNode; + players: React.ReactNode; + tournaments: React.ReactNode; +}) { + // Prevent non-admins from accessing any pages in the group + const { user } = await getSession(); + if (!user?.scopes.includes(Roles.Admin)) { + return redirect('/'); + } + + return ( +
+
+ +
+
+ {children} + {tournaments} + {players} +
+
+ ); +} diff --git a/app/admin/page.tsx b/app/admin/page.tsx new file mode 100644 index 0000000..ee91eb5 --- /dev/null +++ b/app/admin/page.tsx @@ -0,0 +1,16 @@ +export const revalidate = 60; + +import type { Metadata } from 'next'; + +export const metadata: Metadata = { + title: 'Admin Panel', +}; + +export default async function Page() { + return ( +
+

This is the admin page

+

some description here

+
+ ); +} diff --git a/app/auth/route.ts b/app/auth/route.ts index c9bf958..a1c147b 100644 --- a/app/auth/route.ts +++ b/app/auth/route.ts @@ -1,5 +1,5 @@ import { redirect } from 'next/navigation'; -import { login } from '@/app/actions/login' +import { login } from '@/app/actions/login'; import { getSession } from '@/app/actions/session'; export async function GET(request: Request) { diff --git a/app/dashboard/page.module.css b/app/dashboard/page.module.css index 668a0bf..8c6bd8f 100644 --- a/app/dashboard/page.module.css +++ b/app/dashboard/page.module.css @@ -1,4 +1,6 @@ .main { + display: flex; + flex-direction: column; gap: var(--main-padding); } diff --git a/app/dashboard/page.tsx b/app/dashboard/page.tsx index df71760..c5e4203 100644 --- a/app/dashboard/page.tsx +++ b/app/dashboard/page.tsx @@ -11,7 +11,7 @@ import UserTotalMatches from '@/components/Dashboard/Matches/UserTotalMatches/Us import NoDataContainer from '@/components/Dashboard/NoDataContainer/NoDataContainer'; import StatsGrid from '@/components/Dashboard/StatsGrid/StatsGrid'; import UserMainCard from '@/components/Dashboard/UserMainCard/UserMainCard'; -import FormattedNumber from '@/components/FormattedNumber/FormattedNumber'; +import FormattedNumber from '@/components/FormattedData/FormattedNumber'; import Notice from '@/components/Notice/Notice'; import clsx from 'clsx'; import Image from 'next/image'; @@ -33,7 +33,7 @@ export default async function page({ const data = await fetchDashboard(searchParams); return ( -
+
@@ -194,6 +194,6 @@ export default async function page({ )} -
+ ); } diff --git a/app/globals.css b/app/globals.css index 9815432..117aa07 100644 --- a/app/globals.css +++ b/app/globals.css @@ -145,6 +145,12 @@ --search-bar-background: 0, 0%, 97%; --search-bar-foreground: 0, 0%, 15%; + /* ROW STATUS CIRCLE */ + --status-circle-pending: 37, 87%, 94%; + --status-circle-verified: 118, 66%, 67%; + --status-circle-preverified: 36, 100%, 65%; + --status-circle-rejected: 0, 66%, 67%; + /* STATUS BUTTON */ --status-btn-rejected-background: 2, 74%, 90%; --status-btn-rejected-foreground: 4, 62%, 41%; @@ -301,6 +307,12 @@ --search-bar-background: 240, 6%, 10%; --search-bar-foreground: 0, 0%, 75%; + /* ROW STATUS CIRCLE */ + --status-circle-pending: 33, 63%, 37%; + --status-circle-verified: 118, 66%, 67%; + --status-circle-preverified: 36, 100%, 65%; + --status-circle-rejected: 0, 66%, 67%; + /* STATUS BUTTON */ --status-btn-rejected-background: 0, 100%, 81%; --status-btn-rejected-foreground: 0, 71%, 32%; @@ -319,7 +331,7 @@ html, body { max-width: 100vw; - overflow-x: hidden; + overflow-x: clip; /* font-size: 100%; */ font-size: 16px; /* FOR FLUID DESIGN */ scroll-behavior: smooth !important; @@ -340,10 +352,23 @@ body { } main { + flex: 1; display: flex; flex-direction: column; padding: var(--main-padding); - padding-top: 0px; +} + +.container { + flex: 1; +} + +.content { + display: flex; + flex-flow: column; + gap: var(--internal-gap); + padding: var(--main-padding); + border-radius: var(--main-borderRadius); + background-color: hsl(var(--background-content-hsl)); } a { diff --git a/app/layout.tsx b/app/layout.tsx index 1b3ca67..6545058 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,5 +1,4 @@ -import Footer from '@/components/Footer/Footer'; -import { LayoutProvider } from '@/components/LayoutProvider/LayoutProvider'; +import { RootLayoutProvider } from '@/components/RootLayoutProvider/RootLayoutProvider'; import ErrorProvider from '@/util/ErrorContext'; import UserProvider from '@/util/UserLoggedContext'; import type { Metadata } from 'next'; @@ -7,6 +6,8 @@ import { Viewport } from 'next'; import { ThemeProvider } from 'next-themes'; import { Inter } from 'next/font/google'; import './globals.css'; +import React from 'react'; +import { getSession } from '@/app/actions/session'; const inter = Inter({ subsets: ['latin'], variable: '--font-Inter' }); @@ -26,21 +27,20 @@ export const viewport: Viewport = { themeColor: '#FFFFFF', }; -export default function RootLayout({ +export default async function RootLayout({ children, }: { children: React.ReactNode; }) { + const { user } = await getSession(); + return ( - + - - - {children} -