Clean slate

This commit is contained in:
Kalle (Sendou) 2021-10-23 16:06:15 +03:00
parent deaf3564e0
commit e123544358
1469 changed files with 0 additions and 110254 deletions

View File

@ -1,14 +0,0 @@
{
"presets": [
[
"next/babel",
{
"preset-env": {},
"transform-runtime": {},
"styled-jsx": {},
"class-properties": {}
}
]
],
"plugins": ["macros"]
}

5
.env
View File

@ -1,5 +0,0 @@
# used for log-in
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
JWT_SECRET=
NEXTAUTH_URL=http://localhost:3000

View File

@ -1,3 +0,0 @@
{
"extends": ["next", "next/core-web-vitals"]
}

View File

@ -1,6 +0,0 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "daily"

View File

@ -1,51 +0,0 @@
name: CI
on:
- pull_request
jobs:
cypress-run:
name: Cypress tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres
env:
POSTGRES_PASSWORD: password
POSTGRES_USER: sendou_ink
ports:
- 5432:5432
# Set health checks to wait until postgres has started
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
steps:
- name: Check out code
uses: actions/checkout@25a956c84d5dd820d28caab9f86b8d183aeeff3d # v2
- name: Install Node
uses: actions/setup-node@5c355be17065acf11598c7a9bb47112fbcf2bbdc # v2
with:
node-version: "14"
- name: Install dependencies
run: npm install
- name: Write .env file for Prisma
run: echo "DATABASE_URL=postgresql://sendou_ink:password@localhost:${{ job.services.postgres.ports[5432] }}" > prisma/.env
- name: Prep the database
run: npm run migrate
- name: Prep other resources
run: npm run compile && npm run prebuild
- name: Run prettier
run: npm run prettier:check
- name: Run Cypress
uses: cypress-io/github-action@9eab5368cd2520a946489cd3f937583ff5a30443 # v2
with:
start: npm run dev
- name: Save Cypress artifacts
uses: actions/upload-artifact@ea3d524381d563437a7d64af63f3d75ca55521c4 # v2
if: failure()
with:
name: cypress-outputs
path: |
cypress/screenshots/*
cypress/videos/*
retention-days: 30

45
.gitignore vendored
View File

@ -1,45 +0,0 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
.vscode/
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
prisma/.env
# vercel
.vercel
# lingui
locale/_build/
locale/**/*.js
/prisma/scripts/mongo
/prisma/scripts/data
/prisma/scripts/output
dumped.sql

View File

@ -1,8 +0,0 @@
.next/
prisma/scripts/data
prisma/scripts/mongo
# Ignore JSON files generated via script
utils/data/patrons.json
locale/*/*.js

View File

@ -1,8 +0,0 @@
{
"extends": [
"stylelint-config-standard",
"stylelint-config-prettier",
"stylelint-config-idiomatic-order"
],
"plugins": ["stylelint-order"]
}

21
LICENSE
View File

@ -1,21 +0,0 @@
MIT License
Copyright (c) 2021 Sendou
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

103
README.md
View File

@ -1,103 +0,0 @@
[![Discord Server](https://discordapp.com/api/guilds/299182152161951744/embed.png)](https://discord.gg/sendou)
Goal of sendou.ink is to provide useful tools and resources for the Splatoon community.
Live version: [https://sendou.ink/](https://sendou.ink/)
## Technologies used
- React (via Next.JS)
- TypeScript
- Node.js
- PostgreSQL (via Prisma 2)
## A few of the features
🐙 Choose between light and dark mode
🦑 Planner tool where you can draw on any map in the game to conveniently make up game plans
🐙 Calendar that collects together all the events happening in the community
🦑 Users can make an account and submit their builds and browse builds made by others
🐙 It is possible to submit yourself as "free agent". If two FA's like each other they are notified and a new team can be founded
🦑 X Rank Top 500 results can be browsed through far more conveniently than on the official app
🐙 X Rank Top 500 leaderboards to compare yourself against other players
🦑 Form your own team, recruit players and make a profile
🐙 Build analyzer that reveals way more detailed information about builds than the game does
🦑 Salmon Run leaderboards featuring some of the best records
🐙 The most comprehensive link collection in the community
## Setting up the project locally
### Access pages that don't need database access
With the following steps you can access a few pages that don't need a database. For example: home page (`/`), build analyzer (`/analyzer`) and map planner (`/plans`)
1. Clone the project
2. Run `npm i` to install dependencies
3. Run `npm run compile` to compile translation files.
4. Run `npm run dev` to start the development server at http://localhost:3000/. (To stop the server at any time, type `Ctrl+C`.)
### Access rest of the pages
In addition to the steps above the steps below enable access to rest of the pages.
5. Create a file called `.env` in the `prisma` folder. In it you need an environmental variable called `DATABASE_URL` that contains the URL to a running PostgreSQL database. For example mine looks like this while developing:
```
DATABASE_URL=postgresql://sendou@localhost:5432
```
_You can see [Prisma's guide on how to set up a PostgreSQL database running locally](https://www.prisma.io/dataguide/postgresql/setting-up-a-local-postgresql-database) for more info._
6. Use `npm run migrate` to get the database formatted with the right tables.
7. Run `npm run prebuild` to generate a few necessary JSON configuration files.
8. Seed some example data in the database by running `npm run seed`. (This seed data is incomplete see issue #197 if you would like to improve the seed data!)
### Enable logging in
In addition to the steps above the steps below enable logging in.
9. Create a file called `.env.local` in the root folder. In it you need following variables:
```
DISCORD_CLIENT_ID="<your Discord client ID>"
DISCORD_CLIENT_SECRET="<your Discord client secret>"
JWT_SECRET="<a long, cryptographically random string>"
```
a) Go to https://discord.com/developers/applications
b) Select "New Application"
c) Go to your newly generated application
d) On the "General Information" tab both "CLIENT ID" and "CLIENT SECRET" can be found.
e) On the "OAuth2" tab add `http://localhost:3000/api/auth/callback/discord` in the list of redirects.
For `JWT_SECRET`, use a long, cryptographically random string. You can use `node` to generate such a string as follows:
```
node -e "require('crypto').randomBytes(64, function(ex, buf) { console.log(buf.toString('base64')) })"
```
Make sure to restart your server after setting these new values (`Ctrl+C` + `npm run dev`).
## Using API
If you wish to use the sendou.ink API for your own project like a Discord bot you can use the API endpoints under `https://sendou.ink/api/bot` (https://github.com/Sendouc/sendou.ink/tree/main/pages/api/bot) as long as you keep the load on my backend reasonable.
Using other endpoints isn't advised as I change those as I feel to suit the needs of the website. If the endpoints under `/bot` don't meet your use case feel free to leave an issue.
## Contributing
Any kind of contributions are most welcome! If you notice a problem with the website or have a feature request then you can submit an issue for it if one doesn't already exist.
I label [issues that should be the most approachable to contribute towards with the help wanted label](https://github.com/Sendouc/sendou.ink/issues?q=is%3Aopen+is%3Aissue+label%3A%22help+wanted%22). That doesn't mean you couldn't work on other issues just ask if you need extra help with them. If you want to work on something that isn't an issue yet then just make one first so we can discuss it before you start.
If you have any questions you can either make an issue with the label question or ask it on the [Discord server](https://discord.gg/sendou) (there is a channel called `#💻-development` for this purpose).

View File

@ -1,232 +0,0 @@
import {
Box,
Flex,
IconButton,
Popover,
PopoverContent,
PopoverTrigger,
Progress,
useColorMode,
} from "@chakra-ui/react";
import { Ability } from "@prisma/client";
import { ViewSlotsAbilities } from "components/builds/ViewSlots";
import AbilityIcon from "components/common/AbilityIcon";
import { Explanation } from "hooks/useAbilityEffects";
import { useState } from "react";
import { FaChartLine, FaQuestion } from "react-icons/fa";
import { CSSVariables } from "utils/CSSVariables";
import { isMainAbility } from "utils/lists/abilities";
import StatChart from "./StatChart";
interface BuildStatsProps {
explanations: Explanation[];
otherExplanations?: Explanation[];
build: ViewSlotsAbilities;
hideExtra?: boolean;
showNotActualProgress?: boolean;
startChartsAtZero?: boolean;
}
const BuildStats: React.FC<BuildStatsProps> = ({
explanations,
otherExplanations,
build,
hideExtra = true,
showNotActualProgress = false,
startChartsAtZero = true,
}) => {
const [expandedCharts, setExpandedCharts] = useState<Set<string>>(new Set());
const abilityArrays: (Ability | "UNKNOWN")[][] = [
build.headAbilities ?? [],
build.clothingAbilities ?? [],
build.shoesAbilities ?? [],
];
const abilityToPoints: Partial<Record<Ability, number>> = {};
abilityArrays.forEach((arr) =>
arr.forEach((ability, index) => {
if (ability !== "UNKNOWN") {
let abilityPoints = index === 0 ? 10 : 3;
if (isMainAbility(ability)) abilityPoints = 999;
abilityToPoints[ability] = abilityToPoints.hasOwnProperty(ability)
? (abilityToPoints[ability] as any) + abilityPoints
: abilityPoints;
}
})
);
const BuildStat: React.FC<{
title: string;
effect: string;
otherEffect?: string;
ability: Ability;
info?: string;
progressBarValue: number;
otherProgressBarValue?: number;
getEffect?: (ap: number) => number;
ap: number;
otherAp?: number;
chartExpanded: boolean;
toggleChart: () => void;
}> = ({
title,
effect,
ability,
otherEffect,
otherProgressBarValue,
getEffect,
info,
ap,
otherAp,
chartExpanded,
toggleChart,
progressBarValue = 0,
}) => {
const { colorMode } = useColorMode();
return (
<>
<div className="flex justify-space-between">
<Flex fontWeight="bold" mr="1em" mb="0.5em" alignItems="center">
<div>
<AbilityIcon ability={ability} size="TINY" loading="eager" />
</div>
<IconButton
aria-label="Show chart for the stat"
mx="0.5rem"
icon={<FaChartLine />}
onClick={() => toggleChart()}
isRound
variant="ghost"
/>
{title}
{info && (
<Popover trigger="hover" placement="top-start">
<PopoverTrigger>
<Box>
<Box
color={CSSVariables.themeColor}
ml="0.2em"
as={FaQuestion}
mb="0.2em"
/>
</Box>
</PopoverTrigger>
<PopoverContent
zIndex={4}
p="0.5em"
bg={CSSVariables.secondaryBgColor}
border="0"
>
{info}
</PopoverContent>
</Popover>
)}
</Flex>
<Box
fontWeight="bold"
color={`orange.${colorMode === "dark" ? "200" : "500"}`}
alignSelf="flex-end"
>
{effect}
</Box>
</div>
<Progress
colorScheme="orange"
height={otherEffect ? "16px" : "32px"}
value={progressBarValue}
bg={colorMode === "dark" ? "#464b64" : `orange.100`}
/>
{otherEffect && (
<>
<Progress
colorScheme="blue"
height="16px"
value={otherProgressBarValue}
bg={colorMode === "dark" ? "#464b64" : `blue.100`}
/>
<div className="flex justify-space-between">
<div />
<Box
fontWeight="bold"
color={`blue.${colorMode === "dark" ? "200" : "500"}`}
alignSelf="flex-end"
>
{otherEffect}
</Box>
</div>
</>
)}
{getEffect && chartExpanded && (
<Box my="1em" ml="-26px">
<StatChart
title={title}
ap={ap}
otherAp={otherAp}
getEffect={getEffect}
startChartsAtZero={startChartsAtZero}
/>
</Box>
)}
</>
);
};
return (
<>
{explanations.map((_explanation, index) => {
const explanation = explanations[index];
const otherExplanation = otherExplanations
? otherExplanations[index]
: undefined;
if (
explanation.effectFromMax === 0 &&
(!otherExplanation || otherExplanation.effectFromMax === 0) &&
hideExtra
) {
return null;
}
return (
<Box my="1em" key={explanation.title}>
<BuildStat
title={explanation.title}
ability={explanation.ability}
effect={explanation.effect}
progressBarValue={
showNotActualProgress
? explanation.effectFromMax
: explanation.effectFromMaxActual
}
otherEffect={otherExplanation?.effect}
otherProgressBarValue={
showNotActualProgress
? otherExplanation?.effectFromMax
: otherExplanation?.effectFromMaxActual
}
getEffect={explanation.getEffect}
info={explanation.info}
ap={explanation.ap}
otherAp={otherExplanation?.ap}
chartExpanded={expandedCharts.has(explanation.title)}
toggleChart={() => {
const newSet = new Set(expandedCharts);
if (newSet.has(explanation.title)) {
newSet.delete(explanation.title);
} else {
newSet.add(explanation.title);
}
setExpandedCharts(newSet);
}}
/>
</Box>
);
})}
</>
);
};
export default BuildStats;

View File

@ -1,237 +0,0 @@
import { Box, Button, Flex, IconButton } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import ViewSlots, { ViewSlotsAbilities } from "components/builds/ViewSlots";
import AbilitiesSelector from "components/u/AbilitiesSelector";
import { FiCopy, FiEdit, FiRotateCw, FiSquare } from "react-icons/fi";
import { AbilityOrUnknown } from "utils/types";
import HeadOnlyToggle from "./HeadOnlyToggle";
import LdeSlider from "./LdeSlider";
interface EditableBuildsProps {
build: Omit<ViewSlotsAbilities, "weapon">;
otherBuild: Omit<ViewSlotsAbilities, "weapon">;
setBuild: React.Dispatch<
React.SetStateAction<Omit<ViewSlotsAbilities, "weapon">>
>;
showOther: boolean;
setShowOther: React.Dispatch<React.SetStateAction<boolean>>;
otherFocused: boolean;
changeFocus: () => void;
bonusAp: Partial<Record<AbilityOrUnknown, boolean>>;
setBonusAp: React.Dispatch<
React.SetStateAction<Partial<Record<AbilityOrUnknown, boolean>>>
>;
otherBonusAp: Partial<Record<AbilityOrUnknown, boolean>>;
setOtherBonusAp: React.Dispatch<
React.SetStateAction<Partial<Record<AbilityOrUnknown, boolean>>>
>;
lde: number;
otherLde: number;
setLde: React.Dispatch<React.SetStateAction<number>>;
setOtherLde: React.Dispatch<React.SetStateAction<number>>;
resetBuild: () => void;
resetOtherBuild: () => void;
}
const EditableBuilds: React.FC<EditableBuildsProps> = ({
build,
otherBuild,
setBuild,
showOther,
setShowOther,
otherFocused,
changeFocus,
bonusAp,
setBonusAp,
otherBonusAp,
setOtherBonusAp,
lde,
otherLde,
setLde,
setOtherLde,
resetBuild,
resetOtherBuild,
}) => {
const buildToEdit = otherFocused ? otherBuild : build;
const handleChange = (value: Object) =>
setBuild({ ...buildToEdit, ...value });
const handleClickBuildAbility = (
slot: "headAbilities" | "clothingAbilities" | "shoesAbilities",
index: number
) => {
const copy = buildToEdit[slot].slice();
copy[index] = "UNKNOWN";
handleChange({
[slot]: copy,
});
};
const headAbility = build.headAbilities ? build.headAbilities[0] : "SSU";
const otherHeadAbility = otherBuild.headAbilities
? otherBuild.headAbilities[0]
: "SSU";
const shoesAbility = build.shoesAbilities ? build.shoesAbilities[0] : "SSU";
const otherShoesAbility = otherBuild.shoesAbilities
? otherBuild.shoesAbilities[0]
: "SSU";
const isBuildEmpty = (build: Omit<ViewSlotsAbilities, "weapon">) => {
return [
...build.headAbilities,
...build.clothingAbilities,
...build.shoesAbilities,
].every((ability) => ability === "UNKNOWN");
};
return (
<>
<Button
leftIcon={showOther ? <FiSquare /> : <FiCopy />}
onClick={() => {
if (showOther && otherFocused) {
changeFocus();
}
setShowOther(!showOther);
}}
mt="1em"
mb="2em"
size="sm"
variant="outline"
>
{showOther ? t`Stop comparing` : t`Compare`}
</Button>
<Flex justifyContent="space-evenly" flexWrap="wrap" mb="1em">
<Flex flexDirection="column">
<Flex justifyContent="center">
{showOther && (
<IconButton
aria-label="Edit orange build"
disabled={!otherFocused}
colorScheme="orange"
onClick={changeFocus}
icon={<FiEdit />}
isRound
/>
)}
<IconButton
aria-label={showOther ? "Reset orange build" : "Reset build"}
colorScheme="gray"
onClick={resetBuild}
visibility={isBuildEmpty(build) ? "hidden" : "visible"}
icon={<FiRotateCw />}
ml="1em"
isRound
/>
</Flex>
<ViewSlots
abilities={build}
onAbilityClick={!otherFocused ? handleClickBuildAbility : undefined}
m="1em"
cursor={!otherFocused ? undefined : "not-allowed"}
/>
{["OG", "CB"].includes(headAbility) && (
<HeadOnlyToggle
ability={headAbility as any}
active={bonusAp[headAbility] ?? false}
setActive={() =>
setBonusAp({
...bonusAp,
[headAbility]: !bonusAp[headAbility],
})
}
/>
)}
{headAbility === "LDE" && (
<LdeSlider
value={lde}
setValue={(value: number) => setLde(value)}
/>
)}
{shoesAbility === "DR" && (
<HeadOnlyToggle
ability={shoesAbility}
active={bonusAp[shoesAbility] ?? false}
setActive={() =>
setBonusAp({
...bonusAp,
[shoesAbility]: !bonusAp[shoesAbility],
})
}
/>
)}
</Flex>
{showOther && (
<Flex flexDirection="column">
<Flex justifyContent="center">
<IconButton
aria-label="Edit blue build"
disabled={otherFocused}
colorScheme="blue"
onClick={changeFocus}
icon={<FiEdit />}
isRound
/>
<IconButton
aria-label="Reset blue build"
colorScheme="gray"
onClick={resetOtherBuild}
icon={<FiRotateCw />}
visibility={isBuildEmpty(otherBuild) ? "hidden" : "visible"}
ml="1em"
isRound
/>
</Flex>
<ViewSlots
abilities={otherBuild}
onAbilityClick={
otherFocused ? handleClickBuildAbility : undefined
}
m="1em"
cursor={otherFocused ? undefined : "not-allowed"}
/>
{["OG", "CB"].includes(otherHeadAbility) && (
<HeadOnlyToggle
ability={otherHeadAbility as any}
active={otherBonusAp[otherHeadAbility] ?? false}
setActive={() =>
setOtherBonusAp({
...otherBonusAp,
[otherHeadAbility]: !otherBonusAp[otherHeadAbility],
})
}
/>
)}
{otherHeadAbility === "LDE" && (
<LdeSlider
value={otherLde}
setValue={(value: number) => setOtherLde(value)}
/>
)}
{otherShoesAbility === "DR" && (
<HeadOnlyToggle
ability={otherShoesAbility}
active={otherBonusAp[otherShoesAbility] ?? false}
setActive={() =>
setOtherBonusAp({
...otherBonusAp,
[otherShoesAbility]: !otherBonusAp[otherShoesAbility],
})
}
/>
)}
</Flex>
)}
</Flex>
<Box mt="1em">
<AbilitiesSelector
abilities={otherFocused ? otherBuild : build}
setAbilities={(newAbilities) => setBuild(newAbilities)}
/>
</Box>
</>
);
};
export default EditableBuilds;

View File

@ -1,72 +0,0 @@
import { Box, Flex, FormLabel, Switch } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import AbilityIcon from "components/common/AbilityIcon";
import { CSSVariables } from "utils/CSSVariables";
import React from "react";
interface HeadOnlyToggleProps {
ability: "OG" | "CB" | "DR";
active: boolean;
setActive: () => void;
}
const HeadOnlyToggle: React.FC<HeadOnlyToggleProps> = ({
ability,
active,
setActive,
}) => {
return (
<Flex
justifyContent="center"
alignItems="center"
flexDirection="column"
mb="1em"
>
<Box>
<Switch
id="show-all"
color={CSSVariables.themeColor}
isChecked={active}
onChange={() => setActive()}
mr="0.5em"
mb={4}
/>
<FormLabel htmlFor="show-all">
<AbilityIcon ability={ability} size="TINY" />
</FormLabel>
</Box>
{active && ability === "OG" && (
<Box color={CSSVariables.themeColor} fontWeight="bold">
+30{t`AP`}{" "}
{["SSU", "RSU", "RES"].map((ability) => (
<Box as="span" mx="0.2em" key={ability}>
<AbilityIcon ability={ability as any} size="SUBTINY" />
</Box>
))}
</Box>
)}
{active && ability === "CB" && (
<Box color={CSSVariables.themeColor} fontWeight="bold">
+10{t`AP`}{" "}
{["ISM", "ISS", "REC", "RSU", "SSU", "SCU"].map((ability) => (
<Box as="span" mx="0.2em" key={ability}>
<AbilityIcon ability={ability as any} size="SUBTINY" />
</Box>
))}
</Box>
)}
{active && ability === "DR" && (
<Box color={CSSVariables.themeColor} fontWeight="bold">
+10{t`AP`}{" "}
{["RSU", "SSU", "RES"].map((ability) => (
<Box as="span" mx="0.2em" key={ability}>
<AbilityIcon ability={ability as any} size="SUBTINY" />
</Box>
))}
</Box>
)}
</Flex>
);
};
export default HeadOnlyToggle;

View File

@ -1,88 +0,0 @@
import {
Box,
Flex,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Text,
} from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import AbilityIcon from "components/common/AbilityIcon";
import { CSSVariables } from "utils/CSSVariables";
interface LdeSliderProps {
value: number;
setValue: (value: number) => void;
}
const LdeSlider: React.FC<LdeSliderProps> = ({ value, setValue }) => {
const bonusAp = Math.floor((24 / 21) * value);
const getLdeEffect = () => {
if (value === 21)
return t`Enemy has reached the 30 point mark OR there is 30 seconds or less on the clock OR it is overtime`;
const pointMark = 51 - value;
if (value > 0)
return <Trans>Enemy has reached the {pointMark} point mark</Trans>;
return t`Enemy has not reached the 50 point mark or there is more than 30 seconds on the clock`;
};
return (
<Flex
justifyContent="center"
alignItems="center"
flexDirection="column"
mb="1em"
>
<Text
fontSize="sm"
color={CSSVariables.themeGray}
textTransform="uppercase"
letterSpacing="wider"
lineHeight="1rem"
fontWeight="medium"
mb={1}
>
{t`Intensity`}
</Text>
<NumberInput
size="lg"
defaultValue={0}
min={0}
max={21}
value={value}
onChange={(_, value) => setValue(value)}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
{value > 0 && (
<Box color={CSSVariables.themeColor} fontWeight="bold" mt="1em">
+{bonusAp}
{t`AP`}{" "}
{["ISM", "ISS", "REC"].map((ability) => (
<Box as="span" mx="0.2em" key={ability}>
<AbilityIcon ability={ability as any} size="SUBTINY" />
</Box>
))}
</Box>
)}
<Box
color={CSSVariables.themeGray}
fontSize="0.75em"
maxW="200px"
mt="1em"
textAlign="center"
>
{getLdeEffect()}
</Box>
</Flex>
);
};
export default LdeSlider;

View File

@ -1,112 +0,0 @@
import { t } from "@lingui/macro";
import { CSSVariables } from "utils/CSSVariables";
import React from "react";
import {
CartesianGrid,
Legend,
Line,
LineChart,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { abilityPoints } from "utils/lists/abilityPoints";
interface StatChartProps {
title: string;
getEffect: (ap: number) => number;
ap: number;
otherAp?: number;
startChartsAtZero: boolean;
}
const StatChart: React.FC<StatChartProps> = ({
title,
ap,
otherAp,
getEffect,
startChartsAtZero,
}) => {
const getData = () =>
abilityPoints.map((ap) => ({ name: `${ap}AP`, [title]: getEffect(ap) }));
const CustomizedDot = (props: any) => {
const { cx, cy, payload } = props;
if (payload.name === `${ap}${t`AP`}`) {
return (
<svg
x={cx - 5}
y={cy - 5}
width={100}
height={100}
fill="red"
viewBox="0 0 1024 1024"
>
<circle
cx="50"
cy="50"
r="40"
stroke="white"
strokeWidth="18"
fill="#DD6B20"
/>
</svg>
);
}
if (payload.name === `${otherAp}${t`AP`}`) {
return (
<svg
x={cx - 5}
y={cy - 5}
width={100}
height={100}
fill="red"
viewBox="0 0 1024 1024"
>
<circle
cx="50"
cy="50"
r="40"
stroke="white"
strokeWidth="18"
fill="#3182CE"
/>
</svg>
);
}
return null;
};
return (
<ResponsiveContainer width="100%" height={300}>
<LineChart data={getData()}>
<CartesianGrid strokeDasharray="3 3" color="#000" />
<XAxis dataKey="name" />
<YAxis
domain={startChartsAtZero ? undefined : ["dataMin", "dataMax"]}
/>
<Tooltip
contentStyle={{
background: CSSVariables.secondaryBgColor,
borderRadius: "5px",
border: 0,
}}
/>
<Legend />
<Line
type="monotone"
dataKey={title}
stroke={CSSVariables.themeColor}
dot={<CustomizedDot />}
isAnimationActive={false}
/>
</LineChart>
</ResponsiveContainer>
);
};
export default StatChart;

View File

@ -1,101 +0,0 @@
import { Ability } from ".prisma/client";
import { Flex } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import AbilityIcon from "components/common/AbilityIcon";
import SubText from "components/common/SubText";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/common/Table";
import { mainOnlyAbilities } from "utils/lists/abilities";
interface Props {
stats: {
code: Ability;
average: number;
counts: number[][];
}[];
}
const APStats: React.FC<Props> = ({ stats }) => {
if (Number.isNaN(stats[0].average)) return null;
return (
<Flex justify="space-evenly" flexWrap="wrap">
<Table maxW={64} mt={6}>
<TableHead>
<TableRow>
<TableHeader>
<Trans>Ability</Trans>
</TableHeader>
<TableHeader>
<Trans>Average AP</Trans>
</TableHeader>
<TableHeader>
<Trans>Popular values</Trans>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{stats
.filter((stat) => !mainOnlyAbilities.includes(stat.code as any))
.map((stat) => {
return (
<TableRow key={stat.code}>
<TableCell>
<AbilityIcon ability={stat.code} size="TINY" />
</TableCell>
<TableCell fontWeight="bold">
{stat.average.toFixed(2)}
</TableCell>
<TableCell>
{stat.counts.map((count) => {
return (
<Flex align="center" key={count[0]}>
{count[0]} <SubText ml={1}>{count[1]}</SubText>
</Flex>
);
})}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
<Table maxW={64} mt={6}>
<TableHead>
<TableRow>
<TableHeader>
<Trans>Ability</Trans>
</TableHeader>
<TableHeader>
<Trans>Appearance %</Trans>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{stats
.filter((stat) => mainOnlyAbilities.includes(stat.code as any))
.map((stat) => {
return (
<TableRow key={stat.code}>
<TableCell>
<AbilityIcon ability={stat.code} size="TINY" />
</TableCell>
<TableCell fontWeight="bold">
{(stat.average * 10).toFixed(2)}%
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</Flex>
);
};
export default APStats;

View File

@ -1,249 +0,0 @@
import {
Box,
BoxProps,
Flex,
IconButton,
Popover,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from "@chakra-ui/react";
import { Plural, t, Trans } from "@lingui/macro";
import { Ability, Prisma } from "@prisma/client";
import Flag from "components/common/Flag";
import ModeImage from "components/common/ModeImage";
import MyIconButton from "components/common/MyIconButton";
import UserAvatar from "components/common/UserAvatar";
import WeaponImage from "components/common/WeaponImage";
import { CSSVariables } from "utils/CSSVariables";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
import { FiBarChart2, FiEdit, FiInfo, FiTarget } from "react-icons/fi";
import { PartialBy } from "utils/types";
import Gears from "./Gears";
import ViewAP from "./ViewAP";
import ViewSlots from "./ViewSlots";
interface BuildCardProps {
// TODO: don't select unnecessary fields
build: PartialBy<Prisma.BuildGetPayload<{ include: { user: true } }>, "user">;
otherBuildCount?: number;
onShowAllByUser?: () => void;
onEdit?: (build: BuildCardProps["build"]) => void;
showWeapon?: boolean;
}
const BuildCard: React.FC<BuildCardProps & BoxProps> = ({
build,
onEdit,
otherBuildCount,
onShowAllByUser,
showWeapon,
...props
}) => {
const [apView, setApView] = useState(false);
const username = build.user?.username;
return (
<>
<Box
w="300px"
rounded="lg"
overflow="hidden"
boxShadow="md"
bg={CSSVariables.secondaryBgColor}
p="20px"
{...props}
>
<Box display="flex" flexDirection="column" h="100%">
{build.title && (
<Box fontWeight="semibold" as="h4" lineHeight="tight" mt="0.3em">
{build.title}
</Box>
)}
{build.user && (
<Box
my={2}
textOverflow="ellipsis"
color={CSSVariables.themeGray}
fontWeight="semibold"
letterSpacing="wide"
fontSize="sm"
whiteSpace="nowrap"
overflow="hidden"
title={`${build.user.username}#${build.user.discriminator}`}
>
<Link href={`/u/${build.user.discordId}`} prefetch={false}>
<a>
<Flex alignItems="center">
<UserAvatar user={build.user} isSmall mr={2} />
{build.user.username}#{build.user.discriminator}
</Flex>
</a>
</Link>
</Box>
)}
{showWeapon && (
<Box>
<WeaponImage name={build.weapon} size={64} />
</Box>
)}
<Flex alignItems="center">
<Box
color={CSSVariables.themeGray}
fontWeight="semibold"
letterSpacing="wide"
fontSize="xs"
title={build.updatedAt.toLocaleString()}
mr={2}
>
{build.updatedAt.toLocaleDateString()}
</Box>
{build.jpn ? (
<Flag countryCode="JP" />
) : build.top500 ? (
<Image
src={`/layout/xsearch.png`}
height={24}
width={24}
title={t`Maker of the build has finished in the top 500 of X Rank with this weapon`}
alt={t`Maker of the build has finished in the top 500 of X Rank with this weapon`}
/>
) : null}
</Flex>
<Flex mt="0.3em">
<MyIconButton
variant="ghost"
onClick={() => setApView(!apView)}
popup={apView ? t`Show abilities` : t`Show ability points`}
icon={<FiTarget />}
fontSize="20px"
mr="0.5em"
/>
<Link
href={encodeURI(
`/analyzer?weapon=${build.weapon}&head=${build.headAbilities}&clothing=${build.clothingAbilities}&shoes=${build.shoesAbilities}`
)}
>
<a>
<MyIconButton
variant="ghost"
popup={t`Show in Build Analyzer`}
fontSize="20px"
icon={<FiBarChart2 />}
mr="0.5em"
/>
</a>
</Link>
<Description />
{onEdit && (
<MyIconButton
popup={t`Edit build`}
onClick={() => onEdit(build)}
icon={<FiEdit />}
fontSize="20px"
ml="0.5em"
/>
)}
</Flex>
<Box mt="1em">
<Gears build={build} />
</Box>
<Box
display="flex"
flexDirection="column"
flexGrow={1}
justifyContent="center"
mt="1em"
>
{apView ? (
<ViewAP
aps={build.abilityPoints as Record<Ability, number>}
extraAps={
build.clothingAbilities[0] === "AD"
? build.clothingAbilities
.slice(1)
.reduce(
(acc: Partial<Record<Ability, number>>, ability) => {
if (ability in acc) acc[ability]! += 3;
else acc[ability] = 3;
return acc;
},
{}
)
: undefined
}
/>
) : (
<ViewSlots abilities={build} />
)}
</Box>
{otherBuildCount ? (
<Box
display="flex"
justifyContent="space-between"
alignItems="center"
mt="1em"
>
<Box
mx="auto"
fontSize="0.8em"
color={CSSVariables.themeColor}
textAlign="center"
onClick={onShowAllByUser}
cursor="pointer"
_hover={{ textDecoration: "underline" }}
>
<Plural
value={otherBuildCount}
one={<Trans>Show # more build by {username}</Trans>}
other={<Trans>Show # more builds by {username}</Trans>}
/>
</Box>
</Box>
) : null}
</Box>
</Box>
</>
);
function Description() {
if (build.modes.length === 0 && !build.description) return null;
return (
<Popover placement="top" trigger="hover">
<PopoverTrigger>
<IconButton
variant="ghost"
isRound
aria-label="Show description"
fontSize="20px"
icon={<FiInfo />}
/>
</PopoverTrigger>
<PopoverContent
zIndex={4}
width="220px"
backgroundColor={CSSVariables.secondaryBgColor}
>
<PopoverBody whiteSpace="pre-wrap">
<Box>
<Box>
{build.modes.map((mode) => (
<Box key={mode} as="span" mr={1}>
<ModeImage mode={mode} size={24} />
</Box>
))}
</Box>
{build.description}
</Box>
</PopoverBody>
</PopoverContent>
</Popover>
);
}
};
export default BuildCard;

View File

@ -1,332 +0,0 @@
import {
Box,
Flex,
Grid,
IconButton,
NumberDecrementStepper,
NumberIncrementStepper,
NumberInput,
NumberInputField,
NumberInputStepper,
Radio,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { Ability, Mode } from "@prisma/client";
import AbilityIcon from "components/common/AbilityIcon";
import {
UseBuildsByWeaponDispatch,
UseBuildsByWeaponState,
} from "hooks/builds";
import { CSSVariables } from "utils/CSSVariables";
import { Fragment, useState } from "react";
import { FiTrash } from "react-icons/fi";
import { abilities, isMainAbility } from "utils/lists/abilities";
import { components } from "react-select";
import ModeImage from "components/common/ModeImage";
import MySelect, { selectDefaultStyles } from "components/common/MySelect";
interface Props {
filters: UseBuildsByWeaponState["filters"];
dispatch: UseBuildsByWeaponDispatch;
}
const modeOptions = [
{
label: "All modes",
value: "ALL",
},
{
label: "Splat Zones",
value: "SZ",
},
{
label: "Tower Control",
value: "TC",
},
{
label: "Rainmaker",
value: "RM",
},
{
label: "Clam Blitz",
value: "CB",
},
{
label: "Turf War",
value: "TW",
},
] as const;
const abilitiesOptions = abilities.map((item) => {
return { label: item.name, value: item.code };
});
const ModeOption = (props: any) => {
return (
<components.Option {...props}>
<Flex alignItems="center">
<Box mr="0.5em">
{props.value !== "ALL" ? (
<ModeImage size={20} mode={props.value} />
) : (
<></>
)}
</Box>
{props.label}
</Flex>
</components.Option>
);
};
const AbilityOption = (props: any) => {
return (
<components.Option {...props}>
<Flex alignItems="center">
<Box mr="0.5em">
<AbilityIcon ability={props.value} size="SUBTINY" />
</Box>
{props.label}
</Flex>
</components.Option>
);
};
const ModeSingleValue = (props: any) => {
return (
<components.SingleValue {...props}>
{props.data.value !== "ALL" ? (
<Box mr="0.5em">
<ModeImage size={20} mode={props.data.value} />
</Box>
) : (
<></>
)}
{props.data.label}
</components.SingleValue>
);
};
const BuildFilters: React.FC<Props> = ({ filters, dispatch }) => {
const [mode, setMode] = useState<{ label: string; value: string }>(
modeOptions[0]
);
const selectStyles = {
...selectDefaultStyles,
singleValue: (base: any) => ({
...base,
padding: 0,
borderRadius: 5,
color: CSSVariables.textColor,
fontSize: "0.875rem",
display: "flex",
}),
option: (styles: any, { isFocused }: any) => {
return {
...styles,
backgroundColor: isFocused ? CSSVariables.themeColorOpaque : undefined,
fontSize: "0.875rem",
color: CSSVariables.textColor,
};
},
control: (base: any) => ({
...base,
borderColor: CSSVariables.borderColor,
minHeight: 32,
height: 32,
background: "hsla(0, 0%, 0%, 0)",
}),
dropdownIndicator: (base: any) => ({
...base,
padding: 4,
}),
};
return (
<>
<Grid
templateColumns="1fr 1fr 2fr 2fr"
alignItems="center"
justifyContent="center"
placeItems="center"
maxWidth={24}
gridRowGap={4}
mx="auto"
>
{filters.map((filter, index) => (
<Fragment key={filter.ability}>
<Box mb="-1.2rem" />
<Box mb="-1.2rem" />
<Box
mb="-1.2rem"
fontSize="sm"
color={
filter.abilityPoints &&
filter.abilityPoints.min > filter.abilityPoints.max
? "red.500"
: CSSVariables.themeGray
}
pr={2}
>
{isMainAbility(filter.ability) ? (
<Trans>Included</Trans>
) : (
<Trans>Min AP</Trans>
)}
</Box>
<Box
mb="-1.2rem"
fontSize="sm"
color={
filter.abilityPoints &&
filter.abilityPoints.min > filter.abilityPoints.max
? "red.500"
: CSSVariables.themeGray
}
>
{isMainAbility(filter.ability) ? (
<Trans>Excluded</Trans>
) : (
<Trans>Max AP</Trans>
)}
</Box>
<IconButton
icon={<FiTrash />}
onClick={() => dispatch({ type: "REMOVE_FILTER", index })}
aria-label="Remove filter"
variant="ghost"
isRound
/>
<Box mx={2} mt={2}>
<AbilityIcon ability={filter.ability} size="TINY" />
</Box>
{isMainAbility(filter.ability) ? (
<>
<Radio
isChecked={filter.hasAbility}
onClick={() =>
dispatch({
type: "SET_FILTER_HAS_ABILITY",
index,
hasAbility: true,
})
}
value="HAS_ABILITY"
/>
<Radio
isChecked={!filter.hasAbility}
value="DOES_NOT_HAVE_ABILITY"
onClick={() =>
dispatch({
type: "SET_FILTER_HAS_ABILITY",
index,
hasAbility: false,
})
}
/>
</>
) : (
<>
<NumberInput
size="sm"
m={2}
width={24}
min={0}
max={57}
value={filter.abilityPoints!.min}
onChange={(_, value) =>
dispatch({
type: "SET_FILTER_ABILITY_POINTS",
abilityPoints: { ...filter.abilityPoints!, min: value },
index,
})
}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
<NumberInput
size="sm"
m={2}
width={24}
min={0}
max={57}
value={filter.abilityPoints!.max}
onChange={(_, value) =>
dispatch({
type: "SET_FILTER_ABILITY_POINTS",
abilityPoints: { ...filter.abilityPoints!, max: value },
index,
})
}
>
<NumberInputField />
<NumberInputStepper>
<NumberIncrementStepper />
<NumberDecrementStepper />
</NumberInputStepper>
</NumberInput>
</>
)}
</Fragment>
))}
</Grid>
<Flex
mt={4}
justify="space-between"
align="center"
flexDir={["column", "row"]}
>
<Box minW={200} m="2">
<MySelect
name="filter by ability"
isMulti={false}
value={{ label: "Filter by ability", value: "DEFAULT" }}
options={abilitiesOptions.filter((option) => {
return !filters.find(
(filterElement) => filterElement.ability === option.value
);
})}
setValue={(value) => {
dispatch({
type: "ADD_FILTER",
ability: value as Ability,
});
}}
components={{
Option: AbilityOption,
}}
styles={selectStyles}
/>
</Box>
<Box minW={200} m="2">
<MySelect
name="filter by mode"
isMulti={false}
value={mode}
options={modeOptions}
setValue={(value) => {
const mode = modeOptions.find((option) => option.value === value);
if (mode) setMode(mode);
dispatch({
type: "SET_MODE_FILTER",
modeFilter: value === "ALL" ? undefined : (value as Mode),
});
}}
components={{
Option: ModeOption,
SingleValue: ModeSingleValue,
}}
styles={selectStyles}
/>
</Box>
</Flex>
</>
);
};
export default BuildFilters;

View File

@ -1,40 +0,0 @@
import { Flex, Skeleton } from "@chakra-ui/react";
const BuildsSkeleton = () => (
<Flex flexWrap="wrap" justifyContent="center" mt={4}>
<Skeleton
w="300px"
height="500px"
rounded="lg"
boxShadow="md"
p="20px"
m={2}
/>
<Skeleton
w="300px"
height="500px"
rounded="lg"
boxShadow="md"
p="20px"
m={2}
/>
<Skeleton
w="300px"
height="500px"
rounded="lg"
boxShadow="md"
p="20px"
m={2}
/>
<Skeleton
w="300px"
height="500px"
rounded="lg"
boxShadow="md"
p="20px"
m={2}
/>
</Flex>
);
export default BuildsSkeleton;

View File

@ -1,39 +0,0 @@
import { Box, Flex } from "@chakra-ui/react";
import { Prisma } from "@prisma/client";
import GearImage from "components/common/GearImage";
import React from "react";
type BuildViewSlots = Partial<
Prisma.BuildGetPayload<{
select: {
headGear: true;
clothingGear: true;
shoesGear: true;
};
}>
>;
interface GearsProps {
build: BuildViewSlots;
}
const Gears: React.FC<GearsProps> = ({ build }) => {
if (!build.headGear && !build.clothingGear && !build.shoesGear) {
return <Box h="30px" />;
}
return (
<Flex justifyContent="center">
<Box w={build.headGear ? "85px" : undefined} h="85px" mx="2px">
{build.headGear && <GearImage englishName={build.headGear} />}
</Box>
<Box w={build.clothingGear ? "85px" : undefined} h="85px" mx="2px">
{build.clothingGear && <GearImage englishName={build.clothingGear} />}
</Box>
<Box w={build.shoesGear ? "85px" : undefined} h="85px" mx="2px">
{build.shoesGear && <GearImage englishName={build.shoesGear} />}
</Box>
</Flex>
);
};
export default Gears;

View File

@ -1,98 +0,0 @@
import { Box, Flex } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { Ability } from "@prisma/client";
import AbilityIcon from "components/common/AbilityIcon";
import { CSSVariables } from "utils/CSSVariables";
import { mainOnlyAbilities } from "utils/lists/abilities";
interface ViewAPProps {
aps: Record<Ability, number>;
// Extra ability points that are not saved in DB
// used with ability doubler. Forgot to take it in account
// for create build backend code so we do this instead.
extraAps?: Partial<Record<Ability, number>>;
}
const ViewAP: React.FC<ViewAPProps> = ({ aps, extraAps }) => {
const APArrays = Object.entries(aps)
.filter(([ability]) => ability !== "AD")
.reduce((acc: [number, string[]][], [key, value]) => {
let apCount = mainOnlyAbilities.includes(key as any) ? 999 : value;
if (extraAps && key in extraAps) {
const typedKey = key as keyof typeof extraAps;
apCount += extraAps[typedKey]!;
}
const abilityArray = acc.find((el) => el[0] === apCount);
if (abilityArray) abilityArray[1].push(key);
else acc.push([apCount, [key]]);
return acc;
}, []);
let indexToPrintAPAt = APArrays[0][0] === 999 ? 1 : 0;
return (
<Box mt="2">
{APArrays.sort((a, b) => b[0] - a[0]).map((arr, index) => {
return (
<Flex
key={arr[0] as any}
justifyContent="flex-start"
alignItems="center"
gridRowGap="2em"
mt={index === 0 ? "0" : "1em"}
>
{arr[0] !== 999 ? (
<Box
mr={2}
pr={1}
borderRight="1px solid"
borderColor={CSSVariables.themeGray}
>
<Box
color={CSSVariables.themeGray}
width="32px"
minH="45px"
letterSpacing="wide"
fontSize="s"
fontWeight="semibold"
textAlign="center"
pt={indexToPrintAPAt !== index ? "10px" : undefined}
>
{arr[0]}
{indexToPrintAPAt === index ? (
<>
<br />
{t`AP`}
</>
) : null}
</Box>
</Box>
) : (
<Box width="37px" />
)}
{(arr[1] as Array<Ability>).map((ability, abilityIndex) => (
<Box
width="45px"
key={ability}
ml={
abilityIndex !== 0 && arr[1].length > 5
? `-${(arr[1].length - 5) * 5}px`
: undefined
}
>
{/* TODO: center */}
<AbilityIcon ability={ability} size="SUB" />
</Box>
))}
</Flex>
);
})}
</Box>
);
};
export default ViewAP;

View File

@ -1,90 +0,0 @@
import { Box, BoxProps, Flex } from "@chakra-ui/react";
import AbilityIcon from "components/common/AbilityIcon";
import { AbilityOrUnknown } from "utils/types";
export type ViewSlotsAbilities = {
headAbilities: AbilityOrUnknown[];
clothingAbilities: AbilityOrUnknown[];
shoesAbilities: AbilityOrUnknown[];
};
interface ViewSlotsProps {
abilities: ViewSlotsAbilities;
onAbilityClick?: (
gear: "headAbilities" | "clothingAbilities" | "shoesAbilities",
index: number
) => void;
}
const ViewSlots: React.FC<ViewSlotsProps & BoxProps> = ({
abilities,
onAbilityClick,
...props
}) => {
return (
<Box {...props}>
<Flex alignItems="center" justifyContent="center">
{abilities.headAbilities.map((ability, index) => (
<Box
mx="3px"
key={index}
onClick={
onAbilityClick
? () => onAbilityClick("headAbilities", index)
: undefined
}
cursor={onAbilityClick ? "pointer" : undefined}
>
<AbilityIcon
key={index}
ability={ability}
size={index === 0 ? "MAIN" : "SUB"}
/>
</Box>
))}
</Flex>
<Flex alignItems="center" justifyContent="center" my="0.5em">
{abilities.clothingAbilities.map((ability, index) => (
<Box
mx="3px"
key={index}
onClick={
onAbilityClick
? () => onAbilityClick("clothingAbilities", index)
: undefined
}
cursor={onAbilityClick ? "pointer" : undefined}
>
<AbilityIcon
key={index}
ability={ability}
size={index === 0 ? "MAIN" : "SUB"}
/>
</Box>
))}
</Flex>
<Flex alignItems="center" justifyContent="center">
{abilities.shoesAbilities.map((ability, index) => (
<Box
mx="3px"
key={index}
onClick={
onAbilityClick
? () => onAbilityClick("shoesAbilities", index)
: undefined
}
cursor={onAbilityClick ? "pointer" : undefined}
>
<AbilityIcon
key={index}
ability={ability}
size={index === 0 ? "MAIN" : "SUB"}
/>
</Box>
))}
</Flex>
</Box>
);
};
export default ViewSlots;

View File

@ -1,223 +0,0 @@
import { Badge, Box, Button, Flex, Grid, Heading } from "@chakra-ui/react";
import Markdown from "components/common/Markdown";
import MyLink from "components/common/MyLink";
import OutlinedBox from "components/common/OutlinedBox";
import UserAvatar from "components/common/UserAvatar";
import BadgeContainer from "components/u/BadgeContainer";
import { regularTournamentBadges } from "components/u/Badges";
import { useUser } from "hooks/common";
import Image from "next/image";
import { CalendarGet } from "pages/api/calendar";
import React from "react";
import { FiClock, FiEdit, FiExternalLink } from "react-icons/fi";
import { DiscordIcon } from "utils/assets/icons";
import { ADMIN_ID, EVENT_FORMATS, TAGS } from "utils/constants";
import { CSSVariables } from "utils/CSSVariables";
import { Unpacked } from "utils/types";
const nameToImage = [
{ code: "tasl", name: "tasl" },
{ code: "lowink", name: "low ink" },
{ code: "lobstercrossfire", name: "lobster crossfire" },
{ code: "swimorsink", name: "swim or sink" },
{ code: "idtga", name: "it's dangerous to go alone" },
{ code: "rr", name: "reef rushdown" },
{ code: "tg", name: "testing grounds" },
{ code: "ut", name: "unnamed tournament" },
{ code: "kotc", name: "king of the castle" },
{ code: "zones", name: "area cup" },
{ code: "cb", name: "cloudburst" },
{ code: "forecast", name: "forecast" },
{ code: "superjump", name: "superjump" },
{ code: "squidjunction", name: "squid junction" },
{ code: "paddlingpool", name: "paddling pool" },
{ code: "squidjunction", name: "squid junction" },
{ code: "triton-cup", name: "triton" },
] as const;
/**
* Returns event logo image path based on the event name or undefined if no image saved for the event.
*/
export const eventImage = (eventName: string) => {
const eventNameLower = eventName.toLowerCase();
if (eventNameLower.startsWith("plus server")) {
return `/layout/plus.png`;
}
for (const { name, code } of nameToImage) {
if (eventNameLower.startsWith(name)) {
return `/events/${code}.png`;
}
}
return undefined;
};
interface EventInfoProps {
event: Unpacked<CalendarGet>;
edit: () => void;
}
const EventInfo = ({ event, edit }: EventInfoProps) => {
const poster = event.poster;
const [user] = useUser();
const canEdit = user?.id === poster.id || user?.id === ADMIN_ID;
const imgSrc = eventImage(event.name);
const badges = regularTournamentBadges.filter(
(badgeObj) =>
event.name.toUpperCase().includes(badgeObj.name.toUpperCase()) ||
badgeObj.altNames?.some((altName) =>
event.name.toUpperCase().includes(altName.toUpperCase())
)
);
return (
<OutlinedBox
my={4}
py={4}
id={`event-${event.id}`}
data-cy={`event-info-section-${event.name
.toLowerCase()
.replace(/ /g, "-")}`}
>
<Box width="100%">
<Box textAlign="center">
<Box>
{imgSrc && <Image src={imgSrc} width={36} height={36} alt="" />}
<Heading size="lg">{event.name}</Heading>
{event.tags.length > 0 && (
<>
<Flex flexWrap="wrap" justifyContent="center" my={2}>
{event.tags.map((tag) => {
const tagInfo = TAGS.find((tagObj) => tagObj.code === tag)!;
return (
<Badge
key={tag}
mx={1}
my={1}
bg={tagInfo.color}
color="black"
>
{tagInfo.name}
</Badge>
);
})}
</Flex>
{event.tags.some((tag) => tag === "BADGE") &&
badges.length > 0 ? (
<BadgeContainer
showInfo={false}
showBadges={true}
badges={badges.map((badge) => ({
src: `${badge.badgeName}.gif`,
description: "",
count: 1,
hueRotateAngle: badge.hueRotateAngle,
}))}
/>
) : null}
</>
)}
<Grid
templateColumns={["1fr", "2fr 4fr 2fr"]}
placeItems="center flex-start"
gridRowGap="0.5rem"
maxW="32rem"
mx="auto"
mt={1}
mb={3}
>
<Flex placeItems="center" ml={[null, "auto"]} mx={["auto", null]}>
<Box
as={FiClock}
mr="0.5em"
color={CSSVariables.themeColor}
justifySelf="flex-end"
/>
{/* TODO */}
<Box as="time" dateTime={new Date(event.date).toISOString()}>
{new Date(event.date).toLocaleString("en", {
hour: "numeric",
minute: "numeric",
})}
</Box>
</Flex>
<Flex placeItems="center" mx="auto">
<Box
as={FiExternalLink}
mr="0.5em"
color={CSSVariables.themeColor}
justifySelf="flex-end"
/>
<MyLink href={event.eventUrl} isExternal>
{new URL(event.eventUrl).host}
</MyLink>
</Flex>
<Flex placeItems="center" mr={[null, "auto"]} mx={["auto", null]}>
<UserAvatar
user={event.poster}
size="sm"
justifySelf="flex-end"
mr={2}
/>
<MyLink href={`/u/${poster.discordId}`} isColored={false}>
<Box>
{poster.username}#{poster.discriminator}
</Box>
</MyLink>
</Flex>
</Grid>
</Box>
<Grid
templateColumns={["1fr", canEdit ? "1fr 1fr" : "1fr"]}
gridRowGap="1rem"
gridColumnGap="1rem"
maxW={["12rem", canEdit ? "24rem" : "12rem"]}
mx="auto"
mt={4}
>
{event.discordInviteUrl ? (
<MyLink href={event.discordInviteUrl} isExternal>
<Button
leftIcon={<DiscordIcon />}
size="sm"
variant="outline"
width="100%"
>
Join Discord
</Button>
</MyLink>
) : (
<div />
)}
{canEdit && (
<Button
leftIcon={<FiEdit />}
size="sm"
onClick={edit}
variant="outline"
data-cy={`edit-button-${event.name
.toLowerCase()
.replace(/ /g, "-")}`}
>
Edit event
</Button>
)}
</Grid>
</Box>
<Box mt={8} mx="0.5rem" wordBreak="break-word">
<Box color={CSSVariables.themeGray} fontSize="small" mb={2}>
{EVENT_FORMATS.find((format) => format.code === event.format)!.name}
</Box>
<Markdown smallHeaders value={event.description} />
</Box>
</Box>
</OutlinedBox>
);
};
export default EventInfo;

View File

@ -1,287 +0,0 @@
import { Button } from "@chakra-ui/button";
import {
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
} from "@chakra-ui/form-control";
import { Input } from "@chakra-ui/input";
import { Box } from "@chakra-ui/layout";
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
} from "@chakra-ui/modal";
import { Select } from "@chakra-ui/select";
import { zodResolver } from "@hookform/resolvers/zod";
import { t, Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import DatePicker from "components/common/DatePicker";
import MarkdownTextarea from "components/common/MarkdownTextarea";
import { CSSVariables } from "utils/CSSVariables";
import { Controller, useForm } from "react-hook-form";
import { FiTrash } from "react-icons/fi";
import { EVENT_FORMATS } from "utils/constants";
import { eventSchema, EVENT_DESCRIPTION_LIMIT } from "utils/validators/event";
import * as z from "zod";
import TagsSelector from "./TagsSelector";
import { useMutation } from "hooks/common";
import {
CalendarDeleteInput,
CalendarPostInput,
CalendarPutInput,
} from "pages/api/calendar";
export type FormData = z.infer<typeof eventSchema>;
export function EventModal({
onClose,
event,
refetchQuery,
}: {
onClose: () => void;
event?: { id: number } & FormData;
refetchQuery: () => void;
}) {
const { i18n } = useLingui();
const { handleSubmit, errors, register, watch, control } = useForm<FormData>({
resolver: zodResolver(eventSchema),
defaultValues: event,
});
const addEventMutation = useMutation<CalendarPostInput>({
url: "/api/calendar",
method: "POST",
successToastMsg: t`Event added`,
afterSuccess: () => {
refetchQuery();
onClose();
},
});
const updateEventMutation = useMutation<CalendarPutInput>({
url: "/api/calendar",
method: "PUT",
successToastMsg: t`Event updated`,
afterSuccess: () => {
refetchQuery();
onClose();
},
});
const deleteEventMutation = useMutation<CalendarDeleteInput>({
url: "/api/calendar",
method: "DELETE",
successToastMsg: t`Event deleted`,
afterSuccess: () => {
refetchQuery();
onClose();
},
});
const watchDescription = watch("description", event?.description ?? "");
const onSubmit = async (values: FormData) => {
event
? updateEventMutation.mutate({ event: values, eventId: event.id })
: addEventMutation.mutate(values);
};
const onDelete = (eventId: number) => deleteEventMutation.mutate({ eventId });
const defaultDate = new Date();
defaultDate.setHours(defaultDate.getHours() + 1, 0);
return (
<Modal isOpen onClose={onClose} size="xl" closeOnOverlayClick={false}>
<ModalOverlay>
<ModalContent>
<ModalHeader>
{event ? (
<Trans>Editing event</Trans>
) : (
<Trans>Adding a new event</Trans>
)}
</ModalHeader>
<ModalCloseButton borderRadius="50%" />
<form onSubmit={handleSubmit(onSubmit)}>
<ModalBody pb={6}>
{event && (
<Button
leftIcon={<FiTrash />}
variant="outline"
color="red.500"
mb={6}
isLoading={deleteEventMutation.isMutating}
onClick={async () => {
if (window.confirm(t`Delete the event?`))
onDelete(event.id);
}}
data-cy="delete-button"
>
<Trans>Delete event</Trans>
</Button>
)}
<Box fontSize="sm" color={CSSVariables.themeGray} mb={4}>
<Trans>
Add upcoming Splatoon events you are hosting to the calendar.
</Trans>
</Box>
{/* <FormLabel htmlFor="isTournament">
<Trans>Type</Trans>
</FormLabel>
<RadioGroup name="isTournament" ref={register}>
<Stack direction="row">
<Radio value="1">
<Trans>Tournament</Trans>
</Radio>
<Radio value="2">
<Trans>Other</Trans>
</Radio>
</Stack>
</RadioGroup> */}
<FormControl isInvalid={!!errors.name}>
<FormLabel htmlFor="name">
<Trans>Name</Trans>
</FormLabel>
<Input
name="name"
ref={register}
placeholder="In The Zone X"
data-cy="name-input"
/>
<FormErrorMessage>{errors.name?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.date}>
<FormLabel htmlFor="date" mt={4}>
<Trans>Date</Trans>
</FormLabel>
<Controller
name="date"
control={control}
defaultValue={defaultDate.toISOString()}
render={({ onChange, value }) => (
// <Input
// type="datetime-local"
// value={value.substring(0, 16)}
// onChange={onChange}
// min={defaultIsoDateTime}
// />
<DatePicker
selectedDate={new Date(value)}
onChange={(d) => onChange(d.toString())}
/>
)}
/>
<FormHelperText>
<Trans>Input the time in your local time zone:</Trans>{" "}
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</FormHelperText>
<FormErrorMessage>{errors.date?.message}</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.discordInviteUrl}>
<FormLabel htmlFor="discordInviteUrl" mt={4}>
<Trans>Discord invite URL</Trans>
</FormLabel>
<Input
name="discordInviteUrl"
ref={register}
placeholder="https://discord.gg/9KJKn29D"
data-cy="discord-invite-url-input"
/>
<FormErrorMessage>
{errors.discordInviteUrl?.message}
</FormErrorMessage>
</FormControl>
<FormControl isInvalid={!!errors.eventUrl}>
<FormLabel htmlFor="eventUrl" mt={4}>
<Trans>Registration URL</Trans>
</FormLabel>
<Input
name="eventUrl"
ref={register}
placeholder="https://challonge.com/tournaments/signup/Javco7YsUX"
data-cy="registration-url-input"
/>
<FormErrorMessage>{errors.eventUrl?.message}</FormErrorMessage>
</FormControl>
<FormControl>
<FormLabel htmlFor="format" mt={4}>
<Trans>Format</Trans>
</FormLabel>
<Select name="format" ref={register}>
{EVENT_FORMATS.map((format) => (
<option key={format.code} value={format.code}>
{format.name}
</option>
))}
</Select>
</FormControl>
<MarkdownTextarea
fieldName="description"
title={i18n._(t`Description`)}
error={errors.description}
register={register}
value={watchDescription ?? ""}
maxLength={EVENT_DESCRIPTION_LIMIT}
placeholder={i18n._(
t`# Header
All the relevant info about tournament goes here. We can even use **bolding**.`
)}
dataCy="description-markdown"
/>
<FormControl>
<FormLabel htmlFor="tags" mt={4}>
<Trans>Tags</Trans>
</FormLabel>
<Controller
name="tags"
control={control}
defaultValue={[]}
render={({ onChange, value }) => (
<TagsSelector value={value} setValue={onChange} />
)}
/>
</FormControl>
</ModalBody>
<ModalFooter>
<Button
mr={3}
type="submit"
isLoading={
addEventMutation.isMutating || updateEventMutation.isMutating
}
data-cy="save-button"
>
<Trans>Save</Trans>
</Button>
<Button
onClick={onClose}
variant="outline"
isDisabled={
addEventMutation.isMutating || updateEventMutation.isMutating
}
>
<Trans>Cancel</Trans>
</Button>
</ModalFooter>
</form>
</ModalContent>
</ModalOverlay>
</Modal>
);
}

View File

@ -1,55 +0,0 @@
import { Flex } from "@chakra-ui/react";
import { useLingui } from "@lingui/react";
import MySelect from "components/common/MySelect";
import { components } from "react-select";
import { TAGS } from "utils/constants";
interface TagsSelectorProps {
value?: string[];
setValue: (value: string[]) => void;
}
const SingleValue = (props: any) => {
return (
<components.SingleValue {...props}>
<Flex alignItems="center">{props.data.label}</Flex>
</components.SingleValue>
);
};
const Option = (props: any) => {
return (
<components.Option {...props}>
<Flex alignItems="center">{props.data.label}</Flex>
</components.Option>
);
};
const TagsSelector: React.FC<TagsSelectorProps> = (props) => {
const { i18n } = useLingui();
return (
<MySelect
options={TAGS.map((tag) => ({
label: i18n._(tag.name),
value: tag.code,
}))}
value={props.value?.map((value) => ({ value, label: getLabel(value) }))}
setValue={props.setValue}
isClearable
isMulti
components={{
IndicatorSeparator: () => null,
Option,
SingleValue,
}}
/>
);
function getLabel(value: string) {
const tag = TAGS.find(({ code }) => code === value);
return tag?.name;
}
};
export default TagsSelector;

View File

@ -1,14 +0,0 @@
.container {
z-index: 2;
display: inline-block;
width: var(--ability-size);
height: var(--ability-size);
border: 2px solid #888;
border-right: 0;
border-bottom: 0;
background: #000;
background-size: 100%;
border-radius: 50%;
box-shadow: 0 0 0 1px #000;
user-select: none;
}

View File

@ -1,50 +0,0 @@
import { Ability } from ".prisma/client";
import Image from "next/image";
import { abilities } from "utils/lists/abilities";
import styles from "./AbilityIcon.module.css";
//https://github.com/loadout-ink/splat2-calc
const sizeMap = {
MAIN: 50,
SUB: 40,
TINY: 30,
SUBTINY: 20,
} as const;
interface AbilityIconProps {
ability: Ability | "UNKNOWN";
size: keyof typeof sizeMap;
loading?: "eager";
}
const AbilityIcon: React.FC<AbilityIconProps> = ({
ability,
size,
loading,
}) => {
const sizeNumber = sizeMap[size];
const abilityName = abilities.find((a) => a.code === ability)?.name;
return (
<div
className={styles.container}
style={
{
"--ability-size": `${sizeNumber}px`,
} as any
}
>
<Image
src={`/abilityIcons/${ability}.png`}
width={sizeNumber}
height={sizeNumber}
alt={abilityName}
loading={loading ?? "lazy"}
title={abilityName}
/>
</div>
);
};
export default AbilityIcon;

View File

@ -1,139 +0,0 @@
import { Box, Flex, Grid, IconButton, useMediaQuery } from "@chakra-ui/react";
import { CSSVariables } from "utils/CSSVariables";
import { ReactNode } from "react";
import { FiChevronLeft, FiChevronRight } from "react-icons/fi";
const daysInMonth = (month: number, year: number): number[] => {
const monthZeroIndex = month - 1;
const date = new Date(year, monthZeroIndex, 1);
const result = [];
while (date.getMonth() === monthZeroIndex) {
result.push(date.getDate());
date.setDate(date.getDate() + 1);
}
return result;
};
const emptyDaysCount = (currentDate: Date): number => {
return [6, 0, 1, 2, 3, 4, 5, 6][currentDate.getDay()];
};
const isToday = (date: Date) => {
const now = new Date();
return (
date.getDate() === now.getDate() &&
date.getMonth() === now.getMonth() &&
date.getFullYear() === now.getFullYear()
);
};
type MonthYear = { month: number; year: number };
const Square = ({ children }: { children?: ReactNode }) => {
return (
<Box color={CSSVariables.themeGray} fontSize="small" fontWeight="bold">
{children}
</Box>
);
};
const Calendar = ({
current,
min,
handleBackClick,
handleNextClick,
showNextButton,
dateContents,
}: {
current: MonthYear;
min: MonthYear;
handleBackClick: () => void;
handleNextClick: () => void;
showNextButton: boolean;
dateContents: Record<string, ReactNode>;
}) => {
const [noSideNav] = useMediaQuery("(max-width: 991px)");
const currentDate = new Date(current.year, current.month - 1, 1);
return (
<Box
display="inline-block"
textAlign="center"
overflowX="auto"
width={noSideNav ? "100vw" : "calc(100vw - 250px)"}
>
<Flex justify="space-evenly">
<IconButton
aria-label="Go back a month"
variant="ghost"
color="current"
icon={<FiChevronLeft />}
borderRadius="50%"
float="left"
size="lg"
onClick={handleBackClick}
visibility={
current.month === min.month && current.year === min.year
? "hidden"
: "visible"
}
/>
<Box>
{currentDate.toLocaleDateString("en", { month: "long" })}
<br />
<b>{current.year}</b>
</Box>
<IconButton
aria-label="Go forward a month"
variant="ghost"
color="current"
icon={<FiChevronRight />}
borderRadius="50%"
float="right"
size="lg"
onClick={handleNextClick}
visibility={showNextButton ? "visible" : "hidden"}
/>
</Flex>
<Grid
templateColumns="repeat(7, max(14%, 125px))"
gridAutoRows="1fr"
gridRowGap="0.5rem"
gridColumnGap="0.25rem"
mt="1rem"
>
<Square>Mo</Square>
<Square>Tu</Square>
<Square>We</Square>
<Square>Th</Square>
<Square>Fr</Square>
<Square>Sa</Square>
<Square>Su</Square>
{new Array(emptyDaysCount(currentDate)).fill(null).map((_, i) => (
<Square key={i}></Square>
))}
{daysInMonth(current.month, current.year).map((day) => (
<Square key={day}>
<Box
as="span"
display="inline-block"
boxShadow={
isToday(new Date(current.year, current.month - 1, day))
? `inset 0px -2px 0px 0px ${CSSVariables.themeColor}`
: undefined
}
>
{day}
</Box>
{dateContents[`${day}-${current.month}-${current.year}`]}
</Square>
))}
</Grid>
</Box>
);
};
export default Calendar;

View File

@ -1,32 +0,0 @@
import { Select, SelectProps } from "@chakra-ui/react";
interface Props {
value: string;
setValue: (value?: string) => void;
placeholder?: string;
name?: string;
children: React.ReactNode;
}
const ChakraSelect: React.FC<Props & SelectProps> = ({
value,
setValue,
placeholder,
children,
name,
...props
}) => (
<Select
value={value ?? ""}
onChange={(e) =>
setValue(e.target.value === "" ? undefined : e.target.value)
}
name={name}
{...props}
>
{placeholder && <option value="">{placeholder}</option>}
{children}
</Select>
);
export default ChakraSelect;

View File

@ -1,39 +0,0 @@
import { Box } from "@chakra-ui/layout";
import {
Popover,
PopoverArrow,
PopoverBody,
PopoverContent,
PopoverTrigger,
} from "@chakra-ui/popover";
import { CSSVariables } from "utils/CSSVariables";
import { CirclePicker } from "react-color";
interface Props {
color: string;
setColor: (color: string) => void;
}
const ColorPicker: React.FC<Props> = ({ color, setColor }) => {
return (
<>
<Popover>
<PopoverTrigger>
<Box w="25px" h="25px" borderRadius="50%" bg={color} />
</PopoverTrigger>
<PopoverContent width="16.5rem">
<PopoverArrow bg={CSSVariables.secondaryBgColor} />
<PopoverBody bg={CSSVariables.secondaryBgColor} rounded="lg">
<CirclePicker
width="16.5rem"
color={color}
onChange={({ hex }) => setColor(hex)}
/>
</PopoverBody>
</PopoverContent>
</Popover>
</>
);
};
export default ColorPicker;

View File

@ -1,27 +0,0 @@
import { Box, useColorMode } from "@chakra-ui/react";
import React from "react";
import ReactDatePicker from "react-datepicker";
import "react-datepicker/dist/react-datepicker.css";
interface Props {
isClearable?: boolean;
onChange: (date: Date) => any;
selectedDate: Date | undefined;
}
const DatePicker = ({ selectedDate, onChange, isClearable = false }: Props) => {
const { colorMode } = useColorMode();
return (
<Box className={colorMode + "-datepicker"}>
<ReactDatePicker
selected={selectedDate}
onChange={onChange}
isClearable={isClearable}
showTimeSelect
dateFormat="MMMM d, yyyy h:mm aa"
/>
</Box>
);
};
export default DatePicker;

View File

@ -1,65 +0,0 @@
import AbilityIcon from "components/common/AbilityIcon";
import GearImage from "components/common/GearImage";
import WeaponImage from "components/common/WeaponImage";
import Image from "next/image";
import { abilityMarkdownCodes } from "utils/lists/abilityMarkdownCodes";
import { gearMarkdownCodes } from "utils/lists/gearMarkdownCodes";
import { codeToWeapon } from "utils/lists/weaponCodes";
import { subSpecialWeaponMarkdownCodes } from "utils/lists/subSpecialWeaponMarkdownCodes";
const modeCodes: Record<string, string> = {
turf_war: "TW",
splat_zones: "SZ",
tower_control: "TC",
rainmaker: "RM",
clam_blitz: "CB",
} as const;
interface EmojiProps {
value: string;
}
const Emoji: React.FC<EmojiProps> = (props) => {
const value = props.value.replace(/:/g, "").toLowerCase();
//TODO: inline removed : make sure emojis blend in with the text
const keyWeapon = value as keyof typeof codeToWeapon;
const weaponName = codeToWeapon[keyWeapon];
if (!!weaponName) return <WeaponImage name={weaponName} size={32} />;
const keyAbility = value as keyof typeof abilityMarkdownCodes;
const abilityName = abilityMarkdownCodes[keyAbility];
if (!!abilityName) return <AbilityIcon size="TINY" ability={abilityName} />;
const keyGear = value as keyof typeof gearMarkdownCodes;
const gearName = gearMarkdownCodes[keyGear];
if (!!gearName) return <GearImage englishName={gearName} mini />;
const keySubSpecial = value as keyof typeof subSpecialWeaponMarkdownCodes;
const subSpecialWeapon = subSpecialWeaponMarkdownCodes[keySubSpecial];
if (!!subSpecialWeapon)
return (
<Image
src={`/subs-specials/${subSpecialWeapon}.png`}
width={32}
height={32}
alt={`Sub or special weapon with code ${subSpecialWeapon}`}
/>
);
const modeCode = modeCodes[value];
if (!!modeCode)
return (
<Image
src={`/modes/${modeCode}.png`}
width={32}
height={32}
alt={`Mode ${modeCode}`}
/>
);
return <>{props.value}</>;
};
export default Emoji;

View File

@ -1,12 +0,0 @@
import Image from "next/image";
export default function Flag({ countryCode }: { countryCode: string }) {
return (
<Image
width={16}
height={16}
src={`https://www.countryflags.io/${countryCode}/flat/16.png`}
alt={`Flag with country code ${countryCode}`}
/>
);
}

View File

@ -1,28 +0,0 @@
import { useLingui } from "@lingui/react";
import Image from "next/image";
import React from "react";
import englishToInteral from "utils/data/englishToInternal.json";
interface GearImageProps {
englishName: string;
mini?: boolean;
}
const GearImage: React.FC<GearImageProps> = ({ englishName, mini }) => {
const { i18n } = useLingui();
const wh = mini ? 32 : 128;
const key = englishName as keyof typeof englishToInteral;
const gearInternal = englishToInteral[key];
return (
<Image
src={`/images/gear/${gearInternal}.png`}
alt={i18n._(englishName)}
title={i18n._(englishName)}
width={wh}
height={wh}
/>
);
};
export default GearImage;

View File

@ -1,83 +0,0 @@
import { Box, Flex } from "@chakra-ui/react";
import { useLingui } from "@lingui/react";
import { components } from "react-select";
import { gear } from "utils/lists/gear";
import GearImage from "./GearImage";
import MySelect from "./MySelect";
interface WeaponSelectorProps {
value?: string;
setValue: (value: string) => void;
slot: "head" | "clothing" | "shoes";
}
const SingleValue = (props: any) => {
return (
<components.SingleValue {...props}>
<Flex alignItems="center">
<Box mr="0.5em" mb="-5px">
<GearImage mini englishName={props.data.value} />
</Box>
{props.data.label}
</Flex>
</components.SingleValue>
);
};
const Option = (props: any) => {
return (
<components.Option {...props}>
<Flex alignItems="center">
<Box mr="0.5em">
<GearImage mini englishName={props.value} />
</Box>
{props.label}
</Flex>
</components.Option>
);
};
const customFilterOption = (option: any, rawInput: string) => {
const words = rawInput.split(" ");
return words.reduce(
(acc, cur) =>
acc &&
(option.label.toLowerCase().includes(cur.toLowerCase()) ||
option.data?.data?.toLowerCase() == rawInput.toLocaleLowerCase()),
true
);
};
const GearSelector: React.FC<WeaponSelectorProps> = ({
value,
setValue,
slot,
}) => {
const { i18n } = useLingui();
return (
<MySelect
options={gear.map((category) => ({
label: i18n._(category.brand),
options: category[slot].map((gear) => ({
value: gear,
label: i18n._(gear),
data: i18n._(category.brand),
})),
}))}
value={value ? { value, label: i18n._(value) } : undefined}
setValue={setValue}
isClearable
isSearchable
components={{
IndicatorSeparator: () => null,
Option,
SingleValue,
}}
hideMenuBeforeTyping
customFilterOption={customFilterOption}
/>
);
};
export default GearSelector;

View File

@ -1,33 +0,0 @@
import { CircularProgress, CircularProgressProps } from "@chakra-ui/react";
interface Props {
currentLength: number;
maxLength: number;
}
const getColor = (value: number) => {
if (value >= 100) return "red.500";
if (value >= 75) return "yellow.500";
return "theme.500";
};
const LimitProgress: React.FC<Props & CircularProgressProps> = ({
currentLength,
maxLength,
...props
}) => {
const value = Math.floor((currentLength / maxLength) * 100);
return (
<CircularProgress
size="20px"
value={value}
thickness="16px"
color={getColor(value)}
{...props}
/>
);
};
export default LimitProgress;

View File

@ -1,29 +0,0 @@
import { IconButton } from "@chakra-ui/react";
import { FiLink, FiTwitter, FiYoutube } from "react-icons/fi";
const LinkButton = ({ link }: { link: string }) => {
return (
<a key={link} href={link}>
<IconButton
aria-label={`Link to ${link}`}
icon={<LinkIcon link={link} />}
isRound
variant="ghost"
/>
</a>
);
};
function LinkIcon({ link }: { link: string }) {
if (link.includes("youtube") || link.includes("youtu.be")) {
return <FiYoutube />;
}
if (link.includes("twitter")) {
return <FiTwitter />;
}
return <FiLink />;
}
export default LinkButton;

View File

@ -1,228 +0,0 @@
import {
Checkbox,
Code,
Divider,
Heading,
Image,
Link,
List,
ListItem,
Text,
} from "@chakra-ui/react";
import Emoji from "components/common/Emoji";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/common/Table";
import { CSSVariables } from "utils/CSSVariables";
import ReactMarkdown from "react-markdown";
import reactStringReplace from "react-string-replace";
import gfm from "remark-gfm";
import MyLink from "./MyLink";
interface MarkdownProps {
value: string;
allowAll?: boolean;
smallHeaders?: boolean;
}
const Markdown: React.FC<MarkdownProps> = ({
value,
allowAll = false,
smallHeaders = false,
}) => {
//https://github.com/mustaphaturhan/chakra-ui-markdown-renderer/blob/master/src/index.js
const ChakraUIRenderer = () => {
function getCoreProps(props: any) {
return props["data-sourcepos"]
? { "data-sourcepos": props["data-sourcepos"] }
: {};
}
return {
paragraph: function MarkdownParagraph(props: any) {
const { children } = props;
return (
<Text as="div" mb={2}>
{children}
</Text>
);
},
emphasis: function MarkdownEmphasis(props: any) {
const { children } = props;
return <Text as="em">{children}</Text>;
},
blockquote: function MarkdownBlockquote(props: any) {
const { children } = props;
return <Code p={2}>{children}</Code>;
},
code: function MarkdownCode(props: any) {
const { language, value } = props;
const className = language && `language-${language}`;
return (
<pre
{...getCoreProps(props)}
style={{ overflowX: "scroll", maxWidth: "95%" }}
>
<Code p={2} className={className || undefined}>
{value}
</Code>
</pre>
);
},
delete: function MarkdownDelete(props: any) {
const { children } = props;
return <Text as="del">{children}</Text>;
},
thematicBreak: Divider,
link: function MarkdownLink(props: any) {
const { children } = props;
return (
<MyLink isExternal {...props} toNewWindow>
{children}
</MyLink>
);
},
linkReference: function MarkdownLinkReference(props: any) {
const { children } = props;
return (
<Link color={CSSVariables.themeColor} {...props}>
{children}
</Link>
);
},
text: function MarkdownText(props: any) {
const { children } = props;
return (
<Text as="span">
{reactStringReplace(children, /(:\S+:)/g, (match, i) => (
<Emoji key={i} value={match} />
))}
</Text>
);
},
list: function MarkdownList(props: any) {
const { start, ordered, children, depth } = props;
const attrs = getCoreProps(props);
if (start !== null && start !== 1 && start !== undefined) {
// @ts-ignore
attrs.start = start.toString();
}
let styleType = "disc";
if (ordered) styleType = "decimal";
if (depth === 1) styleType = "circle";
return (
<List
spacing={2}
as={ordered ? "ol" : "ul"}
styleType={styleType}
pl={4}
{...attrs}
>
{children}
</List>
);
},
listItem: function MarkdownListItem(props: any) {
const { children, checked } = props;
let checkbox = null;
if (checked !== null && checked !== undefined) {
checkbox = (
<Checkbox isChecked={checked} isReadOnly>
{children}
</Checkbox>
);
}
return (
<ListItem
{...getCoreProps(props)}
listStyleType={checked !== null ? "none" : "inherit"}
>
{checkbox || children}
</ListItem>
);
},
definition: function MarkdownDefinition() {
return null;
},
heading: function MarkdownHeading(props: any) {
const { children } = props;
if (smallHeaders) {
return (
<Heading as="h3" mt={2} mb={1} size="md" {...getCoreProps(props)}>
{children}
</Heading>
);
}
if (props.level === 1) {
return (
<Heading as="h1" mt={8} mb={4} size="2xl" {...getCoreProps(props)}>
{children}
</Heading>
);
}
if (props.level === 2) {
return (
<Heading as="h2" mt={4} mb={2} size={"lg"} {...getCoreProps(props)}>
{children}
</Heading>
);
}
return (
<Heading as="h3" mt={2} mb={1} size="md" {...getCoreProps(props)}>
{children}
</Heading>
);
},
inlineCode: function MarkdownInlineCode(props: any) {
const { children } = props;
return <Code {...getCoreProps(props)}>{children}</Code>;
},
table: function MarkdownTable(props: any) {
const { children } = props;
return <Table {...getCoreProps(props)}>{children}</Table>;
},
tableHead: function MarkdownTableHead(props: any) {
const { children } = props;
return <TableHead {...getCoreProps(props)}>{children}</TableHead>;
},
tableBody: function MarkdownTableBody(props: any) {
const { children } = props;
return <TableBody {...getCoreProps(props)}>{children}</TableBody>;
},
tableRow: function MarkdownTableRow(props: any) {
const { children } = props;
return <TableRow {...getCoreProps(props)}>{children}</TableRow>;
},
tableCell: function MarkdownTableCell(props: any) {
const { children, isHeader } = props;
if (isHeader) {
return <TableHeader {...getCoreProps(props)}>{children}</TableHeader>;
}
return <TableCell {...getCoreProps(props)}>{children}</TableCell>;
},
image: function MarkdownImage(props: any) {
return <Image {...props} my={4} alt="" />;
},
};
};
return (
<ReactMarkdown
source={value.replace(/\n/g, " \n")}
renderers={ChakraUIRenderer()}
disallowedTypes={allowAll ? [] : ["imageReference", "image"]}
plugins={[gfm]}
/>
);
};
export default Markdown;

View File

@ -1,68 +0,0 @@
import {
Box,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
Textarea,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { FieldError } from "react-hook-form";
import LimitProgress from "./LimitProgress";
interface Props {
error?: FieldError;
fieldName: string;
title: string;
value: string;
maxLength: number;
register: any;
placeholder?: string;
dataCy?: string;
}
const MarkdownTextarea: React.FC<Props> = ({
error,
fieldName,
title,
value,
maxLength,
register,
placeholder,
dataCy,
}) => {
return (
<FormControl isInvalid={!!error}>
<FormLabel htmlFor={fieldName} mt={4}>
{title}
</FormLabel>
<Textarea
ref={register}
name={fieldName}
placeholder={placeholder}
resize="vertical"
rows={6}
data-cy={dataCy}
/>
<FormHelperText display="flex" alignItems="center">
<LimitProgress
currentLength={value.length}
maxLength={maxLength}
mr={3}
/>
<Box>
<Trans>
Markdown is supported -{" "}
{/* eslint-disable-next-line @next/next/no-html-link-for-pages */}
<a href="/markdown" target="_blank" rel="noreferrer noopener">
https://sendou.ink/markdown
</a>
</Trans>
</Box>
</FormHelperText>
<FormErrorMessage>{error?.message}</FormErrorMessage>
</FormControl>
);
};
export default MarkdownTextarea;

View File

@ -1,23 +0,0 @@
import Image from "next/image";
import { CSSProperties } from "react";
interface ModeImageProps {
mode: "TW" | "SZ" | "TC" | "RM" | "CB";
size?: 20 | 24 | 32 | 64 | 128;
onClick?: () => void;
style?: CSSProperties;
}
const ModeImage: React.FC<ModeImageProps> = ({ mode, size = 32, onClick }) => {
return (
<Image
src={`/modes/${mode}.png`}
width={size}
height={size}
onClick={onClick}
alt={`Mode (${mode})`}
/>
);
};
export default ModeImage;

View File

@ -1,39 +0,0 @@
import { Radio, RadioGroup, RadioGroupProps, Stack } from "@chakra-ui/react";
import { RankedMode } from "@prisma/client";
import ModeImage from "components/common/ModeImage";
interface Props {
mode: RankedMode;
setMode: (mode: RankedMode) => void;
}
const ModeSelector = ({
mode,
setMode,
...props
}: Props & Omit<RadioGroupProps, "children">) => {
return (
<RadioGroup
value={mode}
onChange={(value) => setMode(value as RankedMode)}
{...props}
>
<Stack direction="row" spacing={4} align="center">
<Radio value="SZ">
<ModeImage mode="SZ" size={32} />
</Radio>
<Radio value="TC">
<ModeImage mode="TC" size={32} />
</Radio>
<Radio value="RM">
<ModeImage mode="RM" size={32} />
</Radio>
<Radio value="CB">
<ModeImage mode="CB" size={32} />
</Radio>
</Stack>
</RadioGroup>
);
};
export default ModeSelector;

View File

@ -1,145 +0,0 @@
import { ChevronDownIcon } from "@chakra-ui/icons";
import { CSSVariables } from "utils/CSSVariables";
import { useState } from "react";
import ReactSelect, {
components,
GroupedOptionsType,
OptionsType,
OptionTypeBase,
ValueType,
} from "react-select";
import { SelectComponents } from "react-select/src/components";
import { Box, Flex } from "@chakra-ui/react";
import ModeImage from "./ModeImage";
import { selectDefaultStyles } from "./MySelect";
interface SelectProps {
options?:
| OptionsType<{
label: string;
value: any;
data?: any;
}>
| GroupedOptionsType<{
label: string;
value: string;
data?: any;
}>;
width?: string;
value?: ValueType<OptionTypeBase, boolean>;
updateMapsModes: (value: any) => void;
defaultValue?: OptionsType<{
label: string;
value: any;
data?: any;
}>;
components?: Partial<SelectComponents<OptionTypeBase, boolean>>;
isDisabled?: boolean;
menuIsOpen?: boolean;
hideMenuBeforeTyping?: boolean;
}
const DropdownIndicator = (props: any) => {
return (
<components.DropdownIndicator {...props}>
<ChevronDownIcon fontSize="1.3rem" color={CSSVariables.textColor} />
</components.DropdownIndicator>
);
};
const Option = (props: any) => {
return (
<components.Option {...props}>
<Flex alignItems="center">
<Box mr="0.5em">
<ModeImage size={24} mode={props.value} />
</Box>
{props.label}
</Flex>
</components.Option>
);
};
const MultipleModeSelector: React.FC<SelectProps> = ({
options,
components,
updateMapsModes,
isDisabled = false,
menuIsOpen = false,
hideMenuBeforeTyping,
defaultValue,
}) => {
const [inputValue, setInputValue] = useState("");
const [selectedModes, setSelectedModes] = useState(defaultValue);
const handleChange = (selectedOption: any) => {
if (!selectedOption) {
setSelectedModes([]);
return;
}
const newSelectedOption = selectedOption.map(
(opt: { label: string; data: string }) => ({
label: opt.label,
data: opt.data,
value: Math.random(),
})
);
setSelectedModes(newSelectedOption);
updateMapsModes(newSelectedOption);
};
const menuIsOpenCheck = () => {
if (menuIsOpen) return true;
if (hideMenuBeforeTyping) {
return !!(inputValue.length >= 3);
}
return undefined;
};
return (
<ReactSelect
className="basic-single"
classNamePrefix="select"
value={selectedModes}
inputValue={inputValue}
onInputChange={(newValue) => setInputValue(newValue)}
menuIsOpen={menuIsOpenCheck()}
onChange={handleChange}
placeholder={null}
isMulti={true}
isDisabled={isDisabled}
isClearable={true}
options={options}
components={
hideMenuBeforeTyping
? {
IndicatorSeparator: () => null,
DropdownIndicator: () => null,
Option,
...components,
}
: {
IndicatorSeparator: () => null,
DropdownIndicator,
Option,
...components,
}
}
theme={(theme) => ({
...theme,
borderRadius: 5,
colors: {
...theme.colors,
primary25: `${CSSVariables.themeColor}`,
primary: CSSVariables.themeColor,
neutral0: CSSVariables.bgColor,
neutral5: CSSVariables.bgColor,
},
})}
styles={selectDefaultStyles}
/>
);
};
export default MultipleModeSelector;

View File

@ -1,12 +0,0 @@
import { Alert, AlertIcon } from "@chakra-ui/alert";
const MyError = ({ message }: { message: string }) => {
return (
<Alert status="error" rounded="lg">
<AlertIcon />
{message}
</Alert>
);
};
export default MyError;

View File

@ -1,18 +0,0 @@
import Head from "next/head";
interface Props {
title: string;
appendSendouInk?: boolean;
}
const MyHead: React.FC<Props> = ({ title, appendSendouInk = true }) => {
const pageTitle = appendSendouInk ? `${title} | sendou.ink` : title;
return (
<Head>
<title>{pageTitle}</title>
<meta property="og:title" content={pageTitle} key="title" />
</Head>
);
};
export default MyHead;

View File

@ -1,44 +0,0 @@
import {
IconButton,
IconButtonProps,
Popover,
PopoverArrow,
PopoverContent,
PopoverHeader,
PopoverTrigger,
} from "@chakra-ui/react";
import { CSSVariables } from "utils/CSSVariables";
interface Props {
onClick?: () => void;
icon: React.ReactElement;
popup: string;
}
const MyIconButton: React.FC<Props & Omit<IconButtonProps, "aria-label">> = ({
onClick,
icon,
popup,
...props
}) => {
return (
<Popover trigger="hover" variant="responsive">
<PopoverTrigger>
<IconButton
variant="ghost"
isRound
onClick={onClick}
aria-label={popup}
icon={icon}
{...props}
/>
</PopoverTrigger>
<PopoverContent bg={CSSVariables.secondaryBgColor}>
<PopoverHeader fontWeight="semibold">{popup}</PopoverHeader>
<PopoverArrow bg={CSSVariables.secondaryBgColor} />
</PopoverContent>
</Popover>
);
};
export default MyIconButton;

View File

@ -1,26 +0,0 @@
import { Flex } from "@chakra-ui/react";
import { useState } from "react";
import InfiniteScroll from "react-infinite-scroller";
const ON_PAGE = 6;
const MyInfiniteScroller: React.FC = ({ children }) => {
const [elementsToShow, setElementsToShow] = useState(ON_PAGE);
if (!Array.isArray(children))
throw Error("children for MyInfiniteScroller is not an array");
return (
<InfiniteScroll
pageStart={1}
loadMore={(page) => setElementsToShow(page * ON_PAGE)}
hasMore={elementsToShow < children.length}
>
<Flex flexWrap="wrap" justifyContent="center" mt={4}>
{children.slice(0, elementsToShow)}
</Flex>
</InfiniteScroll>
);
};
export default MyInfiniteScroller;

View File

@ -1,51 +0,0 @@
import { Link as ChakraLink, LinkProps } from "@chakra-ui/react";
import { CSSVariables } from "utils/CSSVariables";
import NextLink from "next/link";
interface Props {
children: React.ReactNode;
href: string;
isExternal?: boolean;
prefetch?: boolean;
isColored?: boolean;
toNewWindow?: boolean;
noUnderline?: boolean;
chakraLinkProps?: LinkProps;
}
const MyLink: React.FC<Props> = ({
children,
href,
isExternal,
prefetch = false,
isColored = true,
toNewWindow,
noUnderline,
chakraLinkProps = {},
}) => {
if (isExternal) {
return (
<ChakraLink
href={href}
color={isColored ? CSSVariables.themeColor : undefined}
target={toNewWindow ? "_blank" : undefined}
{...chakraLinkProps}
>
{children}
</ChakraLink>
);
}
return (
<NextLink href={href} prefetch={prefetch ? undefined : false} passHref>
<ChakraLink
className={noUnderline ? "nounderline" : undefined}
color={isColored ? CSSVariables.themeColor : undefined}
{...chakraLinkProps}
>
{children}
</ChakraLink>
</NextLink>
);
};
export default MyLink;

View File

@ -1,171 +0,0 @@
import { ChevronDownIcon } from "@chakra-ui/icons";
import { CSSVariables } from "utils/CSSVariables";
import { useState } from "react";
import ReactSelect, {
components,
GroupedOptionsType,
OptionsType,
OptionTypeBase,
ValueType,
} from "react-select";
import { SelectComponents } from "react-select/src/components";
interface SelectProps {
options?:
| OptionsType<{
label: string;
value: any;
data?: any;
}>
| GroupedOptionsType<{
label: string;
value: string;
data?: any;
}>;
name?: string;
width?: string;
value?: ValueType<OptionTypeBase, boolean>;
setValue: (value: any) => void;
autoFocus?: boolean;
components?: Partial<SelectComponents<OptionTypeBase, boolean>>;
isClearable?: boolean;
isMulti?: boolean;
isLoading?: boolean;
isDisabled?: boolean;
isSearchable?: boolean;
menuIsOpen?: boolean;
hideMenuBeforeTyping?: boolean;
customFilterOption?: (option: any, rawInput: string) => boolean;
styles?: any;
}
const DropdownIndicator = (props: any) => {
return (
<components.DropdownIndicator {...props}>
<ChevronDownIcon fontSize="1.3rem" color={CSSVariables.textColor} />
</components.DropdownIndicator>
);
};
export const selectDefaultStyles = {
singleValue: (base: any) => ({
...base,
padding: 5,
borderRadius: 5,
color: CSSVariables.textColor,
display: "flex",
}),
input: (base: any) => ({
...base,
color: CSSVariables.textColor,
}),
multiValue: (base: any) => ({
...base,
background: CSSVariables.themeColor,
color: "black",
}),
option: (styles: any, { isFocused }: any) => {
return {
...styles,
backgroundColor: isFocused ? CSSVariables.themeColorOpaque : undefined,
color: CSSVariables.textColor,
};
},
menu: (styles: any) => ({ ...styles, zIndex: 999 }),
control: (base: any) => ({
...base,
borderColor: CSSVariables.borderColor,
minHeight: "2.5rem",
background: "hsla(0, 0%, 0%, 0)",
}),
};
const MySelect: React.FC<SelectProps> = ({
options,
components,
value,
setValue,
name,
isClearable = false,
autoFocus = false,
isMulti = false,
isLoading = false,
isDisabled = false,
isSearchable = false,
menuIsOpen = false,
hideMenuBeforeTyping,
customFilterOption,
styles,
}) => {
const [inputValue, setInputValue] = useState("");
const handleChange = (selectedOption: any) => {
if (!selectedOption) {
setValue(isMulti ? [] : null);
return;
}
if (Array.isArray(selectedOption)) {
setValue(selectedOption.map((obj) => obj.value));
} else {
setValue(selectedOption?.value);
}
};
const menuIsOpenCheck = () => {
if (menuIsOpen) return true;
if (hideMenuBeforeTyping) {
return !!(inputValue.length >= 3);
}
return undefined;
};
return (
<ReactSelect
className="basic-single"
classNamePrefix="select"
name={name}
value={value}
inputValue={inputValue}
onInputChange={(newValue) => setInputValue(newValue)}
menuIsOpen={menuIsOpenCheck()}
onChange={handleChange}
placeholder={null}
isSearchable={!!isSearchable}
isMulti={!!isMulti}
isLoading={isLoading}
isDisabled={isDisabled}
isClearable={isClearable}
options={options}
filterOption={customFilterOption}
components={
hideMenuBeforeTyping
? {
IndicatorSeparator: () => null,
DropdownIndicator: () => null,
...components,
}
: {
IndicatorSeparator: () => null,
DropdownIndicator,
...components,
}
}
theme={(theme) => ({
...theme,
borderRadius: 5,
colors: {
...theme.colors,
primary25: `${CSSVariables.themeColor}`,
primary: CSSVariables.themeColor,
neutral0: CSSVariables.bgColor,
neutral5: CSSVariables.bgColor,
},
})}
autoFocus={autoFocus}
styles={styles ? styles : selectDefaultStyles}
/>
);
};
export default MySelect;

View File

@ -1,8 +0,0 @@
import { Spinner } from "@chakra-ui/spinner";
import { CSSVariables } from "utils/CSSVariables";
const MySpinner = () => {
return <Spinner color={CSSVariables.themeColor} thickness="3px" />;
};
export default MySpinner;

View File

@ -1,149 +0,0 @@
import { Box, Grid } from "@chakra-ui/layout";
import { useMediaQuery } from "@chakra-ui/media-query";
import {
Table,
TableCaption,
Tbody,
Td,
Tfoot,
Th,
Thead,
Tr,
} from "@chakra-ui/table";
import React, { Fragment } from "react";
import { getRankingString } from "utils/strings";
import OutlinedBox from "./OutlinedBox";
export type TableRow = Record<string, React.ReactNode> & { id: number };
export default function NewTable({
caption,
headers,
data,
smallAtPx,
leaderboardKey,
size,
}: {
caption?: string;
headers: {
name: string;
dataKey: string;
}[];
data: (TableRow | null)[];
smallAtPx?: string;
leaderboardKey?: string;
size?: "sm" | "md" | "lg";
}) {
const [isSmall] = useMediaQuery(
smallAtPx ? `(max-width: ${smallAtPx}px)` : "(max-width: 600px)"
);
const nonNullData = data.filter((row): row is TableRow => row !== null);
let lastValue: any = null;
let rankToUse: number = 1;
if (isSmall) {
return (
<>
{nonNullData.map((row, i) => {
if (leaderboardKey) {
if (row[leaderboardKey] !== lastValue) rankToUse = i + 1;
lastValue = row[leaderboardKey];
}
return (
<Grid
key={row.id}
border="1px solid"
borderColor="whiteAlpha.300"
rounded="lg"
px={4}
py={2}
mb={4}
templateColumns="1fr 2fr"
autoRows="1fr"
gridRowGap={1}
alignItems="center"
>
{leaderboardKey ? (
<Fragment>
<Box
textTransform="uppercase"
fontWeight="bold"
fontSize="sm"
fontFamily="heading"
letterSpacing="wider"
color="gray.400"
mr={2}
>
Rank
</Box>
<Box>{getRankingString(rankToUse)}</Box>
</Fragment>
) : null}
{headers.map(({ name, dataKey }) => {
return (
<Fragment key={dataKey}>
<Box
textTransform="uppercase"
fontWeight="bold"
fontSize="sm"
fontFamily="heading"
letterSpacing="wider"
color="gray.400"
mr={2}
>
{name}
</Box>
<Box>{row[dataKey]}</Box>
</Fragment>
);
})}
</Grid>
);
})}
</>
);
}
return (
<OutlinedBox>
<Table variant="simple" fontSize="sm" size={size ?? "md"}>
{caption && <TableCaption placement="top">{caption}</TableCaption>}
<Thead>
<Tr>
{leaderboardKey ? <Th>Rank</Th> : null}
{headers.map(({ name }) => (
<Th key={name}>{name}</Th>
))}
</Tr>
</Thead>
<Tbody>
{nonNullData.map((row, i) => {
if (leaderboardKey) {
if (row[leaderboardKey] !== lastValue) rankToUse = i + 1;
lastValue = row[leaderboardKey];
}
return (
<Tr key={row.id}>
{leaderboardKey ? <Th>{getRankingString(rankToUse)}</Th> : null}
{headers.map(({ dataKey }) => {
return <Td key={dataKey}>{row[dataKey]}</Td>;
})}
</Tr>
);
})}
</Tbody>
<Tfoot>
<Tr>
{leaderboardKey ? <Th>Rank</Th> : null}
{headers.map(({ name }) => (
<Th key={name}>{name}</Th>
))}
</Tr>
</Tfoot>
</Table>
</OutlinedBox>
);
}

View File

@ -1,21 +0,0 @@
import { BoxProps, Flex } from "@chakra-ui/layout";
import React, { ReactNode } from "react";
export default function OutlinedBox({
children,
...props
}: { children: ReactNode } & BoxProps) {
return (
<Flex
border="1px solid"
borderColor="whiteAlpha.300"
rounded="lg"
px={4}
py={2}
alignItems="center"
{...props}
>
{children}
</Flex>
);
}

View File

@ -1,47 +0,0 @@
import { NextSeo } from "next-seo";
import { useRouter } from "next/router";
interface Props {
title: string;
description: string;
// 1200x628
imageSrc: string;
appendTitle?: boolean;
}
const SEO: React.FC<Props> = ({
title,
description,
imageSrc,
appendTitle = true,
}) => {
const router = useRouter();
const fullTitle = appendTitle ? `${title} | sendou.ink` : title;
const url = "https://sendou.ink" + router.pathname;
return (
<NextSeo
title={fullTitle}
description={description}
openGraph={{
url,
title,
description,
images: [
{
url: imageSrc,
width: 1200,
height: 628,
},
],
site_name: "sendou.ink",
}}
twitter={{
site: "@sendouink",
cardType: "summary_large_image",
}}
/>
);
};
export default SEO;

View File

@ -1,24 +0,0 @@
import { Box, BoxProps } from "@chakra-ui/react";
import { CSSVariables } from "utils/CSSVariables";
interface Props {
children: React.ReactNode;
}
const SubText: React.FC<Props & BoxProps> = ({ children, ...props }) => {
return (
<Box
fontSize="xs"
textColor={CSSVariables.themeColor}
textTransform="uppercase"
letterSpacing="wider"
lineHeight="1rem"
fontWeight="medium"
{...props}
>
{children}
</Box>
);
};
export default SubText;

View File

@ -1,33 +0,0 @@
import { Box, BoxProps, Flex } from "@chakra-ui/react";
import { useState } from "react";
import SubText from "./SubText";
interface Props {
title: string;
children: React.ReactNode;
isOpenByDefault?: boolean;
}
const SubTextCollapse: React.FC<Props & BoxProps> = ({
title,
children,
isOpenByDefault = false,
...props
}) => {
const [isOpen, setIsOpen] = useState(isOpenByDefault);
return (
<Box {...props}>
<SubText onClick={() => setIsOpen(!isOpen)} cursor="pointer" mb={2}>
<Flex userSelect="none">
<Box w={4} transform="rotate(0deg)">
{isOpen ? "▼" : "►"}
</Box>{" "}
{title}
</Flex>
</SubText>
{isOpen && <>{children}</>}
</Box>
);
};
export default SubTextCollapse;

View File

@ -1,87 +0,0 @@
// https://github.com/chakra-ui/chakra-ui/issues/135#issuecomment-644878591
import { Box, BoxProps } from "@chakra-ui/react";
import { CSSVariables } from "utils/CSSVariables";
/**
* Represents tabular data - that is, information presented in a
* two-dimensional table comprised of rows and columns of cells containing
* data. It renders a `<table>` HTML element.
*/
export function Table(props: BoxProps) {
return (
<Box overflow="auto">
<Box as="table" width="full" {...props} />
</Box>
);
}
/**
* Defines a set of rows defining the head of the columns of the table. It
* renders a `<thead>` HTML element.
*/
export function TableHead(props: BoxProps) {
return <Box as="thead" {...props} />;
}
/**
* Defines a row of cells in a table. The row's cells can then be established
* using a mix of `TableCell` and `TableHeader` elements. It renders a `<tr>`
* HTML element.
*/
export function TableRow(props: BoxProps) {
return (
<Box
as="tr"
{...props}
_even={{ backgroundColor: CSSVariables.bgColor }}
borderRadius="5px"
/>
);
}
export function TableHeader(props: BoxProps) {
return (
<>
<Box
as="th"
px="4"
py="3"
backgroundColor={CSSVariables.themeColor}
color={CSSVariables.secondaryBgColor}
textAlign="left"
fontSize="xs"
textTransform="uppercase"
letterSpacing="wider"
lineHeight="1rem"
fontWeight="medium"
{...props}
/>
</>
);
}
/**
* Encapsulates a set of table rows, indicating that they comprise the body of
* the table. It renders a `<tbody>` HTML element.
*/
export function TableBody(props: BoxProps) {
return <Box as="tbody" {...props} />;
}
/**
* Defines a cell of a table that contains data. It renders a `<td>` HTML
* element.
*/
export function TableCell(props: BoxProps) {
return (
<Box
as="td"
px="4"
py="4"
lineHeight="1.25rem"
whiteSpace="nowrap"
{...props}
/>
);
}

View File

@ -1,22 +0,0 @@
import { Avatar, AvatarProps } from "@chakra-ui/react";
import React from "react";
interface Props {
twitterName: string;
isSmall?: boolean;
}
const TwitterAvatar: React.FC<Props & AvatarProps> = ({
twitterName,
isSmall,
...props
}) => (
<Avatar
name={twitterName}
src={`https://api.microlink.io/?url=https://twitter.com/${twitterName}&amps;embed=image.url`}
size={isSmall ? "sm" : undefined}
{...props}
/>
);
export default TwitterAvatar;

View File

@ -1,32 +0,0 @@
import { Avatar, AvatarProps } from "@chakra-ui/react";
import { User } from "@prisma/client";
import React from "react";
interface Props {
user: Pick<User, "discordId" | "username" | "discordAvatar">;
isSmall?: boolean;
}
const UserAvatar: React.FC<Props & AvatarProps> = ({
user,
isSmall,
...props
}) => (
<Avatar
name={user.username}
src={
user.discordAvatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${
user.discordAvatar
}.jpg${isSmall ? "?size=40" : ""}`
: // default avatar
`https://cdn.discordapp.com/avatars/455039198672453645/f809176af93132c3db5f0a5019e96339.jpg${
isSmall ? "?size=40" : ""
}`
}
size={isSmall ? "sm" : undefined}
{...props}
/>
);
export default UserAvatar;

View File

@ -1,113 +0,0 @@
import { Box, Center, Flex } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { GetAllUsersLeanData } from "prisma/queries/getAllUsersLean";
import { components } from "react-select";
import useSWR from "swr";
import MySelect from "./MySelect";
import UserAvatar from "./UserAvatar";
interface SingleSelectorProps {
value?: number;
setValue: (value: number) => void;
isMulti: false | undefined;
maxMultiCount: undefined;
}
interface MultiSelectorProps {
value: number[];
setValue: (value: number[]) => void;
isMulti: true;
maxMultiCount: number;
}
const customFilterOption = (option: any, rawInput: string) => {
return (
option.label.toLowerCase().includes(rawInput.toLowerCase()) ||
option.data.data.profile?.twitterName
?.toLowerCase()
.includes(rawInput.toLowerCase())
);
};
const UserSelector: React.FC<SingleSelectorProps | MultiSelectorProps> = ({
value,
setValue,
isMulti,
maxMultiCount,
}) => {
const { data } = useSWR<GetAllUsersLeanData>("/api/users");
const singleOption = function UserSelectorSingleOption(props: any) {
return (
<components.Option {...props}>
<Flex alignItems="center">
<Box mr="0.5em">
<UserAvatar user={props.data.data} />
</Box>
{createLabel(props.data.data)}
</Flex>
</components.Option>
);
};
return (
<MySelect
options={getUsersArray().map((user) => ({
label: `${user.username}#${user.discriminator}`,
value: "" + user.id,
data: user,
}))}
setValue={(newValue) => {
if (isMulti) {
setValue(newValue.map((value: string) => parseInt(value)));
} else {
setValue(newValue ? parseInt(newValue) : newValue);
}
}}
isSearchable
isMulti={isMulti}
components={{
IndicatorSeparator: () => null,
Option: singleOption,
NoOptionsMessage: function UserSelectorNoOptionsMessage() {
return (
<Center p={4}>
{isTooManyItems() ? (
<Trans>Only {maxMultiCount} users allowed</Trans>
) : (
<Trans>No results with this filter</Trans>
)}
</Center>
);
},
}}
hideMenuBeforeTyping
isClearable={!isMulti}
customFilterOption={customFilterOption}
/>
);
function getUsersArray() {
if (!data) return [];
if (isTooManyItems()) {
return [];
}
return data;
}
function isTooManyItems() {
return (
maxMultiCount && Array.isArray(value) && maxMultiCount <= value.length
);
}
function createLabel(user: any) {
let label = `${user.username}#${user.discriminator}`;
if (user.profile && user.profile.twitterName)
label += ` (${user.profile.twitterName})`;
return label;
}
};
export default UserSelector;

View File

@ -1,56 +0,0 @@
import { useEffect, useRef } from "react";
const Video = ({
clipName,
time,
playbackRate,
}: {
clipName: "trailer1";
time: { start: number; end: number };
playbackRate?: number;
}) => {
const ref = useRef<HTMLVideoElement>(null);
const src = `/splatoon3/${clipName}.mp4#t=${time.start},${time.end}`;
useEffect(() => {
if (!ref.current) return;
function loopVideoIfNeeded() {
const video = ref.current;
if (!video) return;
if (video.currentTime > time.end) {
video.currentTime = time.start;
video.play();
}
}
ref.current.addEventListener("timeupdate", loopVideoIfNeeded);
const current = ref.current;
return () => {
current.removeEventListener("timeupdate", loopVideoIfNeeded);
};
}, [time.start, time.end]);
useEffect(() => {
if (!ref.current || !playbackRate) return;
ref.current.playbackRate = playbackRate;
}, [playbackRate]);
return (
<video
ref={ref}
src={src}
playsInline
loop
controls
/*autoPlay*/ muted
width="500"
/>
);
};
export default Video;

View File

@ -1,36 +0,0 @@
import { t } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import Image from "next/image";
import React from "react";
interface WeaponImageProps {
name: string;
size: 32 | 64 | 128;
noTitle?: boolean;
}
const WeaponImage: React.FC<WeaponImageProps> = ({ name, size, noTitle }) => {
const { i18n } = useLingui();
if (!name) return <></>;
return (
<Image
src={`/weapons/${name.replace(".", "").trim()}.png`}
alt={i18n._(name)}
title={getTitle()}
width={size}
height={size}
/>
);
function getTitle() {
if (noTitle) return undefined;
if (name === "RANDOM") return t`Random`;
if (name === "RANDOM_GRIZZCO") return t`Random (Grizzco)`;
return i18n._(name);
}
};
export default WeaponImage;

View File

@ -1,214 +0,0 @@
import { Box, Center, Flex } from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { components } from "react-select";
import {
salmonRunWeapons,
weaponsWithHeroCategorized,
} from "utils/lists/weaponsWithHero";
import { weaponsAliases } from "utils/lists/weaponsAliases";
import weaponJson from "utils/data/weaponData.json";
import MySelect from "./MySelect";
import WeaponImage from "./WeaponImage";
interface SelectorProps {
autoFocus?: boolean;
isClearable?: boolean;
menuIsOpen?: boolean;
isDisabled?: boolean;
pool?: "WITH_ALTS" | "SALMON_RUN";
}
interface SingleSelectorProps extends SelectorProps {
value?: string | null;
setValue: (value: string) => void;
isClearable?: true;
isMulti: false;
}
interface MultiSelectorProps extends SelectorProps {
value?: string[];
setValue: (value: string[]) => void;
isMulti: true;
maxMultiCount: number;
}
const SingleValue = (props: any) => {
return (
<components.SingleValue {...props}>
<Flex alignItems="center">
<Box mr="0.5em" mb="-5px">
<WeaponImage size={32} name={props.data.value} />
</Box>
{props.data.label}
</Flex>
</components.SingleValue>
);
};
const Option = (props: any) => {
return (
<components.Option {...props}>
<Flex alignItems="center">
<Box mr="0.5em">
<WeaponImage size={32} name={props.value} />
</Box>
{props.label}
</Flex>
</components.Option>
);
};
const customFilterOption = (option: any, rawInput: string) => {
const words = rawInput.split(" ");
return words.reduce(
(acc, cur) =>
acc &&
(option.label.toLowerCase().includes(cur.toLowerCase()) ||
filterWeaponsByString(rawInput, option.data?.data)),
true
);
};
function filterWeaponsByString(
rawInput: string,
weaponData: WeaponData
): boolean {
const inputNormalized = rawInput.toLowerCase();
if (!weaponData) return false;
if (weaponData?.sub.toLowerCase() === inputNormalized) return true;
if (weaponData?.special.toLowerCase() === inputNormalized) return true;
if (weaponData?.aliases) {
for (const alias of weaponData.aliases) {
if (alias?.toLowerCase().includes(inputNormalized)) return true;
}
}
return false;
}
type WeaponData = {
name: string;
sub: string;
special: string;
aliases: typeof weaponsAliases[keyof typeof weaponsAliases];
};
function initWeaponData() {
const weaponData: Record<any, any> = weaponJson;
const weaponsArray: WeaponData[] = [];
for (const [key, value] of Object.entries(weaponData)) {
if (value.Special && value.Sub) {
const typedKey = key as keyof typeof weaponsAliases;
const aliases = weaponsAliases[typedKey];
weaponsArray.push({
name: key,
special: value.Special,
sub: value.Sub,
aliases,
});
}
}
return weaponsArray;
}
const weaponData = initWeaponData();
const WeaponSelector: React.FC<SingleSelectorProps | MultiSelectorProps> = (
props
) => {
const { i18n } = useLingui();
const maxMultiCount = props.isMulti ? props.maxMultiCount : Infinity;
return (
<MySelect
name="weapon"
options={getWeaponArray().map((category) => ({
label: i18n._(category.name),
options: category.weapons.map((weapon) => ({
value: weapon,
label: getLabel(weapon),
data: weaponData.find((obj) => {
return obj.name === weapon;
}),
})),
}))}
value={getValue()}
setValue={props.setValue}
isClearable={!!props.isClearable}
isSearchable
isMulti={props.isMulti}
menuIsOpen={!!props.menuIsOpen}
components={{
IndicatorSeparator: () => null,
Option,
SingleValue,
NoOptionsMessage: function NoOptionsMessage() {
return (
<Center p={4}>
{isTooManyItems() ? (
<Trans>Only {maxMultiCount} weapons allowed</Trans>
) : (
<Trans>No results with this filter</Trans>
)}
</Center>
);
},
}}
autoFocus={!!props.autoFocus}
isDisabled={!!props.isDisabled}
customFilterOption={customFilterOption}
/>
);
function getValue() {
if (typeof props.value === "string") {
return { value: props.value, label: getLabel(props.value) };
}
if (Array.isArray(props.value)) {
return props.value.map((singleValue) => ({
value: singleValue,
label: getLabel(singleValue),
}));
}
return undefined;
}
function getLabel(value: string) {
if (value === "RANDOM") return t`Random`;
if (value === "RANDOM_GRIZZCO") return t`Random (Grizzco)`;
return i18n._(value);
}
function getWeaponArray() {
if (isTooManyItems()) return [];
if (props.pool === "WITH_ALTS") return weaponsWithHeroCategorized;
if (props.pool === "SALMON_RUN")
return [
{ name: t`Salmon Run`, weapons: ["RANDOM", "RANDOM_GRIZZCO"] },
].concat(
weaponsWithHeroCategorized.map((category) => ({
...category,
weapons: category.weapons.filter((wpn) => salmonRunWeapons.has(wpn)),
}))
);
return weaponsWithHeroCategorized.map((category) => ({
...category,
weapons: category.weapons.filter(
(wpn) => !wpn.includes("Hero") && !wpn.includes("Octo Shot")
),
}));
}
function isTooManyItems() {
return maxMultiCount && maxMultiCount <= (props.value ?? []).length;
}
};
export default WeaponSelector;

View File

@ -1,43 +0,0 @@
.button {
position: relative;
display: inline-flex;
width: auto;
min-width: var(--sizes-10);
height: var(--sizes-10);
align-items: center;
justify-content: center;
appearance: none;
background-color: var(--theme-color);
border-radius: var(--radii-md);
color: var(--chakra-colors-gray-800);
font-size: var(--fontSizes-md);
font-weight: var(--fontWeights-semibold);
line-height: 1.2;
outline: 2px solid transparent;
outline-offset: 2px;
padding-inline-end: var(--space-4);
padding-inline-start: var(--space-4);
transition-duration: var(--transition-duration-normal);
transition-property: var(--transition-property-common);
user-select: none;
vertical-align: middle;
white-space: nowrap;
}
.button:focus {
outline: 2px solid var(--theme-color-secondary);
outline-offset: 2px;
}
.button:hover {
background-color: var(--theme-color-lighter);
}
.disabled {
cursor: not-allowed;
opacity: var(--disabled-opacity);
}
.disabled:hover {
background-color: var(--theme-color);
}

View File

@ -1,25 +0,0 @@
import classNames from "classnames";
import { ButtonHTMLAttributes, DetailedHTMLProps } from "react";
import styles from "./Button.module.css";
const Button = ({
isLoading = false,
...props
}: DetailedHTMLProps<
ButtonHTMLAttributes<HTMLButtonElement>,
HTMLButtonElement
> & {
isLoading?: boolean;
}) => {
return (
<button
{...props}
className={classNames(props.className, styles.button, {
[styles.disabled]: isLoading,
})}
disabled={props.disabled || isLoading}
/>
);
};
export default Button;

View File

@ -1,5 +0,0 @@
.heading {
font-size: var(--heading-size);
font-weight: var(--fontWeights-bold);
line-height: 1.2;
}

View File

@ -1,29 +0,0 @@
import { ReactNode } from "react";
import styles from "./Heading.module.css";
type Size = "3xl" | "4xl";
const Heading = ({
children,
className = "",
size = "4xl",
}: {
children: ReactNode;
className?: string;
size?: Size;
}) => {
return (
<h2
style={
{
"--heading-size": `var(--fontSizes-${size})`,
} as any
}
className={[styles.heading, className].join(" ")}
>
{children}
</h2>
);
};
export default Heading;

View File

@ -1,28 +0,0 @@
.input {
position: relative;
width: 100%;
min-width: 0;
height: var(--sizes-10);
border: 1px solid;
border-color: inherit;
appearance: none;
background: inherit;
border-radius: var(--radii-md);
font-size: var(--fontSizes-md);
outline: 2px solid transparent;
outline-offset: 2px;
padding-inline-end: var(--space-4);
padding-inline-start: var(--space-4);
transition-duration: var(--transition-duration-normal);
transition-property: var(--transition-property-common);
}
.input:focus {
z-index: 1;
border-color: var(--theme-color-secondary);
box-shadow: var(--theme-color-secondary) 0 0 0 1px;
}
.input:hover {
border-color: var(--theme-color-lighter);
}

View File

@ -1,13 +0,0 @@
import styles from "./Input.module.css";
import { DetailedHTMLProps, InputHTMLAttributes } from "react";
const Input = (
props: DetailedHTMLProps<
InputHTMLAttributes<HTMLInputElement>,
HTMLInputElement
>
) => {
return <input className={styles.input} {...props} />;
};
export default Input;

View File

@ -1,133 +0,0 @@
import {
Box,
Center,
FormControl,
FormLabel,
Grid,
Radio,
RadioGroup,
Stack,
Switch,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { Playstyle } from "@prisma/client";
import MySelect from "components/common/MySelect";
import WeaponSelector from "components/common/WeaponSelector";
import { UseFreeAgentsDispatch, UseFreeAgentsState } from "hooks/freeagents";
const regionOptions = [
{
label: "Americas",
value: "AMERICAS",
},
{
label: "Europe",
value: "EUROPE",
},
{
label: "Asia/Oceania",
value: "ASIA",
},
] as const;
export default function FAFilters({
state,
dispatch,
}: {
state: UseFreeAgentsState;
dispatch: UseFreeAgentsDispatch;
}) {
return (
<Box>
<Grid
gridTemplateColumns={["1fr", "1fr 1fr"]}
gridRowGap={[2, 4]}
gridColumnGap="1rem"
mt={4}
>
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="xp" mb="0">
Show Top 500 only
</FormLabel>
<Switch
id="xp"
isChecked={state.xp}
onChange={(e) =>
dispatch({ type: "SET_XP_VALUE", value: e.target.checked })
}
/>
</FormControl>
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="plus-server" mb="0">
Show Plus Server members only
</FormLabel>
<Switch
id="plus-server"
isChecked={state.plusServer}
onChange={(e) =>
dispatch({
type: "SET_PLUS_SERVER_VALUE",
value: e.target.checked,
})
}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="weapon">Filter by weapon</FormLabel>
<WeaponSelector
isMulti={false}
isClearable
value={state.weapon ?? null}
setValue={(value) => dispatch({ type: "SET_WEAPON", value })}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="region">Filter by region</FormLabel>
<MySelect
name="region"
isMulti={false}
isClearable
value={
regionOptions.find((option) => state.region === option.value) ??
null
}
options={regionOptions}
setValue={(value) => dispatch({ type: "SET_REGION", value })}
/>
</FormControl>
</Grid>
<FormControl>
<FormLabel htmlFor="roles" textAlign="center" mt={4}>
Filter by role
</FormLabel>
<Center>
<RadioGroup
name="roles"
value={state.playstyle ?? "ALL"}
onChange={(value) =>
dispatch({
type: "SET_PLAYSTYLE",
value: value === "ALL" ? undefined : (value as Playstyle),
})
}
>
<Stack spacing={4} direction={["column", "row"]}>
<Radio value="ALL">All</Radio>
<Radio value="FRONTLINE">
<Trans>Frontline</Trans>
</Radio>
<Radio value="MIDLINE">
<Trans>Support</Trans>
</Radio>
<Radio value="BACKLINE">
<Trans>Backline</Trans>
</Radio>
</Stack>
</RadioGroup>
</Center>
</FormControl>
</Box>
);
}

View File

@ -1,206 +0,0 @@
import {
Button,
Checkbox,
CheckboxGroup,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
HStack,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Radio,
RadioGroup,
Stack,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { t, Trans } from "@lingui/macro";
import MarkdownTextarea from "components/common/MarkdownTextarea";
import { useMutation } from "hooks/common";
import { FreeAgentsGet } from "pages/api/free-agents";
import { Controller, useForm } from "react-hook-form";
import { FiTrash } from "react-icons/fi";
import { Unpacked } from "utils/types";
import {
FA_POST_CONTENT_LIMIT,
freeAgentPostSchema,
} from "utils/validators/fapost";
import * as z from "zod";
interface Props {
onClose: () => void;
refetchQuery: () => void;
post?: Unpacked<FreeAgentsGet>;
}
type FreeAgentPostSchemaInput = z.infer<typeof freeAgentPostSchema>;
const FAModal = ({ onClose, post, refetchQuery }: Props) => {
const { handleSubmit, errors, register, watch, control } =
useForm<FreeAgentPostSchemaInput>({
resolver: zodResolver(freeAgentPostSchema),
defaultValues: post,
});
const upsertFreeAgentPostMutation = useMutation<FreeAgentPostSchemaInput>({
url: "/api/free-agents",
method: "POST",
successToastMsg: post
? t`Free agent post updated`
: t`Free agent post submitted`,
afterSuccess: () => {
refetchQuery();
onClose();
},
});
const deleteFreeAgentPostMutation = useMutation<FreeAgentPostSchemaInput>({
url: "/api/free-agents",
method: "DELETE",
successToastMsg: t`Free agent post deleted`,
afterSuccess: () => {
refetchQuery();
onClose();
},
});
const watchContent = watch("content", ""); // TODO: get initial fa content from props
return (
<Modal isOpen onClose={onClose} size="xl" closeOnOverlayClick={false}>
<ModalOverlay>
<ModalContent>
<ModalHeader>
{post ? (
<Trans>Editing free agent post</Trans>
) : (
<Trans>Submitting a new free agent post</Trans>
)}
</ModalHeader>
<ModalCloseButton borderRadius="50%" />
<form
onSubmit={handleSubmit((data) =>
upsertFreeAgentPostMutation.mutate(data)
)}
>
<ModalBody pb={6}>
{post && (
<>
<Button
leftIcon={<FiTrash />}
variant="outline"
color="red.500"
isLoading={deleteFreeAgentPostMutation.isMutating}
onClick={async () => {
if (window.confirm(t`Delete the free agent post?`)) {
deleteFreeAgentPostMutation.mutate();
}
}}
>
<Trans>Delete free agent post</Trans>
</Button>
<FormControl>
<FormHelperText mb={6}>
<Trans>
Please note deleting your free agent post also deletes
all the likes you have given and received.
</Trans>
</FormHelperText>
</FormControl>
</>
)}
<FormControl isInvalid={!!errors.playstyles}>
<FormLabel htmlFor="playstyles">
<Trans>Roles</Trans>
</FormLabel>
<Controller
name="playstyles"
control={control}
defaultValue={[]}
render={({ onChange, value }) => (
<CheckboxGroup value={value} onChange={onChange}>
<HStack>
<Checkbox value="FRONTLINE">
<Trans>Frontline</Trans>
</Checkbox>
<Checkbox value="MIDLINE">
<Trans>Support</Trans>
</Checkbox>
<Checkbox value="BACKLINE">
<Trans>Backline</Trans>
</Checkbox>
</HStack>
</CheckboxGroup>
)}
/>
<FormErrorMessage>
{/* @ts-ignore */}
{errors.playstyles?.message}
</FormErrorMessage>
</FormControl>
<FormControl>
<FormLabel htmlFor="canVC" mt={4}>
<Trans>Can you voice chat?</Trans>
</FormLabel>
<Controller
name="canVC"
control={control}
defaultValue="YES"
render={({ onChange, value }) => (
<RadioGroup value={value} onChange={onChange}>
<Stack direction="row">
<Radio value="YES">Yes</Radio>
<Radio value="MAYBE">Sometimes</Radio>
<Radio value="NO">No</Radio>
</Stack>
</RadioGroup>
)}
/>
</FormControl>
<MarkdownTextarea
fieldName="content"
title={t`Post`}
error={errors.content}
register={register}
value={watchContent ?? ""}
maxLength={FA_POST_CONTENT_LIMIT}
/>
</ModalBody>
<ModalFooter>
<Button
mr={3}
type="submit"
isLoading={
upsertFreeAgentPostMutation.isMutating ||
deleteFreeAgentPostMutation.isMutating
}
>
<Trans>Save</Trans>
</Button>
<Button
onClick={onClose}
variant="outline"
isDisabled={
upsertFreeAgentPostMutation.isMutating ||
deleteFreeAgentPostMutation.isMutating
}
>
<Trans>Cancel</Trans>
</Button>
</ModalFooter>
</form>
</ModalContent>
</ModalOverlay>
</Modal>
);
};
export default FAModal;

View File

@ -1,191 +0,0 @@
import { Box, Divider, Flex, IconButton } from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import Flag from "components/common/Flag";
import Markdown from "components/common/Markdown";
import MyLink from "components/common/MyLink";
import SubText from "components/common/SubText";
import SubTextCollapse from "components/common/SubTextCollapse";
import UserAvatar from "components/common/UserAvatar";
import WeaponImage from "components/common/WeaponImage";
import { countries } from "countries-list";
import { CSSVariables } from "utils/CSSVariables";
import Image from "next/image";
import { RefObject } from "react";
import { FaHeart, FaRegHeart } from "react-icons/fa";
import {
RiAnchorLine,
RiMicFill,
RiPaintLine,
RiSwordLine,
} from "react-icons/ri";
import { Unpacked } from "utils/types";
import { FreeAgentsGet } from "pages/api/free-agents";
import { useMutation } from "hooks/common";
import { useSWRConfig } from "swr";
const playstyleToEmoji = {
FRONTLINE: RiSwordLine,
MIDLINE: RiPaintLine,
BACKLINE: RiAnchorLine,
} as const;
const FreeAgentSection = ({
post,
isLiked,
canLike,
showXp,
showPlusServerMembership,
postRef,
}: {
post: Unpacked<FreeAgentsGet>;
isLiked: boolean;
canLike: boolean;
showXp: boolean;
showPlusServerMembership: boolean;
postRef?: RefObject<HTMLDivElement>;
}) => {
const { mutate } = useSWRConfig();
const addLikeMutation = useMutation<{ postId: number }>({
url: "/api/free-agents/likes",
method: "POST",
afterSuccess: () => {
mutate("/api/free-agents/likes");
},
});
const deleteLikeMutation = useMutation<{ postId: number }>({
url: "/api/free-agents/likes",
method: "DELETE",
afterSuccess: () => {
mutate("/api/free-agents/likes");
},
});
const handleClick = () =>
isLiked
? deleteLikeMutation.mutate({ postId: post.id })
: addLikeMutation.mutate({ postId: post.id });
return (
<>
<Box ref={postRef} as="section" my={8}>
<Flex alignItems="center" fontWeight="bold" fontSize="1.25rem">
<UserAvatar user={post.user} mr={3} />
<MyLink href={`/u/${post.user.discordId}`} isColored={false}>
{post.user.username}#{post.user.discriminator}
</MyLink>
</Flex>
{showXp ? (
<Flex my={2} ml={1} align="center" fontSize="sm" fontWeight="bold">
<Image
src="/layout/xsearch.png"
height={24}
width={24}
alt="X Power icon"
/>
<Box ml={1}>{post.user.player?.placements[0]?.xPower}</Box>
</Flex>
) : null}
{showPlusServerMembership ? (
<Flex my={2} ml={1} align="center" fontSize="sm" fontWeight="bold">
<Image
src="/layout/plus.png"
height={24}
width={24}
alt="Plus Server icon"
/>
<Box ml={1}>+{post.user.plusStatus?.membershipTier}</Box>
</Flex>
) : null}
{post.user.profile?.country && (
<Flex align="center" ml={2} my={2}>
<Box as="span" mr={1} mt={1}>
<Flag countryCode={post.user.profile.country} />{" "}
</Box>
{
Object.entries(countries).find(
([key]) => key === post.user.profile?.country
)![1].name
}
</Flex>
)}
{post.user.profile && post.user.profile?.weaponPool.length > 0 && (
<Box my={2}>
{post.user.profile.weaponPool.map((wpn) => (
<WeaponImage key={wpn} name={wpn} size={32} />
))}
</Box>
)}
<Flex mt={4} mb={2}>
{post.playstyles.map((style) => (
<Box
key={style}
w={6}
h={6}
mx={1}
color={CSSVariables.themeColor}
as={playstyleToEmoji[style]}
/>
))}
</Flex>
{post.canVC !== "NO" && (
<Flex alignItems="center" my={4}>
<Box
w={6}
h={6}
mx={1}
mr={2}
color={CSSVariables.themeColor}
as={RiMicFill}
/>
<SubText>
{post.canVC === "YES" ? (
<Trans>Can VC</Trans>
) : (
<Trans>Can VC sometimes</Trans>
)}
</SubText>
</Flex>
)}
<SubTextCollapse
title={t`Free agent post`}
isOpenByDefault
mt={4}
my={6}
wordBreak="break-word"
>
<Markdown value={post.content} smallHeaders />
</SubTextCollapse>
{post.user.profile?.bio && (
<SubTextCollapse title={t`Bio`} mt={4} wordBreak="break-word">
<Markdown value={post.user.profile.bio} smallHeaders />
</SubTextCollapse>
)}
{canLike && (
<IconButton
color="red.500"
aria-label="Like"
size="lg"
isRound
mt={4}
variant="ghost"
icon={isLiked ? <FaHeart /> : <FaRegHeart />}
disabled={
addLikeMutation.isMutating || deleteLikeMutation.isMutating
}
onClick={handleClick}
/>
)}
</Box>
<Divider />
</>
);
};
export default FreeAgentSection;

View File

@ -1,47 +0,0 @@
import { Alert, AlertIcon, Flex, Wrap, WrapItem } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import SubText from "components/common/SubText";
import UserAvatar from "components/common/UserAvatar";
import { FreeAgentsGet } from "pages/api/free-agents";
import { Unpacked } from "utils/types";
const MatchesInfo = ({
matchedPosts,
focusOnMatch,
}: {
matchedPosts: (Unpacked<FreeAgentsGet> | undefined)[];
focusOnMatch: (id: number) => void;
}) => {
if (!matchedPosts.length)
return (
<Alert status="info" my={6}>
<AlertIcon />
<Trans>
Once you match with other free agents they are shown here.
</Trans>
</Alert>
);
return (
<Flex flexDir="column" align="center">
<SubText mt={4}>
<Trans>Matches</Trans>
</SubText>
<Wrap mt={4} mb={2}>
{matchedPosts.map((post) =>
post ? (
<WrapItem key={post.id}>
<UserAvatar
user={post.user}
onClick={() => focusOnMatch(post.id)}
cursor="pointer"
/>
</WrapItem>
) : null
)}
</Wrap>
</Flex>
);
};
export default MatchesInfo;

View File

@ -1,59 +0,0 @@
import { Image as ChakraImage, useColorMode } from "@chakra-ui/react";
import randomColor from "randomcolor";
import { useEffect, useState } from "react";
import { getFilters } from "utils/getFilters";
const BeautifulDrawingOfBorzoic = ({ type }: { type: "girl" | "boy" }) => {
const [hexCode, setHexCode] = useState(randomColor());
const { colorMode } = useColorMode();
const [drawingImgSrc, setDrawingImgSrc] = useState(
`/layout/new_${type}_${colorMode}.png`
);
const [drawingLoaded, setDrawingLoaded] = useState(false);
const handleColorChange = () => setHexCode(randomColor());
useEffect(() => {
const loadImage = (imageUrl: string): Promise<string> => {
return new Promise((resolve, reject) => {
const loadImg = new Image();
loadImg.src = imageUrl;
loadImg.onload = () => setTimeout(() => resolve(imageUrl));
loadImg.onerror = (err) => reject(err);
});
};
loadImage(`/layout/new_${type}_${colorMode}.png`)
.then((src) => setDrawingImgSrc(src))
.catch((err) => console.error("Failed to load images", err));
}, [type, colorMode]);
return (
<>
<ChakraImage
src={`/layout/new_${type}_bg.png`}
filter={getFilters(hexCode)}
height={["150px", "240px", "300px", "360px"]}
gridColumn={type === "girl" ? "2 / 3" : "1 / 2"}
justifySelf={type === "girl" ? "flex-start" : "flex-end"}
gridRow="1"
alt=""
visibility={drawingLoaded ? "visible" : "hidden"}
/>
<ChakraImage
onClick={handleColorChange}
onMouseEnter={handleColorChange}
src={drawingImgSrc}
height={["150px", "240px", "300px", "360px"]}
gridColumn={type === "girl" ? "2 / 3" : "1 / 2"}
justifySelf={type === "girl" ? "flex-start" : "flex-end"}
gridRow="1"
zIndex="10"
alt=""
onLoad={() => setDrawingLoaded(true)}
/>
</>
);
};
export default BeautifulDrawingOfBorzoic;

View File

@ -1,28 +0,0 @@
import { IconButton, useColorMode } from "@chakra-ui/react";
import { FiMoon, FiSun } from "react-icons/fi";
const ColorModeSwitcher = ({ isMobile }: { isMobile?: boolean }) => {
const { colorMode, toggleColorMode } = useColorMode();
return (
<IconButton
data-cy="color-mode-toggle"
aria-label={`Switch to ${colorMode === "light" ? "dark" : "light"} mode`}
variant="ghost"
color="current"
onClick={toggleColorMode}
icon={colorMode === "light" ? <FiSun /> : <FiMoon />}
_hover={
colorMode === "dark"
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius={isMobile ? "50%" : "0"}
size={isMobile ? "lg" : "sm"}
height="50px"
mx={2}
display={isMobile ? "flex" : ["none", null, null, "flex"]}
/>
);
};
export default ColorModeSwitcher;

View File

@ -1,61 +0,0 @@
import { Box, Image } from "@chakra-ui/react";
import { useRouter } from "next/dist/client/router";
import { useLayoutEffect } from "react";
import { useState } from "react";
import { getFilters } from "utils/getFilters";
import FooterContent from "./FooterContent";
import FooterWaves from "./FooterWaves";
const Footer: React.FC = () => {
const [bgColor, setBgColor] = useState(() =>
getComputedStyle(document.body).getPropertyValue("--theme-color").trim()
);
const imgBase =
useRouter().asPath.charCodeAt(1) % 2 === 0 ? "boing" : "b8ing";
useLayoutEffect(() => {
const customColor = getComputedStyle(document.body)
.getPropertyValue("--custom-theme-color")
.trim();
if (customColor) setBgColor(customColor);
}, []);
return (
<Box as="footer" mt="auto" display="grid" gridTemplateColumns="1fr">
<Image
src={`/layout/${imgBase}_bg.png`}
w="80px"
ml="auto"
mr="35%"
mb="-5.1%"
mt="5rem"
userSelect="none"
loading="lazy"
alt=""
filter={getFilters(bgColor)}
gridRow="1"
gridColumn="1 / 2"
zIndex="1"
/>
<Image
src={`/layout/${imgBase}.png`}
w="80px"
ml="auto"
mr="35%"
mb="-5.1%"
mt="5rem"
userSelect="none"
loading="lazy"
alt=""
gridRow="1"
gridColumn="1 / 2"
zIndex="10"
/>
<FooterWaves />
<FooterContent />
</Box>
);
};
export default Footer;

View File

@ -1,127 +0,0 @@
import { Box, Flex } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import MyLink from "components/common/MyLink";
import { CSSVariables } from "utils/CSSVariables";
import { ReactNode } from "react";
import { FaGithub, FaPatreon, FaTwitter } from "react-icons/fa";
import { FiHelpCircle, FiInfo } from "react-icons/fi";
import { DiscordIcon } from "utils/assets/icons";
import patrons from "utils/data/patrons.json";
import { getFullUsername } from "utils/strings";
const FooterContent: React.FC = () => {
return (
<Box bg={CSSVariables.themeColor} color={CSSVariables.bgColor}>
<Flex
flexDir={["column", null, "row"]}
alignItems="center"
flexWrap="wrap"
justifyContent="center"
fontWeight="bold"
py={2}
>
<ExternalLink icon={FiInfo} href="/about">
About
</ExternalLink>
<ExternalLink icon={FiHelpCircle} href="/faq">
FAQ
</ExternalLink>
</Flex>
<Flex
flexDir={["column", null, "row"]}
alignItems="center"
flexWrap="wrap"
justifyContent="center"
fontWeight="bold"
py={2}
>
<ExternalLink
icon={FaGithub}
href="https://github.com/Sendouc/sendou.ink"
isExternal
>
View source code on Github
</ExternalLink>
<ExternalLink
icon={FaTwitter}
href="https://twitter.com/sendouink"
isExternal
>
Follow @sendouink on Twitter
</ExternalLink>
<ExternalLink
icon={DiscordIcon}
href="https://discord.gg/sendou"
isExternal
>
Chat on Discord
</ExternalLink>
<ExternalLink
icon={FaPatreon}
href="https://www.patreon.com/sendou"
isExternal
>
Sponsor on Patreon
</ExternalLink>
</Flex>
<Box p={3} textAlign="center">
<Box fontWeight="bold">
<Trans>Thanks to the patrons for their support </Trans>
</Box>
<Flex flexWrap="wrap" justify="center" align="center" mt={2}>
{(
patrons as {
username: string;
discriminator: string;
patreonTier: number;
discordId: string;
}[]
).map((patron) => (
<MyLink
key={patron.discordId}
href={`/u/${patron.discordId}`}
isColored={false}
>
<Box
fontSize={["0", "0.9rem", "1rem", "1.1rem"][patron.patreonTier]}
mx={1}
>
{getFullUsername(patron)}
</Box>
</MyLink>
))}
</Flex>
</Box>
</Box>
);
};
function ExternalLink({
children,
icon,
href,
isExternal,
}: {
children: ReactNode;
icon: any;
href: string;
isExternal?: boolean;
}) {
return (
<MyLink
href={href}
isExternal={isExternal}
chakraLinkProps={{
display: "flex",
alignItems: "center",
mx: 2,
my: 2,
color: CSSVariables.bgColor,
}}
>
<Box as={icon} display="inline" mr={1} size="20px" /> {children}
</MyLink>
);
}
export default FooterContent;

View File

@ -1,16 +0,0 @@
import { CSSVariables } from "utils/CSSVariables";
import React from "react";
const FooterWaves = () => {
return (
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 225">
<path
fill={CSSVariables.themeColor}
fillOpacity="1"
d="M0,128L60,138.7C120,149,240,171,360,160C480,149,600,107,720,85.3C840,64,960,64,1080,90.7C1200,117,1320,171,1380,197.3L1440,224L1440,320L1380,320C1320,320,1200,320,1080,320C960,320,840,320,720,320C600,320,480,320,360,320C240,320,120,320,60,320L0,320Z"
></path>
</svg>
);
};
export default FooterWaves;

View File

@ -1,20 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-1);
background-color: var(--secondary-bg-color);
font-weight: bold;
justify-self: center;
}
.leftIconsContainer {
display: flex;
width: 6rem;
align-items: center;
padding: 0 var(--space-2);
@media screen and (min-width: var(--media-xl)) {
width: 13rem;
}
}

View File

@ -1,110 +0,0 @@
import {
Button,
IconButton,
useColorMode,
useMediaQuery,
} from "@chakra-ui/react";
import MyLink from "components/common/MyLink";
import { useActiveNavItem, useUser } from "hooks/common";
import { signIn, signOut } from "next-auth/client";
import Image from "next/image";
import Link from "next/link";
import { FiHeart, FiLogIn, FiLogOut, FiMenu } from "react-icons/fi";
import ColorModeSwitcher from "./ColorModeSwitcher";
import LanguageSwitcher from "./LanguageSwitcher";
import styles from "./Header.module.css";
const Header = ({ openNav }: { openNav: () => void }) => {
const [isSmall] = useMediaQuery("(max-width: 400px)");
const [user] = useUser();
const { colorMode } = useColorMode();
const activeNavItem = useActiveNavItem();
return (
<header className={styles.container}>
<div className={styles.leftIconsContainer}>
<ColorModeSwitcher /> <LanguageSwitcher />
<IconButton
aria-label="Open menu"
variant="ghost"
color="current"
onClick={openNav}
icon={<FiMenu />}
_hover={
colorMode === "dark"
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius="0"
display={["flex", null, null, "none"]}
/>
</div>
<div className="flex justify-center align-center">
<Link href="/">{isSmall ? "s.ink" : "sendou.ink"}</Link>{" "}
{activeNavItem && (
<>
<div className="mx-1">-</div>{" "}
<Image
src={`/layout/${
activeNavItem.imageSrc ?? activeNavItem.code
}.png`}
className={
activeNavItem.code === "splatoon3" ? "rounded" : undefined
}
height={36}
width={36}
priority
alt={`${activeNavItem.name} icon`}
/>
<div className="ml-1">{activeNavItem.name}</div>
</>
)}
</div>
<div>
<MyLink isExternal isColored={false} href="https://patreon.com/sendou">
<Button
variant="ghost"
color="current"
leftIcon={<FiHeart />}
_hover={
colorMode === "dark"
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius="0"
size="xs"
px={2}
height="50px"
mr="0.5rem"
display={["none", null, null, "inline-block"]}
>
Sponsor
</Button>
</MyLink>
<Button
width="6rem"
data-cy="color-mode-toggle"
aria-label="Log in"
variant="ghost"
color="current"
onClick={() => (user ? signOut() : signIn("discord"))}
leftIcon={user ? <FiLogOut /> : <FiLogIn />}
_hover={
colorMode === "dark"
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius="0"
size="xs"
px={2}
height="50px"
ml="0.5rem"
>
{user ? "Log out" : "Log in"}
</Button>
</div>
</header>
);
};
export default Header;

View File

@ -1,80 +0,0 @@
import {
IconButton,
Menu,
MenuButton,
MenuItemOption,
MenuList,
MenuOptionGroup,
useColorMode,
} from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { CSSVariables } from "utils/CSSVariables";
import React from "react";
import { FiGlobe } from "react-icons/fi";
import { activateLocale } from "utils/i18n";
export const languages = [
{ code: "de", name: "Deutsch" },
{ code: "en", name: "English" },
{ code: "es", name: "Español" },
{ code: "fr", name: "Français" },
{ code: "it", name: "Italiano" },
{ code: "nl", name: "Nederlands" },
{ code: "pt", name: "Português" },
{ code: "sv", name: "Svenska" },
{ code: "el", name: "Ελληνικά" },
{ code: "ru", name: "Русский" },
{ code: "ja", name: "日本語" },
{ code: "ko", name: "한국어" },
//{ code: "zh", name: "繁體中文" },
{ code: "he", name: "עברית" },
] as const;
export const LanguageSwitcher = ({ isMobile }: { isMobile?: boolean }) => {
const { colorMode } = useColorMode();
const { i18n } = useLingui();
return (
<Menu>
<MenuButton
as={IconButton}
data-cy="color-mode-toggle"
aria-label="Switch language"
variant="ghost"
color="current"
icon={<FiGlobe />}
_hover={
colorMode === "dark"
? { bg: "white !important", color: "black" }
: { bg: "black !important", color: "white" }
}
borderRadius={isMobile ? "50%" : "0"}
size={isMobile ? "lg" : "sm"}
height="50px"
display={isMobile ? "flex" : ["none", null, null, "flex"]}
/>
<MenuList
bg={CSSVariables.secondaryBgColor}
color={CSSVariables.textColor}
>
<MenuOptionGroup
title={t`Choose language`}
value={i18n.locale}
onChange={(newLocale) => {
window.localStorage.setItem("locale", newLocale as string);
activateLocale(newLocale as string);
}}
>
{languages.map((lang) => (
<MenuItemOption key={lang.code} value={lang.code}>
{lang.name}
</MenuItemOption>
))}
</MenuOptionGroup>
</MenuList>
</Menu>
);
};
export default LanguageSwitcher;

View File

@ -1,58 +0,0 @@
import { Button } from "@chakra-ui/button";
import { CloseButton } from "@chakra-ui/close-button";
import { Flex } from "@chakra-ui/layout";
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerOverlay,
} from "@chakra-ui/modal";
import MyLink from "components/common/MyLink";
import { CSSVariables } from "utils/CSSVariables";
import { FiHeart } from "react-icons/fi";
import ColorModeSwitcher from "./ColorModeSwitcher";
import LanguageSwitcher from "./LanguageSwitcher";
import NavButtons from "./NavButtons";
const MobileNav = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
return (
<Drawer isOpen={isOpen} onClose={onClose} size="full" placement="left">
<DrawerOverlay>
<DrawerContent bg={CSSVariables.bgColor}>
<DrawerBody pb={16}>
<Flex align="center" justifyContent="space-between">
<Flex align="center">
<ColorModeSwitcher isMobile />
<LanguageSwitcher isMobile />
<MyLink
isExternal
isColored={false}
href="https://patreon.com/sendou"
>
<Button
variant="ghost"
color="current"
leftIcon={<FiHeart />}
pl={5}
>
Sponsor
</Button>
</MyLink>
</Flex>
<CloseButton onClick={onClose} />
</Flex>
<NavButtons onButtonClick={onClose} />
</DrawerBody>
</DrawerContent>
</DrawerOverlay>
</Drawer>
);
};
export default MobileNav;

View File

@ -1,88 +0,0 @@
import { Box, Flex, IconButton } from "@chakra-ui/react";
import MyLink from "components/common/MyLink";
import { useActiveNavItem } from "hooks/common";
import Image from "next/image";
import { useRouter } from "next/router";
import { useState } from "react";
import { FiArrowLeft, FiArrowRight } from "react-icons/fi";
import { navItems } from "utils/constants";
import { CSSVariables } from "utils/CSSVariables";
import UserItem from "./UserItem";
const Nav = () => {
const router = useRouter();
const navItem = useActiveNavItem();
const [expanded, setExpanded] = useState(() =>
JSON.parse(window.localStorage.getItem("nav-expanded") ?? "true")
);
if (router.pathname === "/") return null;
return (
<Box
width={expanded ? "175px" : "60px"}
marginRight={expanded ? "-175px" : "-60px"}
as="nav"
flexShrink={0}
position="sticky"
alignSelf="flex-start"
display={["none", null, null, "block"]}
className="scrollableNavigation"
>
{navItems.map(({ code, name, imageSrc }) => {
const isActive = navItem?.code === code;
return (
<Box
key={code}
borderLeft="4px solid"
borderColor={
isActive ? CSSVariables.themeColor : CSSVariables.bgColor
}
pl={2}
>
<MyLink href={"/" + code} isColored={false} noUnderline>
<Flex
width="100%"
rounded="lg"
p={2}
fontSize="sm"
fontWeight="bold"
align="center"
whiteSpace="nowrap"
_hover={{
bg: CSSVariables.secondaryBgColor,
}}
>
<Image
src={`/layout/${imageSrc ?? code}.png`}
className={code === "splatoon3" ? "rounded" : undefined}
height={32}
width={32}
priority
alt={`${name} icon`}
/>
{expanded && <Box ml={2}>{name}</Box>}
</Flex>
</MyLink>
</Box>
);
})}
<UserItem expanded={expanded} />
<IconButton
icon={expanded ? <FiArrowLeft /> : <FiArrowRight />}
aria-label={expanded ? "Collapse menu" : "Expand menu"}
variant="ghost"
ml={4}
mt={2}
onClick={() => {
window.localStorage.setItem("nav-expanded", String(!expanded));
setExpanded(!expanded);
}}
borderRadius="50%"
/>
</Box>
);
};
export default Nav;

View File

@ -1,74 +0,0 @@
import { Box, Flex } from "@chakra-ui/layout";
import MyLink from "components/common/MyLink";
import UserAvatar from "components/common/UserAvatar";
import { useUser } from "hooks/common";
import Image from "next/image";
import { navItems } from "utils/constants";
import { CSSVariables } from "utils/CSSVariables";
const NavButtons = ({ onButtonClick }: { onButtonClick?: () => void }) => {
const [user] = useUser();
return (
<Flex mt={2} flexWrap="wrap" alignItems="center" justifyContent="center">
{navItems.map(({ imageSrc, code, name }) => {
return (
<MyLink key={code} href={"/" + code} isColored={false} noUnderline>
<Flex
width="9.5rem"
rounded="lg"
p={1}
m={2}
fontSize="sm"
fontWeight="bold"
align="center"
whiteSpace="nowrap"
bg={CSSVariables.secondaryBgColor}
border="2px solid"
borderColor={CSSVariables.secondaryBgColor}
_hover={{
bg: CSSVariables.bgColor,
}}
onClick={onButtonClick}
>
<Image
src={`/layout/${imageSrc ?? code}.png`}
className={code === "splatoon3" ? "rounded" : undefined}
height={32}
width={32}
priority
alt={`${name} icon`}
/>
<Box ml={2}>{name}</Box>
</Flex>
</MyLink>
);
})}
{user && (
<MyLink href={"/u/" + user.discordId} isColored={false} noUnderline>
<Flex
width="9.5rem"
rounded="lg"
p={1}
m={2}
fontSize="sm"
fontWeight="bold"
align="center"
whiteSpace="nowrap"
bg={CSSVariables.secondaryBgColor}
border="2px solid"
borderColor={CSSVariables.secondaryBgColor}
_hover={{
bg: CSSVariables.bgColor,
}}
onClick={onButtonClick}
>
<UserAvatar user={user} size="sm" />
<Box ml={2}>My Page</Box>
</Flex>
</MyLink>
)}
</Flex>
);
};
export default NavButtons;

View File

@ -1,37 +0,0 @@
import { Box, Flex } from "@chakra-ui/layout";
import MyLink from "components/common/MyLink";
import UserAvatar from "components/common/UserAvatar";
import { useUser } from "hooks/common";
import { CSSVariables } from "utils/CSSVariables";
export const UserItem = ({ expanded }: { expanded: boolean }) => {
const [user] = useUser();
if (!user) return null;
return (
<Box borderLeft="4px solid" borderColor={CSSVariables.bgColor} pl={2}>
<MyLink href={"/u/" + user.discordId} isColored={false} noUnderline>
<Flex
width="100%"
rounded="lg"
p={2}
fontSize="sm"
fontWeight="bold"
align="center"
whiteSpace="nowrap"
_hover={{
bg: CSSVariables.secondaryBgColor,
}}
>
<>
<UserAvatar user={user} size="sm" />
{expanded && <Box ml={2}>My Page</Box>}
</>
</Flex>
</MyLink>
</Box>
);
};
export default UserItem;

View File

@ -1,70 +0,0 @@
import { useToast } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { useState } from "react";
import { SWRConfig } from "swr";
import Footer from "./Footer";
import Header from "./Header";
import MobileNav from "./MobileNav";
import Nav from "./Nav";
const DATE_KEYS = ["createdAt", "updatedAt"];
const Layout = ({ children }: { children: React.ReactNode }) => {
const [navIsOpen, setNavIsOpen] = useState(false);
const [errors, setErrors] = useState(new Set<string>());
const toast = useToast();
return (
<SWRConfig
value={{
fetcher: (resource, init) =>
fetch(resource, init).then(async (res) => {
const data = await res.text();
return JSON.parse(data, reviver);
}),
revalidateOnFocus: false,
revalidateOnReconnect: false,
onError: (_, key) => {
console.error(key + ": " + _);
if (errors.has(key)) return;
setErrors(new Set([...errors, key]));
toast({
duration: null,
isClosable: true,
position: "top-right",
status: "error",
description: t`An error occurred`,
onCloseComplete: () =>
setErrors(
new Set(Array.from(errors).filter((error) => error !== key))
),
});
},
}}
>
<Header openNav={() => setNavIsOpen(true)} />
<Nav />
<MobileNav isOpen={navIsOpen} onClose={() => setNavIsOpen(false)} />
<main>{children}</main>
<Footer />
</SWRConfig>
);
};
function reviver(key: any, value: any) {
if (Array.isArray(value)) {
return value.map((entry) => {
if (entry.updatedAt)
return { ...entry, updatedAt: new Date(entry.updatedAt) };
return entry;
});
}
if (DATE_KEYS.includes(key)) return new Date(value);
return value;
}
export default Layout;

View File

@ -1,131 +0,0 @@
import { Flex } from "@chakra-ui/layout";
import ModeImage from "components/common/ModeImage";
import MyLink from "components/common/MyLink";
import NewTable from "components/common/NewTable";
import UserAvatar from "components/common/UserAvatar";
import WeaponImage from "components/common/WeaponImage";
import { LeaderboardsPageProps } from "pages/leaderboards/[[...slug]]";
import React from "react";
const LeaderboardTable = (props: LeaderboardsPageProps) => {
switch (props.type) {
case "LEAGUE":
const addToHeaders =
props.placements[0].members.length === 4
? [
{ name: "member 3", dataKey: "member3" },
{ name: "member 4", dataKey: "member4" },
]
: [];
return (
<NewTable
smallAtPx="800"
headers={[
{ name: "power", dataKey: "leaguePower" },
{ name: "time", dataKey: "time" },
{ name: "member 1", dataKey: "member1" },
{ name: "member 2", dataKey: "member2" },
...addToHeaders,
{ name: "weapons", dataKey: "weapons" },
]}
data={props.placements.map((placement) => {
const members = placement.members.reduce(
(acc: Record<string, React.ReactNode>, member, i) => {
const name =
member.player.user?.username ?? member.player.name ?? "???";
acc[`member${i + 1}`] = (
<Flex align="center" wordBreak="break-all" maxW={36}>
{member.player.user ? (
<MyLink href={`/u/${member.player.user.discordId}`}>
<UserAvatar
user={member.player.user}
size="xs"
mr={1}
/>
</MyLink>
) : null}
<MyLink
href={`/player/${member.switchAccountId}`}
isColored={false}
>
{name}
</MyLink>
</Flex>
);
return acc;
},
{}
);
return {
id: placement.id,
leaguePower: placement.leaguePower,
time: new Date(placement.startTime).toLocaleString("en", {
month: "numeric",
year: "2-digit",
hour: "numeric",
day: "numeric",
}),
weapons: (
<Flex align="center" flexWrap="wrap">
{placement.members.map((member) => (
<WeaponImage
key={member.switchAccountId}
name={member.weapon}
size={32}
/>
))}
</Flex>
),
...members,
};
})}
/>
);
case "XPOWER_PEAK":
return (
<NewTable
leaderboardKey="xPower"
headers={[
{ name: "name", dataKey: "name" },
{ name: "x power", dataKey: "xPower" },
{ name: "weapon", dataKey: "weapon" },
{ name: "mode", dataKey: "mode" },
{ name: "month", dataKey: "month" },
]}
data={props.placements.map((placement) => {
return {
id: placement.id,
name: (
<Flex align="center">
{placement.player.user ? (
<MyLink href={`/u/${placement.player.user.discordId}`}>
<UserAvatar
user={placement.player.user}
size="xs"
mr={1}
/>
</MyLink>
) : null}
<MyLink
href={`/player/${placement.switchAccountId}`}
isColored={false}
>
{placement.playerName}
</MyLink>
</Flex>
),
xPower: placement.xPower,
weapon: <WeaponImage name={placement.weapon} size={32} />,
mode: <ModeImage mode={placement.mode} size={32} />,
month: `${placement.month}/${placement.year}`,
};
})}
/>
);
default:
throw Error("invalid leaderboard type");
}
};
export default LeaderboardTable;

View File

@ -1,88 +0,0 @@
import { RankedMode } from "@prisma/client";
import { Box, Grid, useMediaQuery } from "@chakra-ui/react";
import ModeImage from "../common/ModeImage";
import SubText from "../common/SubText";
import { t } from "@lingui/macro";
import { FaCheck } from "react-icons/fa";
import { CSSVariables } from "../../utils/CSSVariables";
import NewTable, { TableRow } from "../common/NewTable";
function getModeCells(
enabledModes: RankedMode[]
): { [mode in RankedMode]: JSX.Element | null } {
return Object.values(RankedMode).reduce((map, mode) => {
map[mode] = enabledModes.includes(mode) ? (
<FaCheck color={CSSVariables.themeColor} style={{ margin: "auto" }} />
) : null;
return map;
}, {} as { [mode in RankedMode]: JSX.Element | null });
}
function getTableData(
stagesSelected: Record<string, RankedMode[]>
): (TableRow | null)[] {
return Object.entries(stagesSelected).map(([stage, modes], index) =>
modes.length > 0
? {
id: index,
stage: t({ id: stage }),
...getModeCells(modes),
}
: null
);
}
function getGridModeCells(
stagesSelected: Record<string, RankedMode[]>
): JSX.Element[] {
return Object.values(RankedMode).map((mode) => {
return (
<div key={mode}>
<Box textAlign="center">
<ModeImage mode={mode} />
</Box>
<Box textAlign="center">
{Object.entries(stagesSelected).map(([stage, modes]) =>
modes.includes(mode) ? (
<SubText key={stage} mb={1}>
{t({ id: stage })}
</SubText>
) : null
)}
</Box>
</div>
);
});
}
export function MapPoolDisplay({
stagesSelected,
}: {
stagesSelected: Record<string, RankedMode[]>;
}) {
const [isMobile] = useMediaQuery("(max-width: 48em)");
return isMobile ? (
<Grid
mb={8}
templateColumns={{ base: "repeat(2, 1fr)", sm: "repeat(4, 1fr)" }}
rowGap={4}
columnGap={4}
display="grid"
>
{getGridModeCells(stagesSelected)}
</Grid>
) : (
<NewTable
size="sm"
headers={[
{ name: t`Stage name`, dataKey: "stage" },
{ name: t`Splat Zones`, dataKey: RankedMode.SZ },
{ name: t`Tower Control`, dataKey: RankedMode.TC },
{ name: t`Rainmaker`, dataKey: RankedMode.RM },
{ name: t`Clam Blitz`, dataKey: RankedMode.CB },
]}
data={getTableData(stagesSelected)}
/>
);
}

View File

@ -1,176 +0,0 @@
import { Box, Flex, IconButton } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import ColorPicker from "components/common/ColorPicker";
import { CSSVariables } from "utils/CSSVariables";
import { Tool } from "pages/plans";
import { useState } from "react";
import Draggable from "react-draggable";
import { useHotkeys } from "react-hotkeys-hook";
import { AiOutlineLine } from "react-icons/ai";
import {
FaFont,
FaPencilAlt,
FaRedo,
FaRegCircle,
FaRegObjectGroup,
FaRegSquare,
FaTrashAlt,
FaUndo,
} from "react-icons/fa";
interface DraggableToolsSelectorProps {
tool: Tool;
setTool: React.Dispatch<Tool>;
redo: () => void;
redoIsDisabled: boolean;
undo: () => void;
undoIsDisabled: boolean;
removeSelected: () => void;
addText: () => void;
color: string;
setColor: (newColor: string) => void;
}
const DraggableToolsSelector: React.FC<DraggableToolsSelectorProps> = ({
tool,
setTool,
redo,
redoIsDisabled,
undo,
undoIsDisabled,
removeSelected,
addText,
color,
setColor,
}) => {
const [activeDrags, setActiveDrags] = useState(0);
useHotkeys("p", () => setTool("pencil"));
useHotkeys("l", () => setTool("line"));
useHotkeys("r", () => setTool("rectangle"));
useHotkeys("c", () => setTool("circle"));
useHotkeys("s", () => setTool("select"));
const onStart = () => {
setActiveDrags(activeDrags + 1);
};
const onStop = () => {
setActiveDrags(activeDrags - 1);
};
return (
<Draggable handle="strong" onStart={onStart} onStop={onStop}>
<Box
position="fixed"
zIndex={900}
background={CSSVariables.secondaryBgColor}
borderRadius="7px"
boxShadow="7px 14px 13px 2px rgba(0,0,0,0.24)"
width="100px"
>
<strong style={{ cursor: "move" }}>
<Box
fontSize="17px"
borderRadius="7px 7px 0 0"
padding="0.3em"
textAlign="center"
>
<Trans>Tools</Trans>
</Box>
</strong>
<Flex flexWrap="wrap" justifyContent="center">
<IconButton
onClick={() => setTool("pencil")}
variant="ghost"
size="lg"
aria-label="Pencil (P)"
icon={<FaPencilAlt />}
border={tool === "pencil" ? "2px solid" : undefined}
borderColor={CSSVariables.secondaryBgColor}
title="Pencil (P)"
/>
<IconButton
onClick={() => setTool("line")}
variant="ghost"
size="lg"
aria-label="Line (L)"
icon={<AiOutlineLine />}
border={tool === "line" ? "2px solid" : undefined}
borderColor={CSSVariables.secondaryBgColor}
title="Line (L)"
/>
<IconButton
onClick={() => setTool("rectangle")}
variant="ghost"
size="lg"
aria-label="Rectangle (R)"
icon={<FaRegSquare />}
border={tool === "rectangle" ? "2px solid" : undefined}
borderColor={CSSVariables.secondaryBgColor}
title="Rectangle (R)"
/>
<IconButton
onClick={() => setTool("circle")}
variant="ghost"
size="lg"
aria-label="Circle (C)"
icon={<FaRegCircle />}
border={tool === "circle" ? "2px solid" : undefined}
borderColor={CSSVariables.secondaryBgColor}
title="Circle (C)"
/>
<IconButton
onClick={() => setTool("select")}
variant="ghost"
size="lg"
aria-label="Select (S)"
icon={<FaRegObjectGroup />}
border={tool === "select" ? "2px solid" : undefined}
borderColor={CSSVariables.secondaryBgColor}
title="Select (S)"
/>
<IconButton
onClick={() => removeSelected()}
isDisabled={tool !== "select"}
variant="ghost"
size="lg"
aria-label="Delete selected"
icon={<FaTrashAlt />}
title="Delete selected"
/>
<IconButton
onClick={() => undo()}
isDisabled={undoIsDisabled}
variant="ghost"
size="lg"
aria-label="Undo"
icon={<FaUndo />}
title="Undo"
/>
<IconButton
onClick={() => redo()}
isDisabled={redoIsDisabled}
variant="ghost"
size="lg"
aria-label="Redo"
icon={<FaRedo />}
title="Redo"
/>
<IconButton
onClick={() => addText()}
variant="ghost"
size="lg"
aria-label="Add text"
icon={<FaFont />}
title="Add text"
/>
<Flex justify="center" align="center" w="48px" h="48px">
<ColorPicker color={color} setColor={(color) => setColor(color)} />
</Flex>
</Flex>
</Box>
</Draggable>
);
};
export default DraggableToolsSelector;

View File

@ -1,143 +0,0 @@
import { Box, Flex } from "@chakra-ui/react";
import WeaponImage from "components/common/WeaponImage";
import Image from "next/image";
import { weapons } from "utils/lists/weapons";
interface ImageAdderProps {
addImageToSketch: (imgSrc: string) => void;
}
const SUB_WEAPON_CODES = [
"Bomb_Curling",
"Bomb_Piyo",
"Bomb_Quick",
"Bomb_Robo",
"Bomb_Splash",
"Bomb_Suction",
"Bomb_Tako",
"Flag",
"PointSensor",
"PoisonFog",
"Shield",
"Sprinkler",
"TimerTrap",
];
const SPECIAL_WEAPON_CODES = [
"AquaBall",
"Jetpack",
"LauncherCurling",
"LauncherQuick",
"LauncherRobo",
"LauncherSplash",
"LauncherSuction",
"RainCloud",
"SuperArmor",
"SuperBall",
"SuperBubble",
"SuperLanding",
"SuperMissile",
"SuperStamp",
"WaterCutter",
];
const ImageAdder = ({ addImageToSketch }: ImageAdderProps) => {
return (
<Flex flexWrap="wrap" mt={4} mx="4" justify="center">
{weapons.map((wpn) => (
<Box
key={wpn}
onClick={() =>
addImageToSketch(`/weapons/${wpn.replace(".", "")}.png`)
}
m="3px"
>
<WeaponImage name={wpn} size={32} />
</Box>
))}
{["Blaster", "Brella", "Charger", "Slosher"].map((grizzcoWeaponClass) => {
const imgSrc = `/weapons/Grizzco ${grizzcoWeaponClass}.png`;
return (
<Box key={grizzcoWeaponClass} m="3px">
<Image
onClick={() => addImageToSketch(imgSrc)}
src={imgSrc}
width={32}
height={32}
alt={`Salmon Run Weapon ${grizzcoWeaponClass}`}
/>
</Box>
);
})}
{SUB_WEAPON_CODES.map((code) => {
const imgSrc = `/subs-specials/Wsb_${code}.png`;
return (
<Box key={code} m="3px">
<Image
onClick={() => addImageToSketch(imgSrc)}
src={imgSrc}
width={32}
height={32}
alt={`Sub weapon with code ${code}`}
/>
</Box>
);
})}
{SPECIAL_WEAPON_CODES.map((code) => {
const imgSrc = `/subs-specials/Wsp_${code}.png`;
return (
<Box key={code} m="3px">
<Image
onClick={() => addImageToSketch(imgSrc)}
src={imgSrc}
width={32}
height={32}
alt={`Special weapon with code ${code}`}
/>
</Box>
);
})}
{["TC", "RM", "CB"].map((mode) => {
const imgSrc = `/modes/${mode}.png`;
return (
<Box key={mode} m="3px">
<Image
onClick={() => addImageToSketch(imgSrc)}
src={imgSrc}
width={32}
height={32}
alt={`Ranked mode (${mode})`}
/>
</Box>
);
})}
{[
"Drizzler",
"Flyfish",
"Goldie",
"Griller",
"Maws",
"Scrapper",
"Steel Eal",
"Steelhead",
"Stinger",
"Golden Egg",
].map((boss) => {
const imgSrc = `/images/salmonRunIcons/${boss}.png`;
return (
<Box key={boss} m="3px">
<Image
onClick={() => addImageToSketch(imgSrc)}
src={imgSrc}
width={32}
height={32}
alt={`Salmon Run Boss ${boss}`}
/>
</Box>
);
})}
</Flex>
);
};
export default ImageAdder;

View File

@ -1,39 +0,0 @@
import { Box } from "@chakra-ui/react";
import { SketchField } from "@sendou/react-sketch";
import { Tool } from "pages/plans";
interface MapSketchProps {
sketch: any;
controlledValue: any;
onSketchChange: any;
color: any;
tool: Tool;
}
const MapSketch: React.FC<MapSketchProps> = ({
sketch,
controlledValue,
color,
onSketchChange,
tool,
}) => {
return (
<Box ml="2.5rem">
<SketchField
name="sketch"
className="canvas-area"
ref={sketch}
lineColor={color}
lineWidth={5}
width={1127}
height={634}
value={controlledValue}
onChange={onSketchChange}
tool={tool}
style={{ position: "relative", left: "-27px" }}
/>
</Box>
);
};
export default MapSketch;

View File

@ -1,3 +0,0 @@
.sr-icon {
cursor: pointer;
}

View File

@ -1,126 +0,0 @@
import { Box, Flex, HStack, Radio, RadioGroup, Select } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { RankedMode } from "@prisma/client";
import ModeSelector from "components/common/ModeSelector";
import SubText from "components/common/SubText";
import { PlannerMapBg } from "pages/plans";
import NextImage from "next/image";
import salmonRunHighTide from "utils/assets/SalmonRunHighTide.svg";
import salmonRunLowTide from "utils/assets/SalmonRunLowTide.svg";
import salmonRunMidTide from "utils/assets/SalmonRunMidTide.svg";
import { salmonRunStages, stages } from "utils/lists/stages";
import styles from "./StageSelector.module.css";
interface StageSelectorProps {
handleChange: (event: React.ChangeEvent<HTMLSelectElement>) => void;
currentBackground: PlannerMapBg;
changeMode: (mode: "SZ" | "TC" | "RM" | "CB") => void;
changeTide: (tide: "low" | "mid" | "high") => void;
changeView: (view: "M" | "R") => void;
}
const StageSelector: React.FC<StageSelectorProps> = ({
handleChange,
currentBackground,
changeMode,
changeTide,
changeView,
}) => {
const { i18n } = useLingui();
return (
<Box maxW="20rem" m="0 auto 2rem auto">
<Select value={currentBackground.stage} onChange={handleChange}>
{salmonRunStages
.concat(stages)
.sort((a, b) => a.localeCompare(b))
.concat(["Blank"])
.map((stage) => (
<option key={stage} value={stage}>
{i18n._(stage)}
</option>
))}
</Select>
{!currentBackground.tide &&
!currentBackground.mode ? null : currentBackground.tide ? (
<>
<HStack my={4} display="flex" justifyContent="center">
<Flex flexDir="column" alignItems="center">
<NextImage
className={styles["sr-icon"]}
width={32}
height={32}
src={salmonRunLowTide}
alt="Salmon Run low tide"
onClick={() => changeTide("low")}
/>
{currentBackground.tide === "low" ? (
<SubText>
<Trans>Low</Trans>
</SubText>
) : (
<Box h={4} />
)}
</Flex>
<Flex flexDir="column" alignItems="center">
<NextImage
className={styles["sr-icon"]}
width={32}
height={32}
src={salmonRunMidTide}
alt="Salmon Run mid-tide"
onClick={() => changeTide("mid")}
/>
{currentBackground.tide === "mid" ? (
<SubText>
<Trans>Mid</Trans>
</SubText>
) : (
<Box h={4} />
)}
</Flex>
<Flex flexDir="column" alignItems="center">
<NextImage
className={styles["sr-icon"]}
width={32}
height={32}
src={salmonRunHighTide}
alt="Salmon Run high tide"
onClick={() => changeTide("high")}
/>
{currentBackground.tide === "high" ? (
<SubText>
<Trans>High</Trans>
</SubText>
) : (
<Box h={4} />
)}
</Flex>
</HStack>
</>
) : (
<>
<ModeSelector
mode={currentBackground.mode as RankedMode}
setMode={changeMode}
mt={2}
display="flex"
justifyContent="center"
/>
<RadioGroup value={currentBackground.view} onChange={changeView}>
<HStack justifyContent="center" spacing={6}>
<Radio size="sm" value="M">
<Trans>Minimap</Trans>
</Radio>
<Radio size="sm" value="R">
<Trans>Top-down</Trans>
</Radio>
</HStack>
</RadioGroup>
</>
)}
</Box>
);
};
export default StageSelector;

View File

@ -1,126 +0,0 @@
import { Box, Flex, Text } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { Player } from "@prisma/client";
import MyLink from "components/common/MyLink";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/common/Table";
import WeaponImage from "components/common/WeaponImage";
import { CSSVariables } from "utils/CSSVariables";
import { GetPlayerWithPlacementsData } from "prisma/queries/getPlayerWithPlacements";
import { getRankingString } from "utils/strings";
interface Props {
player: NonNullable<GetPlayerWithPlacementsData>;
}
const QuadTable: React.FC<Props> = ({ player }) => {
const { i18n } = useLingui();
return (
<Table maxW="50rem">
<TableHead>
<TableRow>
<TableHeader />
<TableHeader>
<Trans>Date</Trans>
</TableHeader>
<TableHeader>
<Trans>Power</Trans>
</TableHeader>
<TableHeader>
<Trans>Weapon</Trans>
</TableHeader>
<TableHeader>
<Trans>Mates</Trans>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{player.leaguePlacements.QUAD.map(({ squad }, index) => {
return (
<TableRow key={squad.id}>
<TableCell color={CSSVariables.themeGray}>
{getRankingString(index + 1)}
</TableCell>
<TableCell>
{new Date(squad.startTime).toLocaleString(i18n.locale, {
month: "numeric",
year: "numeric",
hour: "numeric",
day: "numeric",
})}
</TableCell>
<TableCell>
<Text fontWeight="bold">{squad.leaguePower}</Text>
</TableCell>
<TableCell>
<WeaponImage
name={
squad.members.find(
(member) =>
member.player.switchAccountId === player.switchAccountId
)!.weapon
}
size={32}
/>
</TableCell>
<TableCell>
<LeagueMates
mates={squad.members.filter(
(member) =>
member.player.switchAccountId !== player.switchAccountId
)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};
function LeagueMates({
mates,
}: {
mates: {
player: Player & {
user: {
username: string;
discriminator: string;
discordId: string;
discordAvatar: string | null;
} | null;
};
weapon: string;
}[];
}) {
return (
<>
{mates.map((mate) => (
<Flex align="center" key={mate.player.switchAccountId}>
<WeaponImage name={mate.weapon} size={32} />
<Box ml={2}>
<MyLink
href={
mate.player.user
? `/u/${mate.player.user.discordId}`
: `/player/${mate.player.switchAccountId}`
}
>
{mate.player.name ?? "???"}
</MyLink>
</Box>
</Flex>
))}
</>
);
}
export default QuadTable;

View File

@ -1,134 +0,0 @@
import { Flex, Text } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { Player } from "@prisma/client";
import MyLink from "components/common/MyLink";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/common/Table";
import UserAvatar from "components/common/UserAvatar";
import WeaponImage from "components/common/WeaponImage";
import { CSSVariables } from "utils/CSSVariables";
import { GetPlayerWithPlacementsData } from "prisma/queries/getPlayerWithPlacements";
import { getRankingString } from "utils/strings";
interface Props {
player: NonNullable<GetPlayerWithPlacementsData>;
}
const TwinTable: React.FC<Props> = ({ player }) => {
const { i18n } = useLingui();
return (
<Table maxW="50rem">
<TableHead>
<TableRow>
<TableHeader />
<TableHeader>
<Trans>Date</Trans>
</TableHeader>
<TableHeader>
<Trans>Power</Trans>
</TableHeader>
<TableHeader>
<Trans>Weapon</Trans>
</TableHeader>
<TableHeader>
<Trans>Mate</Trans>
</TableHeader>
<TableHeader>
<Trans>Mate&apos;s weapon</Trans>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{player.leaguePlacements.TWIN.map(({ squad }, index) => {
return (
<TableRow key={squad.id}>
<TableCell color={CSSVariables.themeGray}>
{getRankingString(index + 1)}
</TableCell>
<TableCell>
{new Date(squad.startTime).toLocaleString(i18n.locale, {
month: "numeric",
year: "numeric",
hour: "numeric",
day: "numeric",
})}
</TableCell>
<TableCell>
<Text fontWeight="bold">{squad.leaguePower}</Text>
</TableCell>
<TableCell>
<WeaponImage
name={
squad.members.find(
(member) =>
member.player.switchAccountId === player.switchAccountId
)!.weapon
}
size={32}
/>
</TableCell>
<TableCell>
<LeagueMate
mate={
squad.members.find(
(member) =>
member.player.switchAccountId !== player.switchAccountId
)!.player
}
/>
</TableCell>
<TableCell>
<WeaponImage
name={
squad.members.find(
(member) =>
member.player.switchAccountId !== player.switchAccountId
)!.weapon
}
size={32}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};
function LeagueMate({
mate,
}: {
mate: Player & {
user: {
username: string;
discriminator: string;
discordId: string;
discordAvatar: string | null;
} | null;
};
}) {
if (!mate.user && !mate.name) {
return <MyLink href={`/player/${mate.switchAccountId}`}>{"???"}</MyLink>;
}
if (mate.user)
return (
<MyLink href={`/u/${mate.user.discordId}`}>
<Flex alignItems="center">
<UserAvatar user={mate.user} size="sm" mr={2} />
{mate.user.username}
</Flex>
</MyLink>
);
return <MyLink href={`/player/${mate.switchAccountId}`}>{mate.name}</MyLink>;
}
export default TwinTable;

View File

@ -1,74 +0,0 @@
import { Text } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import ModeImage from "components/common/ModeImage";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/common/Table";
import WeaponImage from "components/common/WeaponImage";
import { CSSVariables } from "utils/CSSVariables";
import { GetPlayerWithPlacementsData } from "prisma/queries/getPlayerWithPlacements";
import { getRankingString } from "utils/strings";
interface Props {
player: NonNullable<GetPlayerWithPlacementsData>;
}
const XRankTable: React.FC<Props> = ({ player }) => {
return (
<Table maxW="50rem">
<TableHead>
<TableRow>
<TableHeader width={4} />
<TableHeader>
<Trans>Name</Trans>
</TableHeader>
<TableHeader>
<Trans>X Power</Trans>
</TableHeader>
<TableHeader>
<Trans>Mode</Trans>
</TableHeader>
<TableHeader>
<Trans>Weapon</Trans>
</TableHeader>
<TableHeader width={4}>
<Trans>Month</Trans>
</TableHeader>
<TableHeader>
<Trans>Year</Trans>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{player.placements.map((record) => {
return (
<TableRow key={record.id}>
<TableCell color={CSSVariables.themeGray}>
{getRankingString(record.ranking)}
</TableCell>
<TableCell>{record.playerName}</TableCell>
<TableCell>
<Text fontWeight="bold">{record.xPower}</Text>
</TableCell>
<TableCell>
<ModeImage mode={record.mode} size={32} />
</TableCell>
<TableCell>
<WeaponImage name={record.weapon} size={32} />
</TableCell>
<TableCell>{record.month}</TableCell>
<TableCell>{record.year}</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
);
};
export default XRankTable;

View File

@ -1,85 +0,0 @@
import { Button, IconButton } from "@chakra-ui/button";
import { Flex } from "@chakra-ui/layout";
import { useState } from "react";
import { FiEdit } from "react-icons/fi";
import { PlusVotingButton } from "./PlusVotingButton";
export function ChangeVoteButtons({
score,
isSameRegion,
editVote,
isLoadingMutation,
}: {
score: number;
isSameRegion: boolean;
editVote: (score: number) => void;
isLoadingMutation: boolean;
}) {
const [editing, setEditing] = useState(false);
const [currentScore, setCurrentScore] = useState(score);
const getNextScore = () => {
if (!isSameRegion) {
if (currentScore === 1) return -1;
return 1;
}
if (currentScore === -2) return -1;
if (currentScore === -1) return 1;
if (currentScore === 1) return 2;
return -2;
};
return (
<>
<Flex align="center">
<PlusVotingButton
number={currentScore}
onClick={() => setCurrentScore(getNextScore())}
disabled={!editing}
isSmall
/>
</Flex>
{editing ? (
<Button
size="sm"
mr={2}
my="auto"
onClick={() => {
editVote(currentScore);
setEditing(false);
}}
disabled={isLoadingMutation}
>
Save
</Button>
) : (
<IconButton
aria-label="Edit vote"
icon={<FiEdit />}
colorScheme="gray"
variant="ghost"
onClick={() => setEditing(!editing)}
my="auto"
/>
)}
{editing ? (
<Button
colorScheme="red"
onClick={() => {
setEditing(false);
setCurrentScore(score);
}}
size="sm"
ml={2}
my="auto"
>
Cancel
</Button>
) : (
<div />
)}
</>
);
}

View File

@ -1,28 +0,0 @@
import { Button } from "@chakra-ui/button";
export function PlusVotingButton({
number,
onClick,
disabled,
isSmall,
}: {
number: number;
onClick: () => void;
disabled?: boolean;
isSmall?: boolean;
}) {
return (
<Button
borderRadius="50%"
height={isSmall ? 10 : 12}
width={isSmall ? 10 : 12}
variant="outline"
colorScheme={number < 0 ? "red" : "theme"}
onClick={onClick}
disabled={disabled}
>
{number > 0 ? "+" : ""}
{number}
</Button>
);
}

View File

@ -1,154 +0,0 @@
import { Button } from "@chakra-ui/button";
import { Box, Flex } from "@chakra-ui/layout";
import {
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
Text,
Textarea,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/macro";
import MyLink from "components/common/MyLink";
import SubText from "components/common/SubText";
import UserAvatar from "components/common/UserAvatar";
import { useMutation } from "hooks/common";
import type { SuggestionsGet } from "pages/api/plus/suggestions";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { useSWRConfig } from "swr";
import { getVotingRange } from "utils/plus";
import { getFullUsername } from "utils/strings";
import { Unpacked } from "utils/types";
import {
resuggestionSchema,
SUGGESTION_DESCRIPTION_LIMIT,
} from "utils/validators/suggestion";
import * as z from "zod";
type SuggestionsData = z.infer<typeof resuggestionSchema>;
const Suggestion = ({
suggestion,
canSuggest,
}: {
suggestion: Unpacked<SuggestionsGet>;
canSuggest: boolean;
}) => {
const [showTextarea, setShowTextarea] = useState(false);
const { handleSubmit, errors, register, watch } = useForm<SuggestionsData>({
resolver: zodResolver(resuggestionSchema),
});
const { mutate } = useSWRConfig();
const suggestionMutation = useMutation<SuggestionsData>({
url: "/api/plus/suggestions",
method: "POST",
successToastMsg: "Comment added",
afterSuccess: () => {
mutate("/api/plus/suggestions");
setShowTextarea(false);
},
});
const watchDescription = watch("description", "");
return (
<Box as="section" my={8}>
<Flex alignItems="center" fontWeight="bold" fontSize="1.25rem">
<UserAvatar user={suggestion.suggestedUser} mr={3} />
<MyLink
href={`/u/${suggestion.suggestedUser.discordId}`}
isColored={false}
>
{getFullUsername(suggestion.suggestedUser)}
</MyLink>
</Flex>
<Box>
<SubText mt={4}>+{suggestion.tier}</SubText>
<Box mt={4} fontSize="sm">
&quot;{suggestion.description}&quot; -{" "}
<MyLink href={`/u/${suggestion.suggesterUser.discordId}`}>
{getFullUsername(suggestion.suggesterUser)}
</MyLink>
<Text as="i" fontSize="xs">
{" "}
({new Date(suggestion.createdAt).toLocaleString("en")})
</Text>
</Box>
{suggestion.resuggestions?.map((resuggestion) => {
return (
<Box key={resuggestion.suggesterUser.id} mt={4} fontSize="sm">
&quot;{resuggestion.description}&quot; -{" "}
<MyLink href={`/u/${resuggestion.suggesterUser.discordId}`}>
{getFullUsername(resuggestion.suggesterUser)}
</MyLink>
<Text as="i" fontSize="xs">
{" "}
({new Date(resuggestion.createdAt).toLocaleString("en")})
</Text>
</Box>
);
})}
{canSuggest && !showTextarea && !getVotingRange().isHappening && (
<Button
variant="outline"
size="sm"
onClick={() => setShowTextarea(!showTextarea)}
mt={4}
data-cy="comment-button"
>
Add comment
</Button>
)}
{showTextarea && (
<form
onSubmit={handleSubmit((values) =>
suggestionMutation.mutate({
...values,
// @ts-expect-error region doesn't matter as it is not updated after the first suggestion
region: "NA" as const,
tier: suggestion.tier,
suggestedId: suggestion.suggestedUser.id,
})
)}
>
<FormControl isInvalid={!!errors.description}>
<FormLabel htmlFor="description" mt={4}>
Comment to suggestion
</FormLabel>
<Textarea
name="description"
ref={register}
data-cy="comment-textarea"
/>
<FormHelperText mb={4}>
{(watchDescription ?? "").length}/{SUGGESTION_DESCRIPTION_LIMIT}
</FormHelperText>
<FormErrorMessage>{errors.description?.message}</FormErrorMessage>
</FormControl>
<Button
size="sm"
mr={3}
type="submit"
isLoading={suggestionMutation.isMutating}
data-cy="submit-button"
>
<Trans>Save</Trans>
</Button>
<Button
size="sm"
onClick={() => setShowTextarea(false)}
variant="outline"
>
<Trans>Cancel</Trans>
</Button>
</form>
)}
</Box>
</Box>
);
};
export default Suggestion;

View File

@ -1,178 +0,0 @@
import {
Button,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
Textarea,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import UserSelector from "components/common/UserSelector";
import { useMutation } from "hooks/common";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { useSWRConfig } from "swr";
import {
suggestionFullSchema,
SUGGESTION_DESCRIPTION_LIMIT,
} from "utils/validators/suggestion";
import * as z from "zod";
interface Props {
userPlusMembershipTier: number;
}
type SuggestionsData = z.infer<typeof suggestionFullSchema>;
const SuggestionModal: React.FC<Props> = ({ userPlusMembershipTier }) => {
const [isOpen, setIsOpen] = useState(false);
const { handleSubmit, errors, register, watch, control } =
useForm<SuggestionsData>({
resolver: zodResolver(suggestionFullSchema),
});
const { mutate } = useSWRConfig();
const suggestionMutation = useMutation<SuggestionsData>({
url: "/api/plus/suggestions",
method: "POST",
successToastMsg: "New suggestion submitted",
afterSuccess: () => {
mutate("/api/plus/suggestions");
setIsOpen(false);
},
});
const watchDescription = watch("description", "");
return (
<>
<Button
size="sm"
mb={4}
onClick={() => setIsOpen(true)}
data-cy="suggestion-button"
>
Add new suggestion
</Button>
{isOpen && (
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
size="xl"
closeOnOverlayClick={false}
>
<ModalOverlay>
<ModalContent>
<ModalHeader>Adding a new suggestion</ModalHeader>
<ModalCloseButton borderRadius="50%" />
<form
onSubmit={handleSubmit((data) =>
suggestionMutation.mutate(data)
)}
>
<ModalBody pb={2}>
<FormLabel>Tier</FormLabel>
<Controller
name="tier"
control={control}
defaultValue={userPlusMembershipTier}
render={({ value, onChange }) => (
<Select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
>
{userPlusMembershipTier === 1 && (
<option value="1">+1</option>
)}
{userPlusMembershipTier <= 2 && (
<option value="2">+2</option>
)}
<option value="3">+3</option>
</Select>
)}
/>
<FormControl isInvalid={!!errors.suggestedId}>
<FormLabel mt={4}>User</FormLabel>
<Controller
name="suggestedId"
control={control}
render={({ value, onChange }) => (
<UserSelector
value={value}
setValue={onChange}
isMulti={false}
maxMultiCount={undefined}
/>
)}
/>
<FormErrorMessage>
{errors.suggestedId?.message}
</FormErrorMessage>
</FormControl>
<FormControl>
<FormLabel mt={4}>Region</FormLabel>
<Select
name="region"
ref={register}
data-cy="region-select"
>
<option value="NA">NA</option>
<option value="EU">EU</option>
</Select>
<FormHelperText>
If the player isn&apos;t from either region then choose
the one they play most commonly with.
</FormHelperText>
</FormControl>
<FormControl isInvalid={!!errors.description}>
<FormLabel htmlFor="description" mt={4}>
Description
</FormLabel>
<Textarea
name="description"
ref={register}
data-cy="description-textarea"
/>
<FormHelperText>
{(watchDescription ?? "").length}/
{SUGGESTION_DESCRIPTION_LIMIT}
</FormHelperText>
<FormErrorMessage>
{errors.description?.message}
</FormErrorMessage>
</FormControl>
</ModalBody>
<ModalFooter>
<Button
mr={3}
type="submit"
isLoading={suggestionMutation.isMutating}
data-cy="submit-button"
>
Save
</Button>
<Button onClick={() => setIsOpen(false)} variant="outline">
Cancel
</Button>
</ModalFooter>
</form>
</ModalContent>
</ModalOverlay>
</Modal>
)}
</>
);
};
export default SuggestionModal;

View File

@ -1,33 +0,0 @@
import MyLink from "components/common/MyLink";
import { getVotingRange } from "utils/plus";
const VotingInfoHeader = ({ isMember }: { isMember: boolean }) => {
const { isHappening, endDate, nextVotingDate } = getVotingRange();
if (isHappening && isMember) {
return (
<>
Voting is open till {endDate.toLocaleString()}.{" "}
<MyLink href="/plus/voting">Go vote</MyLink> or{" "}
<MyLink href="/plus/history">view voting history</MyLink>
</>
);
}
if (isHappening)
return (
<>
Voting is happening till {endDate.toLocaleString()}.{" "}
<MyLink href="/plus/history">View voting history</MyLink>
</>
);
return (
<>
Next voting starts {nextVotingDate.toLocaleString()}.{" "}
<MyLink href="/plus/history">View voting history</MyLink>
</>
);
};
export default VotingInfoHeader;

View File

@ -1,144 +0,0 @@
import {
Button,
FormControl,
FormErrorMessage,
FormHelperText,
FormLabel,
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Select,
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import UserSelector from "components/common/UserSelector";
import { useMutation } from "hooks/common";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { vouchSchema } from "utils/validators/vouch";
import * as z from "zod";
interface Props {
canVouchFor: number;
}
type VouchData = z.infer<typeof vouchSchema>;
const VouchModal: React.FC<Props> = ({ canVouchFor }) => {
const [isOpen, setIsOpen] = useState(false);
const { handleSubmit, errors, register, control } = useForm<VouchData>({
resolver: zodResolver(vouchSchema),
});
const vouchMutation = useMutation<VouchData>({
url: "/api/plus/vouches",
method: "POST",
successToastMsg: "Successfully vouched",
afterSuccess: () => {
setIsOpen(false);
},
});
return (
<>
<Button
size="sm"
mb={4}
ml={2}
onClick={() => setIsOpen(true)}
data-cy="vouch-button"
>
Vouch
</Button>
{isOpen && (
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
size="xl"
closeOnOverlayClick={false}
>
<ModalOverlay>
<ModalContent>
<ModalHeader>Vouching</ModalHeader>
<ModalCloseButton borderRadius="50%" />
<form
onSubmit={handleSubmit((data) => vouchMutation.mutate(data))}
>
<ModalBody pb={2}>
<FormLabel>Tier</FormLabel>
<Controller
name="tier"
control={control}
defaultValue={canVouchFor}
render={({ value, onChange }) => (
<Select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
>
{canVouchFor === 1 && <option value="1">+1</option>}
{canVouchFor <= 2 && <option value="2">+2</option>}
<option value="3">+3</option>
</Select>
)}
/>
<FormControl isInvalid={!!errors.vouchedId}>
<FormLabel mt={4}>User</FormLabel>
<Controller
name="vouchedId"
control={control}
render={({ value, onChange }) => (
<UserSelector
value={value}
setValue={onChange}
isMulti={false}
maxMultiCount={undefined}
/>
)}
/>
<FormErrorMessage>
{errors.vouchedId?.message}
</FormErrorMessage>
</FormControl>
<FormControl>
<FormLabel mt={4}>Region</FormLabel>
<Select
name="region"
ref={register}
data-cy="region-select"
>
<option value="NA">NA</option>
<option value="EU">EU</option>
</Select>
<FormHelperText>
If the player isn&apos;t from either region then choose
the one they play most commonly with.
</FormHelperText>
</FormControl>
</ModalBody>
<ModalFooter>
<Button
mr={3}
type="submit"
isLoading={vouchMutation.isMutating}
data-cy="submit-button"
>
Save
</Button>
<Button onClick={() => setIsOpen(false)} variant="outline">
Cancel
</Button>
</ModalFooter>
</form>
</ModalContent>
</ModalOverlay>
</Modal>
)}
</>
);
};
export default VouchModal;

Some files were not shown because too many files have changed in this diff Show More