mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-03-21 18:04:39 -05:00
Clean slate
This commit is contained in:
parent
deaf3564e0
commit
e123544358
14
.babelrc
14
.babelrc
|
|
@ -1,14 +0,0 @@
|
|||
{
|
||||
"presets": [
|
||||
[
|
||||
"next/babel",
|
||||
{
|
||||
"preset-env": {},
|
||||
"transform-runtime": {},
|
||||
"styled-jsx": {},
|
||||
"class-properties": {}
|
||||
}
|
||||
]
|
||||
],
|
||||
"plugins": ["macros"]
|
||||
}
|
||||
5
.env
5
.env
|
|
@ -1,5 +0,0 @@
|
|||
# used for log-in
|
||||
DISCORD_CLIENT_ID=
|
||||
DISCORD_CLIENT_SECRET=
|
||||
JWT_SECRET=
|
||||
NEXTAUTH_URL=http://localhost:3000
|
||||
6
.github/dependabot.yml
vendored
6
.github/dependabot.yml
vendored
|
|
@ -1,6 +0,0 @@
|
|||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "daily"
|
||||
51
.github/workflows/ci.yml
vendored
51
.github/workflows/ci.yml
vendored
|
|
@ -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
45
.gitignore
vendored
|
|
@ -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
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
.next/
|
||||
|
||||
prisma/scripts/data
|
||||
prisma/scripts/mongo
|
||||
|
||||
# Ignore JSON files generated via script
|
||||
utils/data/patrons.json
|
||||
locale/*/*.js
|
||||
|
|
@ -1,8 +0,0 @@
|
|||
{
|
||||
"extends": [
|
||||
"stylelint-config-standard",
|
||||
"stylelint-config-prettier",
|
||||
"stylelint-config-idiomatic-order"
|
||||
],
|
||||
"plugins": ["stylelint-order"]
|
||||
}
|
||||
21
LICENSE
21
LICENSE
|
|
@ -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
103
README.md
|
|
@ -1,103 +0,0 @@
|
|||
[](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).
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}&s;embed=image.url`}
|
||||
size={isSmall ? "sm" : undefined}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export default TwitterAvatar;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,5 +0,0 @@
|
|||
.heading {
|
||||
font-size: var(--heading-size);
|
||||
font-weight: var(--fontWeights-bold);
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
.sr-icon {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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">
|
||||
"{suggestion.description}" -{" "}
|
||||
<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">
|
||||
"{resuggestion.description}" -{" "}
|
||||
<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;
|
||||
|
|
@ -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'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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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'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
Loading…
Reference in New Issue
Block a user