├── .dockerignore ├── .github └── workflows │ ├── deploy.yml │ └── test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json └── settings.json ├── Dockerfile ├── LICENSE.md ├── README.md ├── app ├── app.scss ├── assets │ ├── clash.svg │ ├── eternals.svg │ ├── logo-bg.svg │ ├── logo-slogan.svg │ └── logo.svg ├── components │ ├── Buttons │ │ ├── Buttons.tsx │ │ ├── Radio.tsx │ │ └── buttons.module.scss │ ├── ChallengeManager │ │ ├── Challenge.tsx │ │ ├── ChallengeManager.tsx │ │ ├── challenge.module.scss │ │ ├── challengeManager.module.scss │ │ ├── filterChallenges.ts │ │ └── sortChallenges.ts │ ├── Container │ │ ├── Container.tsx │ │ └── container.module.scss │ ├── ErrorWrapper │ │ ├── ErrorWrapper.tsx │ │ └── errorWrapper.module.scss │ ├── Heading │ │ ├── Heading.tsx │ │ └── heading.module.scss │ ├── HomeSearch │ │ ├── SearchResults.tsx │ │ ├── Searchbar.tsx │ │ ├── searchbar.module.scss │ │ └── searchresults.module.scss │ ├── Icons │ │ ├── CapstoneIcon.tsx │ │ ├── Title.tsx │ │ └── index.tsx │ ├── Leaderboard │ │ ├── Layout │ │ │ ├── LeaderboardLayout.tsx │ │ │ └── leaderboard.layout.module.scss │ │ ├── Leaderboard.tsx │ │ └── leaderboard.module.scss │ ├── Loader │ │ ├── Loader.tsx │ │ └── loader.module.scss │ ├── Navigation │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── Navigation.tsx │ │ ├── footer.module.scss │ │ ├── header.module.scss │ │ └── navigation.module.scss │ ├── ProgressBar │ │ ├── ProgressBar.tsx │ │ └── progressBar.module.scss │ ├── Searchbar │ │ ├── Searchbar.tsx │ │ └── searchbar.module.scss │ ├── SplashBackground │ │ ├── SplashBackground.tsx │ │ └── splashBackground.module.scss │ ├── Title │ │ ├── Title.tsx │ │ └── title.module.scss │ ├── Tooltip │ │ ├── Tooltip.tsx │ │ └── tooltip.module.scss │ └── User │ │ ├── Statistics │ │ ├── Categories.tsx │ │ ├── Distribution.tsx │ │ ├── UserStatistics.tsx │ │ ├── theme.ts │ │ └── userStatistics.module.scss │ │ ├── layout.ts │ │ └── layout │ │ ├── LayoutHeading.tsx │ │ ├── LayoutLoader.tsx │ │ ├── LayoutNavigation.tsx │ │ ├── layoutHeading.module.scss │ │ ├── layoutLoader.module.scss │ │ └── layoutNavigation.module.scss ├── config │ ├── config.ts │ ├── home.ts │ ├── json │ │ ├── regions.json │ │ └── regions.types.ts │ └── seasonalCapstones.ts ├── hooks │ ├── usePageTransition.ts │ └── useStaticData.ts ├── loader │ ├── _index.ts │ ├── challenge.layout.ts │ ├── challenge.ts │ ├── challengesFilter.ts │ ├── profile.layout.ts │ ├── root.ts │ └── titles.ts ├── root.tsx ├── routes.ts ├── routes │ ├── _index.tsx │ ├── challenges.$challengeId._index.tsx │ ├── challenges.$challengeId.tsx │ ├── challenges._index.tsx │ ├── profile.$profile._index.tsx │ ├── profile.$profile.statistics.tsx │ ├── profile.$profile.titles.tsx │ ├── profile.$profile.tsx │ └── titles.tsx ├── styles │ ├── _colors.scss │ ├── _config.scss │ ├── challenges.module.scss │ ├── home.module.scss │ ├── profile.layout.module.scss │ ├── titles.module.scss │ └── variables.module.scss └── utils │ ├── api.ts │ ├── capitalize.ts │ ├── cdn.ts │ ├── challengeSource.tsx │ ├── challenges.ts │ ├── champions.d.ts │ ├── debounce.ts │ ├── endpoints │ ├── getChallenges.ts │ ├── getLeaderboard.ts │ ├── getProfile.ts │ ├── getStaticData.ts │ ├── getVerified.ts │ └── types.d.ts │ ├── formatNumber.ts │ ├── getChallenge.ts │ ├── getParent.ts │ ├── getProximityScore.ts │ ├── getTier.ts │ ├── getTitle.ts │ ├── recents.ts │ ├── regionToString.ts │ ├── suffixToTier.ts │ └── tier.d.ts ├── package.json ├── public ├── favicon.ico └── no-js.css ├── react-router.config.ts ├── tests ├── capitalize.test.tsx ├── container.test.tsx ├── debounce.test.tsx ├── formatNumber.test.tsx ├── heading.test.tsx ├── proximityScore.test.tsx └── setup.ts ├── tsconfig.json └── vite.config.ts /.dockerignore: -------------------------------------------------------------------------------- 1 | .react-router 2 | build 3 | node_modules 4 | README.md -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Deployment 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - "*" 9 | 10 | concurrency: 11 | group: ${{ (github.ref_type == 'tag' && 'Production') || (github.ref_type == 'branch' && github.ref_name == 'main' && 'Staging') || ''}} 12 | cancel-in-progress: true 13 | 14 | jobs: 15 | testing: 16 | runs-on: ubuntu-latest 17 | 18 | strategy: 19 | matrix: 20 | node-version: [22.x] 21 | 22 | steps: 23 | - uses: actions/checkout@v4 24 | - name: Run test with Node.js ${{ matrix.node-version }} 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: ${{ matrix.node-version }} 28 | cache: "npm" 29 | - run: npm ci 30 | - run: npm run typecheck 31 | - run: npm run build --if-present 32 | - run: npm test 33 | 34 | deployment: 35 | needs: 36 | - testing 37 | runs-on: ubuntu-latest 38 | environment: ${{ (github.ref_type == 'tag' && 'Production') || (github.ref_type == 'branch' && github.ref_name == 'main' && 'Staging') || ''}} 39 | steps: 40 | - name: Checkout 41 | uses: actions/checkout@v4 42 | - name: Docker meta 43 | id: meta 44 | uses: docker/metadata-action@v5 45 | with: 46 | images: | 47 | darkintaqt/challenges 48 | tags: | 49 | type=raw,value=latest,enable=${{ github.ref_type == 'tag' }} 50 | type=raw,value=${{ github.ref_name }} 51 | type=raw,value=${{ github.sha }},enable=${{ github.ref_type == 'branch' }} 52 | flavor: | 53 | latest=false 54 | - name: Set up Docker Buildx 55 | uses: docker/setup-buildx-action@v3 56 | - name: Login to DockerHub 57 | uses: docker/login-action@v3 58 | with: 59 | username: ${{ secrets.DOCKER_USERNAME }} 60 | password: ${{ secrets.DOCKER_SECRET }} 61 | - name: Build and push 62 | uses: docker/build-push-action@v5 63 | with: 64 | context: . 65 | file: ./Dockerfile 66 | platforms: linux/amd64,linux/arm64 67 | push: true 68 | tags: ${{ steps.meta.outputs.tags }} 69 | labels: ${{ steps.meta.outputs.labels }} 70 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Testing 2 | 3 | on: 4 | pull_request: 5 | branches: ["main"] 6 | push: 7 | branches: ["main"] 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | node-version: [22.x] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | - name: Run test with Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | cache: "npm" 24 | - run: npm ci 25 | - run: npm run typecheck 26 | - run: npm run build --if-present 27 | - run: npm test 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /node_modules/ 3 | 4 | # React Router 5 | /.react-router/ 6 | /build/ 7 | 8 | /.idea/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | .react-router 2 | node_modules -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": true, 3 | "singleQuote": false, 4 | "tabWidth": 3, 5 | "bracketSpacing": true, 6 | "printWidth": 90, 7 | "endOfLine": "lf", 8 | "importOrder": [ 9 | "^./+types/(.*)$", 10 | "", 11 | "^(@cgg)/(.*)$", 12 | "^(utils)/(.*)$", 13 | "^components/(.*)$", 14 | "^[./]([^\\+]*)$" 15 | ], 16 | "importOrderSeparation": false, 17 | "importOrderSortSpecifiers": true, 18 | "plugins": [ 19 | "@trivago/prettier-plugin-sort-imports" 20 | ] 21 | } 22 | -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": ["esbenp.prettier-vscode", "sibiraj-s.vscode-scss-formatter"] 3 | } 4 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[json]": { 3 | "editor.defaultFormatter": "esbenp.prettier-vscode" 4 | }, 5 | "[javascript]": { 6 | "editor.defaultFormatter": "esbenp.prettier-vscode" 7 | }, 8 | "[typescript]": { 9 | "editor.defaultFormatter": "esbenp.prettier-vscode" 10 | }, 11 | "[css]": { 12 | "editor.defaultFormatter": "esbenp.prettier-vscode" 13 | }, 14 | "[scss]": { 15 | "editor.defaultFormatter": "sibiraj-s.vscode-scss-formatter" 16 | }, 17 | "[typescriptreact]": { 18 | "editor.defaultFormatter": "esbenp.prettier-vscode" 19 | }, 20 | "prettier.configPath": "", 21 | "editor.defaultFormatter": "esbenp.prettier-vscode", 22 | "scss.implicitlyLabel": "(imported)" 23 | } 24 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:24-alpine AS development-dependencies-env 2 | COPY . /app 3 | WORKDIR /app 4 | RUN npm ci 5 | 6 | FROM node:24-alpine AS production-dependencies-env 7 | COPY ./package.json package-lock.json /app/ 8 | WORKDIR /app 9 | RUN npm ci --omit=dev 10 | 11 | FROM node:24-alpine AS build-env 12 | COPY . /app/ 13 | COPY --from=development-dependencies-env /app/node_modules /app/node_modules 14 | WORKDIR /app 15 | RUN npm run build 16 | 17 | FROM node:24-alpine 18 | COPY ./package.json package-lock.json /app/ 19 | COPY --from=production-dependencies-env /app/node_modules /app/node_modules 20 | COPY --from=build-env /app/build /app/build 21 | WORKDIR /app 22 | 23 | EXPOSE 3000 24 | 25 | CMD ["npm", "run", "start"] -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 DarkIntaqt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Challenges.GG Frontend 2 | 3 | [![Testing](https://github.com/DarkIntaqt/challenges/actions/workflows/test.yml/badge.svg)](https://github.com/DarkIntaqt/challenges/actions/workflows/test.yml) -------------------------------------------------------------------------------- /app/app.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | @use "@fontsource/roboto/400.css" as c1; 5 | @use "@fontsource/roboto/700.css" as c2; 6 | 7 | @use "@fontsource/istok-web/700.css" as c3; 8 | 9 | * { 10 | margin: 0; 11 | padding: 0; 12 | box-sizing: border-box; 13 | } 14 | 15 | a { 16 | text-decoration: none; 17 | color: inherit; 18 | } 19 | 20 | html { 21 | font-size: 14px; 22 | } 23 | 24 | body { 25 | background-color: color.$background; 26 | 27 | font-family: "Roboto", sans-serif; 28 | font-size: config.$font-default; 29 | color: color.$text; 30 | } 31 | -------------------------------------------------------------------------------- /app/assets/clash.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /app/assets/eternals.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | -------------------------------------------------------------------------------- /app/assets/logo-bg.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | 9 | -------------------------------------------------------------------------------- /app/assets/logo-slogan.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 11 | 14 | -------------------------------------------------------------------------------- /app/assets/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | -------------------------------------------------------------------------------- /app/components/Buttons/Buttons.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import type { Dispatch, ReactNode, SetStateAction } from "react"; 3 | import css from "./buttons.module.scss"; 4 | 5 | export interface IButtons { 6 | name: string | ReactNode; 7 | id: T; 8 | } 9 | 10 | export default function Buttons({ 11 | buttons, 12 | state, 13 | setState, 14 | column, 15 | }: { 16 | buttons: (IButtons | null | undefined)[]; 17 | state: T[]; 18 | setState: Dispatch>; 19 | column?: boolean; 20 | }) { 21 | function toggle(item: T) { 22 | setState((prevState) => 23 | prevState.includes(item) 24 | ? prevState.filter((value) => item !== value) 25 | : [...prevState, item], 26 | ); 27 | } 28 | 29 | return ( 30 |
31 | {buttons 32 | .filter((x) => x !== null && x !== undefined) 33 | .map((button, i) => { 34 | return ( 35 | 45 | ); 46 | })} 47 |
48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /app/components/Buttons/Radio.tsx: -------------------------------------------------------------------------------- 1 | import { type Dispatch, type SetStateAction, useEffect, useState } from "react"; 2 | import type { IButtons } from "./Buttons"; 3 | import Buttons from "./Buttons"; 4 | 5 | export default function Radio({ 6 | values, 7 | state, 8 | setState, 9 | column, 10 | }: { 11 | values: (IButtons | null | undefined)[]; 12 | state: T; 13 | setState: Dispatch>; 14 | column?: boolean; 15 | }) { 16 | return ( 17 | 18 | buttons={values} 19 | state={[state]} 20 | setState={(val) => { 21 | const nextValue = typeof val === "function" ? val([state]) : val; 22 | 23 | if (nextValue.length === 0) { 24 | return; 25 | } 26 | 27 | const newValue = nextValue.filter((x) => x !== state)[0] || state; 28 | setState(newValue); 29 | }} 30 | column={column} 31 | /> 32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /app/components/Buttons/buttons.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/colors" as color; 2 | @use "@cgg/styles/config" as config; 3 | 4 | .buttons { 5 | display: flex; 6 | gap: 0.5rem; 7 | 8 | .button { 9 | border: 1px solid color.$border; 10 | background: color.$box; 11 | border-radius: config.$borderRadius; 12 | padding: 0.5rem 1rem; 13 | 14 | color: color.$text; 15 | cursor: pointer; 16 | font-size: config.$font-default; 17 | 18 | &.enabled { 19 | background-color: color.$primary; 20 | color: color.$heading; 21 | border-color: color.$primary; 22 | // it seems to look better without box-shadow 23 | // box-shadow: 0 0 5px color.$primary; 24 | } 25 | } 26 | 27 | &.column { 28 | flex-direction: column; 29 | 30 | .button { 31 | text-align: left; 32 | transition: 0.25s padding-left; 33 | 34 | &.enabled { 35 | background-color: color.$accent; 36 | padding-left: 1.5rem; 37 | } 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /app/components/ChallengeManager/Challenge.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { Link } from "react-router"; 3 | import { seasonalCapstones } from "@cgg/config/seasonalCapstones"; 4 | import { useStaticData } from "@cgg/hooks/useStaticData"; 5 | import variables from "@cgg/styles/variables.module.scss"; 6 | import { capitalize } from "@cgg/utils/capitalize"; 7 | import { getChallengeIcon, getGamemodeIcon } from "@cgg/utils/cdn"; 8 | import { getChallengeSourceIcon } from "@cgg/utils/challengeSource"; 9 | import type { IChallengeDTO } from "@cgg/utils/challenges"; 10 | import type { IApiChallenge } from "@cgg/utils/endpoints/types"; 11 | import { formatNumber } from "@cgg/utils/formatNumber"; 12 | import { getChallenge } from "@cgg/utils/getChallenge"; 13 | import { getParents } from "@cgg/utils/getParent"; 14 | import { getNextTier, getPosition, getTier } from "@cgg/utils/getTier"; 15 | import Heading from "../Heading/Heading"; 16 | import ProgressBar from "../ProgressBar/ProgressBar"; 17 | import css from "./challenge.module.scss"; 18 | 19 | export default function Challenge({ 20 | challenge, 21 | user, 22 | }: { 23 | challenge: IChallengeDTO; 24 | user?: IApiChallenge; 25 | }) { 26 | const data = useStaticData(); 27 | const tier = getTier(challenge, user); 28 | const parents = getParents(challenge, data.challenges); 29 | 30 | const isUser = typeof user !== "undefined"; 31 | const nextTier = getNextTier(tier, challenge, true); 32 | let nextValue = challenge.thresholds[nextTier].points; 33 | let currentValue = user?.value; 34 | 35 | // Get the category of the challenge 36 | const category = parents[parents.length - 1]; 37 | const gameMode = challenge.gameMode; 38 | const source = challenge.source; 39 | const isSeasonal = category === -1 && seasonalCapstones.includes(parents[0]); 40 | 41 | const isRetired = challenge.retired; 42 | const position = isUser ? getPosition(user, challenge) : 0; 43 | 44 | return ( 45 | 54 | {challenge.name} 60 |
61 | 62 | {challenge.name} 63 | 64 | 65 |

66 | {!isUser && 67 | parents 68 | .map((parentId) => { 69 | if (parentId === -1) return "Legacy"; 70 | if (parentId < 10 && parents.length > 1) return null; 71 | return getChallenge(parentId, data)?.name; 72 | }) 73 | .filter((n) => n !== null) 74 | .join(" > ")} 75 | {isUser && ( 76 | <> 77 | {/* Position is disabled until further notice */} 78 | {position > 0 && `#${formatNumber(position)} - `} 79 | {user.percentile !== undefined && 80 | `Top ${(user.percentile * 100).toFixed(1)}% - `} 81 | {user.percentile === undefined && 82 | user.tier === "UNRANKED" && 83 | "Top 100.0% - "} 84 | {formatNumber(currentValue ?? 0, "M")} /{" "} 85 | {formatNumber(nextValue, "M")} 86 | 87 | )} 88 |

89 |
90 |
91 | {isSeasonal && ( 92 | 93 | '{challenge.id.toString().charAt(2) + challenge.id.toString().charAt(3)} 94 | 95 | )} 96 | {category && !isSeasonal && ( 97 | 102 | )} 103 | {gameMode !== "none" && ( 104 | 109 | )} 110 | {source !== "EOGD" && getChallengeSourceIcon(source)} 111 |
112 |

{challenge.descriptionShort}

113 | 114 | {isUser &&

{capitalize(tier)}

} 115 | 116 | {isUser && ( 117 | 123 | )} 124 | 125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /app/components/ChallengeManager/challenge.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .challenge { 5 | // $darkTier is used for e.g. the borders 6 | $darkTier: color-mix(in hsl, var(--tier), black 35%); 7 | 8 | width: 100%; 9 | height: 60px; 10 | overflow: hidden; 11 | position: relative; 12 | 13 | background-color: color.$background; 14 | padding: 5px 10px; 15 | 16 | border: 1px solid $darkTier; 17 | border-radius: config.$borderRadius; 18 | 19 | transition: 0.25s background-color; 20 | 21 | &:hover { 22 | background-color: color.$accent; 23 | } 24 | 25 | display: grid; 26 | grid-template-columns: 40px 2fr 20px 3fr; 27 | gap: 1rem; 28 | align-items: center; 29 | 30 | img.icon { 31 | width: 40px; 32 | height: 40px; 33 | 34 | aspect-ratio: 1/1; 35 | object-fit: contain; 36 | object-position: center; 37 | } 38 | 39 | .text { 40 | width: 100%; 41 | position: relative; 42 | overflow: hidden; 43 | 44 | h3.title { 45 | width: 100%; 46 | font-size: config.$font-default; 47 | font-weight: normal; 48 | 49 | white-space: nowrap; 50 | overflow: hidden; 51 | text-overflow: ellipsis; 52 | } 53 | 54 | p { 55 | width: 100%; 56 | font-size: config.$font-small; 57 | 58 | white-space: nowrap; 59 | overflow: hidden; 60 | text-overflow: ellipsis; 61 | } 62 | } 63 | 64 | .icons { 65 | display: flex; 66 | flex-direction: column; 67 | align-items: center; 68 | justify-content: center; 69 | gap: 3px; 70 | 71 | filter: brightness(0.75); 72 | 73 | img { 74 | width: 100%; 75 | } 76 | 77 | span { 78 | color: white; 79 | font-size: config.$font-small; 80 | } 81 | 82 | > :nth-child(n + 3) { 83 | display: none; 84 | } 85 | } 86 | 87 | p.description { 88 | font-size: config.$font-small; 89 | line-height: config.$font-small * 1.25; 90 | $line-clamp: 3; 91 | 92 | line-clamp: $line-clamp; 93 | -webkit-line-clamp: $line-clamp; 94 | -webkit-box-orient: vertical; 95 | 96 | overflow: hidden; 97 | text-overflow: ellipsis; 98 | width: 100%; 99 | 100 | display: -webkit-box; 101 | } 102 | 103 | p.tier { 104 | text-align: center; 105 | color: var(--tier); 106 | font-size: config.$font-small; 107 | } 108 | 109 | &.retired { 110 | border-color: color.$border; 111 | 112 | img.icon { 113 | filter: grayscale(0.75) brightness(0.75); 114 | } 115 | 116 | .text { 117 | h3.title, 118 | p { 119 | color: color.$secondary; 120 | } 121 | } 122 | 123 | .icons { 124 | filter: brightness(0.5); 125 | } 126 | 127 | p.description { 128 | color: color.$secondary; 129 | } 130 | 131 | .progress { 132 | filter: brightness(0.75); 133 | } 134 | } 135 | 136 | &.user { 137 | grid-template-columns: 40px 2fr 20px 3fr 1fr; 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /app/components/ChallengeManager/challengeManager.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .grid { 5 | padding: 1rem 0; 6 | display: grid; 7 | 8 | grid-template-columns: 2fr 7fr; 9 | 10 | gap: 1rem; 11 | 12 | .filters { 13 | position: sticky; 14 | top: config.$header-height + 10px; 15 | 16 | height: calc(100vh - (config.$header-height + 10px)); 17 | // overflow-x: scroll; 18 | overflow-y: hidden; 19 | padding-bottom: 15px; 20 | width: 100%; 21 | 22 | .innerScrollbar { 23 | display: flex; 24 | flex-direction: column; 25 | gap: 5px; 26 | 27 | .options { 28 | width: 100%; 29 | height: 53px; 30 | } 31 | 32 | .group { 33 | // background-color: color.$box; 34 | padding: 8px; 35 | border-radius: config.$borderRadius; 36 | 37 | .heading { 38 | color: color.$heading; 39 | padding-bottom: 5px; 40 | } 41 | 42 | div.buttonText { 43 | display: grid; 44 | grid-template-columns: 18px auto; 45 | font-size: config.$font-default; 46 | align-items: center; 47 | 48 | gap: 5px; 49 | 50 | img, 51 | svg { 52 | width: 100%; 53 | aspect-ratio: 1/1; 54 | object-fit: contain; 55 | } 56 | } 57 | } 58 | 59 | p.disclaimer { 60 | padding: 5px; 61 | font-size: config.$font-small; 62 | color: color.$secondary; 63 | 64 | a { 65 | text-decoration: underline; 66 | } 67 | } 68 | } 69 | } 70 | 71 | .challenges { 72 | .searchbar { 73 | width: 100%; 74 | backdrop-filter: blur(5px); 75 | position: sticky; 76 | padding-top: 10px; 77 | top: config.$header-height; 78 | 79 | z-index: 5; 80 | } 81 | 82 | .challengeList { 83 | padding: 10px; 84 | display: flex; 85 | flex-direction: column; 86 | gap: 5px; 87 | 88 | .noResults { 89 | display: flex; 90 | align-items: center; 91 | justify-content: center; 92 | flex-direction: column; 93 | 94 | margin: 120px auto; 95 | width: 50%; 96 | text-align: center; 97 | 98 | img { 99 | width: 50%; 100 | aspect-ratio: 1/1; 101 | } 102 | } 103 | } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /app/components/ChallengeManager/filterChallenges.ts: -------------------------------------------------------------------------------- 1 | import type { Category, GameMode, IChallengeDTO, Source } from "@cgg/utils/challenges"; 2 | 3 | export interface IChallengeFilter { 4 | search: string; 5 | category: Category[]; 6 | source: Source[]; 7 | gameMode: GameMode[]; 8 | } 9 | 10 | export function filterChallenges( 11 | challenges: IChallengeDTO[], 12 | filter: IChallengeFilter, 13 | ): IChallengeDTO[] { 14 | return challenges.filter((challenge) => { 15 | const { search, category, source, gameMode } = filter; 16 | 17 | if (search.length > 0) { 18 | let found = false; 19 | // checks whether all results are excluded by default 20 | let purelyExclude = true; 21 | // checks whether a result was excluded, it can not be included again 22 | let breakAll = false; 23 | 24 | const split = search.split(","); 25 | for (let i = 0; i < split.length; i++) { 26 | // whether to include or exclude search results 27 | let mode: "include" | "exclude" = "include"; 28 | let term = split[i].trim().toLowerCase(); 29 | 30 | if (term.startsWith("-")) { 31 | mode = "exclude"; 32 | term = term.slice(1).trim(); 33 | } 34 | 35 | if (mode === "include" && term.length > 0) { 36 | purelyExclude = false; 37 | } 38 | 39 | if ( 40 | term !== "" && 41 | (challenge.name.toLowerCase().search(term) >= 0 || 42 | challenge.id.toString() == term || 43 | challenge.description.toLowerCase().search(term) >= 0 || 44 | challenge.descriptionShort.toLowerCase().search(term) >= 0) 45 | ) { 46 | if (mode === "exclude") { 47 | breakAll = true; 48 | } 49 | found = true; 50 | } 51 | } 52 | 53 | if ((!found && !purelyExclude) || breakAll) return false; 54 | } 55 | 56 | if (category.length > 0) { 57 | let found = false; 58 | for (let i = 0; i < category.length; i++) { 59 | if (challenge.categoryId === category[i]) { 60 | found = true; 61 | break; 62 | } 63 | } 64 | if (!found) return false; 65 | } 66 | 67 | if (gameMode.length > 0) { 68 | let found = false; 69 | for (let i = 0; i < gameMode.length; i++) { 70 | if (challenge.gameMode === gameMode[i]) { 71 | found = true; 72 | break; 73 | } 74 | } 75 | if (!found) return false; 76 | } 77 | 78 | if (source.length > 0) { 79 | let found = false; 80 | for (let i = 0; i < source.length; i++) { 81 | if (challenge.source === source[i]) { 82 | found = true; 83 | break; 84 | } 85 | } 86 | if (!found) return false; 87 | } 88 | 89 | return true; 90 | }); 91 | } 92 | -------------------------------------------------------------------------------- /app/components/ChallengeManager/sortChallenges.ts: -------------------------------------------------------------------------------- 1 | import type { IChallengeDTO } from "@cgg/utils/challenges"; 2 | import type { IApiChallenge } from "@cgg/utils/endpoints/types"; 3 | import { getNextTier, getTierIndex } from "@cgg/utils/getTier"; 4 | 5 | export type SortMode = 6 | | "Name-ASC" 7 | | "Name-DESC" 8 | | "Rank" 9 | | "Last Updated" 10 | | "Position" 11 | | "Levelup"; 12 | 13 | // TODO: Maps would probably be more efficient here 14 | export function sortChallenges( 15 | challenges: IChallengeDTO[], 16 | mode: SortMode, 17 | userChallenges?: IApiChallenge[], 18 | ): IChallengeDTO[] { 19 | switch (mode) { 20 | case "Name-ASC": 21 | challenges = challenges.sort((a, b) => { 22 | if (a.retired && !b.retired) return 1; 23 | if (!a.retired && b.retired) return -1; 24 | 25 | return a.name.localeCompare(b.name); 26 | }); 27 | break; 28 | case "Name-DESC": 29 | challenges = challenges.sort((a, b) => { 30 | if (a.retired && !b.retired) return 1; 31 | if (!a.retired && b.retired) return -1; 32 | 33 | return b.name.localeCompare(a.name); 34 | }); 35 | break; 36 | 37 | case "Rank": 38 | // When this filter - for whatever reason - gets selected on a non-user page 39 | if (!userChallenges) { 40 | return sortChallenges(challenges, "Name-ASC"); 41 | } 42 | challenges = challenges.sort((a, b) => { 43 | if (a.retired && !b.retired) return 1; 44 | if (!a.retired && b.retired) return -1; 45 | 46 | const userChallengeA = userChallenges.find((uc) => uc.challengeId === a.id); 47 | const userChallengeB = userChallenges.find((uc) => uc.challengeId === b.id); 48 | const tierA = getTierIndex(userChallengeA?.tier); 49 | const tierB = getTierIndex(userChallengeB?.tier); 50 | 51 | // sort by tier first, then by percentile 52 | if (tierA !== tierB) return tierB - tierA; 53 | 54 | return (userChallengeA?.percentile || 0) - (userChallengeB?.percentile || 0); 55 | }); 56 | break; 57 | 58 | case "Last Updated": 59 | // When this filter - for whatever reason - gets selected on a non-user page 60 | if (!userChallenges) { 61 | return sortChallenges(challenges, "Name-ASC"); 62 | } 63 | challenges = challenges.sort((a, b) => { 64 | if (a.retired && !b.retired) return 1; 65 | if (!a.retired && b.retired) return -1; 66 | 67 | const userChallengeA = userChallenges.find((uc) => uc.challengeId === a.id); 68 | const userChallengeB = userChallenges.find((uc) => uc.challengeId === b.id); 69 | 70 | if (userChallengeA && userChallengeB) { 71 | return ( 72 | new Date(userChallengeB.achievedTime || 0).getTime() - 73 | new Date(userChallengeA.achievedTime || 0).getTime() 74 | ); 75 | } 76 | 77 | if (userChallengeA && !userChallengeB) return -1; 78 | if (!userChallengeA && userChallengeB) return 1; 79 | return 0; 80 | }); 81 | break; 82 | 83 | case "Position": 84 | // When this filter - for whatever reason - gets selected on a non-user page 85 | if (!userChallenges) { 86 | return sortChallenges(challenges, "Name-ASC"); 87 | } 88 | challenges = challenges.sort((a, b) => { 89 | if (a.retired && !b.retired) return 1; 90 | if (!a.retired && b.retired) return -1; 91 | 92 | const userChallengeA = userChallenges.find((uc) => uc.challengeId === a.id); 93 | const userChallengeB = userChallenges.find((uc) => uc.challengeId === b.id); 94 | 95 | if (userChallengeA && userChallengeB) { 96 | const positionA = userChallengeA.position || 0; 97 | const positionB = userChallengeB.position || 0; 98 | 99 | // A validation check with <= 0 is necessary, as it is not uncommon for the API to return negative positions 100 | if (positionA > 0 && positionB <= 0) return -1; 101 | if (positionA <= 0 && positionB > 0) return 1; 102 | if (positionA <= 0 && positionB <= 0) return 0; 103 | return (positionA as number) - (positionB as number); 104 | } 105 | 106 | if (userChallengeA && !userChallengeB) return -1; 107 | if (!userChallengeA && userChallengeB) return 1; 108 | return 0; 109 | }); 110 | 111 | break; 112 | 113 | case "Levelup": 114 | // When this filter - for whatever reason - gets selected on a non-user page 115 | if (!userChallenges) { 116 | return sortChallenges(challenges, "Name-ASC"); 117 | } 118 | challenges = challenges.sort((a, b) => { 119 | if (a.retired && !b.retired) return 1; 120 | if (!a.retired && b.retired) return -1; 121 | 122 | const userChallengeA = userChallenges.find((uc) => uc.challengeId === a.id); 123 | const userChallengeB = userChallenges.find((uc) => uc.challengeId === b.id); 124 | if (userChallengeA && !userChallengeB) return -1; 125 | if (!userChallengeA && userChallengeB) return 1; 126 | if (!userChallengeA && !userChallengeB) return 0; 127 | 128 | const currentValueA = userChallengeA!.value; 129 | const nextValueA = 130 | a.thresholds[getNextTier(userChallengeA!.tier, a, false)].points; 131 | const currentValueB = userChallengeB!.value; 132 | const nextValueB = 133 | b.thresholds[getNextTier(userChallengeB!.tier, b, false)].points; 134 | 135 | if (nextValueA === 0 && nextValueB === 0) return 0; 136 | if (nextValueA === 0) return -1; 137 | if (nextValueB === 0) return 1; 138 | 139 | const progressA = Math.min(currentValueA / nextValueA, 1); 140 | const progressB = Math.min(currentValueB / nextValueB, 1); 141 | 142 | // Already "maxed challenges" (progress >= 1) can be put at the back of the list 143 | if (progressA >= 1 && progressA === progressB) return 0; 144 | if (progressA >= 1 && progressB < 1) return 1; 145 | if (progressA < 1 && progressB >= 1) return -1; 146 | 147 | return progressB - progressA; 148 | }); 149 | 150 | break; 151 | default: 152 | break; 153 | } 154 | 155 | return challenges; 156 | } 157 | -------------------------------------------------------------------------------- /app/components/Container/Container.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import css from "./container.module.scss"; 3 | 4 | export default function Container({ 5 | className = "", 6 | children, 7 | large = false, 8 | small = false, 9 | center = false, 10 | flex = false, 11 | justify = false, 12 | fullHeight = false, 13 | fullWidth = false, 14 | column = false, 15 | headerPadding = false, 16 | }: { 17 | className?: string; 18 | children?: React.ReactNode; 19 | large?: boolean; 20 | small?: boolean; 21 | center?: boolean; 22 | flex?: boolean; 23 | justify?: boolean; 24 | fullHeight?: boolean; 25 | fullWidth?: boolean; 26 | column?: boolean; 27 | headerPadding?: boolean; 28 | }) { 29 | if (large && small) { 30 | throw new Error("Container cannot be both large and small at the same time."); 31 | } 32 | 33 | return ( 34 |
49 | {children} 50 |
51 | ); 52 | } 53 | -------------------------------------------------------------------------------- /app/components/Container/container.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | 3 | .container { 4 | position: relative; 5 | width: config.$default-container; 6 | 7 | &.center { 8 | margin: 0 auto; 9 | } 10 | 11 | &:not(.small):not(.large) { 12 | @media only screen and (max-width: config.$default-container) { 13 | width: 100%; 14 | } 15 | } 16 | 17 | &.small { 18 | width: config.$small-container; 19 | @media only screen and (max-width: config.$small-container) { 20 | width: 100%; 21 | } 22 | } 23 | 24 | &.large { 25 | width: config.$large-container; 26 | @media only screen and (max-width: config.$large-container) { 27 | width: 100%; 28 | } 29 | } 30 | 31 | &.flex { 32 | display: flex; 33 | } 34 | 35 | &.flex.justify { 36 | align-items: center; 37 | justify-content: center; 38 | } 39 | 40 | &.flex.column { 41 | flex-direction: column; 42 | } 43 | 44 | &.fullHeight { 45 | min-height: 100vh; 46 | } 47 | 48 | &.fullWidth { 49 | min-width: 100%; 50 | } 51 | 52 | &.headerPadding { 53 | padding-top: config.$header-height; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /app/components/ErrorWrapper/ErrorWrapper.tsx: -------------------------------------------------------------------------------- 1 | import Container from "@cgg/components/Container/Container"; 2 | import Heading from "@cgg/components/Heading/Heading"; 3 | import { cdnAssets } from "@cgg/utils/cdn"; 4 | import css from "./errorWrapper.module.scss"; 5 | 6 | export default function ErrorWrapper({ 7 | message, 8 | details, 9 | stack, 10 | }: { 11 | message: string; 12 | details: string; 13 | stack?: string; 14 | }) { 15 | return ( 16 | 17 | {/* on the left */} 18 |
19 |

So, uhm... There was an error...:

20 | {message} 21 |

{details}

22 | {stack &&
{stack}
} 23 |
24 | 25 | {/* on the right */} 26 | 27 |
28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /app/components/ErrorWrapper/errorWrapper.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .wrapper { 5 | display: grid; 6 | grid-template-columns: 1fr 200px; 7 | min-height: 100vh; 8 | align-items: center; 9 | 10 | .content { 11 | display: flex; 12 | flex-direction: column; 13 | justify-content: center; 14 | 15 | .intro { 16 | padding-bottom: 1rem; 17 | } 18 | 19 | .details { 20 | color: color.$heading; 21 | font-size: config.$font-large; 22 | } 23 | } 24 | img { 25 | width: 200px; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /app/components/Heading/Heading.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { createElement } from "react"; 3 | import css from "./heading.module.scss"; 4 | 5 | export default function Heading({ 6 | level = 1, 7 | children, 8 | className = "", 9 | }: { 10 | level?: 1 | 2 | 3 | 4 | 5 | 6; 11 | children: React.ReactNode; 12 | className?: string; 13 | }) { 14 | return createElement( 15 | "h" + level, 16 | { className: clsx(css.heading, className) }, 17 | children, 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /app/components/Heading/heading.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .heading { 5 | color: color.$heading; 6 | } 7 | 8 | h1.heading { 9 | font-size: config.$font-heading; 10 | line-height: config.$font-heading * 1.25; 11 | font-family: "Istok Web"; 12 | } 13 | 14 | h2.heading { 15 | font-size: config.$font-large; 16 | line-height: config.$font-large * 1.25; 17 | } 18 | 19 | h3.heading { 20 | font-size: config.$font-large; 21 | line-height: config.$font-large * 1.25; 22 | } 23 | -------------------------------------------------------------------------------- /app/components/HomeSearch/SearchResults.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { useEffect, useState } from "react"; 3 | import { Link, useNavigate } from "react-router"; 4 | import { getChallengeIcon, getProfileIcon } from "@cgg/utils/cdn"; 5 | import type { RecentChallenge, RecentSummoner } from "@cgg/utils/recents"; 6 | import css from "./searchresults.module.scss"; 7 | 8 | export interface SearchResults { 9 | recent: SearchResult[]; 10 | challenges: SearchResultChallenge[]; 11 | summoner: SearchResultSummoner[]; 12 | } 13 | 14 | export type SearchResultChallenge = RecentChallenge; 15 | export interface SearchResultSummoner extends RecentSummoner { 16 | draft?: boolean; 17 | loading?: boolean; 18 | } 19 | 20 | export type SearchResult = SearchResultChallenge | SearchResultSummoner; 21 | 22 | export default function SearchResults({ 23 | results, 24 | visible, 25 | }: { 26 | results: SearchResults; 27 | visible: boolean; 28 | }) { 29 | const [pointer, setPointer] = useState(0); 30 | const navigate = useNavigate(); 31 | const showResults = 32 | results.recent.length > 0 || 33 | results.challenges.length > 0 || 34 | results.summoner.length > 0; 35 | 36 | /* 37 | * Listen to key events to change result selection 38 | */ 39 | useEffect(() => { 40 | // Click the selected result when Enter is pressed 41 | function handleSubmit() { 42 | if (pointer !== 0) { 43 | const result = document.querySelectorAll(`.${css.result}`)[pointer - 1] as 44 | | HTMLAnchorElement 45 | | undefined; 46 | if (!result) return; 47 | 48 | result.click(); 49 | } else { 50 | const searchResult = results.summoner.find((r) => r.draft); 51 | 52 | if (searchResult) { 53 | navigate(`/profile/${searchResult.name}-${searchResult.tagLine}`); 54 | } 55 | } 56 | } 57 | 58 | // Listen to key events to change result selection 59 | function listenToKeyEvents(e: KeyboardEvent) { 60 | if (e.key !== "ArrowDown" && e.key !== "ArrowUp" && e.key !== "Enter") return; 61 | if (!showResults || !visible) return; 62 | 63 | e.preventDefault(); 64 | if (e.key === "ArrowDown") { 65 | setPointer((prev) => { 66 | if ( 67 | prev < 68 | results.recent.length + 69 | results.challenges.length + 70 | results.summoner.length 71 | ) { 72 | return prev + 1; 73 | } 74 | return prev; 75 | }); 76 | return; 77 | } 78 | 79 | if (e.key === "ArrowUp") { 80 | setPointer((prev) => { 81 | if (prev > 0) { 82 | return prev - 1; 83 | } 84 | return prev; 85 | }); 86 | return; 87 | } 88 | 89 | handleSubmit(); 90 | } 91 | 92 | document.addEventListener("keydown", listenToKeyEvents); 93 | 94 | // Reset pointer when pointer points into emptiness :/ 95 | const resultLength = Object.values(results).flat().length; 96 | if (pointer > resultLength) { 97 | setPointer(0); 98 | } 99 | 100 | return () => { 101 | document.removeEventListener("keydown", listenToKeyEvents); 102 | }; 103 | }, [results, visible, showResults, pointer]); 104 | 105 | return ( 106 |
107 | {results.recent.length > 0 && ( 108 |
109 | Recent Pages 110 | {results.recent.map((result, i) => ( 111 | 112 | ))} 113 |
114 | )} 115 | {results.challenges.length > 0 && ( 116 |
117 | Challenges 118 | {results.challenges.map((result, i) => ( 119 | 124 | ))} 125 |
126 | )} 127 | {results.summoner.length > 0 && ( 128 |
129 | Summoner 130 | {results.summoner.map((result, i) => ( 131 | 139 | ))} 140 |
141 | )} 142 |
143 | ); 144 | } 145 | 146 | function Result({ result, active }: { result: SearchResult; active: boolean }) { 147 | const isSummoner = result.type === "summoner"; 148 | const isLoading = isSummoner && result.loading; 149 | 150 | const icon = isSummoner 151 | ? getProfileIcon(result.icon) 152 | : getChallengeIcon(result.iconId, "MASTER"); 153 | 154 | const link = isSummoner 155 | ? `/profile/${result.name}-${result.tagLine}` 156 | : `/challenges/${result.id}`; 157 | 158 | return ( 159 | 160 | {isLoading &&
} 161 | {result.name} 162 |

163 | {result.name} 164 | {isSummoner && {`#${result.tagLine}`}} 165 |

166 | 167 | ); 168 | } 169 | -------------------------------------------------------------------------------- /app/components/HomeSearch/Searchbar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { nanoid } from "nanoid"; 3 | import { useEffect, useMemo, useState } from "react"; 4 | import { FaSearch } from "react-icons/fa"; 5 | import { useStaticData } from "@cgg/hooks/useStaticData"; 6 | import debounce from "@cgg/utils/debounce"; 7 | import { getProfile } from "@cgg/utils/endpoints/getProfile"; 8 | import { getProximityScore } from "@cgg/utils/getProximityScore"; 9 | import { type Recent, type RecentChallenge, getRecentSearches } from "@cgg/utils/recents"; 10 | import SearchResults, { 11 | type SearchResult, 12 | type SearchResultSummoner, 13 | } from "./SearchResults"; 14 | import css from "./searchbar.module.scss"; 15 | 16 | type ChallengeIndex = { 17 | id: number; 18 | iconId: number; 19 | name: string; 20 | }[]; 21 | 22 | export default function Searchbar() { 23 | const [focus, setFocus] = useState(false); 24 | const [search, setSearch] = useState(""); 25 | const [summonerSearchResult, setSummonerSearchResult] = useState< 26 | SearchResultSummoner[] 27 | >([]); 28 | 29 | const [recentSearches, setRecentSearched] = useState([]); 30 | 31 | const staticData = useStaticData(); 32 | const challengeIndex: ChallengeIndex = useMemo(() => { 33 | return Object.values(staticData.challenges) 34 | .map((challenge) => { 35 | if (challenge.retired) return; 36 | 37 | return { 38 | id: challenge.id, 39 | iconId: challenge.iconId, 40 | name: challenge.name, 41 | }; 42 | }) 43 | .filter((x) => x !== undefined); 44 | }, [staticData.challenges]); 45 | 46 | const debounceInput = useMemo(() => { 47 | return debounce(() => { 48 | setFocus((prev) => { 49 | const searchbar = document.getElementById(css.input); 50 | if (!searchbar) { 51 | return prev; 52 | } 53 | 54 | if (document.activeElement === searchbar) { 55 | return true; 56 | } 57 | 58 | return false; 59 | }); 60 | }, 350); 61 | }, []); 62 | 63 | const debounceSummonerLookup = useMemo(() => { 64 | return debounce( 65 | async ({ 66 | gameName, 67 | tagLine, 68 | id, 69 | }: { 70 | gameName: string; 71 | tagLine: string; 72 | id: string; 73 | }) => { 74 | const profile = await getProfile(gameName, tagLine); 75 | 76 | setSummonerSearchResult((prev) => { 77 | // Check if the search changed during the API request 78 | if (prev.some((r) => r.type === "summoner" && r.id === id)) { 79 | if (!profile) 80 | return [ 81 | { 82 | type: "summoner", 83 | id: id, 84 | name: gameName, 85 | tagLine: tagLine || "", 86 | icon: 29, 87 | draft: true, 88 | loading: false, 89 | }, 90 | ]; 91 | 92 | const response = { 93 | type: "summoner", 94 | id: id, 95 | name: profile.gameName, 96 | tagLine: profile.tagLine, 97 | icon: profile.summoner.profileIcon, 98 | draft: true, 99 | } as SearchResultSummoner; 100 | 101 | return [response]; 102 | } 103 | 104 | // If it did, keep previous results 105 | return [...prev]; 106 | }); 107 | }, 108 | 350, 109 | ); 110 | }, []); 111 | 112 | useEffect(() => { 113 | setRecentSearched(getRecentSearches()); 114 | }, []); 115 | 116 | function updateSearch(e: React.ChangeEvent) { 117 | const value = e.target.value; 118 | setSearch(value); 119 | if (value.length <= 0) { 120 | setSummonerSearchResult([]); 121 | return; 122 | } 123 | const [gameName, tagLine] = value.split("#"); 124 | const id = nanoid(); 125 | const summonerSearch: SearchResultSummoner[] = [ 126 | { 127 | type: "summoner", 128 | id: id, 129 | name: gameName, 130 | tagLine: tagLine || "", 131 | icon: 29, 132 | draft: true, 133 | loading: !!tagLine, 134 | }, 135 | ]; 136 | 137 | setSummonerSearchResult(summonerSearch); 138 | if (!tagLine) return; 139 | debounceSummonerLookup({ gameName, tagLine: tagLine, id }); 140 | } 141 | 142 | useEffect(() => { 143 | const input = document.getElementById(css.input) as HTMLInputElement | null; 144 | if (!input) return; 145 | input.focus(); 146 | }, []); 147 | 148 | return ( 149 | <> 150 |
151 | setFocus(true)} 156 | onBlur={() => debounceInput()} 157 | onInput={updateSearch} 158 | onKeyDown={(e) => { 159 | if (e.key === "Escape") { 160 | e.preventDefault(); 161 | (e.target as HTMLInputElement).blur(); 162 | } 163 | }} 164 | /> 165 | 181 | 189 |
190 | 191 | ); 192 | } 193 | 194 | function getRecents(recents: SearchResult[], input: string): SearchResult[] { 195 | const cleanedInput = input.toLowerCase().replace(/\s+/g, ""); 196 | 197 | return recents.filter((recent) => { 198 | return (recent.name + (recent.type === "summoner" ? "#" + recent.tagLine : "")) 199 | .toLowerCase() 200 | .replace(/\s+/g, "") 201 | .startsWith(cleanedInput); 202 | }); 203 | } 204 | 205 | function getChallenges(challenges: ChallengeIndex, input: string): RecentChallenge[] { 206 | const cleanedInput = input.toLowerCase().replace(/\s+/g, ""); 207 | if (!cleanedInput || cleanedInput.length === 0) return []; 208 | 209 | return challenges 210 | .map((challenge) => { 211 | const cleanName = challenge.name.toLowerCase().replace(/\s+/g, ""); 212 | const proximityScore = getProximityScore(cleanName, cleanedInput); 213 | 214 | return { 215 | challenge, 216 | proximityScore, 217 | }; 218 | }) 219 | .filter((challenge) => challenge.proximityScore > 0) 220 | .sort((a, b) => b.proximityScore - a.proximityScore) 221 | .slice(0, 3) 222 | .map(({ challenge }) => ({ 223 | type: "challenge", 224 | id: challenge.id, 225 | iconId: challenge.iconId, 226 | name: challenge.name, 227 | description: "", 228 | })); 229 | } 230 | -------------------------------------------------------------------------------- /app/components/HomeSearch/searchbar.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .searchbar { 5 | width: 75%; 6 | background-color: color.$border; 7 | 8 | display: grid; 9 | grid-template-columns: auto 50px; 10 | align-items: center; 11 | justify-content: stretch; 12 | 13 | border-radius: config.$borderRadius; 14 | border: 1px solid color.$border; 15 | box-shadow: 0 0 5px #000; 16 | 17 | position: relative; 18 | 19 | transition: 20 | 0.25s border, 21 | 0.25s box-shadow; 22 | 23 | &.focus { 24 | box-shadow: 0 0 15px color.$primary; 25 | border: 1px solid color.$primary; 26 | } 27 | 28 | .input { 29 | // Reset styles 30 | outline: none; 31 | background: color.$heading; 32 | border: none; 33 | 34 | padding: config.$font-default 1.5 * config.$font-default; 35 | 36 | font-size: config.$font-default; 37 | line-height: config.$font-default; 38 | color: color.$accent; 39 | border-radius: config.$borderRadius - 1 0 0 config.$borderRadius - 1; 40 | 41 | position: relative; 42 | z-index: 5; 43 | } 44 | 45 | .submit { 46 | // Reset styles 47 | outline: none; 48 | background: color.$heading; 49 | border: none; 50 | 51 | height: 100%; 52 | display: flex; 53 | align-items: center; 54 | justify-content: center; 55 | 56 | cursor: pointer; 57 | 58 | color: color.$accent; 59 | font-size: config.$font-default; 60 | border-radius: 0 config.$borderRadius - 1 config.$borderRadius - 1 0; 61 | 62 | position: relative; 63 | z-index: 5; 64 | 65 | transition: 66 | 0.25s color, 67 | 0.25s background-color, 68 | 0.25s box-shadow; 69 | 70 | &:hover { 71 | background-color: color.$accent; 72 | color: color.$heading; 73 | box-shadow: 0 0 5px #000; 74 | } 75 | } 76 | 77 | &.focus .submit:hover { 78 | background-color: color.$primary; 79 | box-shadow: 0 0 5px color.$primary; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /app/components/HomeSearch/searchresults.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .results { 5 | position: absolute; 6 | display: none; 7 | z-index: 1; 8 | 9 | top: calc(2px + 3 * #{config.$font-default}); 10 | 11 | left: 5%; 12 | width: 90%; 13 | 14 | background-color: color.$gray; 15 | border: 1px solid color.$border; 16 | padding: config.$font-default; 17 | border-radius: 0 0 config.$borderRadius config.$borderRadius; 18 | box-shadow: 0 0 5px #000; 19 | 20 | &.visible { 21 | display: block; 22 | } 23 | 24 | .group { 25 | display: flex; 26 | flex-direction: column; 27 | gap: 2px; 28 | margin-bottom: 5px; 29 | 30 | .namespace { 31 | font-size: config.$font-small; 32 | } 33 | 34 | .result { 35 | display: grid; 36 | grid-template-columns: 30px auto; 37 | gap: 5px; 38 | 39 | align-items: center; 40 | border: 1px solid transparent; 41 | border-radius: 5px; 42 | padding: 3px 5px; 43 | 44 | img { 45 | width: 30px; 46 | height: 30px; 47 | object-fit: cover; 48 | position: relative; 49 | z-index: 1; 50 | 51 | &.summoner { 52 | border: 1px solid color.$border; 53 | border-radius: 3px; 54 | } 55 | } 56 | 57 | &:hover { 58 | border: 1px solid color.$border; 59 | background-color: color.$gray; 60 | } 61 | 62 | .name { 63 | font-size: config.$font-default; 64 | 65 | .tagLine { 66 | font-size: config.$font-small; 67 | color: color.$secondary; 68 | } 69 | } 70 | 71 | &.active { 72 | border: 1px solid color.$border; 73 | background-color: color.$light-gray; 74 | } 75 | 76 | .loader { 77 | position: absolute; 78 | width: 30px; 79 | height: 30px; 80 | z-index: 2; 81 | 82 | border-radius: 50%; 83 | border: 2px solid color.$border; 84 | border-left-color: transparent; 85 | border-right-color: transparent; 86 | 87 | animation: rotate 1s linear infinite; 88 | } 89 | } 90 | } 91 | } 92 | 93 | @keyframes rotate { 94 | to { 95 | rotate: 1turn; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /app/components/Icons/CapstoneIcon.tsx: -------------------------------------------------------------------------------- 1 | export function CapstoneIcon({ ...props }: React.SVGProps) { 2 | return ( 3 | 10 | 16 | 17 | ); 18 | } 19 | -------------------------------------------------------------------------------- /app/components/Icons/Title.tsx: -------------------------------------------------------------------------------- 1 | export function TitleIcon({ ...props }: React.SVGProps) { 2 | return ( 3 | 11 | 17 | 23 | 29 | 35 | 36 | ); 37 | } 38 | -------------------------------------------------------------------------------- /app/components/Icons/index.tsx: -------------------------------------------------------------------------------- 1 | import { CapstoneIcon } from "./CapstoneIcon"; 2 | import { TitleIcon } from "./Title"; 3 | 4 | export { TitleIcon, CapstoneIcon }; 5 | -------------------------------------------------------------------------------- /app/components/Leaderboard/Layout/LeaderboardLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router"; 2 | import Container from "@cgg/components/Container/Container"; 3 | import regions from "@cgg/config/json/regions.json"; 4 | import type { RegionsJSON } from "@cgg/config/json/regions.types"; 5 | import css from "./leaderboard.layout.module.scss"; 6 | 7 | export default function LeaderboardLayout({ children }: { children: React.ReactNode }) { 8 | return ( 9 | 10 | {(regions as RegionsJSON).map((region) => ( 11 | 16 | {region.abbreviation} 17 | 18 | ))} 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /app/components/Leaderboard/Layout/leaderboard.layout.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .container { 5 | padding-bottom: 10px; 6 | } 7 | -------------------------------------------------------------------------------- /app/components/Leaderboard/Leaderboard.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { TbRosetteDiscountCheckFilled } from "react-icons/tb"; 3 | import { Link } from "react-router"; 4 | import { usePageTransition } from "@cgg/hooks/usePageTransition"; 5 | import variables from "@cgg/styles/variables.module.scss"; 6 | import { capitalize } from "@cgg/utils/capitalize"; 7 | import { getProfileIcon } from "@cgg/utils/cdn"; 8 | import type { IChallengeDTO } from "@cgg/utils/challenges"; 9 | import type { IApiLeaderboardEntry } from "@cgg/utils/endpoints/types"; 10 | import { formatNumber } from "@cgg/utils/formatNumber"; 11 | import Heading from "../Heading/Heading"; 12 | import Loader from "../Loader/Loader"; 13 | import css from "./leaderboard.module.scss"; 14 | 15 | export default function Leaderboard({ 16 | entries, 17 | challenge, 18 | }: { 19 | entries: IApiLeaderboardEntry[]; 20 | challenge: IChallengeDTO; 21 | }) { 22 | const { transition } = usePageTransition(); 23 | 24 | return ( 25 |
26 | "{challenge.name}" Leaderboard 27 |
28 | 29 | 30 | 31 | 34 | 37 | 40 | 43 | 44 | 45 | 46 | {!transition && 47 | entries.map((entry, index) => ( 48 | 49 | 50 | 76 | 79 | 82 | 83 | ))} 84 | {transition && ( 85 | 86 | 92 | 93 | )} 94 | 95 |
32 | Position 33 | 35 | Summoner 36 | 38 | Tier 39 | 41 | Points 42 |
{index + 1}. 51 | 55 | 61 | {entry.verified && ( 62 | 66 | 67 | 68 | )} 69 |

70 | {entry.gameName}#{entry.tagLine} 71 |
72 | {entry.region} 73 |

74 | 75 |
77 | {capitalize(entry.tier)} 78 | 80 | {formatNumber(entry.points, false)} 81 |
87 |
88 | 89 |

Loading...

90 |
91 |
96 |
97 | ); 98 | } 99 | -------------------------------------------------------------------------------- /app/components/Leaderboard/leaderboard.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .leaderboard { 5 | width: 70%; 6 | background-color: color.$background; 7 | 8 | padding: 1rem; 9 | border-radius: config.$borderRadius; 10 | border: 1px solid color.$border; 11 | 12 | .line { 13 | width: 90%; 14 | margin: 10px 5%; 15 | height: 2px; 16 | background-color: color.$border; 17 | } 18 | 19 | .table { 20 | width: 100%; 21 | border-collapse: collapse; 22 | table-layout: fixed; 23 | 24 | .head { 25 | top: config.$header-height; 26 | background-color: color.$background; 27 | 28 | position: sticky; 29 | border-bottom: 1px solid color.$border; 30 | z-index: 5; 31 | 32 | > tr { 33 | > th { 34 | padding: 10px; 35 | color: color.$text; 36 | } 37 | 38 | .position { 39 | text-align: left; 40 | width: 10%; 41 | } 42 | 43 | .player { 44 | width: 45%; 45 | } 46 | 47 | .tier { 48 | width: 20%; 49 | } 50 | 51 | .points { 52 | width: 25%; 53 | } 54 | } 55 | } 56 | 57 | .body { 58 | > tr { 59 | &.zebra { 60 | background-color: color.$accent; 61 | } 62 | 63 | > th, 64 | > td { 65 | font-weight: normal; 66 | padding: 10px; 67 | } 68 | 69 | .position { 70 | border-radius: config.$borderRadius 0 0 config.$borderRadius; 71 | } 72 | 73 | .player { 74 | position: relative; 75 | $imgWidth: 40px; 76 | text-align: left; 77 | 78 | > a { 79 | width: 100%; 80 | display: flex; 81 | align-items: center; 82 | gap: 8px; 83 | 84 | .icon { 85 | width: 40px; 86 | aspect-ratio: 1/1; 87 | background-color: color.$accent; 88 | border-radius: 8px; 89 | } 90 | 91 | .verifiedBadge { 92 | display: block; 93 | position: absolute; 94 | left: $imgWidth - 5px; 95 | top: $imgWidth - 5px; 96 | color: color.$primary; 97 | font-size: 1.5rem; 98 | } 99 | 100 | p { 101 | color: color.$heading; 102 | white-space: nowrap; 103 | overflow: hidden; 104 | text-overflow: ellipsis; 105 | 106 | span { 107 | color: color.$secondary; 108 | font-style: italic; 109 | font-size: config.$font-small; 110 | } 111 | } 112 | } 113 | 114 | &:hover { 115 | p { 116 | text-decoration: underline; 117 | } 118 | } 119 | } 120 | 121 | .tier { 122 | text-align: center; 123 | color: var(--tier); 124 | font-size: config.$font-small; 125 | } 126 | 127 | .points { 128 | text-align: center; 129 | color: color.$heading; 130 | border-radius: 0 config.$borderRadius config.$borderRadius 0; 131 | } 132 | } 133 | } 134 | } 135 | 136 | .loader { 137 | width: 100%; 138 | padding: 25vh 0 75vh; 139 | display: flex; 140 | flex-direction: column; 141 | gap: 10px; 142 | align-items: center; 143 | justify-content: center; 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /app/components/Loader/Loader.tsx: -------------------------------------------------------------------------------- 1 | import css from "./loader.module.scss"; 2 | 3 | export default function Loader() { 4 | return
; 5 | } 6 | -------------------------------------------------------------------------------- /app/components/Loader/loader.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/colors" as color; 2 | 3 | .loader { 4 | width: 30px; 5 | height: 30px; 6 | border: 4px solid color.$border; 7 | border-top-color: color.$primary; 8 | 9 | border-radius: 50%; 10 | 11 | animation: 1s rotate linear infinite; 12 | } 13 | 14 | @keyframes rotate { 15 | to { 16 | rotate: 1turn; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /app/components/Navigation/Footer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { FaHeart } from "react-icons/fa6"; 3 | import { Link } from "react-router"; 4 | import logo from "@cgg/assets/logo.svg?no-inline"; 5 | import Container from "@cgg/components/Container/Container"; 6 | import { brandName } from "@cgg/config/config"; 7 | import css from "./footer.module.scss"; 8 | 9 | export default function Footer() { 10 | return ( 11 |
12 | 13 | 14 |
15 | {brandName} 16 |

17 | {brandName} 18 |
19 | © 2022 - {new Date().getFullYear()} 20 |

21 |
22 |

23 | Made with by{" "} 24 | 25 | DarkIntaqt 26 | 27 | , the{" "} 28 | 29 | YearIn.LoL team 30 | {" "} 31 | and{" "} 32 | 33 | contributors 34 | 35 | . 36 |

37 |
38 | 39 | 40 | Sitemap 41 | Home 42 | Titles 43 | Challenges 44 | Leaderboard 45 | 46 | 47 | 48 | Contact Us 49 | 50 | About 51 | 52 | 53 | Contact 54 | 55 | 56 | Help & FAQ 57 | 58 | 59 | 60 | 61 | Socials 62 | 63 | Twitter 64 | 65 | 66 | Community 67 | 68 | 69 | Contribute on GitHub 70 | 71 | 72 | 73 | 74 | Resources 75 | 76 | Imprint 77 | 78 | 79 | Privacy Policy 80 | 81 | 82 | Terms of Service 83 | 84 | 85 |
86 |
87 | ); 88 | } 89 | 90 | function Group({ 91 | children, 92 | center = false, 93 | }: { 94 | children: React.ReactNode; 95 | center?: boolean; 96 | }) { 97 | return
{children}
; 98 | } 99 | -------------------------------------------------------------------------------- /app/components/Navigation/Header.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { type ReactNode, useEffect, useState } from "react"; 3 | import { Link, useMatch, useMatches } from "react-router"; 4 | import logo from "@cgg/assets/logo.svg?no-inline"; 5 | import { brandName } from "@cgg/config/config"; 6 | import Container from "../Container/Container"; 7 | import { CapstoneIcon, TitleIcon } from "../Icons"; 8 | import css from "./header.module.scss"; 9 | 10 | type HeaderLink = { 11 | to: string; 12 | label: string; 13 | icon?: ReactNode; 14 | }; 15 | 16 | const links: HeaderLink[] = [ 17 | { 18 | to: "/titles", 19 | label: "Titles", 20 | icon: , 21 | }, 22 | { 23 | to: "/challenges", 24 | label: "Challenges", 25 | icon: , 26 | }, 27 | ]; 28 | 29 | export default function Header() { 30 | const matches = useMatches(); 31 | const [hideHeader, setHideHeader] = useState(true); 32 | 33 | const transparentHeader = matches.some( 34 | (match) => (match.handle as Record)?.transparentHeader, 35 | ); 36 | 37 | useEffect(() => { 38 | const handleScroll = () => { 39 | setHideHeader(window.scrollY < 125); 40 | }; 41 | 42 | window.addEventListener("scroll", handleScroll); 43 | 44 | // Well this shouldnt happen, just in case 45 | return () => { 46 | window.removeEventListener("scroll", handleScroll); 47 | }; 48 | }, []); 49 | 50 | return ( 51 |
54 | 55 | 56 | {`${brandName} 57 | {brandName} 58 | 59 | 60 | {links.map((link) => { 61 | const isActive = useMatch(link.to); 62 | return ( 63 | 68 | {link.icon && link.icon} 69 | {link.label} 70 | 71 | ); 72 | })} 73 | 74 |
75 | ); 76 | } 77 | -------------------------------------------------------------------------------- /app/components/Navigation/Navigation.tsx: -------------------------------------------------------------------------------- 1 | import Footer from "./Footer"; 2 | import Header from "./Header"; 3 | import css from "./navigation.module.scss"; 4 | 5 | export default function Navigation({ children }: { children: React.ReactNode }) { 6 | return ( 7 |
8 |
9 |
10 |
{children}
11 |
12 |
13 |
14 | ); 15 | } 16 | -------------------------------------------------------------------------------- /app/components/Navigation/footer.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .footer { 5 | width: 100%; 6 | 7 | background-color: color.$background; 8 | border-top: 1px solid color.$border; 9 | 10 | padding: 25px 0; 11 | box-shadow: 0 0 5px #000; 12 | position: relative; 13 | z-index: 75; 14 | 15 | .content { 16 | display: grid; 17 | grid-template-columns: 3fr 2fr 2fr 2fr 2fr; 18 | 19 | gap: 2rem; 20 | 21 | .group { 22 | display: flex; 23 | 24 | &.center { 25 | align-items: center; 26 | 27 | p { 28 | text-align: center; 29 | } 30 | } 31 | 32 | flex-direction: column; 33 | gap: 0.5rem; 34 | 35 | p { 36 | font-size: config.$font-default; 37 | color: color.$secondary; 38 | 39 | svg { 40 | color: rgb(231, 63, 63); 41 | 42 | &:hover { 43 | animation: pulse 1s; 44 | } 45 | } 46 | 47 | a:hover { 48 | text-decoration: underline; 49 | color: color.$text; 50 | } 51 | } 52 | 53 | > span { 54 | display: inline-block; 55 | color: color.$secondary; 56 | padding-bottom: 0.25rem; 57 | } 58 | 59 | > a { 60 | color: color.$text; 61 | 62 | &:hover { 63 | text-decoration: underline; 64 | } 65 | } 66 | 67 | .special { 68 | $logo: 50px; 69 | display: grid; 70 | gap: 8px; 71 | 72 | grid-template-columns: $logo auto; 73 | align-items: center; 74 | 75 | img.logo { 76 | width: $logo; 77 | height: $logo; 78 | } 79 | 80 | > p { 81 | text-align: left; 82 | 83 | &.name { 84 | font-size: config.$font-large; 85 | color: color.$heading; 86 | font-weight: bold; 87 | 88 | font-family: "Istok Web"; 89 | line-height: 1.2; 90 | 91 | > span { 92 | font-family: "Roboto"; 93 | font-size: config.$font-default; 94 | font-weight: normal; 95 | color: color.$text; 96 | } 97 | } 98 | } 99 | } 100 | } 101 | } 102 | } 103 | 104 | @keyframes pulse { 105 | 0% { 106 | transform: scale(1.1); 107 | } 108 | 109 | 70% { 110 | transform: scale(1); 111 | } 112 | 113 | 100% { 114 | transform: scale(1.1); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /app/components/Navigation/header.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .header { 5 | top: 0; 6 | position: fixed; 7 | width: 100%; 8 | height: config.$header-height; 9 | 10 | z-index: 99; 11 | border-bottom: 1px solid transparent; 12 | 13 | &:not(.transparent) { 14 | background-color: color.$background; 15 | 16 | border-bottom-color: color.$border; 17 | box-shadow: 0 0 5px #000; 18 | } 19 | 20 | transition: 21 | 0.25s background-color, 22 | 0.25s border-bottom-color, 23 | 0.25s box-shadow; 24 | 25 | .content { 26 | height: 100%; 27 | align-items: center; 28 | gap: 1rem; 29 | 30 | a.link { 31 | padding: 0.5rem 0.4rem; 32 | gap: 0.5rem; 33 | display: flex; 34 | align-items: center; 35 | 36 | svg { 37 | color: color.$heading; 38 | font-size: config.$font-large; 39 | } 40 | 41 | span { 42 | color: color.$heading; 43 | font-size: config.$font-default; 44 | } 45 | 46 | &.logo { 47 | margin-right: auto; 48 | 49 | img { 50 | width: 40px; 51 | } 52 | 53 | span { 54 | font-weight: bold; 55 | font-size: config.$font-large; 56 | 57 | transition: 0.25s color; 58 | font-family: "Istok Web"; 59 | padding-top: 6px; 60 | } 61 | 62 | &:hover { 63 | span { 64 | color: color.$primary; 65 | } 66 | } 67 | } 68 | &:not(.logo) { 69 | border-bottom: 2px solid transparent; 70 | 71 | &:hover { 72 | border-bottom: 2px solid white; 73 | } 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /app/components/Navigation/navigation.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/colors" as color; 2 | 3 | .layout { 4 | display: flex; 5 | align-items: stretch; 6 | 7 | min-height: 100vh; 8 | position: relative; 9 | 10 | .content { 11 | position: relative; 12 | background-color: color.$accent; 13 | 14 | flex: 1; 15 | 16 | main { 17 | min-height: 100vh; 18 | } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /app/components/ProgressBar/ProgressBar.tsx: -------------------------------------------------------------------------------- 1 | import clsx, { type ClassValue } from "clsx"; 2 | import variables from "@cgg/styles/variables.module.scss"; 3 | import type { Tier } from "@cgg/utils/tier"; 4 | import css from "./progressBar.module.scss"; 5 | 6 | export default function ProgressBar({ 7 | current, 8 | next, 9 | tier, 10 | className, 11 | progress, 12 | pinned = false, 13 | }: { 14 | current: number | undefined; 15 | next: number | undefined; 16 | tier?: Tier; 17 | className?: ClassValue; 18 | progress?: number; 19 | pinned?: boolean; 20 | }) { 21 | const progressPct = Math.min( 22 | 1, 23 | Math.max(0, progress ? progress : (current ?? 0) / (next ?? 1)), 24 | ); 25 | 26 | return ( 27 |
35 |
41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /app/components/ProgressBar/progressBar.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .progress { 5 | $darkTier: color-mix(in hsl, var(--tier, color.$secondary), black 35%); 6 | 7 | height: 4px; 8 | background-color: $darkTier; 9 | width: 100%; 10 | 11 | &.pinned { 12 | position: absolute; 13 | bottom: 0; 14 | left: 0; 15 | } 16 | 17 | .indicator { 18 | height: 100%; 19 | background-color: var(--tier); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /app/components/Searchbar/Searchbar.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { nanoid } from "nanoid"; 3 | import { type Dispatch, type SetStateAction, useMemo, useState } from "react"; 4 | import { FaSearch } from "react-icons/fa"; 5 | import css from "./searchbar.module.scss"; 6 | 7 | export default function Searchbar({ 8 | placeholder, 9 | value, 10 | onChange, 11 | id = nanoid(), 12 | }: { 13 | placeholder?: string; 14 | value?: string; 15 | id?: string; 16 | onChange: Dispatch>; 17 | }) { 18 | const [focus, setFocus] = useState(false); 19 | 20 | return ( 21 |
22 | 25 | setFocus(true)} 28 | onBlur={() => setFocus(false)} 29 | value={value} 30 | placeholder={placeholder} 31 | onChange={(e) => onChange(e.target.value)} 32 | /> 33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /app/components/Searchbar/searchbar.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/colors" as color; 2 | @use "@cgg/styles/config" as config; 3 | 4 | .searchbar { 5 | border-bottom: 2px solid color.$border; 6 | width: 100%; 7 | 8 | position: relative; 9 | padding-bottom: 0.25rem; 10 | 11 | display: flex; 12 | align-items: center; 13 | 14 | label { 15 | height: 100%; 16 | display: flex; 17 | align-items: center; 18 | 19 | svg { 20 | color: color.$text; 21 | font-size: config.$font-default; 22 | } 23 | } 24 | 25 | input { 26 | padding: 0.5rem; 27 | width: 100%; 28 | 29 | background: none; 30 | border: none; 31 | outline: none; 32 | color: color.$text; 33 | 34 | font-size: config.$font-default; 35 | 36 | &::placeholder { 37 | color: color.$secondary; 38 | } 39 | } 40 | 41 | &::after { 42 | content: ""; 43 | position: absolute; 44 | width: 0; 45 | height: 2px; 46 | background: color.$primary; 47 | bottom: -2px; 48 | 49 | transition: 0.25s width; 50 | } 51 | 52 | &.focus { 53 | &::after { 54 | width: 100%; 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /app/components/SplashBackground/SplashBackground.tsx: -------------------------------------------------------------------------------- 1 | import type { ResponseProfiles } from "@cgg/loader/_index"; 2 | import css from "./splashBackground.module.scss"; 3 | 4 | export default function SplashBackground({ splash }: { splash: ResponseProfiles }) { 5 | if (splash.isVideo) { 6 | return ( 7 | 13 | ); 14 | } 15 | 16 | return ( 17 | 18 | {splash.hq && ( 19 | 20 | )} 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/components/SplashBackground/splashBackground.module.scss: -------------------------------------------------------------------------------- 1 | .bgSplash { 2 | position: absolute; 3 | 4 | width: 100%; 5 | height: 100%; 6 | filter: brightness(0.5); 7 | object-fit: cover; 8 | 9 | source, 10 | img, 11 | video { 12 | width: 100%; 13 | height: 100%; 14 | object-fit: cover; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /app/components/Title/Title.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { Link } from "react-router"; 3 | import Heading from "@cgg/components/Heading/Heading"; 4 | import { useStaticData } from "@cgg/hooks/useStaticData"; 5 | import variables from "@cgg/styles/variables.module.scss"; 6 | import { capitalize } from "@cgg/utils/capitalize"; 7 | import { cdn, getChallengeIcon } from "@cgg/utils/cdn"; 8 | import type { ITitleDTO } from "@cgg/utils/challenges"; 9 | import type { IChampionDTO } from "@cgg/utils/champions"; 10 | import getTitle from "@cgg/utils/getTitle"; 11 | import css from "./title.module.scss"; 12 | 13 | export default function Title({ 14 | title, 15 | champions, 16 | }: { 17 | title: ITitleDTO; 18 | champions: IChampionDTO[]; 19 | }) { 20 | const staticData = useStaticData(); 21 | 22 | const titleData = getTitle(title.id, staticData); 23 | if (!titleData) return; 24 | 25 | let icon: string = 26 | title.icon !== undefined 27 | ? cdn("challenges/titles/" + title.icon, false) 28 | : cdn("challenges/titles/player_title_generic.svg", false); 29 | let name: string = title.requirement?.name ?? ""; 30 | let description: string = title.requirement?.description ?? ""; 31 | let type: "CHALLENGE" | "CHAMPION" | "EVENT" = "EVENT"; 32 | let percentile: number | undefined; 33 | 34 | if (title.challengeId !== undefined && titleData.challenge !== undefined) { 35 | // Add challenge name and tier to name 36 | // Set icon to the challenge icon 37 | icon = getChallengeIcon(titleData.challenge.iconId, titleData.tier); 38 | name = titleData.challenge.name; 39 | name += ` (${capitalize(titleData.tier)} tier)`; 40 | description = titleData.challenge.description; 41 | type = "CHALLENGE"; 42 | 43 | percentile = titleData.challenge?.percentiles?.[titleData.tier]; 44 | } else if ( 45 | title.type === "CHAMPION_MASTERY" && 46 | typeof title.requirement !== "undefined" 47 | ) { 48 | // Find the champion to provide the right image 49 | type = "CHAMPION"; 50 | 51 | const champion = champions.find( 52 | (c) => c.name === title.requirement!.name || c.id === title.requirement!.name, 53 | ); 54 | 55 | if (!champion) { 56 | console.warn("Can't find champion", title.requirement.name); 57 | return null; 58 | } 59 | 60 | icon = cdn(`champions/tiles/${champion.id}_0`); 61 | name = title.requirement.name; 62 | description = title.requirement.description; 63 | } 64 | 65 | const content = ( 66 | <> 67 | {title.name} 68 | 69 | {/* 70 | Show default for apprentice title, otherwise show TYPE 71 | If type is CHALLENGE, also show the challenges name 72 | */} 73 | {title.id === 1 ? "Default" : capitalize(type)} title 74 | {type === "CHALLENGE" && ` - ${name}`} 75 | {percentile !== undefined && ` - Top ${(percentile * 100).toFixed(1)}%`} 76 | 77 | 78 | {type === "CHAMPION" && ( 79 |

80 | Reach {description} on {name}. 81 |

82 | )} 83 | {type === "EVENT" && title.id !== 1 && ( 84 |

85 | Requirements: {name}, {description}. 86 |

87 | )} 88 | {type === "CHALLENGE" &&

{description}.

} 89 | {name} 96 | 97 | ); 98 | 99 | if (type === "CHALLENGE" && titleData.challenge !== undefined) { 100 | return ( 101 | 110 | {content} 111 | 112 | ); 113 | } 114 | 115 | return ( 116 |
117 | {content} 118 |
119 | ); 120 | } 121 | -------------------------------------------------------------------------------- /app/components/Title/title.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/colors" as color; 2 | @use "@cgg/styles/config" as config; 3 | 4 | .title { 5 | padding: 1.5rem; 6 | 7 | background-color: color.$box; 8 | border-radius: config.$borderRadius; 9 | 10 | border: 2px solid var(--tier); 11 | box-shadow: 0 0 5px color.$box; 12 | transition: 0.25s box-shadow; 13 | 14 | position: relative; 15 | overflow: hidden; 16 | 17 | z-index: 1; 18 | 19 | &.link { 20 | &:hover { 21 | box-shadow: 0 0 5px var(--tier); 22 | } 23 | } 24 | 25 | &.CHAMPION, 26 | &.EVENT { 27 | img.icon { 28 | position: absolute; 29 | width: 40%; 30 | aspect-ratio: 1/1; 31 | object-fit: cover; 32 | object-position: center; 33 | 34 | top: 0; 35 | right: 0; 36 | 37 | z-index: 0; 38 | mask-image: linear-gradient(70deg, transparent 25%, rgba(255, 255, 255, 1) 100%); 39 | } 40 | } 41 | 42 | &.CHALLENGE { 43 | img.icon { 44 | position: absolute; 45 | height: 110%; 46 | top: -15px; 47 | right: -15px; 48 | 49 | z-index: 0; 50 | mask-image: linear-gradient( 51 | 70deg, 52 | transparent 15%, 53 | rgba(255, 255, 255, 0.75) 100% 54 | ); 55 | } 56 | } 57 | 58 | > p, 59 | span, 60 | h2 { 61 | position: relative; 62 | z-index: 1; 63 | text-shadow: 0 0 5px #000; 64 | } 65 | 66 | span.titleType { 67 | font-size: config.$font-small; 68 | color: color.$secondary; 69 | } 70 | 71 | p { 72 | margin-top: 1rem; 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /app/components/Tooltip/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import css from "./tooltip.module.scss"; 3 | 4 | export default function Tooltip({ 5 | children, 6 | tooltip, 7 | className, 8 | }: { 9 | children: React.ReactNode; 10 | tooltip: string | React.ReactNode; 11 | className?: string; 12 | }) { 13 | return ( 14 | 15 | {children} 16 | 17 | 18 | {tooltip} 19 | 20 | 21 | 22 | 23 | ); 24 | } 25 | -------------------------------------------------------------------------------- /app/components/Tooltip/tooltip.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .wrapper { 5 | position: relative; 6 | display: inline-block; 7 | 8 | .tooltip { 9 | pointer-events: none; 10 | position: absolute; 11 | display: block; 12 | 13 | opacity: 0; 14 | transition: 0.25s opacity; 15 | bottom: calc(100% + 8px); 16 | left: 50%; 17 | transform: translateX(-50%); 18 | 19 | .tooltipInner { 20 | display: block; 21 | padding: 0.5rem 0.8rem; 22 | border-radius: config.$borderRadius; 23 | 24 | background-color: color.$light-gray; 25 | border: 2px solid color.$border; 26 | 27 | white-space: nowrap; 28 | 29 | .arrow { 30 | position: absolute; 31 | display: block; 32 | 33 | top: 100%; 34 | left: 50%; 35 | transform: translateX(-50%); 36 | 37 | border-style: solid; 38 | border-color: transparent; 39 | border-width: 4px; 40 | border-top-color: color.$border; 41 | } 42 | } 43 | } 44 | 45 | &:hover { 46 | .tooltip { 47 | opacity: 1; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /app/components/User/Statistics/Categories.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { Link } from "react-router"; 3 | import ProgressBar from "@cgg/components/ProgressBar/ProgressBar"; 4 | import { useStaticData } from "@cgg/hooks/useStaticData"; 5 | import variables from "@cgg/styles/variables.module.scss"; 6 | import { getChallengeIcon } from "@cgg/utils/cdn"; 7 | import type { IApiChallengeResponse } from "@cgg/utils/endpoints/types"; 8 | import { formatNumber } from "@cgg/utils/formatNumber"; 9 | import { getChallenge } from "@cgg/utils/getChallenge"; 10 | import { getNextTier } from "@cgg/utils/getTier"; 11 | import css from "./userStatistics.module.scss"; 12 | 13 | // Total (0), Collection (5), Expertise (2), Veterancy (3), Imagination (1), Teamwork (4) 14 | const categories = [0, 5, 2, 3, 1, 4]; 15 | 16 | export default function Categories({ 17 | playerData, 18 | }: { 19 | playerData: IApiChallengeResponse; 20 | }) { 21 | const data = useStaticData(); 22 | 23 | return ( 24 |
25 | {categories.map((cat) => { 26 | const challenge = getChallenge(cat, data); 27 | const playerProgress = playerData.challenges.find( 28 | (c) => c.challengeId === cat, 29 | ); 30 | 31 | if (!challenge) return null; 32 | if (!playerProgress) return null; 33 | 34 | const nextTier = getNextTier(playerProgress.tier, challenge, false); 35 | const nextPoints = challenge.thresholds[nextTier].points; 36 | const currentTierPoints = challenge.thresholds[playerProgress.tier].points; 37 | const currentPoints = playerProgress.value; 38 | 39 | if (challenge.id === 0) { 40 | challenge.name = "Total Points"; 41 | } 42 | 43 | return ( 44 | 49 |
50 | 54 |

{challenge.name}

55 |
56 |
57 | 58 | {formatNumber(currentPoints, false)} /{" "} 59 | {formatNumber(nextPoints, false)} 60 | 61 |
62 | 72 | 73 | ); 74 | })} 75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /app/components/User/Statistics/Distribution.tsx: -------------------------------------------------------------------------------- 1 | import { LineChart } from "@mui/x-charts"; 2 | import { useStaticData } from "@cgg/hooks/useStaticData"; 3 | import { capitalize } from "@cgg/utils/capitalize"; 4 | import type { IApiChallengeResponse } from "@cgg/utils/endpoints/types"; 5 | import { getChallenge } from "@cgg/utils/getChallenge"; 6 | import { tierList } from "@cgg/utils/suffixToTier"; 7 | import type { Tier } from "@cgg/utils/tier"; 8 | 9 | const tiers = ["UNRANKED" as Tier, ...tierList]; 10 | 11 | export default function Distribution({ 12 | playerData, 13 | }: { 14 | playerData: IApiChallengeResponse; 15 | }) { 16 | const data = useStaticData(); 17 | 18 | return ( 19 | capitalize(t)), 24 | }, 25 | ]} 26 | series={[ 27 | { 28 | data: tiers.map((t) => { 29 | let amount = playerData.challenges.filter((c) => { 30 | const challenge = getChallenge(c.challengeId, data); 31 | if (!challenge) return false; 32 | if (challenge.retired) return false; 33 | 34 | if (t === "UNRANKED") { 35 | return (["UNRANKED", "NONCHALLENGE", "NONE"] as Tier[]).includes( 36 | c.tier, 37 | ); 38 | } 39 | return c.tier === t; 40 | }).length; 41 | if (t === "UNRANKED") { 42 | amount += 43 | Object.keys(data.challenges).length - 44 | playerData.challenges.length; 45 | } 46 | 47 | return amount; 48 | }), 49 | label: "challenges in tier", 50 | color: "#0dbdff", 51 | }, 52 | ]} 53 | hideLegend={true} 54 | height={300} 55 | grid={{ vertical: true, horizontal: true }} 56 | /> 57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/components/User/Statistics/UserStatistics.tsx: -------------------------------------------------------------------------------- 1 | import { ThemeProvider } from "@mui/material/styles"; 2 | import { LineChart } from "@mui/x-charts"; 3 | import { axisClasses } from "@mui/x-charts/ChartsAxis"; 4 | import Heading from "@cgg/components/Heading/Heading"; 5 | import { capitalize } from "@cgg/utils/capitalize"; 6 | import type { IApiChallengeResponse } from "@cgg/utils/endpoints/types"; 7 | import { tierList } from "@cgg/utils/suffixToTier"; 8 | import Categories from "./Categories"; 9 | import Distribution from "./Distribution"; 10 | import darkTheme from "./theme"; 11 | import css from "./userStatistics.module.scss"; 12 | 13 | export default function UserStatistics({ 14 | playerData, 15 | }: { 16 | playerData: IApiChallengeResponse; 17 | }) { 18 | return ( 19 | 20 |
21 |
22 | Categories 23 | 24 |
25 |
26 | Distribution 27 | 28 |
29 |
30 |
31 | ); 32 | } 33 | -------------------------------------------------------------------------------- /app/components/User/Statistics/theme.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from "@mui/material/styles"; 2 | 3 | const darkTheme = createTheme({ 4 | palette: { 5 | mode: "dark", 6 | }, 7 | }); 8 | 9 | export default darkTheme; 10 | -------------------------------------------------------------------------------- /app/components/User/Statistics/userStatistics.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .wrapper { 5 | padding: 1rem 0; 6 | 7 | display: grid; 8 | grid-template-columns: repeat(2, 1fr); 9 | grid-template-rows: repeat(2, auto); 10 | gap: 1rem; 11 | 12 | .section { 13 | background-color: color.$box; 14 | padding: 1rem; 15 | border-radius: config.$borderRadius; 16 | border: 1px solid color.$border; 17 | 18 | h2 { 19 | margin-bottom: 0.5rem; 20 | } 21 | } 22 | } 23 | 24 | .categories { 25 | display: grid; 26 | grid-template-columns: repeat(6, 1fr); 27 | justify-items: center; 28 | gap: 0.5rem; 29 | 30 | .category { 31 | display: flex; 32 | align-items: center; 33 | flex-direction: column; 34 | position: relative; 35 | 36 | width: 100%; 37 | padding: 1rem; 38 | 39 | background-color: color.$accent; 40 | border: 1px solid color.$border; 41 | border-radius: config.$borderRadius; 42 | overflow: hidden; 43 | 44 | transition: 0.25s background-color; 45 | 46 | .head { 47 | display: flex; 48 | gap: 5px; 49 | align-items: center; 50 | 51 | .title { 52 | color: color.$heading; 53 | font-size: config.$font-default; 54 | } 55 | 56 | img { 57 | height: config.$font-default * 1.25; 58 | aspect-ratio: 1/1; 59 | object-fit: contain; 60 | } 61 | } 62 | 63 | .progress { 64 | span { 65 | font-size: config.$font-small; 66 | } 67 | } 68 | 69 | &:hover { 70 | background-color: color.$background; 71 | } 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /app/components/User/layout.ts: -------------------------------------------------------------------------------- 1 | import { LayoutHeading } from "./layout/LayoutHeading"; 2 | import LayoutNavigation from "./layout/LayoutNavigation"; 3 | 4 | export { LayoutHeading, LayoutNavigation }; 5 | -------------------------------------------------------------------------------- /app/components/User/layout/LayoutHeading.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import Heading from "@cgg/components/Heading/Heading"; 3 | import { TitleIcon } from "@cgg/components/Icons"; 4 | import Tooltip from "@cgg/components/Tooltip/Tooltip"; 5 | import { useStaticData } from "@cgg/hooks/useStaticData"; 6 | import cssVariables from "@cgg/styles/variables.module.scss"; 7 | import { capitalize } from "@cgg/utils/capitalize"; 8 | import { getChallengeIcon, getProfileIcon } from "@cgg/utils/cdn"; 9 | import type { IApiChallengeResponse } from "@cgg/utils/endpoints/types"; 10 | import { formatNumber } from "@cgg/utils/formatNumber"; 11 | import { getChallenge, getUserChallenge } from "@cgg/utils/getChallenge"; 12 | import getTitle from "@cgg/utils/getTitle"; 13 | import { regionToString } from "@cgg/utils/regionToString"; 14 | import css from "./layoutHeading.module.scss"; 15 | 16 | export function LayoutHeading({ 17 | playerData, 18 | }: Readonly<{ playerData: IApiChallengeResponse }>) { 19 | const { summoner, gameName, tagLine, region } = playerData; 20 | const tier = summoner.tier; 21 | const data = useStaticData(); 22 | 23 | const fullName = `${gameName}#${tagLine}`; 24 | const title = getTitle(summoner.title, data); 25 | 26 | return ( 27 |
28 |
29 | {`${fullName}'s 33 | {summoner.level} 34 |
35 | 36 |
37 | 38 | {gameName} 39 | #{tagLine} 40 | in {regionToString(region).name} 41 | 42 |
43 | 46 | {capitalize(tier)} tier 47 |

48 | This summoner has {formatNumber(summoner.totalPoints)} total 49 | points. 50 |

51 | 52 | } 53 | className={cssVariables[tier]} 54 | > 55 | 56 | {capitalize(tier)} ({formatNumber(summoner.totalPoints)}) 57 | 58 |
59 | 60 | {/* Title */} 61 | {title && ( 62 | 65 | 66 | {title.tier !== "NONCHALLENGE" 67 | ? capitalize(title.tier) 68 | : "Custom "}{" "} 69 | Title 70 | 71 | {title.challengeId && ( 72 |

{getChallenge(title.challengeId, data)?.description}

73 | )} 74 | 75 | } 76 | className={cssVariables[title.tier]} 77 | > 78 | 79 | 80 | {title.name} 81 | 82 |
83 | )} 84 |
85 | 86 |
87 | {summoner.displayedChallenges 88 | ?.filter((challenge) => challenge >= 0) 89 | .map((challenge) => { 90 | const challengeData = getChallenge(challenge, data); 91 | 92 | if (!challengeData) return; 93 | 94 | return ( 95 | 99 | {challengeData.name} 100 |

{challengeData.description}

101 | 102 | } 103 | > 104 | {String(challenge)} 112 |
113 | ); 114 | })} 115 |
116 |
117 |
118 | ); 119 | } 120 | -------------------------------------------------------------------------------- /app/components/User/layout/LayoutLoader.tsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router"; 2 | import Container from "@cgg/components/Container/Container"; 3 | import Loader from "@cgg/components/Loader/Loader"; 4 | import { usePageTransition } from "@cgg/hooks/usePageTransition"; 5 | import { profileNavigationLinks } from "./LayoutNavigation"; 6 | import css from "./layoutLoader.module.scss"; 7 | 8 | export default function LayoutLoader() { 9 | const { transition, to } = usePageTransition(); 10 | const { profile } = useParams<{ profile: string }>(); 11 | 12 | // Dont show loader if not transitioning 13 | if (!transition) return null; 14 | 15 | // Get target path for the loader text 16 | // e.g. /profile/username/titles -> /titles 17 | const basePath = `/profile/${encodeURIComponent(profile || "")}`; 18 | let target = to?.pathname?.replace(basePath, "") || ""; 19 | 20 | // Get the link name from the navigation links to generate a text. 21 | // Fallback to "content" if not found 22 | const text = `Loading ${profileNavigationLinks.find((link) => link.relativePath === target)?.name || "content"}...`; 23 | 24 | return ( 25 | 26 | 27 |

{text}

28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /app/components/User/layout/LayoutNavigation.tsx: -------------------------------------------------------------------------------- 1 | import clsx from "clsx"; 2 | import { Link, useLocation, useParams } from "react-router"; 3 | import css from "./layoutNavigation.module.scss"; 4 | 5 | const links = [ 6 | { 7 | name: "Overview", 8 | relativePath: "", 9 | }, 10 | { 11 | name: "Titles", 12 | relativePath: "/titles", 13 | }, 14 | { 15 | name: "Statistics", 16 | relativePath: "/statistics", 17 | }, 18 | { 19 | name: "History", 20 | relativePath: "/history", 21 | }, 22 | { 23 | name: "Tracker", 24 | relativePath: "/tracker", 25 | }, 26 | ]; 27 | 28 | export { links as profileNavigationLinks }; 29 | 30 | export default function LayoutNavigation() { 31 | const params = useParams<{ profile: string }>(); 32 | 33 | const location = useLocation(); 34 | 35 | return ( 36 | 50 | ); 51 | } 52 | -------------------------------------------------------------------------------- /app/components/User/layout/layoutHeading.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | $iconWidth: 110px; 5 | 6 | .head { 7 | display: grid; 8 | grid-template-columns: $iconWidth auto; 9 | gap: 1.5rem; 10 | 11 | background-color: color.$box; 12 | border-radius: config.$borderRadius; 13 | box-shadow: 0 0 5px color.$box; 14 | padding: 1.5rem; 15 | 16 | .icon { 17 | width: $iconWidth; 18 | height: $iconWidth; 19 | 20 | position: relative; 21 | 22 | img { 23 | width: $iconWidth; 24 | height: $iconWidth; 25 | box-shadow: 0 0 10px var(--tier); 26 | 27 | border: 3px solid var(--tier); 28 | border-radius: config.$borderRadius; 29 | } 30 | 31 | .level { 32 | position: absolute; 33 | display: inline-block; 34 | 35 | width: fit-content; 36 | padding: 5px 8px; 37 | left: 50%; 38 | bottom: 2px; // this is slightly overflowing into the icon, idk why 39 | 40 | transform: translate(-50%, 50%); 41 | 42 | background-color: color.$accent; 43 | color: color.$text; 44 | font-size: config.$font-small; 45 | 46 | border: 2px solid var(--tier); 47 | border-radius: config.$borderRadius * 0.8; 48 | box-shadow: 0 0 10px var(--tier); 49 | } 50 | } 51 | 52 | .right { 53 | display: grid; 54 | grid-template-rows: auto 1fr 35px; 55 | gap: 0.3rem; 56 | 57 | h1 { 58 | font-size: config.$font-heading; 59 | line-height: unset; 60 | margin-bottom: -5px; 61 | 62 | .tagLine { 63 | font-size: config.$font-large; 64 | color: color.$text; 65 | } 66 | 67 | .region { 68 | font-size: config.$font-default; 69 | color: color.$text; 70 | font-weight: normal; 71 | } 72 | } 73 | 74 | .achievements, 75 | .displayed { 76 | display: flex; 77 | gap: 0.5rem; 78 | 79 | .tier { 80 | display: flex; 81 | gap: 0.2rem; 82 | color: var(--tier); 83 | cursor: help; 84 | 85 | svg { 86 | display: inline-flex; 87 | } 88 | } 89 | 90 | h2 { 91 | font-size: config.$font-large; 92 | } 93 | 94 | img.display { 95 | aspect-ratio: 1/1; 96 | width: 35px; 97 | height: 35px; 98 | } 99 | } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /app/components/User/layout/layoutLoader.module.scss: -------------------------------------------------------------------------------- 1 | .loaderSection { 2 | padding: 20% 0; 3 | 4 | position: absolute; 5 | 6 | gap: 1rem; 7 | } 8 | -------------------------------------------------------------------------------- /app/components/User/layout/layoutNavigation.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .links { 5 | width: 100%; 6 | display: flex; 7 | gap: 1rem; 8 | 9 | padding: 1rem; 10 | 11 | .link { 12 | font-size: config.$font-default; 13 | color: color.$heading; 14 | 15 | padding: 0 0.25rem; 16 | padding-bottom: 0.3rem; 17 | border-bottom: 2px solid transparent; 18 | 19 | &:hover:not(.active) { 20 | border-bottom-color: color.$heading; 21 | } 22 | 23 | &.active { 24 | color: color.$primary; 25 | border-bottom-color: color.$primary; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /app/config/config.ts: -------------------------------------------------------------------------------- 1 | export const cookieNames = { 2 | titleFilter: "cgg.apprentice", 3 | 4 | overviewChallengeFilter: "cgg.overview", 5 | profileChallengeFilter: "cgg.profile", 6 | }; 7 | 8 | export const storageNames = { 9 | recents: "cgg.search", 10 | revalidate: "cgg.zilean", 11 | }; 12 | 13 | export const brandName = "Challenges.GG"; 14 | -------------------------------------------------------------------------------- /app/config/home.ts: -------------------------------------------------------------------------------- 1 | import { cdnData } from "@cgg/utils/cdn"; 2 | 3 | export interface HomeSplashes { 4 | months: MonthlySplashEntry[]; 5 | } 6 | 7 | export type QualityProfiles = { 8 | isVideo?: boolean; // default false 9 | // lq?: MediaEntry; 10 | normal: MediaEntry; 11 | hq?: MediaEntry; 12 | }; 13 | 14 | export interface MonthlySplashEntry { 15 | default: QualityProfiles; 16 | special?: Record; 17 | } 18 | export type MediaEntry = MediaEntrySplash | MediaEntryUrl; 19 | 20 | interface MediaEntrySplash { 21 | type: "splash" | "centered"; 22 | championKey: string; 23 | skinId: number; 24 | url?: never; 25 | } 26 | 27 | interface MediaEntryUrl { 28 | type?: never; 29 | championKey?: never; 30 | skinId?: never; 31 | url: string; 32 | } 33 | 34 | const homeSplashes: HomeSplashes = { 35 | months: [ 36 | { 37 | // January 38 | default: { 39 | normal: { 40 | championKey: "Anivia", 41 | type: "splash", 42 | skinId: 46, 43 | }, 44 | }, 45 | }, 46 | { 47 | // February 48 | default: { 49 | normal: { 50 | championKey: "Vi", 51 | type: "splash", 52 | skinId: 29, 53 | }, 54 | }, 55 | }, 56 | { 57 | // March 58 | default: { 59 | normal: { 60 | championKey: "Kassadin", 61 | type: "centered", 62 | skinId: 14, 63 | }, 64 | }, 65 | }, 66 | { 67 | // April 68 | default: { 69 | normal: { 70 | championKey: "Karma", 71 | type: "splash", 72 | skinId: 7, 73 | }, 74 | }, 75 | }, 76 | { 77 | // May 78 | default: { 79 | normal: { 80 | championKey: "Olaf", 81 | type: "splash", 82 | skinId: 25, 83 | }, 84 | }, 85 | }, 86 | { 87 | // June 88 | default: { 89 | normal: { 90 | championKey: "Bard", 91 | type: "splash", 92 | skinId: 8, 93 | }, 94 | }, 95 | }, 96 | { 97 | // July 98 | default: { 99 | normal: { 100 | championKey: "Graves", 101 | type: "splash", 102 | skinId: 5, 103 | }, 104 | }, 105 | }, 106 | { 107 | // August 108 | default: { 109 | normal: { 110 | championKey: "Fiora", 111 | type: "splash", 112 | skinId: 1, 113 | }, 114 | }, 115 | }, 116 | { 117 | // September 118 | default: { 119 | normal: { 120 | championKey: "Diana", 121 | type: "centered", 122 | skinId: 26, 123 | }, 124 | }, 125 | }, 126 | { 127 | // October 128 | default: { 129 | normal: { 130 | championKey: "Elise", 131 | type: "splash", 132 | skinId: 6, 133 | }, 134 | hq: { 135 | url: cdnData("home/Elise_6_HQ.webp"), 136 | }, 137 | }, 138 | }, 139 | { 140 | // November 141 | default: { 142 | normal: { 143 | championKey: "Jax", 144 | type: "centered", 145 | skinId: 13, 146 | }, 147 | hq: { 148 | url: cdnData("home/Jax_13_HQ.webp"), 149 | }, 150 | }, 151 | }, 152 | { 153 | // December 154 | default: { 155 | normal: { 156 | championKey: "Poppy", 157 | type: "splash", 158 | skinId: 14, 159 | }, 160 | hq: { 161 | url: cdnData("home/Poppy_14_HQ.webp"), 162 | }, 163 | }, 164 | }, 165 | ], 166 | }; 167 | 168 | export { homeSplashes }; 169 | -------------------------------------------------------------------------------- /app/config/json/regions.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "key": "na1", 4 | "name": "North America", 5 | "abbreviation": "NA", 6 | "platform": "americas" 7 | }, 8 | { 9 | "key": "euw1", 10 | "name": "Europe West", 11 | "abbreviation": "EUW", 12 | "platform": "europe" 13 | }, 14 | { 15 | "key": "eun1", 16 | "name": "Europe Nordic & East", 17 | "abbreviation": "EUNE", 18 | "platform": "europe" 19 | }, 20 | { 21 | "key": "me", 22 | "name": "Middle East", 23 | "abbreviation": "ME", 24 | "platform": "europe" 25 | }, 26 | { 27 | "key": "kr", 28 | "name": "Korea", 29 | "abbreviation": "KR", 30 | "platform": "asia" 31 | }, 32 | { 33 | "key": "jp1", 34 | "name": "Japan", 35 | "abbreviation": "JP", 36 | "platform": "asia" 37 | }, 38 | { 39 | "key": "br1", 40 | "name": "Brazil", 41 | "abbreviation": "BR", 42 | "platform": "americas" 43 | }, 44 | { 45 | "key": "la1", 46 | "name": "Latin America North", 47 | "abbreviation": "LAN", 48 | "platform": "americas" 49 | }, 50 | { 51 | "key": "la2", 52 | "name": "Latin America South", 53 | "abbreviation": "LAS", 54 | "platform": "americas" 55 | }, 56 | { 57 | "key": "oc1", 58 | "name": "Oceania", 59 | "abbreviation": "OCE", 60 | "platform": "americas" 61 | }, 62 | { 63 | "key": "tr1", 64 | "name": "Turkey", 65 | "abbreviation": "TR", 66 | "platform": "europe" 67 | }, 68 | { 69 | "key": "ru", 70 | "name": "Russia", 71 | "abbreviation": "RU", 72 | "platform": "europe" 73 | }, 74 | { 75 | "key": "ph2", 76 | "name": "Philippines", 77 | "abbreviation": "PH", 78 | "platform": "asia" 79 | }, 80 | { 81 | "key": "sg2", 82 | "name": "Singapore", 83 | "abbreviation": "SG", 84 | "platform": "asia" 85 | }, 86 | { 87 | "key": "th2", 88 | "name": "Thailand", 89 | "abbreviation": "TH", 90 | "platform": "asia" 91 | }, 92 | { 93 | "key": "vn2", 94 | "name": "Vietnam", 95 | "abbreviation": "VN", 96 | "platform": "asia" 97 | }, 98 | { 99 | "key": "tw2", 100 | "name": "Taiwan", 101 | "abbreviation": "TW", 102 | "platform": "asia" 103 | } 104 | ] 105 | -------------------------------------------------------------------------------- /app/config/json/regions.types.ts: -------------------------------------------------------------------------------- 1 | export type RegionsJSON = { 2 | key: string; 3 | name: string; 4 | abbreviation: string; 5 | platform: string; 6 | }[]; 7 | -------------------------------------------------------------------------------- /app/config/seasonalCapstones.ts: -------------------------------------------------------------------------------- 1 | export const seasonalCapstones: number[] = [ 2 | 2022000, // 2022 seasonal 3 | 2023000, // 2023 seasonal 4 | 2024100, // 2024 split 1 sesonal 5 | 2024200, // 2024 split 2 seasonal 6 | 2024300, // 2024 split 3 seasonal 7 | ]; 8 | -------------------------------------------------------------------------------- /app/hooks/usePageTransition.ts: -------------------------------------------------------------------------------- 1 | import { useLocation, useNavigation } from "react-router"; 2 | 3 | export function usePageTransition() { 4 | const navigation = useNavigation(); 5 | const location = useLocation(); 6 | 7 | const isPageTransition = 8 | (navigation.state === "loading" || navigation.state === "submitting") && 9 | navigation.location.key !== location.key; 10 | 11 | // TODO is not working when navigating away from a profile 12 | // apparently it does work now? 13 | const isProfileTransition = 14 | location.pathname.startsWith("/profile/") && 15 | navigation.state === "loading" && 16 | navigation.location.pathname.startsWith("/profile/"); 17 | 18 | const isChallengeTransition = 19 | location.pathname.startsWith("/challenges/") && 20 | navigation.state === "loading" && 21 | navigation.location.pathname.startsWith("/challenges/") && 22 | location.pathname === navigation.location.pathname; 23 | 24 | return { 25 | transition: isPageTransition, 26 | customLoader: isProfileTransition || isChallengeTransition, 27 | from: location, 28 | to: navigation.location, 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /app/hooks/useStaticData.ts: -------------------------------------------------------------------------------- 1 | import type { Route } from "../+types/root"; 2 | import { useRouteLoaderData } from "react-router"; 3 | 4 | export function useStaticData() { 5 | const rootData = useRouteLoaderData("root"); 6 | return rootData.staticData as Route.ComponentProps["loaderData"]["staticData"]; 7 | } 8 | -------------------------------------------------------------------------------- /app/loader/_index.ts: -------------------------------------------------------------------------------- 1 | import logoSlogan from "@cgg/assets/logo-slogan.svg?no-inline"; 2 | import { type MediaEntry, homeSplashes } from "@cgg/config/home"; 3 | import { fetchApi } from "@cgg/utils/api"; 4 | import { cdnData, getChampionImage } from "@cgg/utils/cdn"; 5 | 6 | export interface ResponseProfiles { 7 | isVideo: boolean; 8 | normal: string; 9 | // lq?: string; 10 | hq?: string; 11 | } 12 | 13 | interface LogoReq { 14 | logo: "default" | string; 15 | } 16 | 17 | function getSplashMedia(entry?: MediaEntry): string | undefined { 18 | if (!entry) return undefined; 19 | 20 | if (entry.url) { 21 | return entry.url; 22 | } else if (entry.championKey) { 23 | return getChampionImage(entry.championKey, entry.skinId, entry.type); 24 | } 25 | 26 | // this shouldnt happen 27 | return undefined; 28 | } 29 | 30 | export async function indexLoader() { 31 | const date = new Date(); 32 | const today = date.getDate(); 33 | 34 | const month = homeSplashes.months[date.getMonth()]; 35 | let media = month.default; 36 | if (month.special && month.special[today]) { 37 | media = month.special[today]; 38 | } 39 | 40 | const isVideo = media.isVideo ?? false; 41 | 42 | const splashResponse: ResponseProfiles = { 43 | isVideo, 44 | normal: getSplashMedia(media.normal)!, 45 | hq: getSplashMedia(media.hq), 46 | // lq: getSplashMedia(media.lq), 47 | }; 48 | 49 | const getLogo = await fetchApi(cdnData("/home/config.json")); 50 | if (getLogo === null) { 51 | throw new Error("Failed to fetch logo config"); 52 | } 53 | const logo = getLogo.logo === "default" ? logoSlogan : cdnData(`home/${getLogo.logo}`); 54 | 55 | return { splash: splashResponse, logo }; 56 | } 57 | -------------------------------------------------------------------------------- /app/loader/challenge.layout.ts: -------------------------------------------------------------------------------- 1 | import { getStaticData } from "@cgg/utils/endpoints/getStaticData"; 2 | 3 | // FIXME: this is stupid 4 | export async function challengeLayoutLoader({ 5 | params, 6 | }: { 7 | params: { challengeId: string }; 8 | }) { 9 | const staticData = await getStaticData(); 10 | if (staticData === null) { 11 | throw new Response("Static data unavailable", { status: 503 }); 12 | } 13 | 14 | if (staticData.challenges[params.challengeId] === undefined) { 15 | throw new Response("Not Found", { 16 | status: 404, 17 | statusText: "Challenge not found", 18 | }); 19 | } 20 | 21 | return; 22 | } 23 | -------------------------------------------------------------------------------- /app/loader/challenge.ts: -------------------------------------------------------------------------------- 1 | import { getLeaderboard } from "@cgg/utils/endpoints/getLeaderboard"; 2 | 3 | export async function challengeLoader({ 4 | params, 5 | url, 6 | }: { 7 | params: { challengeId: string }; 8 | url: string; 9 | }) { 10 | const { challengeId } = params; 11 | const searchParams = new URL(url).searchParams; 12 | const region = searchParams.get("region") || undefined; 13 | 14 | const leaderboard = await getLeaderboard(challengeId, region); 15 | if (leaderboard === null) { 16 | throw new Response("Not Found", { 17 | status: 404, 18 | statusText: "Challenge not found", 19 | }); 20 | } 21 | 22 | return { leaderboard }; 23 | } 24 | -------------------------------------------------------------------------------- /app/loader/challengesFilter.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "cookie"; 2 | import type { IChallengeFilter } from "@cgg/components/ChallengeManager/filterChallenges"; 3 | import { cookieNames } from "@cgg/config/config"; 4 | 5 | export type ChallengesLoaderLocation = "overview" | "profile"; 6 | 7 | export async function challengesLoader( 8 | request: Request, 9 | location: ChallengesLoaderLocation, 10 | client: boolean = false, 11 | ) { 12 | let filter: IChallengeFilter = { 13 | search: "", 14 | category: [], 15 | source: [], 16 | gameMode: [], 17 | }; 18 | let cookie = null; 19 | if (!client) { 20 | cookie = request.headers.get("Cookie"); 21 | } else { 22 | cookie = document.cookie; 23 | } 24 | if (cookie) { 25 | const cookiename = 26 | location === "overview" 27 | ? cookieNames.overviewChallengeFilter 28 | : cookieNames.profileChallengeFilter; 29 | const values = parse(cookie); 30 | if (values[cookiename]) { 31 | try { 32 | const parsed = JSON.parse(atob(values[cookiename] ?? "{}")); 33 | filter = { 34 | search: parsed.search.trim() ?? "", 35 | category: parsed.category ?? [], 36 | source: parsed.source ?? [], 37 | gameMode: parsed.gameMode ?? [], 38 | }; 39 | } catch (e) { 40 | // ignore invalid cookie 41 | } 42 | } 43 | } 44 | 45 | return { filter }; 46 | } 47 | -------------------------------------------------------------------------------- /app/loader/profile.layout.ts: -------------------------------------------------------------------------------- 1 | import { getChallenges } from "@cgg/utils/endpoints/getChallenges"; 2 | 3 | export async function profileLayoutLoader({ params }: { params: { profile: string } }) { 4 | const { profile } = params; 5 | const nameSections = profile.split("-", 2); 6 | 7 | if (nameSections.length !== 2) { 8 | throw new Response("Bad Request", { 9 | status: 400, 10 | statusText: "Invalid profile format", 11 | }); 12 | } 13 | 14 | const [gameName, tagLine] = nameSections; 15 | 16 | const challengeData = await getChallenges(gameName, tagLine); 17 | if (challengeData === null) { 18 | throw new Response("Not Found", { 19 | status: 404, 20 | statusText: "Profile not found", 21 | }); 22 | } 23 | 24 | return await challengeData; 25 | } 26 | -------------------------------------------------------------------------------- /app/loader/root.ts: -------------------------------------------------------------------------------- 1 | import { getStaticData } from "@cgg/utils/endpoints/getStaticData"; 2 | 3 | export async function rootLoader() { 4 | const staticData = await getStaticData(); 5 | 6 | if (staticData === null) { 7 | throw new Response("Static data unavailable", { status: 503 }); 8 | } 9 | 10 | return { staticData: staticData }; 11 | } 12 | -------------------------------------------------------------------------------- /app/loader/titles.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "cookie"; 2 | import { cookieNames } from "@cgg/config/config"; 3 | import type { titleFilter } from "@cgg/routes/titles"; 4 | import { getChampions } from "@cgg/utils/endpoints/getStaticData"; 5 | 6 | export async function titleLoader(request: Request, client: boolean = false) { 7 | const championData = await getChampions(); 8 | 9 | if (championData === null) { 10 | throw new Response("Champion data unavailable", { status: 503 }); 11 | } 12 | 13 | let filter: titleFilter[] = []; 14 | let search: string = ""; 15 | let cookie = null; 16 | if (!client) { 17 | cookie = request.headers.get("Cookie"); 18 | } else { 19 | cookie = document.cookie; 20 | } 21 | if (cookie) { 22 | const values = parse(cookie); 23 | if (values[cookieNames.titleFilter]) { 24 | try { 25 | const parsed = JSON.parse(atob(values[cookieNames.titleFilter] ?? "{}")); 26 | filter = (parsed.filters ?? []) as titleFilter[]; 27 | search = parsed.search ?? ""; 28 | } catch (e) { 29 | // ignore invalid cookie 30 | } 31 | } 32 | } 33 | 34 | return { championData, filter, search }; 35 | } 36 | -------------------------------------------------------------------------------- /app/root.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/root"; 2 | import { 3 | Links, 4 | Meta, 5 | Outlet, 6 | Scripts, 7 | ScrollRestoration, 8 | type ShouldRevalidateFunctionArgs, 9 | isRouteErrorResponse, 10 | } from "react-router"; 11 | import Container from "@cgg/components/Container/Container"; 12 | import ErrorWrapper from "@cgg/components/ErrorWrapper/ErrorWrapper"; 13 | import Loader from "@cgg/components/Loader/Loader"; 14 | import Navigation from "@cgg/components/Navigation/Navigation"; 15 | import { usePageTransition } from "@cgg/hooks/usePageTransition"; 16 | import { rootLoader } from "@cgg/loader/root"; 17 | import { cdnDomain, cupcakeDomain } from "@cgg/utils/cdn"; 18 | import "./app.scss"; 19 | import { brandName, storageNames } from "./config/config"; 20 | 21 | export const links: Route.LinksFunction = () => [ 22 | { 23 | rel: "preconnect", 24 | href: cdnDomain, 25 | }, 26 | { 27 | rel: "preconnect", 28 | href: cupcakeDomain, 29 | }, 30 | ]; 31 | 32 | export function Layout({ children }: { children: React.ReactNode }) { 33 | return ( 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | 45 | {children} 46 | 47 | 48 | 49 | 50 | ); 51 | } 52 | 53 | // Black magic 54 | function loaderFunction() { 55 | return rootLoader(); 56 | } 57 | 58 | export { loaderFunction as loader, loaderFunction as clientLoader }; 59 | 60 | // Revalidate every 15 minutes 61 | export const shouldRevalidate = ({}: ShouldRevalidateFunctionArgs) => { 62 | const revalidate = sessionStorage.getItem(storageNames.revalidate); 63 | if (!revalidate) { 64 | sessionStorage.setItem( 65 | storageNames.revalidate, 66 | JSON.stringify(Date.now() + 1000 * 60 * 15), 67 | ); 68 | return false; 69 | } 70 | 71 | const expiry = JSON.parse(revalidate); 72 | if (Date.now() > expiry) { 73 | sessionStorage.setItem( 74 | storageNames.revalidate, 75 | JSON.stringify(Date.now() + 1000 * 60 * 15), 76 | ); 77 | return true; 78 | } 79 | return false; 80 | }; 81 | 82 | export default function App() { 83 | const { transition, customLoader } = usePageTransition(); 84 | return ( 85 | 86 | {transition && !customLoader ? ( 87 | 88 | 89 | 90 | ) : ( 91 | 92 | )} 93 | 94 | ); 95 | } 96 | 97 | export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) { 98 | let message = "Oops!"; 99 | let details = "An unexpected error occurred."; 100 | let stack: string | undefined; 101 | 102 | if (isRouteErrorResponse(error)) { 103 | message = error.status === 404 ? "404" : error.data || "Error"; 104 | details = 105 | error.status === 404 106 | ? "The requested page could not be found." 107 | : error.statusText || details; 108 | } else if (import.meta.env.DEV && error && error instanceof Error) { 109 | details = error.message; 110 | stack = error.stack; 111 | } 112 | 113 | return ( 114 | 115 | 116 | 117 | ); 118 | } 119 | -------------------------------------------------------------------------------- /app/routes.ts: -------------------------------------------------------------------------------- 1 | import { type RouteConfig } from "@react-router/dev/routes"; 2 | import { flatRoutes } from "@react-router/fs-routes"; 3 | 4 | export default flatRoutes() satisfies RouteConfig; 5 | -------------------------------------------------------------------------------- /app/routes/_index.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/_index"; 2 | import Container from "@cgg/components/Container/Container"; 3 | import Heading from "@cgg/components/Heading/Heading"; 4 | import Searchbar from "@cgg/components/HomeSearch/Searchbar"; 5 | import SplashBackground from "@cgg/components/SplashBackground/SplashBackground"; 6 | import { brandName } from "@cgg/config/config"; 7 | import { indexLoader } from "@cgg/loader/_index"; 8 | import css from "@cgg/styles/home.module.scss"; 9 | 10 | export function meta({}: Route.MetaArgs) { 11 | return [ 12 | { 13 | title: `${brandName}`, 14 | }, 15 | { 16 | name: "description", 17 | content: 18 | "Lookup and track your League of League of Legends Challenges Progress. Compare your Challenge stats with others in any region. ", 19 | }, 20 | { 21 | name: "keywords", 22 | content: 23 | "league of legends challenges lookup, challenges overview, league of legends, challenge stats, league of legends challenge tracker, lol challenges, challenge progress checker, challenge stats checker, lol challenge tracker, darkintaqt challenges", 24 | }, 25 | ]; 26 | } 27 | 28 | export const handle = { 29 | transparentHeader: true, 30 | }; 31 | 32 | export async function loader(data: Route.LoaderArgs) { 33 | return indexLoader(); 34 | } 35 | 36 | export async function clientLoader(data: Route.LoaderArgs) { 37 | return indexLoader(); 38 | } 39 | 40 | export const shouldRevalidate = () => false; 41 | 42 | export default function Home({ loaderData }: Route.ComponentProps) { 43 | const { splash, logo } = loaderData; 44 | 45 | return ( 46 |
47 | 48 |
49 | 50 | 51 | {brandName} 52 | 53 | Challenge Leaderboards and Progress Tracker 54 | 55 | 56 |
57 | ); 58 | } 59 | -------------------------------------------------------------------------------- /app/routes/challenges.$challengeId._index.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/challenges.$challengeId._index"; 2 | import type { ShouldRevalidateFunctionArgs } from "react-router"; 3 | import LeaderboardComponent from "@cgg/components/Leaderboard/Leaderboard"; 4 | import { useStaticData } from "@cgg/hooks/useStaticData"; 5 | import { challengeLoader } from "@cgg/loader/challenge"; 6 | 7 | export default function Leaderboard({ loaderData, params }: Route.ComponentProps) { 8 | const challenge = useStaticData().challenges[params.challengeId]; 9 | return ; 10 | } 11 | 12 | export async function loader({ params, request }: Route.LoaderArgs) { 13 | return await challengeLoader({ params, url: request.url }); 14 | } 15 | 16 | export async function clientLoader(args: Route.LoaderArgs) { 17 | return await challengeLoader({ params: args.params, url: args.request.url }); 18 | } 19 | 20 | export const shouldRevalidate = ({ 21 | currentParams, 22 | currentUrl, 23 | nextParams, 24 | nextUrl, 25 | }: ShouldRevalidateFunctionArgs) => { 26 | return ( 27 | currentParams.challengeId !== nextParams.challengeId || 28 | currentUrl.searchParams.getAll("region").toString() !== 29 | nextUrl.searchParams.getAll("region").toString() 30 | ); 31 | }; 32 | -------------------------------------------------------------------------------- /app/routes/challenges.$challengeId.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/challenges.$challengeId"; 2 | import { useEffect } from "react"; 3 | import { Outlet, type ShouldRevalidateFunctionArgs } from "react-router"; 4 | import LeaderboardLayout from "@cgg/components/Leaderboard/Layout/LeaderboardLayout"; 5 | import { brandName } from "@cgg/config/config"; 6 | import { useStaticData } from "@cgg/hooks/useStaticData"; 7 | import { challengeLayoutLoader } from "@cgg/loader/challenge.layout"; 8 | import { type Recent, addRecentSearch } from "@cgg/utils/recents"; 9 | 10 | export default function Challenge({ params }: Route.ComponentProps) { 11 | const data = useStaticData(); 12 | const challenge = data?.challenges[params.challengeId]; 13 | if (!challenge) { 14 | return new Error("Challenge not found"); 15 | } 16 | 17 | useEffect(() => { 18 | addRecentSearch({ 19 | type: "challenge", 20 | id: challenge.id, 21 | iconId: challenge.iconId, 22 | name: challenge.name, 23 | description: challenge.description, 24 | } as Recent); 25 | }, []); 26 | 27 | return ( 28 | 29 | {`${challenge.name} Leaderboard | ${brandName}`} 30 | 31 | 32 | ); 33 | } 34 | 35 | export async function loader({ params }: Route.LoaderArgs) { 36 | return await challengeLayoutLoader({ params }); 37 | } 38 | 39 | export async function clientLoader({ params }: Route.LoaderArgs) { 40 | return await challengeLayoutLoader({ params }); 41 | } 42 | 43 | export const shouldRevalidate = ({ 44 | currentParams, 45 | nextParams, 46 | }: ShouldRevalidateFunctionArgs) => { 47 | return currentParams.challengeId !== nextParams.challengeId; 48 | }; 49 | -------------------------------------------------------------------------------- /app/routes/challenges._index.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/challenges._index"; 2 | import ChallengeManager from "@cgg/components/ChallengeManager/ChallengeManager"; 3 | import Container from "@cgg/components/Container/Container"; 4 | import Heading from "@cgg/components/Heading/Heading"; 5 | import { brandName } from "@cgg/config/config"; 6 | import { challengesLoader } from "@cgg/loader/challengesFilter"; 7 | import css from "@cgg/styles/challenges.module.scss"; 8 | 9 | export function meta({}: Route.MetaArgs) { 10 | return [ 11 | { title: `Challenges | ${brandName}` }, 12 | { 13 | name: "description", 14 | content: "All challenges in League of Legends and how to get them. ", 15 | }, 16 | ]; 17 | } 18 | 19 | export const shouldRevalidate = () => false; 20 | 21 | export async function loader({ request }: Route.LoaderArgs) { 22 | return await challengesLoader(request, "overview", false); 23 | } 24 | 25 | export async function clientLoader({ request }: Route.LoaderArgs) { 26 | return await challengesLoader(request, "overview", true); 27 | } 28 | 29 | export default function Challenges({ loaderData }: Route.ComponentProps) { 30 | const { filter } = loaderData; 31 | 32 | return ( 33 | 34 |
35 | List of all Challenges 36 |

Overview, stats and how to obtain them.

37 |
38 | 39 | 40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /app/routes/profile.$profile._index.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/profile.$profile._index"; 2 | import ChallengeManager from "@cgg/components/ChallengeManager/ChallengeManager"; 3 | import { brandName } from "@cgg/config/config"; 4 | import { challengesLoader } from "@cgg/loader/challengesFilter"; 5 | 6 | export async function loader({ request }: Route.LoaderArgs) { 7 | return await challengesLoader(request, "profile", false); 8 | } 9 | 10 | export async function clientLoader({ request }: Route.LoaderArgs) { 11 | return await challengesLoader(request, "profile", true); 12 | } 13 | 14 | export default function Profile({ matches, loaderData }: Route.ComponentProps) { 15 | const playerData = matches[1].loaderData; 16 | const { gameName, tagLine } = playerData; 17 | 18 | return ( 19 | <> 20 | {`${gameName}#${tagLine} - Profile | ${brandName}`} 21 | 26 | 27 | ); 28 | } 29 | -------------------------------------------------------------------------------- /app/routes/profile.$profile.statistics.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/profile.$profile._index"; 2 | import UserStatistics from "@cgg/components/User/Statistics/UserStatistics"; 3 | 4 | export default function Profile({ matches }: Route.ComponentProps) { 5 | const playerData = matches[1].loaderData; 6 | 7 | return ; 8 | } 9 | -------------------------------------------------------------------------------- /app/routes/profile.$profile.titles.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/profile.$profile._index"; 2 | 3 | export default function Profile({ matches }: Route.ComponentProps) { 4 | const playerData = matches[1].loaderData; 5 | 6 | return "titles, " + playerData.gameName + " " + playerData.tagLine; 7 | } 8 | -------------------------------------------------------------------------------- /app/routes/profile.$profile.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/profile.$profile"; 2 | import { useEffect } from "react"; 3 | import type { ShouldRevalidateFunctionArgs } from "react-router"; 4 | import { Outlet } from "react-router"; 5 | import Container from "@cgg/components/Container/Container"; 6 | import { LayoutHeading, LayoutNavigation } from "@cgg/components/User/layout"; 7 | import LayoutLoader from "@cgg/components/User/layout/LayoutLoader"; 8 | import { brandName } from "@cgg/config/config"; 9 | import { usePageTransition } from "@cgg/hooks/usePageTransition"; 10 | import { profileLayoutLoader } from "@cgg/loader/profile.layout"; 11 | import css from "@cgg/styles/profile.layout.module.scss"; 12 | import { cdnAssets } from "@cgg/utils/cdn"; 13 | import { type Recent, addRecentSearch } from "@cgg/utils/recents"; 14 | 15 | export const shouldRevalidate = ({ 16 | currentParams, 17 | nextParams, 18 | }: ShouldRevalidateFunctionArgs) => { 19 | // TODO revalidate after x minutes 20 | return !(currentParams.profile === nextParams.profile); 21 | }; 22 | 23 | export default function Layout({ loaderData }: Route.ComponentProps) { 24 | const { summoner, gameName, tagLine } = loaderData; 25 | const tier = summoner.tier; 26 | const { transition } = usePageTransition(); 27 | 28 | useEffect(() => { 29 | addRecentSearch({ 30 | type: "summoner", 31 | id: loaderData.id, 32 | name: gameName, 33 | tagLine: tagLine, 34 | icon: summoner.profileIcon, 35 | } as Recent); 36 | }, []); 37 | 38 | return ( 39 | <> 40 | {/* {`${gameName}#${tagLine} - Profile | ${brandName}`} */} 41 | 45 | {""} 51 | 52 | 53 | 54 | 55 | {/* 56 | Need a content wrapper, maybe even in the . 57 | Outlet is not hidden on transition, Loader is pos absolute 58 | */} 59 | 60 | {!transition && } 61 | 62 | 63 | ); 64 | } 65 | 66 | export async function clientLoader({ params }: Route.LoaderArgs) { 67 | return await profileLayoutLoader({ params }); 68 | } 69 | 70 | export async function loader({ params }: Route.LoaderArgs) { 71 | return await profileLayoutLoader({ params }); 72 | } 73 | -------------------------------------------------------------------------------- /app/routes/titles.tsx: -------------------------------------------------------------------------------- 1 | import type { Route } from "./+types/titles"; 2 | import { serialize } from "cookie"; 3 | import { useEffect, useState } from "react"; 4 | import Buttons from "@cgg/components/Buttons/Buttons"; 5 | import Container from "@cgg/components/Container/Container"; 6 | import Heading from "@cgg/components/Heading/Heading"; 7 | import Searchbar from "@cgg/components/Searchbar/Searchbar"; 8 | import Title from "@cgg/components/Title/Title"; 9 | import { brandName, cookieNames } from "@cgg/config/config"; 10 | import { useStaticData } from "@cgg/hooks/useStaticData"; 11 | import { titleLoader } from "@cgg/loader/titles"; 12 | import css from "@cgg/styles/titles.module.scss"; 13 | 14 | export function meta({}: Route.MetaArgs) { 15 | return [ 16 | { title: `Titles | ${brandName}` }, 17 | { 18 | name: "description", 19 | content: "All obtainable titles in League of Legends and how to get them. ", 20 | }, 21 | ]; 22 | } 23 | 24 | export async function loader({ request }: Route.LoaderArgs) { 25 | return await titleLoader(request, false); 26 | } 27 | 28 | export async function clientLoader({ request }: Route.LoaderArgs) { 29 | return await titleLoader(request, true); 30 | } 31 | 32 | export type titleFilter = "CHALLENGE" | "EVENT" | "CHAMPION"; 33 | 34 | export default function Titles({ loaderData }: Route.ComponentProps) { 35 | const data = useStaticData(); 36 | const { championData } = loaderData; 37 | const champions = Object.values(championData.data); 38 | const titles = data.titles; 39 | 40 | const [filters, setFilter] = useState(loaderData.filter); 41 | const [search, setSearch] = useState(loaderData.search); 42 | 43 | useEffect(() => { 44 | document.cookie = serialize( 45 | cookieNames.titleFilter, 46 | btoa(JSON.stringify({ filters, search })), 47 | { 48 | path: "/", 49 | }, 50 | ); 51 | }, [filters, search]); 52 | 53 | return ( 54 | 55 |
56 | All Titles 57 |

58 | A list of all League of Legends Title Challenges and how to achieve them 59 |

60 |
61 | 62 |
63 | 69 | 70 | buttons={[ 71 | { name: "Challenges", id: "CHALLENGE" }, 72 | { name: "Champions", id: "CHAMPION" }, 73 | { name: "Events", id: "EVENT" }, 74 | ]} 75 | state={filters} 76 | setState={setFilter} 77 | /> 78 |
79 | 80 |
81 | {Object.values(titles) 82 | .filter((title) => { 83 | let type: titleFilter = "EVENT"; 84 | if (title.challengeId !== undefined) type = "CHALLENGE"; 85 | if (title.type === "CHAMPION_MASTERY" && title.requirement) 86 | type = "CHAMPION"; 87 | 88 | const typeMatch = filters.length === 0 || filters.includes(type); 89 | const searchMatch = 90 | search.length === 0 || 91 | title.name.toLowerCase().includes(search.toLowerCase()) || 92 | title.requirement?.name 93 | .toLowerCase() 94 | .includes(search.toLowerCase()) || 95 | title.requirement?.description 96 | .toLowerCase() 97 | .includes(search.toLowerCase()); 98 | 99 | return typeMatch && searchMatch; 100 | }) 101 | .sort((a, b) => a.name.localeCompare(b.name)) 102 | .map((title) => ( 103 | 104 | ))} 105 | </div> 106 | </Container> 107 | ); 108 | } 109 | 110 | export const shouldRevalidate = () => false; 111 | -------------------------------------------------------------------------------- /app/styles/_colors.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Primary color 3 | * this color shouldn't be used more than absolutely neccessary 4 | */ 5 | $primary: rgb(13, 189, 255) !default; 6 | 7 | /* 8 | * Dark-ish colors 9 | */ 10 | $background: rgb(28, 28, 38) !default; 11 | $accent: rgb(20, 20, 30) !default; // used for header/footer 12 | 13 | $gray: rgb(34, 34, 44) !default; // used to highlight elements 14 | $light-gray: rgb(50, 50, 60) !default; // hover highlighted elements 15 | $border: rgb(60, 60, 70) !default; // hover highlighted elements 16 | 17 | $box: color-mix(in srgb, $gray 60%, transparent 40%) !default; 18 | 19 | /* 20 | * Text colors 21 | */ 22 | $heading: #fff !default; 23 | $text: rgb(206, 206, 210) !default; 24 | $secondary: rgb(170, 170, 174) !default; 25 | 26 | $win: #4287f5 !default; // match history and promotion 27 | $loss: #eb4034 !default; // match history and demotion 28 | $good: #2abf52 !default; 29 | $gold: #fcba03 !default; // perfect 30 | -------------------------------------------------------------------------------- /app/styles/_config.scss: -------------------------------------------------------------------------------- 1 | /* 2 | * Font sizes 3 | */ 4 | $font-heading: 2.5rem !default; 5 | $font-large: 1.4rem !default; 6 | $font-default: 1.1rem !default; 7 | $font-small: 0.85rem !default; 8 | 9 | /* 10 | * Sizes 11 | */ 12 | $borderRadius: 12px !default; 13 | $navigation: 70px !default; 14 | $default-container: 1000px !default; 15 | $small-container: 700px !default; 16 | $large-container: 1200px !default; 17 | $header-height: 70px !default; 18 | -------------------------------------------------------------------------------- /app/styles/challenges.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .challengesContainer { 5 | margin-top: 20px !important; 6 | } 7 | -------------------------------------------------------------------------------- /app/styles/home.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .home { 5 | width: 100%; 6 | 7 | min-height: 100vh; 8 | 9 | background-color: color.$accent; 10 | position: relative; 11 | 12 | display: flex; 13 | align-items: center; 14 | justify-content: center; 15 | 16 | .overlay { 17 | position: absolute; 18 | width: 100%; 19 | height: 100%; 20 | 21 | z-index: 2; 22 | 23 | background-color: transparent; 24 | background-image: radial-gradient(ellipse, transparent 40%, color.$background); 25 | } 26 | 27 | .content { 28 | z-index: 3; 29 | 30 | display: flex; 31 | align-items: center; 32 | flex-direction: column; 33 | 34 | h1, 35 | h2 { 36 | text-shadow: 0 0 5px #000; 37 | } 38 | 39 | h1 { 40 | font-size: config.$font-heading * 1.5; 41 | line-height: config.$font-heading * 1.5 * 1.25; 42 | } 43 | 44 | h2 { 45 | padding-bottom: config.$font-default; 46 | 47 | font-size: config.$font-large * 1.5; 48 | line-height: config.$font-large * 1.5 * 1.25; 49 | font-family: "Istok Web"; 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /app/styles/profile.layout.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | 3 | .bg { 4 | width: 100%; 5 | max-height: 100%; 6 | position: absolute; 7 | z-index: 0; 8 | 9 | object-fit: cover; 10 | 11 | opacity: 0.5; 12 | } 13 | 14 | .layout { 15 | // Important flag is required here 16 | padding: config.$header-height + 20px 0 0 !important; 17 | } 18 | -------------------------------------------------------------------------------- /app/styles/titles.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/config" as config; 2 | @use "@cgg/styles/colors" as color; 3 | 4 | .titlesContainer { 5 | margin-top: 20px !important; 6 | 7 | .head { 8 | } 9 | 10 | .filters { 11 | background: color.$accent; 12 | top: config.$header-height; 13 | position: sticky; 14 | z-index: 25; 15 | padding: 1rem 0; 16 | display: flex; 17 | gap: 1rem; 18 | 19 | border-bottom: 1px solid color.$border; 20 | box-shadow: 0 0 5px color.$accent; 21 | align-items: stretch; 22 | } 23 | 24 | .titles { 25 | display: grid; 26 | 27 | gap: 0.5rem; 28 | grid-template-columns: 1fr 1fr; 29 | padding: 1rem 1rem 2rem; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /app/styles/variables.module.scss: -------------------------------------------------------------------------------- 1 | @use "@cgg/styles/colors" as color; 2 | 3 | .NONE, 4 | .NONCHALLENGE { 5 | --tier: #{color.$secondary}; 6 | } 7 | 8 | .IRON, 9 | .UNRANKED { 10 | --tier: hsl(0, 0%, 41%); 11 | } 12 | 13 | .BRONZE { 14 | --tier: hsl(17, 42%, 39%); 15 | } 16 | 17 | .SILVER { 18 | --tier: hsl(189, 12%, 56%); 19 | } 20 | 21 | .GOLD { 22 | --tier: hsl(31, 60%, 52%); 23 | } 24 | 25 | .PLATINUM { 26 | --tier: hsl(171, 65%, 36%); 27 | } 28 | 29 | .DIAMOND { 30 | --tier: hsl(230, 55%, 57%); 31 | } 32 | 33 | // Discontinued 34 | .EMERALD { 35 | --tier: hsl(154, 44%, 49%); 36 | } 37 | 38 | .MASTER { 39 | --tier: hsl(286, 53%, 51%); 40 | } 41 | 42 | .GRANDMASTER { 43 | --tier: hsl(351, 65%, 66%); 44 | } 45 | 46 | .CHALLENGER { 47 | --tier: hsl(40, 85%, 70%); 48 | } 49 | -------------------------------------------------------------------------------- /app/utils/api.ts: -------------------------------------------------------------------------------- 1 | const apiBase = "https://challenges.gg/api"; 2 | 3 | export async function fetchApiPath<T>( 4 | path: string, 5 | method: "GET" | "POST" = "GET", 6 | ): Promise<T | null> { 7 | return fetchApi(`${apiBase}${path}`, method); 8 | } 9 | 10 | export async function fetchApi<T>( 11 | url: string, 12 | method: "GET" | "POST" = "GET", 13 | ): Promise<T | null> { 14 | try { 15 | const request = await fetch(url, { 16 | method, 17 | headers: { 18 | // "Content-Type": "application/json", 19 | }, 20 | }); 21 | 22 | if (await !request.ok) return null; 23 | const response = (await request.json()) as T; 24 | 25 | return response; 26 | } catch (err) { 27 | console.error("Failed to fetch API path", err); 28 | return null; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /app/utils/capitalize.ts: -------------------------------------------------------------------------------- 1 | export function capitalize(input: string) { 2 | return input 3 | .toLowerCase() 4 | .split(" ") 5 | .map((word) => { 6 | return word.charAt(0).toUpperCase() + word.slice(1); 7 | }) 8 | .join(" "); 9 | } 10 | -------------------------------------------------------------------------------- /app/utils/cdn.ts: -------------------------------------------------------------------------------- 1 | import type { GameMode } from "./challenges"; 2 | import type { Tier } from "./tier"; 3 | 4 | export const cdnDomain = "https://cdn.yearin.lol"; 5 | export const cupcakeDomain = "https://cupcake.yearin.lol"; 6 | const cdnPath = cdnDomain + "/cupcake/"; 7 | const cupcakePath = cupcakeDomain + "/cupcake/"; 8 | 9 | const dataPath = cdnDomain + "/cgg-data/"; 10 | 11 | // Get a CDN dynamic asset path (latest images update once per patch) 12 | export function cdn(file: string, isImage = true): string { 13 | let path = `${cupcakePath}latest/`; 14 | path += file; 15 | 16 | if (isImage) { 17 | path += ".webp"; 18 | } 19 | 20 | return path; 21 | } 22 | 23 | // Get static CDN path data in the cgg-data bucket 24 | export function cdnData(file: string): string { 25 | let path = `${dataPath}${file}`; 26 | return path; 27 | } 28 | 29 | // Get a raw CDN path 30 | export function cdnBase(file: string, isImage = true): string { 31 | let path = `${cdnPath}${file}`; 32 | 33 | if (isImage) { 34 | path += ".webp"; 35 | } 36 | 37 | return path; 38 | } 39 | 40 | // Get a CDN path prefixed with assets/ 41 | export function cdnAssets(file: string, isImage = true): string { 42 | return cdnBase(`assets/${file}`, isImage); 43 | } 44 | 45 | export function getChallengeIcon(icon: number, tier: Tier = "MASTER"): string { 46 | if (tier === "NONCHALLENGE" || tier === "UNRANKED" || tier === "NONE") tier = "IRON"; 47 | 48 | if (icon > 99) { 49 | return cdn(`challenges/tokens/${icon}-${tier.toLowerCase()}`); 50 | } 51 | 52 | if (icon === 0) { 53 | return cdnAssets(`challenges/crystals/${tier.toLowerCase()}`); 54 | } 55 | 56 | return cdnAssets(`challenges/categories/${icon}.webp`, false); 57 | } 58 | 59 | export function getProfileIcon(iconId: number): string { 60 | return cdn(`icons/${iconId}`); 61 | } 62 | 63 | export function getChampionImage( 64 | championKey: string, 65 | skinId: number | string = 0, 66 | type: "centered" | "splash" = "splash", 67 | ): string { 68 | return cdn(`champions/${type}/${championKey}_${skinId}`); 69 | } 70 | 71 | export function getGamemodeIcon(gamemode: GameMode): string { 72 | let fileType = "svg"; 73 | 74 | if (gamemode === "bot") { 75 | fileType = "png"; 76 | } 77 | 78 | return cdnData(`icons/gamemodes/${gamemode}.${fileType}`); 79 | } 80 | -------------------------------------------------------------------------------- /app/utils/challengeSource.tsx: -------------------------------------------------------------------------------- 1 | import { FaAnglesUp, FaBoxOpen, FaPlay, FaRankingStar, FaUser } from "react-icons/fa6"; 2 | import clashIcon from "@cgg/assets/clash.svg?no-inline"; 3 | import eternalsIcon from "@cgg/assets/eternals.svg?no-inline"; 4 | import type { Source } from "./challenges"; 5 | 6 | export function getChallengeSourceIcon(source: Source) { 7 | switch (source) { 8 | case "CHALLENGES": 9 | return <FaAnglesUp />; 10 | case "EOGD": 11 | return <FaPlay />; 12 | case "ETERNALS": 13 | return <img src={eternalsIcon} alt="" draggable={false} />; 14 | case "CLASH": 15 | return <img src={clashIcon} alt="" draggable={false} />; 16 | case "CAP_INVENTORY": 17 | return <FaBoxOpen />; 18 | case "RANKED": 19 | return <FaRankingStar />; 20 | case "SUMMONER": 21 | return <FaUser />; 22 | default: 23 | return null; 24 | } 25 | } 26 | 27 | export function getChallengeSourceName(source: Source): string { 28 | switch (source) { 29 | case "CHALLENGES": 30 | return "Progress"; 31 | case "EOGD": 32 | return "Ingame"; 33 | case "ETERNALS": 34 | return "Eternals"; 35 | case "CLASH": 36 | return "Clash"; 37 | case "CAP_INVENTORY": 38 | return "Inventory"; 39 | case "RANKED": 40 | return "Ranked"; 41 | case "SUMMONER": 42 | return "Profile"; 43 | default: 44 | return source; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /app/utils/challenges.ts: -------------------------------------------------------------------------------- 1 | import type { Tier } from "./tier"; 2 | 3 | export const gameModes = ["rift", "aram", "arena", "bot", "swarm", "none"] as const; 4 | export type GameMode = (typeof gameModes)[number]; 5 | 6 | export function gameModeToString(gamemode: GameMode): string { 7 | switch (gamemode) { 8 | case "rift": 9 | return "Summoner's Rift"; 10 | case "aram": 11 | return "ARAM"; 12 | case "arena": 13 | return "Arena"; 14 | case "bot": 15 | return "Co-op vs. AI"; 16 | case "swarm": 17 | return "Swarm"; 18 | case "none": 19 | return "None"; 20 | default: 21 | return gamemode; 22 | } 23 | } 24 | 25 | export const sources = [ 26 | "CHALLENGES", 27 | "EOGD", 28 | "CLASH", 29 | "ETERNALS", 30 | "CAP_INVENTORY", 31 | "SUMMONER", 32 | "RANKED", 33 | ] as const; 34 | export type Source = (typeof sources)[number]; 35 | 36 | export const categories = [1, 2, 3, 4, 5, -1] as const; 37 | export type Category = (typeof categories)[number]; 38 | 39 | interface IChallengesFullDTO { 40 | challenges: Record<string, IChallengeDTO>; 41 | titles: Record<string, ITitleDTO>; 42 | } 43 | 44 | interface IChallengeDTO { 45 | id: number; 46 | iconId: number; 47 | name: string; 48 | description: string; 49 | descriptionShort: string; 50 | source: Source; 51 | tags: { 52 | isCapstone?: string; 53 | isCategory?: string; 54 | parent?: number | string; 55 | }; 56 | queueIds: number[]; 57 | seasons: number[]; 58 | endTimestamp?: number; 59 | thresholds: Record<Tier | "CROWN", IThresholdDTO>; 60 | percentiles?: Record<Tier, number>; 61 | leaderboard: boolean; 62 | reverseDirection?: boolean; 63 | titles?: ITitleRewardDTO[]; 64 | categoryId: number; 65 | gameMode: GameMode; 66 | retired: boolean; 67 | } 68 | 69 | interface IThresholdDTO { 70 | points: number; 71 | playersInLevel?: number; 72 | } 73 | 74 | interface ITitleRewardDTO { 75 | level: Tier; 76 | titleId: number; 77 | category: string; 78 | quantity?: number; 79 | } 80 | 81 | interface ITitleDTO { 82 | name: string; 83 | id: number; 84 | challengeId?: number; 85 | type: string; 86 | requirement?: { 87 | name: string; 88 | description: string; 89 | }; 90 | icon?: string; 91 | } 92 | 93 | export type { IChallengesFullDTO, IChallengeDTO, ITitleRewardDTO, ITitleDTO }; 94 | -------------------------------------------------------------------------------- /app/utils/champions.d.ts: -------------------------------------------------------------------------------- 1 | interface IChampionDTO { 2 | name: string; 3 | id: string; 4 | key: number; 5 | spells: { [key: string]: SpellDTO }; 6 | } 7 | 8 | interface IChampionFullDTO { 9 | version: string; 10 | data: { [key: string]: ChampionDTO }; 11 | } 12 | 13 | export type { IChampionDTO, IChampionFullDTO }; 14 | -------------------------------------------------------------------------------- /app/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | function debounce<Params extends any[]>( 2 | func: (...args: Params) => any, 3 | timeout: number, 4 | ): (...args: Params) => void { 5 | let timer: NodeJS.Timeout; 6 | return (...args: Params) => { 7 | clearTimeout(timer); 8 | timer = setTimeout(() => { 9 | func(...args); 10 | }, timeout); 11 | }; 12 | } 13 | 14 | export default debounce; 15 | -------------------------------------------------------------------------------- /app/utils/endpoints/getChallenges.ts: -------------------------------------------------------------------------------- 1 | import { fetchApiPath } from "../api"; 2 | import type { IApiChallengeResponse } from "./types"; 3 | 4 | export async function getChallenges(gameName: string, tagLine: string) { 5 | const response = await fetchApiPath<IApiChallengeResponse>( 6 | `/challenges/by-riot-id/${gameName}/${tagLine}`, 7 | ); 8 | 9 | return response; 10 | } 11 | -------------------------------------------------------------------------------- /app/utils/endpoints/getLeaderboard.ts: -------------------------------------------------------------------------------- 1 | import { fetchApiPath } from "../api"; 2 | import type { IApiLeaderboardEntry } from "./types"; 3 | 4 | export async function getLeaderboard(challengeId: string, region?: string) { 5 | let path = `/leaderboard/${challengeId}`; 6 | if (region) { 7 | path += `?region=${region}`; 8 | } 9 | 10 | const response = await fetchApiPath<IApiLeaderboardEntry[]>(path); 11 | 12 | return response; 13 | } 14 | -------------------------------------------------------------------------------- /app/utils/endpoints/getProfile.ts: -------------------------------------------------------------------------------- 1 | import { fetchApiPath } from "../api"; 2 | import type { IApiProfileResponse } from "./types"; 3 | 4 | export async function getProfile(gameName: string, tagLine: string) { 5 | const response = await fetchApiPath<IApiProfileResponse>( 6 | `/summoner/by-riot-id/${gameName}/${tagLine}`, 7 | ); 8 | 9 | return response; 10 | } 11 | -------------------------------------------------------------------------------- /app/utils/endpoints/getStaticData.ts: -------------------------------------------------------------------------------- 1 | import type { IChampionFullDTO } from "@cgg/utils/champions"; 2 | import { fetchApi } from "../api"; 3 | import { cdn, cdnData } from "../cdn"; 4 | import type { IChallengesFullDTO } from "../challenges"; 5 | 6 | export async function getStaticData(lang: string = "en-US", region: string = "euw1") { 7 | return await fetchApi<IChallengesFullDTO>( 8 | cdnData(`challenges/${lang}/${region}.json`), 9 | ); 10 | } 11 | 12 | export async function getChampions(lang: string = "en-US") { 13 | return await fetchApi<IChampionFullDTO>(cdn(`data/${lang}/champion.json`, false)); 14 | } 15 | -------------------------------------------------------------------------------- /app/utils/endpoints/getVerified.ts: -------------------------------------------------------------------------------- 1 | import { fetchApiPath } from "../api"; 2 | import type { IApiVerifiedResponse } from "./types"; 3 | 4 | export async function getVerifiedProfile(puuid: string) { 5 | const response = await fetchApiPath<IApiVerifiedResponse>(`/verified/${puuid}`); 6 | 7 | return response; 8 | } 9 | -------------------------------------------------------------------------------- /app/utils/endpoints/types.d.ts: -------------------------------------------------------------------------------- 1 | import type { Tier } from "../tier"; 2 | 3 | export interface IApiAccountResponse { 4 | id: string; 5 | gameName: string; 6 | tagLine: string; 7 | region: string; 8 | } 9 | 10 | interface IApiProfileSummoner { 11 | profileIcon: number; 12 | level: number; 13 | } 14 | 15 | export interface IApiProfileResponse extends IApiAccountResponse { 16 | summoner: IApiProfileSummoner; 17 | } 18 | 19 | interface IApiChallengeSummoner extends IApiProfileSummoner { 20 | tier: Tier; 21 | totalPoints: number; 22 | percentile: number; 23 | title: string | null; 24 | displayedChallenges?: number[]; 25 | } 26 | 27 | interface IApiChallenge { 28 | challengeId: number; 29 | percentile: number; 30 | tier: Tier; 31 | value: number; 32 | achievedTime?: number; 33 | position?: number; 34 | playersInLevel?: number; 35 | } 36 | 37 | export interface IApiChallengeResponse extends IApiProfileResponse { 38 | summoner: IApiChallengeSummoner; 39 | challenges: IApiChallenge[]; 40 | } 41 | 42 | export interface IApiVerifiedResponse {} 43 | 44 | export interface IApiLeaderboardEntry { 45 | puuid: string; 46 | gameName: string; 47 | tagLine: string; 48 | region: string; 49 | iconId: number; 50 | tier: Tier; 51 | verified?: boolean; 52 | points: number; 53 | } 54 | -------------------------------------------------------------------------------- /app/utils/formatNumber.ts: -------------------------------------------------------------------------------- 1 | const units = ["K", "M", "B", "T"] as const; 2 | type Units = (typeof units)[number]; 3 | 4 | export function formatNumber(num: number, minify: boolean | Units = false) { 5 | if (typeof num !== "number") return ""; 6 | 7 | if (minify) { 8 | const index = units.indexOf(minify === true ? units[0] : minify); 9 | 10 | if (index >= 0 && num >= 10 ** ((index + 1) * 3)) { 11 | let unit = ""; 12 | let value = num; 13 | for (let i = units.length - 1; i >= 0; i--) { 14 | const size = 10 ** ((i + 1) * 3); 15 | if (num >= size) { 16 | value = num / size; 17 | unit = units[i]; 18 | break; 19 | } 20 | } 21 | return `${value.toFixed(1).replace(/\.0$/, "")}${unit}`; 22 | } 23 | } 24 | 25 | return num.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ","); 26 | } 27 | -------------------------------------------------------------------------------- /app/utils/getChallenge.ts: -------------------------------------------------------------------------------- 1 | import type { IChallengeDTO, IChallengesFullDTO } from "./challenges"; 2 | import type { IApiChallengeResponse } from "./endpoints/types"; 3 | import type { Tier } from "./tier"; 4 | 5 | export function getChallenge( 6 | challengeId: string | number | null, 7 | data: IChallengesFullDTO, 8 | ): IChallengeDTO | null { 9 | if (challengeId === null || typeof challengeId === "undefined") { 10 | return null; 11 | } 12 | 13 | const challenges = data.challenges; 14 | 15 | const challenge = challenges[challengeId]; 16 | if (!challenge) { 17 | return null; 18 | } 19 | 20 | return challenge; 21 | } 22 | 23 | interface IChallengeUserResponse extends IChallengeDTO { 24 | percentile: number; 25 | tier: Tier; 26 | value: number; 27 | achievedTime?: number; 28 | position?: number; 29 | playersInLevel?: number; 30 | } 31 | 32 | export function getUserChallenge( 33 | challengeId: string | number | null, 34 | profile: IApiChallengeResponse, 35 | data: IChallengesFullDTO, 36 | ): IChallengeUserResponse | null { 37 | const challenge = getChallenge(challengeId, data); 38 | if (!challenge) { 39 | return null; 40 | } 41 | 42 | const userChallenge = profile.challenges.find((ch) => ch.challengeId === challengeId); 43 | if (!userChallenge) { 44 | return null; 45 | } 46 | 47 | return { 48 | ...challenge, 49 | ...userChallenge, 50 | }; 51 | } 52 | -------------------------------------------------------------------------------- /app/utils/getParent.ts: -------------------------------------------------------------------------------- 1 | import type { IChallengeDTO, IChallengesFullDTO } from "./challenges"; 2 | 3 | export function getParents( 4 | challenge: IChallengeDTO, 5 | challenges: IChallengesFullDTO["challenges"], 6 | ): number[] { 7 | const parents: number[] = []; 8 | let parent = challenge.tags.parent || challenge.categoryId || 0; 9 | if (typeof parent === "string") { 10 | parent = parseInt(parent); 11 | } 12 | 13 | if (parent > 0 && parent !== challenge.id) { 14 | parents.push(parent); 15 | } 16 | 17 | while (parent > 0) { 18 | let nextParent: string | number = 19 | challenges[parent].tags.parent || challenges[parent].categoryId || 0; 20 | 21 | if (typeof nextParent === "string") { 22 | nextParent = parseInt(nextParent); 23 | } 24 | 25 | if (nextParent === parent) { 26 | break; 27 | } 28 | 29 | if (nextParent > 0 || nextParent === -1) { 30 | parents.push(nextParent); 31 | } 32 | 33 | parent = nextParent; 34 | } 35 | 36 | return parents; 37 | } 38 | -------------------------------------------------------------------------------- /app/utils/getProximityScore.ts: -------------------------------------------------------------------------------- 1 | const maxScore = 100; 2 | const baseScore = 10; 3 | 4 | /* 5 | * Compares how close b is to a 6 | */ 7 | export function getProximityScore(a: string, b: string): number { 8 | if (!a.includes(b)) return 0; 9 | 10 | let score = baseScore; 11 | 12 | if (a === b) { 13 | score = maxScore; 14 | } else if (a.startsWith(b)) { 15 | score = 50; 16 | } 17 | 18 | // todo, this algo could probably be optimized but it is good enough haha 19 | return score; 20 | } 21 | -------------------------------------------------------------------------------- /app/utils/getTier.ts: -------------------------------------------------------------------------------- 1 | import type { IChallengeDTO } from "./challenges"; 2 | import type { IApiChallenge } from "./endpoints/types"; 3 | import { tierList } from "./suffixToTier"; 4 | import type { Tier } from "./tier"; 5 | 6 | export function getTier(challenge: IChallengeDTO, userData?: IApiChallenge): Tier { 7 | let tier: Tier = "MASTER"; 8 | 9 | tierList.forEach((t) => { 10 | if (challenge.thresholds.hasOwnProperty(t)) { 11 | tier = t; 12 | } 13 | }); 14 | 15 | if (userData) { 16 | if (challenge.thresholds.hasOwnProperty(userData.tier)) { 17 | tier = userData.tier; 18 | } else { 19 | return userData.tier; 20 | } 21 | } 22 | 23 | return tier; 24 | } 25 | 26 | export function getNextTier( 27 | tier: Tier, 28 | challenge: IChallengeDTO, 29 | includeCrown = false, 30 | ): Tier | "CROWN" { 31 | const index = tierList.indexOf(tier); 32 | if (index === tierList.length - 1) { 33 | if (includeCrown && challenge.thresholds.hasOwnProperty("CROWN")) { 34 | return "CROWN"; 35 | } 36 | return tier; 37 | } 38 | for (let i = index + 1; i < tierList.length; i++) { 39 | if (challenge.thresholds.hasOwnProperty(tierList[i])) { 40 | return tierList[i]; 41 | } 42 | } 43 | 44 | return tier; 45 | } 46 | 47 | export function getPosition(user: IApiChallenge, challenge: IChallengeDTO): number { 48 | if (!challenge.leaderboard) return 0; 49 | if (!user.position) return 0; 50 | 51 | let position = user.position; 52 | 53 | let currentTier: Tier | "CROWN" = user.tier; 54 | 55 | while (currentTier !== "CROWN") { 56 | const nextTier = getNextTier(currentTier, challenge, true); 57 | if (currentTier === nextTier) break; 58 | 59 | if (challenge.thresholds[currentTier].playersInLevel) { 60 | position += challenge.thresholds[currentTier].playersInLevel!; 61 | } 62 | 63 | currentTier = nextTier; 64 | } 65 | 66 | return position; 67 | } 68 | 69 | export function getTierIndex(tier: Tier | "CROWN" | undefined): number { 70 | if (!tier) return -2; 71 | if (tier === "CROWN") return tierList.length; 72 | return tierList.indexOf(tier) || -1; 73 | } 74 | -------------------------------------------------------------------------------- /app/utils/getTitle.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | IChallengeDTO, 3 | IChallengesFullDTO, 4 | ITitleDTO, 5 | } from "./challenges"; 6 | import { suffixToTier } from "./suffixToTier"; 7 | import type { Tier } from "./tier"; 8 | 9 | interface ITitleResponse extends ITitleDTO { 10 | tier: Tier; 11 | challenge?: IChallengeDTO; 12 | } 13 | 14 | export default function getTitle( 15 | titleId: string | number | null, 16 | data: IChallengesFullDTO, 17 | ): ITitleResponse | null { 18 | if (!titleId) { 19 | return null; 20 | } 21 | 22 | const titles = data.titles; 23 | 24 | const title = titles[titleId]; 25 | if (!title) { 26 | return null; 27 | } 28 | 29 | let tier: Tier = "NONCHALLENGE"; 30 | let challenge: IChallengeDTO | undefined = undefined; 31 | if (title.challengeId) { 32 | challenge = data.challenges[title.challengeId]; 33 | if (challenge) { 34 | const rewards = challenge.titles ?? []; 35 | 36 | const foundTitle = rewards.find((t) => t.titleId === title.id); 37 | if (foundTitle) { 38 | tier = foundTitle.level; 39 | } 40 | } 41 | } 42 | 43 | return { 44 | ...title, 45 | tier, 46 | challenge, 47 | }; 48 | } 49 | -------------------------------------------------------------------------------- /app/utils/recents.ts: -------------------------------------------------------------------------------- 1 | import { storageNames } from "@cgg/config/config"; 2 | 3 | export interface RecentSummoner { 4 | type: "summoner"; 5 | id: string; 6 | name: string; 7 | tagLine: string; 8 | icon: number; 9 | } 10 | 11 | export interface RecentChallenge { 12 | type: "challenge"; 13 | id: number; 14 | iconId: number; 15 | name: string; 16 | description: string; 17 | } 18 | 19 | export type Recent = RecentSummoner | RecentChallenge; 20 | 21 | const serverSide = typeof localStorage === "undefined"; 22 | 23 | export function getRecentSearches(noEmptyResults = true): Recent[] { 24 | if (serverSide) return []; 25 | 26 | const recents = localStorage.getItem(storageNames.recents); 27 | if (!recents) { 28 | if (!noEmptyResults) return []; 29 | 30 | return [ 31 | { 32 | type: "summoner", 33 | id: "recent-summoner", 34 | name: "Hide on bush", 35 | tagLine: "KR1", 36 | icon: 6, 37 | }, 38 | { 39 | type: "challenge", 40 | id: 0, 41 | iconId: 0, 42 | name: "Challenge Leaderboard", 43 | description: "", 44 | }, 45 | ] as Recent[]; 46 | } 47 | 48 | try { 49 | // wrong validation will throw an error, I assume 50 | return JSON.parse(recents) as Recent[]; 51 | } catch (e) { 52 | console.error("Failed to parse recents from localStorage", e); 53 | return []; 54 | } 55 | } 56 | 57 | export function addRecentSearch(recent: Recent) { 58 | if (serverSide) return; 59 | const recents = getRecentSearches(false); 60 | 61 | // remove existing recent with same id 62 | const filteredRecents = recents.filter((r) => r.id !== recent.id); 63 | 64 | // add new recent to the front 65 | filteredRecents.unshift(recent); 66 | 67 | if (filteredRecents.length > 5) { 68 | filteredRecents.length = 5; 69 | } 70 | localStorage.setItem(storageNames.recents, JSON.stringify(filteredRecents)); 71 | } 72 | -------------------------------------------------------------------------------- /app/utils/regionToString.ts: -------------------------------------------------------------------------------- 1 | import regions from "@cgg/config/json/regions.json"; 2 | import type { RegionsJSON } from "@cgg/config/json/regions.types"; 3 | 4 | interface IRegionToStringResponse { 5 | name: string; 6 | abbreviation: string; 7 | platform: string; 8 | } 9 | 10 | export function regionToString(region: string): IRegionToStringResponse { 11 | const foundRegion = (regions as RegionsJSON).find((r) => r.key === region) ?? { 12 | key: region, 13 | name: region, 14 | abbreviation: region, 15 | platform: "%", 16 | }; 17 | 18 | return { 19 | name: foundRegion.name, 20 | abbreviation: foundRegion.abbreviation, 21 | platform: foundRegion.platform, 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /app/utils/suffixToTier.ts: -------------------------------------------------------------------------------- 1 | import type { Tier } from "./tier"; 2 | 3 | const tierList: Tier[] = [ 4 | "IRON", 5 | "BRONZE", 6 | "SILVER", 7 | "GOLD", 8 | "PLATINUM", 9 | "DIAMOND", 10 | "MASTER", 11 | "GRANDMASTER", 12 | "CHALLENGER", 13 | ]; 14 | 15 | export function suffixToTier(suffix: string | number): Tier { 16 | if (typeof suffix === "string") { 17 | suffix = parseInt(suffix); 18 | } 19 | 20 | if (isNaN(suffix)) { 21 | return "NONCHALLENGE"; 22 | } 23 | 24 | const index = tierList[suffix]; 25 | if (index) { 26 | return index; 27 | } 28 | return "NONCHALLENGE"; 29 | } 30 | 31 | export { tierList }; 32 | -------------------------------------------------------------------------------- /app/utils/tier.d.ts: -------------------------------------------------------------------------------- 1 | type Tier = 2 | | "NONE" 3 | | "UNRANKED" 4 | | "IRON" 5 | | "BRONZE" 6 | | "SILVER" 7 | | "GOLD" 8 | | "PLATINUM" 9 | | "DIAMOND" 10 | | "MASTER" 11 | | "GRANDMASTER" 12 | | "CHALLENGER" 13 | | "NONCHALLENGE"; 14 | export type { Tier }; 15 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "challenges", 3 | "private": true, 4 | "type": "module", 5 | "license": "MIT", 6 | "engines": { 7 | "node": ">=22" 8 | }, 9 | "scripts": { 10 | "build": "react-router build", 11 | "dev": "react-router dev", 12 | "start": "react-router-serve ./build/server/index.js", 13 | "typecheck": "react-router typegen && tsc", 14 | "format": "prettier --write --ignore-unknown ./app ", 15 | "test": "vitest run" 16 | }, 17 | "dependencies": { 18 | "@emotion/react": "^11.14.0", 19 | "@emotion/styled": "^11.14.1", 20 | "@fontsource/istok-web": "^5.2.8", 21 | "@fontsource/roboto": "^5.2.6", 22 | "@mui/material": "^7.3.4", 23 | "@mui/x-charts": "^8.13.1", 24 | "@react-router/fs-routes": "7.9.3", 25 | "@react-router/node": "7.9.3", 26 | "@react-router/serve": "7.9.3", 27 | "clsx": "^2.1.1", 28 | "cookie": "^1.0.2", 29 | "isbot": "^5.1.31", 30 | "nanoid": "^5.1.6", 31 | "react": "19.2.0", 32 | "react-dom": "19.2.0", 33 | "react-icons": "^5.5.0", 34 | "react-router": "7.9.3", 35 | "simplebar-react": "^3.3.2", 36 | "vite-plugin-babel": "^1.3.2" 37 | }, 38 | "devDependencies": { 39 | "@babel/preset-typescript": "^7.27.1", 40 | "@react-router/dev": "7.9.3", 41 | "@testing-library/dom": "^10.4.0", 42 | "@testing-library/jest-dom": "^6.6.3", 43 | "@testing-library/react": "^16.3.0", 44 | "@testing-library/user-event": "^14.6.1", 45 | "@trivago/prettier-plugin-sort-imports": "^5.2.2", 46 | "@types/node": "20", 47 | "@types/react": "19.2.0", 48 | "@types/react-dom": "19.2.0", 49 | "babel-plugin-react-compiler": "^19.1.0-rc.3", 50 | "jsdom": "27.0.0", 51 | "prettier": "^3.5.3", 52 | "sass-embedded": "1.93.2", 53 | "typescript": "^5.8.3", 54 | "vite": "7.1.9", 55 | "vite-tsconfig-paths": "^5.1.4", 56 | "vitest": "^3.2.4" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/DarkIntaqt/challenges/HEAD/public/favicon.ico -------------------------------------------------------------------------------- /public/no-js.css: -------------------------------------------------------------------------------- 1 | .simplebar-content-wrapper { 2 | scrollbar-width: auto; 3 | -ms-overflow-style: auto; 4 | } 5 | 6 | .simplebar-content-wrapper::-webkit-scrollbar, 7 | .simplebar-hide-scrollbar::-webkit-scrollbar { 8 | display: initial; 9 | width: initial; 10 | height: initial; 11 | } 12 | -------------------------------------------------------------------------------- /react-router.config.ts: -------------------------------------------------------------------------------- 1 | import type { Config } from "@react-router/dev/config"; 2 | 3 | export default { 4 | // Config options... 5 | // Server-side render by default, to enable SPA mode set this to `false` 6 | ssr: true, 7 | routeDiscovery: { 8 | mode: "initial", 9 | }, 10 | } satisfies Config; 11 | -------------------------------------------------------------------------------- /tests/capitalize.test.tsx: -------------------------------------------------------------------------------- 1 | import { capitalize } from "@cgg/utils/capitalize"; 2 | 3 | test("capitalize function capitalizes the first letter of each word", () => { 4 | expect(capitalize("hello world")).toBe("Hello World"); 5 | expect(capitalize("capitalize this sentence")).toBe("Capitalize This Sentence"); 6 | }); 7 | 8 | test("capitalize function handles single words", () => { 9 | expect(capitalize("test")).toBe("Test"); 10 | expect(capitalize("CAPITALIZE")).toBe("Capitalize"); 11 | }); 12 | -------------------------------------------------------------------------------- /tests/container.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import Container from "@cgg/components/Container/Container"; 3 | import css from "@cgg/components/Container/container.module.scss"; 4 | 5 | const text = "Hello World"; 6 | 7 | test("Container renders with default props", () => { 8 | render(<Container>{text}</Container>); 9 | 10 | const container = screen.getByText(text); 11 | expect(container).toBeInTheDocument(); 12 | expect(container).toBeVisible(); 13 | expect(container).toHaveClass(css.container); 14 | expect(container).not.toHaveClass(css.large); 15 | expect(container).not.toHaveClass(css.small); 16 | expect(container).not.toHaveClass(css.center); 17 | 18 | expect(container).toHaveTextContent(text); 19 | }); 20 | 21 | test("Container renders with large prop", () => { 22 | render(<Container large>{text}</Container>); 23 | 24 | const container = screen.getByText(text); 25 | expect(container).toBeInTheDocument(); 26 | expect(container).toBeVisible(); 27 | expect(container).toHaveClass(css.container); 28 | expect(container).toHaveClass(css.large); 29 | expect(container).not.toHaveClass(css.small); 30 | expect(container).not.toHaveClass(css.center); 31 | 32 | expect(container).toHaveTextContent(text); 33 | }); 34 | 35 | test("Container renders with small props", () => { 36 | render(<Container small>{text}</Container>); 37 | 38 | const container = screen.getByText(text); 39 | expect(container).toBeInTheDocument(); 40 | expect(container).toBeVisible(); 41 | expect(container).toHaveClass(css.container); 42 | expect(container).not.toHaveClass(css.large); 43 | expect(container).toHaveClass(css.small); 44 | expect(container).not.toHaveClass(css.center); 45 | 46 | expect(container).toHaveTextContent(text); 47 | }); 48 | 49 | test("Container renders with center props", () => { 50 | render(<Container center>{text}</Container>); 51 | 52 | const container = screen.getByText(text); 53 | expect(container).toBeInTheDocument(); 54 | expect(container).toBeVisible(); 55 | expect(container).toHaveClass(css.container); 56 | expect(container).not.toHaveClass(css.large); 57 | expect(container).not.toHaveClass(css.small); 58 | expect(container).toHaveClass(css.center); 59 | 60 | expect(container).toHaveTextContent(text); 61 | }); 62 | 63 | test("Container crashes with small and large prop", () => { 64 | expect(() => 65 | render( 66 | <Container large small> 67 | {text} 68 | </Container>, 69 | ), 70 | ).toThrow(); 71 | }); 72 | 73 | test("Container renders without text", () => { 74 | const { container } = render(<Container></Container>); 75 | 76 | const section = container.querySelector("section"); 77 | expect(section).toBeInTheDocument(); 78 | expect(section).toHaveTextContent(""); 79 | }); 80 | -------------------------------------------------------------------------------- /tests/debounce.test.tsx: -------------------------------------------------------------------------------- 1 | import debounce from "@cgg/utils/debounce"; 2 | 3 | test("debounce function delays execution", async () => { 4 | // using vitest fake timers and check if debounce function works 5 | const fn = vi.fn(); 6 | vi.useFakeTimers(); 7 | const bounce = debounce(fn, 100); 8 | 9 | bounce(); 10 | expect(fn).not.toBeCalled(); 11 | 12 | bounce(); 13 | expect(fn).not.toBeCalled(); 14 | 15 | vi.advanceTimersByTime(50); 16 | expect(fn).not.toBeCalled(); 17 | bounce(); 18 | 19 | vi.advanceTimersByTime(90); 20 | expect(fn).not.toBeCalled(); 21 | 22 | vi.advanceTimersByTime(15); 23 | expect(fn).toBeCalledTimes(1); 24 | 25 | bounce(); 26 | vi.advanceTimersByTime(100); 27 | expect(fn).toBeCalledTimes(2); 28 | 29 | vi.useRealTimers(); 30 | }); 31 | -------------------------------------------------------------------------------- /tests/formatNumber.test.tsx: -------------------------------------------------------------------------------- 1 | import { formatNumber } from "@cgg/utils/formatNumber"; 2 | 3 | test("formatNumber returns empty string for non-number input", () => { 4 | // @ts-ignore 5 | expect(formatNumber("not a number")).toBe(""); 6 | // @ts-ignore 7 | expect(formatNumber(null)).toBe(""); 8 | // @ts-ignore 9 | expect(formatNumber(undefined)).toBe(""); 10 | }); 11 | 12 | test("formatNumber returns the number as a string with commas when minify is false", () => { 13 | expect(formatNumber(1234567)).toBe("1,234,567"); 14 | expect(formatNumber(1000)).toBe("1,000"); 15 | expect(formatNumber(9876543210)).toBe("9,876,543,210"); 16 | }); 17 | 18 | test("formatNumber minifies numbers correctly when minify is true", () => { 19 | expect(formatNumber(1500, true)).toBe("1.5K"); 20 | expect(formatNumber(2000000, true)).toBe("2M"); 21 | expect(formatNumber(3500000000, true)).toBe("3.5B"); 22 | expect(formatNumber(7200000000000, true)).toBe("7.2T"); 23 | expect(formatNumber(999, true)).toBe("999"); 24 | }); 25 | 26 | test("formatNumber minifies numbers starting from the provided unit", () => { 27 | expect(formatNumber(1500, "K")).toBe("1.5K"); 28 | // since 1500 is less than 1M, it should not be minified 29 | expect(formatNumber(1500, "M")).toBe("1,500"); 30 | 31 | // since 2000000 is larger than 1K, it should be minified to its next matching unit 32 | expect(formatNumber(2000000, "K")).toBe("2M"); 33 | expect(formatNumber(2000000, "M")).toBe("2M"); 34 | 35 | expect(formatNumber(3500000000, "M")).toBe("3.5B"); 36 | expect(formatNumber(3500000000, "B")).toBe("3.5B"); 37 | 38 | expect(formatNumber(7200000000000, "B")).toBe("7.2T"); 39 | expect(formatNumber(7200000000000, "T")).toBe("7.2T"); 40 | 41 | // since 999 is less than 1K, it should not be minified 42 | expect(formatNumber(999, "K")).toBe("999"); 43 | expect(formatNumber(999, "M")).toBe("999"); 44 | expect(formatNumber(999, "B")).toBe("999"); 45 | expect(formatNumber(999, "T")).toBe("999"); 46 | }); 47 | -------------------------------------------------------------------------------- /tests/heading.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen } from "@testing-library/react"; 2 | import Heading from "@cgg/components/Heading/Heading"; 3 | import css from "@cgg/components/Heading/heading.module.scss"; 4 | 5 | const text = "Hello World"; 6 | 7 | test("Heading renders without props", () => { 8 | render(<Heading>{text}</Heading>); 9 | 10 | const heading = screen.getByText(text); 11 | expect(heading).toBeInTheDocument(); 12 | expect(heading).toBeVisible(); 13 | expect(heading).toHaveClass(css.heading); 14 | expect(heading.tagName).toBe("H1"); 15 | expect(heading).toHaveTextContent(text); 16 | }); 17 | 18 | const className = "test-classname"; 19 | test("Heading applies custom classname", () => { 20 | render(<Heading className={className}>{text}</Heading>); 21 | 22 | const heading = screen.getByText(text); 23 | expect(heading).toBeInTheDocument(); 24 | expect(heading).toBeVisible(); 25 | expect(heading).toHaveClass(css.heading); 26 | expect(heading).toHaveClass(className); 27 | expect(heading.tagName).toBe("H1"); 28 | expect(heading).toHaveTextContent(text); 29 | }); 30 | 31 | test("Heading has levels", () => { 32 | for (let i = 1; i <= 6; i++) { 33 | // @ts-expect-error 34 | render(<Heading level={i}>{i}</Heading>); 35 | const heading = screen.getByText(i); 36 | expect(heading.tagName).toBe("H" + i); 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /tests/proximityScore.test.tsx: -------------------------------------------------------------------------------- 1 | import { getProximityScore } from "@cgg/utils/getProximityScore"; 2 | 3 | test("getProximityScore returns correct score", () => { 4 | const result = getProximityScore("test", "test"); 5 | expect(result).toBe(100); 6 | }); 7 | 8 | test("getProximityScore returns 0 for no match", () => { 9 | const result = getProximityScore("test", "nope"); 10 | expect(result).toBe(0); 11 | }); 12 | 13 | test("getProximityScore returns 50 for startsWith match", () => { 14 | const result = getProximityScore("testing", "test"); 15 | expect(result).toBe(50); 16 | }); 17 | -------------------------------------------------------------------------------- /tests/setup.ts: -------------------------------------------------------------------------------- 1 | import "@testing-library/jest-dom"; 2 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["**/*", "**/.server/**/*", "**/.client/**/*", ".react-router/types/**/*"], 3 | "compilerOptions": { 4 | "lib": ["DOM", "DOM.Iterable", "ES2022"], 5 | "types": ["node", "vite/client", "vitest/globals", "@testing-library/jest-dom"], 6 | "target": "ES2022", 7 | "module": "ES2022", 8 | "moduleResolution": "bundler", 9 | "jsx": "react-jsx", 10 | "rootDirs": [".", "./.react-router/types"], 11 | "baseUrl": ".", 12 | "paths": { 13 | "@cgg/*": ["./app/*"] 14 | }, 15 | "esModuleInterop": true, 16 | "verbatimModuleSyntax": true, 17 | "noEmit": true, 18 | "resolveJsonModule": true, 19 | "skipLibCheck": true, 20 | "strict": true 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { reactRouter } from "@react-router/dev/vite"; 2 | import * as path from "node:path"; 3 | import { defineConfig } from "vite"; 4 | import babel from "vite-plugin-babel"; 5 | import tsconfigPaths from "vite-tsconfig-paths"; 6 | 7 | export default defineConfig({ 8 | plugins: [ 9 | !process.env.VITEST && reactRouter(), 10 | tsconfigPaths(), 11 | babel({ 12 | filter: /\.[jt]sx?$/, 13 | babelConfig: { 14 | presets: ["@babel/preset-typescript"], 15 | plugins: ["babel-plugin-react-compiler"], 16 | generatorOpts: { 17 | compact: true, // silence file size warns 18 | }, 19 | }, 20 | }), 21 | ], 22 | test: { 23 | environment: "jsdom", 24 | globals: true, 25 | setupFiles: ["./tests/setup.ts"], 26 | }, 27 | resolve: { 28 | alias: { 29 | "@cgg": path.resolve(__dirname, "app"), 30 | }, 31 | }, 32 | css: { 33 | modules: { 34 | generateScopedName: 35 | process.env.NODE_ENV === "production" 36 | ? "[hash:base64:8]" 37 | : "[name]_[local]_[hash:base64:4]", 38 | }, 39 | }, 40 | build: { 41 | sourcemap: false, 42 | rollupOptions: { 43 | output: { 44 | entryFileNames: `chunks/component.[hash].js`, 45 | chunkFileNames: `chunks/chunk.[hash].js`, 46 | assetFileNames: `assets/[name].[hash][extname]`, 47 | }, 48 | }, 49 | }, 50 | }); 51 | --------------------------------------------------------------------------------