New layout (#427) closes #405

* side layout initial

* add elements to side nav

* side buttons links

* remove clog

* calendar page initial

* position sticky working

* x trends page initial

* new table

* same mode selector

* mobile friendly table

* no underline for nav links

* xsearch

* x trends page outlined

* sr initial

* relocate calendar components

* calendar fix flex

* topnav fancier look

* layout looking good edition

* relocate xtrends

* xtrends remove linecharts

* x trends new

* calender page new

* delete headbanner, new login

* remove calendar stuff from api

* rename stuff in utils

* fix user item margin

* new home page initial

* remove page concept

* no pointer xtrends

* remove xrank from app

* xtrends service

* move fa from app

* move plus

* maps tweaks

* new table for plus history

* navigational sidebar flex tweaks

* builds page

* analyzer

* user page

* free agents

* plans

* remove mx

* tweaks

* change layout to grid

* home page finalized

* mobile nav

* restrict main content width

* tweaks style

* language switcher

* container in css

* sticky nav

* use duplicate icons for now

* change mapsketch width to old

* chara tour vid

* borzoic icons
This commit is contained in:
Kalle 2021-04-21 17:26:50 +03:00 committed by GitHub
parent 12bcf83532
commit 1589b84c4b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
101 changed files with 2324 additions and 2473 deletions

View File

@ -1,8 +1,8 @@
import { createRouter } from "pages/api/trpc/[trpc]";
import service from "services/calendar";
import { throwIfNotLoggedIn } from "utils/api";
import { eventSchema } from "utils/validators/event";
import * as z from "zod";
import service from "./service";
const calendarApi = createRouter()
.query("events", {
@ -21,7 +21,6 @@ const calendarApi = createRouter()
input: z.object({ event: eventSchema, eventId: z.number() }),
resolve({ ctx, input }) {
const user = throwIfNotLoggedIn(ctx.user);
console.log("input", input);
return service.editEvent({
...input,
userId: user.id,

View File

@ -1,5 +1,5 @@
import service from "app/freeagents/service";
import { createRouter } from "pages/api/trpc/[trpc]";
import service from "services/freeagents";
import { throwIfNotLoggedIn } from "utils/api";
import { freeAgentPostSchema } from "utils/validators/fapost";
import * as z from "zod";

View File

@ -1,9 +1,9 @@
import { createRouter } from "pages/api/trpc/[trpc]";
import service from "services/plus";
import { throwIfNotLoggedIn } from "utils/api";
import { suggestionFullSchema } from "utils/validators/suggestion";
import { voteSchema, votesSchema } from "utils/validators/votes";
import { vouchSchema } from "utils/validators/vouch";
import service from "./service";
const plusApi = createRouter()
.query("suggestions", {

View File

@ -1,116 +0,0 @@
import { Button } from "@chakra-ui/button";
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Box } from "@chakra-ui/layout";
import { t, Trans } from "@lingui/macro";
import SubText from "components/common/SubText";
import { useMyTheme } from "hooks/common";
import { Fragment, useState } from "react";
import { FiSearch } from "react-icons/fi";
import { trpc } from "utils/trpc";
import EventInfo from "./EventInfo";
import { EventModal, FormData } from "./EventModal";
import MyHead from "../../../components/common/MyHead";
export default function CalendarPage() {
const { gray } = useMyTheme();
const events = trpc.useQuery(["calendar.events"], { enabled: false });
const [eventToEdit, setEventToEdit] = useState<
boolean | (FormData & { id: number })
>(false);
const [filter, setFilter] = useState("");
let lastPrintedDate: [number, number, Date] | null = null;
return (
<>
<MyHead title={t`Calendar`} />
<div>
<Button
size="sm"
onClick={() => setEventToEdit(true)}
data-cy="add-event-button"
>
<Trans>Add event</Trans>
</Button>
{eventToEdit && (
<EventModal
onClose={() => setEventToEdit(false)}
event={typeof eventToEdit === "boolean" ? undefined : eventToEdit}
refetchQuery={events.refetch}
/>
)}
</div>
<InputGroup mt={8} maxW="24rem" mx="auto">
<InputLeftElement
pointerEvents="none"
children={<FiSearch color={gray} />}
/>
<Input value={filter} onChange={(e) => setFilter(e.target.value)} />
</InputGroup>
{(events.data ?? [])
.filter((event) =>
event.name.toLowerCase().includes(filter.toLowerCase().trim())
)
.map((event) => {
const printDateHeader =
!lastPrintedDate ||
lastPrintedDate[0] !== event.date.getDate() ||
lastPrintedDate[1] !== event.date.getMonth();
if (printDateHeader) {
lastPrintedDate = [
event.date.getDate(),
event.date.getMonth(),
event.date,
];
}
const now = new Date();
const isToday =
lastPrintedDate![2].getDate() === now.getDate() &&
lastPrintedDate![2].getMonth() === now.getMonth();
return (
<Fragment key={event.id}>
{printDateHeader && (
<Box my={10}>
<SubText>
{/* TODO */}
{event.date.toLocaleDateString("en", {
month: "long",
day: "numeric",
weekday: "long",
})}{" "}
{isToday && <Trans>(Today)</Trans>}
</SubText>
</Box>
)}
<div>
<EventInfo
event={event}
edit={() =>
setEventToEdit({
...event,
date: event.date.toISOString(),
// TODO: remove this if later other event types than tournament are allowed
// currently in the validator we accept the properties as if you can only submit
// tournaments but database is prepared to accept other kind of events
// this makes TS freak out a bit
discordInviteUrl: event.discordInviteUrl!,
tags: event.tags as any,
format: event.format as any,
})
}
/>
</div>
</Fragment>
);
})}
<Box color={gray}>
All events listed in your local time:{" "}
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</Box>
</>
);
}

View File

@ -1,198 +0,0 @@
import {
Badge,
Box,
Button,
Flex,
Grid,
Heading,
Popover,
PopoverArrow,
PopoverContent,
PopoverHeader,
PopoverTrigger,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import Markdown from "components/common/Markdown";
import MyLink from "components/common/MyLink";
import UserAvatar from "components/common/UserAvatar";
import { useMyTheme, useUser } from "hooks/common";
import Image from "next/image";
import React, { useState } from "react";
import { FiClock, FiEdit, FiExternalLink, FiInfo } from "react-icons/fi";
import { DiscordIcon } from "utils/assets/icons";
import { ADMIN_ID } from "utils/constants";
import { Unpacked } from "utils/types";
import { Events } from "../service";
import { eventImage, EVENT_FORMATS, TAGS } from "../utils";
interface EventInfoProps {
event: Unpacked<Events>;
edit: () => void;
}
const TournamentInfo = ({ event, edit }: EventInfoProps) => {
const { secondaryBgColor, gray, themeColorShade } = useMyTheme();
const [expanded, setExpanded] = useState(false);
const poster = event.poster;
const [user] = useUser();
const canEdit = user?.id === poster.id || user?.id === ADMIN_ID;
const imgSrc = eventImage(event.name);
return (
<Box
as="section"
rounded="lg"
overflow="hidden"
boxShadow="md"
bg={secondaryBgColor}
p="20px"
my={5}
data-cy={`event-info-section-${event.name
.toLowerCase()
.replace(/ /g, "-")}`}
>
<Box textAlign="center">
<Box>
{imgSrc && <Image src={imgSrc} width={36} height={36} />}
<Heading size="lg">{event.name}</Heading>
{event.tags.length > 0 && (
<Flex flexWrap="wrap" justifyContent="center" mt={3} mb={2}>
{event.tags.map((tag) => {
const tagInfo = TAGS.find((tagObj) => tagObj.code === tag)!;
return (
<Popover
key={tag}
trigger="hover"
variant="responsive"
placement="top"
>
<PopoverTrigger>
<Badge mx={1} bg={tagInfo.color} color="black">
{tagInfo.name}
</Badge>
</PopoverTrigger>
<PopoverContent bg={secondaryBgColor}>
<PopoverHeader fontWeight="semibold">
{tagInfo.description}
</PopoverHeader>
<PopoverArrow bg={secondaryBgColor} />
</PopoverContent>
</Popover>
);
})}
</Flex>
)}
<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={themeColorShade}
justifySelf="flex-end"
/>
{/* TODO */}
<Box as="time" dateTime={event.date.toISOString()}>
{event.date.toLocaleString("en", {
hour: "numeric",
minute: "numeric",
})}
</Box>
</Flex>
<Flex placeItems="center" mx="auto">
<Box
as={FiExternalLink}
mr="0.5em"
color={themeColorShade}
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" : "1fr 1fr"]}
gridRowGap="1rem"
gridColumnGap="1rem"
maxW={["12rem", canEdit ? "32rem" : "24rem"]}
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 />
)}
<Button
leftIcon={<FiInfo />}
size="sm"
onClick={() => setExpanded(!expanded)}
data-cy={`info-button-${event.name
.toLowerCase()
.replace(/ /g, "-")}`}
>
{expanded ? <Trans>Hide info</Trans> : <Trans>View info</Trans>}
</Button>
{canEdit && (
<Button
leftIcon={<FiEdit />}
size="sm"
onClick={edit}
variant="outline"
data-cy={`edit-button-${event.name
.toLowerCase()
.replace(/ /g, "-")}`}
>
Edit event
</Button>
)}
</Grid>
</Box>
{expanded && (
<Box mt="1rem" mx="0.5rem">
<Box color={gray} fontSize="small" mb={2}>
{EVENT_FORMATS.find((format) => format.code === event.format)!.name}
</Box>
<Markdown smallHeaders value={event.description} />
</Box>
)}
</Box>
);
};
export default TournamentInfo;

View File

@ -1,118 +0,0 @@
import { t } from "@lingui/macro";
export const TAGS = [
{
code: "SZ",
name: t`SZ Only`,
description: t`Splat Zones is the only mode played.`,
color: "#F44336",
},
{
code: "TW",
name: t`Includes TW`,
description: t`Turf War is played.`,
color: "#D50000",
},
{
code: "SPECIAL",
name: t`Special rules`,
description: t`Ruleset that derives from standard e.g. limited what weapons can be used.`,
color: "#CE93D8",
},
{
code: "ART",
name: t`Art prizes`,
description: t`You can win art by playing in this tournament.`,
color: "#AA00FF",
},
{
code: "MONEY",
name: t`Money prizes`,
description: t`You can win money by playing in this tournament.`,
color: "#673AB7",
},
{
code: "REGION",
name: t`Region locked`,
description: t`Limited who can play in this tournament based on location.`,
color: "#C5CAE9",
},
{
code: "LOW",
name: t`Skill cap`,
description: t`Who can play in this tournament is limited by skill.`,
color: "#BBDEFB",
},
{
code: "COUNT",
name: t`Entry limit`,
description: t`Only limited amount of teams can register.`,
color: "#1565C0",
},
{
code: "MULTIPLE",
name: t`Multi-day`,
description: t`This tournament takes place over more than one day.`,
color: "#0277BD",
},
{
code: "S1",
name: t`Splatoon 1`,
description: t`The game played is Splatoon 1.`,
color: "#81C784",
},
{
code: "LAN",
name: t`LAN`,
description: t`This tournament is played locally.`,
color: "#263238",
},
{
code: "QUALIFIER",
name: t`Qualifier`,
description: t`This tournament is a qualifier for another event.`,
color: "#FFC0CB",
},
] as const;
export const EVENT_FORMATS = [
{ code: "SE", name: t`Single Elimination` },
{ code: "DE", name: t`Double Elimination` },
{ code: "GROUPS2SE", name: t`Groups to Single Elimination` },
{ code: "GROUPS2DE", name: t`Groups to Double Elimination` },
{ code: "SWISS2SE", name: t`Swiss to Single Elimination` },
{ code: "SWISS2DE", name: t`Swiss to Double Elimination` },
{ code: "SWISS", name: t`Swiss` },
{ code: "OTHER", name: t`Other` },
] as const;
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" },
] 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;
};

View File

@ -1,171 +0,0 @@
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
Flex,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { useFreeAgents } from "app/freeagents/hooks";
import { useMyTheme, useUser } from "hooks/common";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { mutate } from "swr";
import { sendData } from "utils/postData";
import FAFilters from "./FAFilters";
import FAModal from "./FAModal";
import FreeAgentSection from "./FreeAgentSection";
import MatchesInfo from "./MatchesInfo";
const FreeAgentsPage = () => {
const {
postsData,
refetchPosts,
likesData,
isLoading,
usersPost,
matchedPosts,
allPostsCount,
state,
dispatch,
} = useFreeAgents();
const [user] = useUser();
const router = useRouter();
const [postIdToScrollTo, setPostIdToScrollTo] = useState<undefined | number>(
undefined
);
const { gray } = useMyTheme();
const [sending, setSending] = useState(false);
const postRef = useRef<HTMLDivElement>(null);
const [modalIsOpen, setModalIsOpen] = useState(false);
useEffect(() => {
if (!postRef.current) return;
postRef.current.scrollIntoView();
}, [postRef.current]);
const dateThreeWeeksAgo = new Date();
dateThreeWeeksAgo.setDate(dateThreeWeeksAgo.getDate() - 7 * 3);
const onPostRefresh = async () => {
setSending(true);
const success = await sendData("PUT", "/api/freeagents", {
canVC: usersPost!.canVC,
playstyles: usersPost!.playstyles,
content: usersPost!.content,
});
setSending(false);
if (!success) return;
mutate("/api/freeagents");
};
return (
<>
{modalIsOpen && (
<FAModal
post={usersPost}
onClose={() => setModalIsOpen(false)}
refetchQuery={refetchPosts}
/>
)}
{user && (
<Button size="sm" onClick={() => setModalIsOpen(true)}>
{usersPost ? (
<Trans>Edit free agent post</Trans>
) : (
<Trans>New free agent post</Trans>
)}
</Button>
)}
{usersPost &&
usersPost.updatedAt.getTime() < dateThreeWeeksAgo.getTime() && (
<Alert
status="warning"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
mx="auto"
mt={6}
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="lg">
<Trans>Your free agent post is about to expire</Trans>
</AlertTitle>
<AlertDescription maxWidth="sm">
<Trans>
Free agent posts that haven't been updated in over a month will
be hidden. Please press the button below if you are still a free
agent.
</Trans>
<Box>
<Button
mt={4}
variant="outline"
onClick={onPostRefresh}
isLoading={sending}
>
<Trans>I'm still a free agent</Trans>
</Button>
</Box>
</AlertDescription>
</Alert>
)}
{usersPost && likesData ? (
<MatchesInfo
matchedPosts={matchedPosts}
focusOnMatch={(id) => setPostIdToScrollTo(id)}
/>
) : null}
{!isLoading && <FAFilters state={state} dispatch={dispatch} />}
{allPostsCount > 0 && (
<Flex align="center" fontSize="small" color={gray} mt={4}>
Showing {postsData.length} posts out of {allPostsCount}{" "}
<Button
onClick={() => dispatch({ type: "RESET_FILTERS" })}
visibility={
postsData.length === allPostsCount ? "hidden" : "visible"
}
ml={2}
size="sm"
variant="ghost"
>
Reset filters
</Button>
</Flex>
)}
{postsData.map((post) => (
<FreeAgentSection
key={post.id}
post={post}
isLiked={!!likesData?.likedPostIds.includes(post.id)}
canLike={
!!user && post.user.discordId !== user.discordId && !!usersPost
}
postRef={post.id === getIdToScrollTo() ? postRef : undefined}
showXp={state.xp}
showPlusServerMembership={state.plusServer}
/>
))}
</>
);
function getIdToScrollTo() {
if (postIdToScrollTo) return postIdToScrollTo;
return Number.isNaN(parseInt(router.query.id as any))
? undefined
: parseInt(router.query.id as any);
}
};
export default FreeAgentsPage;

View File

@ -1,219 +0,0 @@
import { Box, Center, Divider, Flex, Heading, Stack } from "@chakra-ui/layout";
import {
Alert,
AlertDescription,
AlertTitle,
Progress,
Radio,
RadioGroup
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { usePlusHomePage } from "app/plus/hooks/usePlusHomePage";
import MyHead from "components/common/MyHead";
import SubText from "components/common/SubText";
import { useUser } from "hooks/common";
import { Fragment } from "react";
import { getVotingRange } from "utils/plus";
import { getFullUsername } from "utils/strings";
import Suggestion from "./Suggestion";
import SuggestionModal from "./SuggestionModal";
import VotingInfoHeader from "./VotingInfoHeader";
import VouchModal from "./VouchModal";
const PlusHomePage = () => {
const [user] = useUser();
const {
plusStatusData,
suggestionsData,
ownSuggestion,
suggestionCounts,
setSuggestionsFilter,
vouchedPlusStatusData,
votingProgress,
} = usePlusHomePage();
if (!plusStatusData?.membershipTier) {
return <Box>
<Box fontSize="sm" mb={4}>
<VotingInfoHeader isMember={!!plusStatusData?.membershipTier} />
</Box>
<Heading size="md">Suggested players this month:</Heading>
<Flex flexWrap="wrap">
{suggestionsData.sort((a,b) => a.createdAt.getTime() - b.createdAt.getTime()).map(suggestion => <Box key={suggestion.tier + "+" + suggestion.suggestedUser.id} m={1}>{getFullUsername(suggestion.suggestedUser)} (+{suggestion.tier})</Box>)}
</Flex>
</Box>
}
return (
<>
<MyHead title="Plus Server" />
<Box fontSize="sm" mb={4}>
<VotingInfoHeader isMember={!!plusStatusData?.membershipTier} />
</Box>
{votingProgress && (
<Box textAlign="center">
<SubText>
+1 ({votingProgress[1].voted}/{votingProgress[1].totalVoterCount})
</SubText>
<Progress
value={
(votingProgress[1].voted / votingProgress[1].totalVoterCount) *
100
}
size="xs"
colorScheme="pink"
mb={6}
/>
<SubText>
+2 ({votingProgress[2].voted}/{votingProgress[2].totalVoterCount})
</SubText>
<Progress
value={
(votingProgress[2].voted / votingProgress[2].totalVoterCount) *
100
}
size="xs"
colorScheme="blue"
mb={6}
/>
<SubText>
+3 ({votingProgress[3].voted}/{votingProgress[3].totalVoterCount})
</SubText>
<Progress
value={
(votingProgress[3].voted / votingProgress[3].totalVoterCount) *
100
}
size="xs"
colorScheme="yellow"
mb={6}
/>
</Box>
)}
{!getVotingRange().isHappening && (
<>
{plusStatusData &&
plusStatusData.membershipTier &&
!ownSuggestion && (
<SuggestionModal
userPlusMembershipTier={plusStatusData.membershipTier}
/>
)}
{plusStatusData &&
plusStatusData.canVouchFor &&
!plusStatusData.canVouchAgainAfter && (
<VouchModal canVouchFor={plusStatusData.canVouchFor} />
)}
</>
)}
{plusStatusData &&
(plusStatusData.canVouchAgainAfter ||
plusStatusData.voucher ||
vouchedPlusStatusData) && (
<Alert
status="success"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
mt={2}
mb={6}
rounded="lg"
>
<AlertDescription maxWidth="sm">
<AlertTitle mb={1} fontSize="lg">
Vouching status
</AlertTitle>
{plusStatusData?.canVouchAgainAfter && (
<Box>
Can vouch again after:{" "}
{plusStatusData.canVouchAgainAfter.toLocaleDateString()}
</Box>
)}
{plusStatusData?.voucher && (
<Box>
Vouched for <b>+{plusStatusData.vouchTier}</b> by{" "}
{getFullUsername(plusStatusData.voucher)}
</Box>
)}
{vouchedPlusStatusData && (
<Box>
Vouched {getFullUsername(vouchedPlusStatusData.user)} to{" "}
<b>+{vouchedPlusStatusData.vouchTier}</b>
</Box>
)}
</AlertDescription>
</Alert>
)}
<Center mt={2}>
<RadioGroup
defaultValue="ALL"
onChange={(value) => {
const tier = [null, "ONE", "TWO", "THREE"].indexOf(value as any);
setSuggestionsFilter(tier === -1 ? undefined : tier);
}}
>
<Stack spacing={4} direction={["column", "row"]}>
<Radio value="ALL">
<Trans>
All (
{suggestionCounts.ONE +
suggestionCounts.TWO +
suggestionCounts.THREE}
)
</Trans>
</Radio>
<Radio value="ONE">
<Flex align="center">
<SubText mr={2}>+1</SubText> ({suggestionCounts.ONE})
</Flex>
</Radio>
<Radio value="TWO">
<Flex align="center">
<SubText mr={2}>+2</SubText> ({suggestionCounts.TWO})
</Flex>
</Radio>
<Radio value="THREE" data-cy="plus-three-radio">
<Flex align="center">
<SubText mr={2}>+3</SubText> ({suggestionCounts.THREE})
</Flex>
</Radio>
</Stack>
</RadioGroup>
</Center>
{suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE ===
0 ? (
<Box mt={4}>No suggestions yet for this month</Box>
) : (
<>
{suggestionsData.map((suggestion, i) => {
const canSuggest = () => {
if (!plusStatusData?.membershipTier) return false;
if (plusStatusData.membershipTier > suggestion.tier) return false;
if (suggestion.suggesterUser.id === user?.id) return false;
if (
suggestion.resuggestions?.some(
(suggestion) => suggestion.suggesterUser.id === user?.id
)
)
return false;
return true;
};
return (
<Fragment
key={suggestion.suggestedUser.id + "-" + suggestion.tier}
>
<Suggestion suggestion={suggestion} canSuggest={canSuggest()} />
{i < suggestionsData.length - 1 && <Divider />}
</Fragment>
);
})}
</>
)}
</>
);
};
export default PlusHomePage;

View File

@ -1,159 +0,0 @@
import { Box } from "@chakra-ui/layout";
import { Select } from "@chakra-ui/select";
import { Trans } from "@lingui/macro";
import { PlusRegion } from "@prisma/client";
import {
DistinctSummaryMonths,
VotingSummariesByMonthAndTier,
} from "app/plus/service";
import MyHead from "components/common/MyHead";
import MyLink from "components/common/MyLink";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "components/common/Table";
import UserAvatar from "components/common/UserAvatar";
import { useRouter } from "next/router";
import { Fragment } from "react";
import { FiCheck } from "react-icons/fi";
import { getFullUsername, getLocalizedMonthYearString } from "utils/strings";
export interface PlusVotingHistoryPageProps {
summaries: VotingSummariesByMonthAndTier;
monthsWithData: DistinctSummaryMonths;
}
const PlusVotingHistoryPage = ({
summaries,
monthsWithData,
}: PlusVotingHistoryPageProps) => {
const router = useRouter();
return (
<>
<MyHead title="Voting History" />
<Select
onChange={(e) => {
router.replace(`/plus/history/${e.target.value}`);
}}
maxW={64}
mb={8}
data-cy="tier-selector"
>
{monthsWithData.map(({ month, year, tier }) => (
<option
key={`${month}${year}${tier}`}
value={`${tier}/${year}/${month}`}
>
+{tier} - {getLocalizedMonthYearString(month, year, "en")}
</option>
))}
</Select>
<Table>
<TableHead>
<TableRow>
<TableHeader />
<TableHeader>
<Trans>Name</Trans>
</TableHeader>
<TableHeader>
<Trans>Percentage</Trans>
</TableHeader>
<TableHeader>
<Trans>Count (NA)</Trans>
</TableHeader>
<TableHeader>
<Trans>Count (EU)</Trans>
</TableHeader>
<TableHeader>
<Trans>Region</Trans>
</TableHeader>
<TableHeader>
<Trans>Suggested</Trans>
</TableHeader>
<TableHeader>
<Trans>Vouched</Trans>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{summaries.map((summary) => {
const getCount = (region: PlusRegion, counts: number[]) => {
if (region === summary.regionForVoting) return counts;
return counts.slice(1, 3);
};
return (
<TableRow key={summary.user.id}>
<TableCell>
<MyLink href={`/u/${summary.user.discordId}`}>
<UserAvatar user={summary.user} />
</MyLink>
</TableCell>
<TableCell>
<MyLink
href={`/u/${summary.user.discordId}`}
isColored={false}
>
{getFullUsername(summary.user)}
</MyLink>
</TableCell>
<TableCell
color={summary.percentage >= 50 ? "green.500" : "red.500"}
>
{summary.percentage}%
</TableCell>
<TableCell>
{getCount("NA", summary.countsNA).map((count, i, arr) => (
<Fragment key={i}>
<Box
as="span"
color={
i + 1 <= arr.length / 2 ? "red.500" : "green.500"
}
>
{count}
</Box>
{i !== arr.length - 1 && <>/</>}
</Fragment>
))}
</TableCell>
<TableCell>
{getCount("EU", summary.countsEU).map((count, i, arr) => (
<Fragment key={i}>
<Box
as="span"
color={
i + 1 <= arr.length / 2 ? "red.500" : "green.500"
}
>
{count}
</Box>
{i !== arr.length - 1 && <>/</>}
</Fragment>
))}
</TableCell>
<TableCell>{summary.regionForVoting}</TableCell>
<TableCell>
{summary.wasSuggested && (
<Box mx="auto" fontSize="xl" as={FiCheck} />
)}
</TableCell>
<TableCell>
{summary.wasVouched && (
<Box mx="auto" fontSize="xl" as={FiCheck} />
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
);
};
export default PlusVotingHistoryPage;

View File

@ -1,177 +0,0 @@
import { Alert, AlertIcon } from "@chakra-ui/alert";
import { Button } from "@chakra-ui/button";
import { Box, Flex, Grid, Heading, HStack } from "@chakra-ui/layout";
import { Progress } from "@chakra-ui/progress";
import Markdown from "components/common/Markdown";
import SubText from "components/common/SubText";
import UserAvatar from "components/common/UserAvatar";
import { useRouter } from "next/router";
import { Fragment, useEffect } from "react";
import { getVotingRange } from "utils/plus";
import { getFullUsername } from "utils/strings";
import usePlusVoting from "../hooks/usePlusVoting";
import { ChangeVoteButtons } from "./ChangeVoteButtons";
import { PlusVotingButton } from "./PlusVotingButton";
const progressBarColor = ["theme", "pink", "blue", "yellow"];
export default function PlusVotingPage() {
const router = useRouter();
const {
isLoading,
shouldRedirect,
plusStatus,
currentUser,
handleVote,
progress,
previousUser,
goBack,
submit,
voteStatus,
votedUsers,
editVote,
} = usePlusVoting();
useEffect(() => {
if (shouldRedirect) router.push("/404");
}, [shouldRedirect]);
if (isLoading || !plusStatus?.membershipTier) return null;
if (votedUsers)
return (
<>
<Alert status="success" variant="subtle" rounded="lg">
<AlertIcon />
Votes succesfully recorded. Voting ends{" "}
{getVotingRange().endDate.toLocaleString()}.
</Alert>
<Grid
mt={6}
justify="center"
templateColumns={["1fr 1fr", "2fr 0.75fr 1fr 1fr"]}
gridRowGap={5}
gridColumnGap="0.5rem"
mx="auto"
maxW="500px"
>
{votedUsers.map((votedUser) => {
return (
<Fragment key={votedUser.userId}>
<Flex align="center">
<UserAvatar user={votedUser} size="sm" mr={4} />{" "}
{getFullUsername(votedUser)}
</Flex>
<ChangeVoteButtons
score={votedUser.score}
isSameRegion={votedUser.region === plusStatus.region}
editVote={(score) =>
editVote({ userId: votedUser.userId, score })
}
/>
</Fragment>
);
})}
</Grid>
</>
);
return (
<>
<Progress
value={progress}
size="xs"
colorScheme={progressBarColor[plusStatus.membershipTier]}
mb={6}
/>
{previousUser ? (
<Box textAlign="center" mb={6}>
<UserAvatar user={previousUser} size="sm" />
<Box my={2} fontSize="sm">
{getFullUsername(previousUser)}
</Box>
<Button
borderRadius="50%"
height={10}
width={10}
variant="outline"
colorScheme={previousUser.score < 0 ? "red" : "theme"}
onClick={goBack}
>
{previousUser.score > 0 ? "+" : ""}
{previousUser.score}
</Button>
</Box>
) : (
<Flex align="center" justify="center" height="6.8rem">
<Heading>+{plusStatus.membershipTier} Voting</Heading>
</Flex>
)}
{currentUser && (
<>
<Box mt={6} textAlign="center">
<UserAvatar user={currentUser} size="2xl" mx="auto" />
<Box fontSize="2rem" fontWeight="bold" mt={2}>
{getFullUsername(currentUser)}
</Box>
</Box>
<HStack justify="center" spacing={4} mt={2}>
{currentUser.region === plusStatus.region && (
<PlusVotingButton
number={-2}
onClick={() =>
handleVote({ userId: currentUser.userId, score: -2 })
}
/>
)}
<PlusVotingButton
number={-1}
onClick={() =>
handleVote({ userId: currentUser.userId, score: -1 })
}
/>
<PlusVotingButton
number={1}
onClick={() =>
handleVote({ userId: currentUser.userId, score: 1 })
}
/>
{currentUser.region === plusStatus.region && (
<PlusVotingButton
number={2}
onClick={() =>
handleVote({ userId: currentUser.userId, score: 2 })
}
/>
)}
</HStack>
{currentUser.suggestions && (
<Box mt={5}>
<SubText>Suggestions</SubText>
{currentUser.suggestions.map((suggestion) => {
return (
<Box key={suggestion.suggesterUser.id} mt={4} fontSize="sm">
"{suggestion.description}" -{" "}
{getFullUsername(suggestion.suggesterUser)}
</Box>
);
})}
</Box>
)}
{currentUser.bio && (
<Box mt={4}>
<SubText mb={4}>Bio</SubText>
<Markdown value={currentUser.bio} smallHeaders />
</Box>
)}
</>
)}
{previousUser && !currentUser && (
<Box onClick={submit} mt={6} textAlign="center">
{" "}
<Button isLoading={voteStatus === "loading"}>Submit</Button>
</Box>
)}
</>
);
}

View File

@ -1,50 +0,0 @@
import { useState } from "react";
import { getVotingRange } from "utils/plus";
import { trpc } from "utils/trpc";
import { useUser } from "../../../hooks/common";
export function usePlusHomePage() {
const [user] = useUser();
const [suggestionsFilter, setSuggestionsFilter] = useState<
number | undefined
>(undefined);
const { data: suggestionsData } = trpc.useQuery(["plus.suggestions"], {
enabled: !getVotingRange().isHappening,
});
const { data: plusStatusData } = trpc.useQuery(["plus.statuses"]);
const { data: votingProgress } = trpc.useQuery(["plus.votingProgress"], {
enabled: getVotingRange().isHappening,
});
const suggestions = suggestionsData ?? [];
return {
plusStatusData: plusStatusData?.find(
(status) => status.user.id === user?.id
),
vouchedPlusStatusData: plusStatusData?.find(
(status) => status.voucher?.id === user?.id
),
suggestionsData: suggestions.filter(
(suggestion) =>
!suggestionsFilter || suggestion.tier === suggestionsFilter
),
suggestionCounts: suggestions.reduce(
(counts, suggestion) => {
const tierString = [null, "ONE", "TWO", "THREE"][
suggestion.tier
] as keyof typeof counts;
counts[tierString]++;
return counts;
},
{ ONE: 0, TWO: 0, THREE: 0 }
),
ownSuggestion: suggestions.find(
(suggestion) => suggestion.suggesterUser.id === user?.id
),
setSuggestionsFilter,
votingProgress,
};
}

View File

@ -1,86 +0,0 @@
import { Text } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
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 { useMyTheme } from "hooks/common";
import Link from "next/link";
import { getProfilePath, getRankingString } from "utils/strings";
import { Top500PlacementsByMonth } from "../service";
interface Props {
placements: Top500PlacementsByMonth;
}
const Top500Table: React.FC<Props> = ({ placements }) => {
const { gray } = useMyTheme();
return (
<>
<Table maxW="50rem">
<TableHead>
<TableRow>
<TableHeader width={4} />
<TableHeader width={4} />
<TableHeader>
<Trans>Name</Trans>
</TableHeader>
<TableHeader>
<Trans>X Power</Trans>
</TableHeader>
<TableHeader>
<Trans>Weapon</Trans>
</TableHeader>
</TableRow>
</TableHead>
<TableBody>
{placements.map((placement) => {
const user = placement.player.user;
return (
<TableRow key={placement.switchAccountId}>
<TableCell color={gray}>
{getRankingString(placement.ranking)}
</TableCell>
<TableCell>
{user && (
<Link
href={getProfilePath({
discordId: user.discordId,
customUrlPath: user.profile?.customUrlPath,
})}
>
<a>
<UserAvatar user={user} isSmall mr="0.5rem" />
</a>
</Link>
)}
</TableCell>
<TableCell>
<MyLink href={`/player/${placement.player.switchAccountId}`}>
{placement.playerName}
</MyLink>
</TableCell>
<TableCell>
<Text fontWeight="bold">{placement.xPower}</Text>
</TableCell>
<TableCell>
<WeaponImage name={placement.weapon} size={32} />
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</>
);
};
export default Top500Table;

View File

@ -1,90 +0,0 @@
import {
Box,
Flex,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
} from "@chakra-ui/react";
import { useLingui } from "@lingui/react";
import WeaponLineChart from "app/xrank/components/WeaponLineChart";
import SubText from "components/common/SubText";
import WeaponImage from "components/common/WeaponImage";
import { useMyTheme } from "hooks/common";
const TrendTier = ({
tier,
weapons,
getDataForChart,
mode,
}: {
tier: { label: string; criteria: number; color: string };
weapons: {
name: string;
count: number;
xPowerAverage: number;
}[];
mode: string;
getDataForChart: (weapon: string) => { count: number }[];
}) => {
const { i18n } = useLingui();
const { gray, secondaryBgColor } = useMyTheme();
if (!weapons.length) return null;
return (
<Flex key={tier.criteria} my={4}>
<Flex
flexDir="column"
w="80px"
minH="100px"
px="10px"
borderRight="5px solid"
borderColor={tier.color}
marginRight="1em"
justifyContent="center"
>
<Box fontSize="2em" fontWeight="bolder">
{tier.label}
</Box>
<Box color={gray}>
{tier.criteria === 0.002 ? ">0%" : `${tier.criteria}%`}
</Box>
</Flex>
<Flex flexDir="row" flex={1} flexWrap="wrap" alignItems="center" py="1em">
{weapons.map((weapon) => (
<Popover key={weapon.name} placement="top-start" isLazy>
<PopoverTrigger>
<Flex m={4} cursor="pointer" flexDir="column" align="center">
<WeaponImage name={weapon.name} size={64} />
<SubText mt={2}>
{weapon.count} / {weapon.xPowerAverage.toFixed(1)}
</SubText>
</Flex>
</PopoverTrigger>
<PopoverContent zIndex={4} p="0.5em" bg={secondaryBgColor}>
<PopoverArrow bg={secondaryBgColor} />
<Flex flexDir="column" alignItems="center">
<Box
as="span"
fontWeight="bolder"
fontSize="1.2em"
mb="0.5em"
textAlign="center"
>
{i18n._(weapon.name)}
<SubText>{i18n._(mode)}</SubText>
</Box>
<WeaponLineChart
getDataForChart={() => getDataForChart(weapon.name)}
/>
</Flex>
</PopoverContent>
</Popover>
))}
</Flex>
</Flex>
);
};
export default TrendTier;

View File

@ -1,26 +0,0 @@
import { useMyTheme } from "hooks/common";
import { Line, LineChart } from "recharts";
interface WeaponLineChartProps {
getDataForChart: () => { count: number }[];
}
const WeaponLineChart: React.FC<WeaponLineChartProps> = ({
getDataForChart,
}) => {
const { themeColorHex } = useMyTheme();
return (
<LineChart width={300} height={100} data={getDataForChart()}>
<Line
type="monotone"
dataKey="count"
stroke={themeColorHex}
strokeWidth={2}
dot={false}
/>
</LineChart>
);
};
export default WeaponLineChart;

View File

@ -1,87 +0,0 @@
import { Radio, RadioGroup, Select, Stack } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { RankedMode } from "@prisma/client";
import Top500Table from "app/xrank/components/Top500Table";
import HeaderBanner from "components/layout/HeaderBanner";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { Top500PlacementsByMonth } from "../service";
import MyHead from "../../../components/common/MyHead";
export interface XSearchPageProps {
placements: Top500PlacementsByMonth;
monthOptions: { label: string; value: string }[];
}
const XSearchPage = ({ placements, monthOptions }: XSearchPageProps) => {
const [variables, setVariables] = useState<{
month: number;
year: number;
mode: RankedMode;
}>({
month: Number(monthOptions[0].value.split(",")[0]),
year: Number(monthOptions[0].value.split(",")[1]),
mode: "SZ" as RankedMode,
});
const router = useRouter();
useEffect(() => {
router.replace(
`/xsearch/${variables.year}/${variables.month}/${variables.mode}`
);
}, [variables]);
//TODO: layout can be persistent between route changes
return (
<>
<MyHead title={t`Top 500 Browser`} />
<Select
value={`${variables.month},${variables.year}`}
onChange={(e) => {
const [month, year] = e.target.value.split(",");
setVariables({
...variables,
month: Number(month),
year: Number(year),
});
}}
mb={4}
maxW={64}
>
{monthOptions.map((monthYear) => (
<option key={monthYear.value} value={monthYear.value}>
{monthYear.label}
</option>
))}
</Select>
<RadioGroup
value={variables.mode}
onChange={(value) =>
setVariables({ ...variables, mode: value as RankedMode })
}
mt={4}
mb={8}
>
<Stack direction="row">
<Radio value="SZ">{t`SZ`}</Radio>
<Radio value="TC">{t`TC`}</Radio>
<Radio value="RM">{t`RM`}</Radio>
<Radio value="CB">{t`CB`}</Radio>
</Stack>
</RadioGroup>
<Top500Table placements={placements} />
</>
);
};
XSearchPage.header = (
<HeaderBanner
icon="xsearch"
title="Top 500 Browser"
subtitle="History of X Rank"
/>
);
export default XSearchPage;

View File

@ -1,137 +0,0 @@
import { Box, Select } from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import ModeSelector from "components/common/ModeSelector";
import HeaderBanner from "components/layout/HeaderBanner";
import { useMyTheme } from "hooks/common";
import { useXTrends } from "../hooks/useXTrends";
import { XTrends } from "../service";
import TrendTier from "./TrendTier";
import MyHead from "../../../components/common/MyHead";
const tiers = [
{
label: "X",
criteria: 6,
color: "purple.700",
},
{
label: "S+",
criteria: 5,
color: "red.700",
},
{
label: "S",
criteria: 4,
color: "red.700",
},
{
label: "A+",
criteria: 3,
color: "orange.700",
},
{
label: "A",
criteria: 2,
color: "orange.700",
},
{
label: "B+",
criteria: 1.5,
color: "yellow.700",
},
{
label: "B",
criteria: 1,
color: "yellow.700",
},
{
label: "C+",
criteria: 0.4,
color: "green.700",
},
{
label: "C",
criteria: 0.002, //1 in 500
color: "green.700",
},
] as const;
export interface XTrendsPageProps {
trends: XTrends;
}
const XTrendsPage = ({ trends }: XTrendsPageProps) => {
const { gray } = useMyTheme();
const {
state,
dispatch,
weaponData,
getDataForChart,
monthOptions,
} = useXTrends(trends);
return (
<>
<MyHead title="Top 500 Trends" />
<Box color={gray} fontSize="sm" mb={8}>
<Trans>
Here you can find X Rank Top 500 usage tier lists. For example for a
weapon to be in the X tier it needs at least 30 placements in that
mode that month. Below the weapon count and X Power average are shown.
</Trans>
</Box>
<Select
value={`${state.month},${state.year}`}
onChange={(e) => {
const [month, year] = e.target.value.split(",");
dispatch({
type: "SET_MONTH_YEAR",
month: Number(month),
year: Number(year),
});
}}
mt={8}
mb={4}
maxW={64}
>
{monthOptions.map((monthYear) => (
<option key={monthYear.value} value={monthYear.value}>
{monthYear.label}
</option>
))}
</Select>
<ModeSelector
mode={state.mode}
setMode={(mode) => dispatch({ type: "SET_MODE", mode })}
/>
{tiers.map((tier, i) => (
<TrendTier
key={tier.label}
tier={tier}
weapons={weaponData.filter((weapon) => {
const targetCount = 500 * (tier.criteria / 100);
const previousTargetCount =
i === 0 ? Infinity : 500 * (tiers[i - 1].criteria / 100);
return (
weapon.count >= targetCount && weapon.count < previousTargetCount
);
})}
getDataForChart={getDataForChart}
mode={state.mode}
/>
))}
</>
);
};
XTrendsPage.header = (
<HeaderBanner
icon="xsearch"
title="Top 500 Trends"
subtitle="What's popular in X Rank now and before"
/>
);
export default XTrendsPage;

View File

@ -1,109 +0,0 @@
import { RankedMode } from "@prisma/client";
import { XTrends } from "app/xrank/service";
import { getMonthOptions } from "pages/xsearch/[[...slug]]";
import { Dispatch, useMemo, useReducer } from "react";
interface XTrendsState {
mode: RankedMode;
month: number;
year: number;
}
type Action =
| {
type: "SET_MODE";
mode: RankedMode;
}
| {
type: "SET_MONTH_YEAR";
month: number;
year: number;
};
export type XTrendsDispatch = Dispatch<Action>;
export function useXTrends(trends: XTrends) {
const getLatestYear = () =>
parseInt(Object.keys(trends).sort((a, b) => parseInt(b) - parseInt(a))[0]);
const getLatestMonth = () =>
parseInt(
Object.keys(trends[getLatestYear()]).sort(
(a, b) => parseInt(b) - parseInt(a)
)[0]
);
const [state, dispatch] = useReducer(
(oldState: XTrendsState, action: Action) => {
switch (action.type) {
case "SET_MODE":
return { ...oldState, mode: action.mode };
case "SET_MONTH_YEAR":
return { ...oldState, month: action.month, year: action.year };
default:
return oldState;
}
},
{
year: getLatestYear(),
month: getLatestMonth(),
mode: "SZ",
}
);
const weapons = trends[state.year][state.month];
const monthOptions = useMemo(() => {
const latestYear = Math.max(
...Object.keys(trends).map((year) => parseInt(year))
);
const latestMonth = Math.max(
...Object.keys(trends[latestYear]).map((month) => parseInt(month))
);
return getMonthOptions(latestMonth, latestYear);
}, []);
const weaponData = Object.entries(weapons)
.reduce(
(
acc: { name: string; count: number; xPowerAverage: number }[],
[weapon, modes]
) => {
const weaponInfo = modes[state.mode];
if (!weaponInfo) return acc;
acc.push({
name: weapon,
count: weaponInfo.count,
xPowerAverage: weaponInfo.xPowerAverage,
});
return acc;
},
[]
)
.sort((a, b) => {
if (a.count !== b.count) return b.count - a.count;
return b.xPowerAverage - a.xPowerAverage;
});
function getDataForChart(weapon: string) {
const counts = monthOptions.map((monthYear) => {
return {
count:
trends[monthYear.year][monthYear.month][weapon]?.[state.mode]
?.count ?? 0,
};
});
counts.reverse();
return counts;
}
return {
state,
dispatch,
weaponData,
getDataForChart,
monthOptions,
};
}

View File

@ -1,102 +0,0 @@
import { Prisma, RankedMode } from "@prisma/client";
import prisma from "prisma/client";
export type XTrends = Prisma.PromiseReturnType<typeof getXTrends>;
const getXTrends = async () => {
const placements = await prisma.xRankPlacement.findMany({
select: { mode: true, month: true, year: true, weapon: true, xPower: true },
});
const result: {
[year: string]: {
[month: string]: {
[weapon: string]: {
SZ?: { xPowerAverage: number; count: number };
TC?: { xPowerAverage: number; count: number };
RM?: { xPowerAverage: number; count: number };
CB?: { xPowerAverage: number; count: number };
};
};
};
} = {};
placements.forEach((placement) => {
if (!result[placement.year]) result[placement.year] = {};
if (!result[placement.year][placement.month]) {
result[placement.year][placement.month] = {};
}
if (!result[placement.year][placement.month][placement.weapon]) {
result[placement.year][placement.month][placement.weapon] = {};
}
const weaponObj = result[placement.year][placement.month][placement.weapon][
placement.mode
] ?? { xPowerAverage: 0, count: 0 };
const previousAverage = weaponObj.xPowerAverage;
const previousCount = weaponObj.count;
weaponObj.xPowerAverage =
(previousAverage * previousCount + placement.xPower) /
(previousCount + 1);
weaponObj.count++;
result[placement.year][placement.month][placement.weapon][
placement.mode
] = weaponObj;
});
return result;
};
export type Top500PlacementsByMonth = Prisma.PromiseReturnType<
typeof getTop500PlacementsByMonth
>;
const getTop500PlacementsByMonth = async ({
month,
year,
mode,
}: {
month: number;
year: number;
mode: RankedMode;
}) => {
return prisma.xRankPlacement.findMany({
where: { month, year, mode },
orderBy: { ranking: "asc" },
select: {
playerName: true,
xPower: true,
ranking: true,
switchAccountId: true,
weapon: true,
player: {
include: {
user: {
select: {
discordId: true,
discordAvatar: true,
discriminator: true,
username: true,
profile: { select: { customUrlPath: true } },
},
},
},
},
},
});
};
export type MostRecentResult = Prisma.PromiseReturnType<
typeof getMostRecentResult
>;
const getMostRecentResult = () => {
return prisma.xRankPlacement.findFirst({
orderBy: [{ year: "desc" }, { month: "desc" }],
});
};
export default { getXTrends, getTop500PlacementsByMonth, getMostRecentResult };

View File

@ -165,7 +165,12 @@ const BuildFilters: React.FC<Props> = ({ filters, dispatch }) => {
</Fragment>
))}
</Grid>
<Flex mt={4} justify="space-between">
<Flex
mt={4}
justify="space-between"
align="center"
flexDir={["column", "row"]}
>
<Select
onChange={(e) =>
dispatch({

View File

@ -0,0 +1,185 @@
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 { useMyTheme, useUser } from "hooks/common";
import Image from "next/image";
import React from "react";
import { FiClock, FiEdit, FiExternalLink } from "react-icons/fi";
import { Events } from "services/calendar";
import { DiscordIcon } from "utils/assets/icons";
import { ADMIN_ID, EVENT_FORMATS, TAGS } from "utils/constants";
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" },
] 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<Events>;
edit: () => void;
}
const EventInfo = ({ event, edit }: EventInfoProps) => {
const { gray, themeColorShade } = useMyTheme();
const poster = event.poster;
const [user] = useUser();
const canEdit = user?.id === poster.id || user?.id === ADMIN_ID;
const imgSrc = eventImage(event.name);
return (
<OutlinedBox
my={4}
py={4}
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} />}
<Heading size="lg">{event.name}</Heading>
{event.tags.length > 0 && (
<Flex flexWrap="wrap" justifyContent="center" mt={3} mb={2}>
{event.tags.map((tag) => {
const tagInfo = TAGS.find((tagObj) => tagObj.code === tag)!;
return (
<Badge key={tag} mx={1} bg={tagInfo.color} color="black">
{tagInfo.name}
</Badge>
);
})}
</Flex>
)}
<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={themeColorShade}
justifySelf="flex-end"
/>
{/* TODO */}
<Box as="time" dateTime={event.date.toISOString()}>
{event.date.toLocaleString("en", {
hour: "numeric",
minute: "numeric",
})}
</Box>
</Flex>
<Flex placeItems="center" mx="auto">
<Box
as={FiExternalLink}
mr="0.5em"
color={themeColorShade}
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={gray} fontSize="small" mb={2}>
{EVENT_FORMATS.find((format) => format.code === event.format)!.name}
</Box>
<Markdown smallHeaders value={event.description} />
</Box>
</Box>
</OutlinedBox>
);
};
export default EventInfo;

View File

@ -26,11 +26,11 @@ import MarkdownTextarea from "components/common/MarkdownTextarea";
import { useMyTheme } from "hooks/common";
import { Controller, useForm } from "react-hook-form";
import { FiTrash } from "react-icons/fi";
import { getToastOptions } from "utils/getToastOptions";
import { EVENT_FORMATS } from "utils/constants";
import { getToastOptions } from "utils/objects";
import { trpc } from "utils/trpc";
import { eventSchema, EVENT_DESCRIPTION_LIMIT } from "utils/validators/event";
import * as z from "zod";
import { EVENT_FORMATS } from "../utils";
import TagsSelector from "./TagsSelector";
export type FormData = z.infer<typeof eventSchema>;

View File

@ -2,7 +2,7 @@ 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";
import { TAGS } from "utils/constants";
interface TagsSelectorProps {
value?: string[];

View File

@ -1,48 +1,38 @@
import { Box, Flex, HStack, StackProps } from "@chakra-ui/react";
import { useLingui } from "@lingui/react";
import { Radio, RadioGroup, RadioGroupProps, Stack } from "@chakra-ui/react";
import { RankedMode } from "@prisma/client";
import ModeImage from "./ModeImage";
import SubText from "./SubText";
import ModeImage from "components/common/ModeImage";
interface Props {
mode: RankedMode;
setMode: (mode: RankedMode) => void;
}
const ALL_MODES = ["SZ", "TC", "RM", "CB"] as const;
const ModeSelector: React.FC<Props & StackProps> = ({
const ModeSelector = ({
mode,
setMode,
...props
}) => {
const { i18n } = useLingui();
}: Props & Omit<RadioGroupProps, "children">) => {
return (
<HStack my={4} {...props}>
{ALL_MODES.map((modeInArr) => (
<Flex key={modeInArr} flexDir="column" alignItems="center">
<Box
style={{
filter: mode === modeInArr ? undefined : "grayscale(100%)",
}}
cursor="pointer"
mb="-6px"
>
<ModeImage
onClick={() => setMode(modeInArr)}
mode={modeInArr}
size={32}
/>
</Box>
{mode === modeInArr ? (
<SubText>{i18n._(mode)}</SubText>
) : (
<Box h={4} />
)}
</Flex>
))}
</HStack>
<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>
);
};

View File

@ -1,17 +0,0 @@
import { Container, ContainerProps } from "@chakra-ui/react";
interface Props {
children: React.ReactNode;
wide?: boolean;
}
const MyContainer: React.FC<Props & ContainerProps> = ({
children,
wide = false,
...props
}) => (
<Container maxW={wide ? "64rem" : "48rem"} {...props}>
{children}
</Container>
);
export default MyContainer;

View File

@ -9,6 +9,7 @@ interface Props {
prefetch?: boolean;
isColored?: boolean;
toNewWindow?: boolean;
noUnderline?: boolean;
}
const MyLink: React.FC<Props> = ({
@ -18,6 +19,7 @@ const MyLink: React.FC<Props> = ({
prefetch = false,
isColored = true,
toNewWindow,
noUnderline,
}) => {
const { themeColorShade } = useMyTheme();
@ -34,7 +36,10 @@ const MyLink: React.FC<Props> = ({
}
return (
<NextLink href={href} prefetch={prefetch ? undefined : false} passHref>
<ChakraLink color={isColored ? themeColorShade : undefined}>
<ChakraLink
className={noUnderline ? "nounderline" : undefined}
color={isColored ? themeColorShade : undefined}
>
{children}
</ChakraLink>
</NextLink>

View File

@ -93,8 +93,6 @@ const MySelect: React.FC<SelectProps> = ({
return undefined;
};
console.log("value", value);
return (
<ReactSelect
className="basic-single"

View File

@ -0,0 +1,108 @@
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 OutlinedBox from "./OutlinedBox";
export default function NewTable({
caption,
headers,
data,
smallAtPx,
}: {
caption?: string;
headers: {
name: string;
dataKey: string;
}[];
data: (Record<string, React.ReactNode> & { id: number })[];
smallAtPx?: string;
}) {
const [isSmall] = useMediaQuery(
smallAtPx ? `(max-width: ${smallAtPx}px)` : "(max-width: 600px)"
);
if (isSmall) {
return (
<>
{data.map((row) => {
return (
<Grid
key={row.id}
border="1px solid"
borderColor="whiteAlpha.300"
rounded="lg"
px={4}
py={2}
mb={4}
templateColumns="1fr 2fr"
gridRowGap={1}
alignItems="center"
>
{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">
{caption && <TableCaption placement="top">{caption}</TableCaption>}
<Thead>
<Tr>
{headers.map(({ name }) => (
<Th key={name}>{name}</Th>
))}
</Tr>
</Thead>
<Tbody>
{data.map((row) => {
return (
<Tr key={row.id}>
{headers.map(({ dataKey }) => {
return <Td key={dataKey}>{row[dataKey]}</Td>;
})}
</Tr>
);
})}
</Tbody>
<Tfoot>
<Tr>
{headers.map(({ name }) => (
<Th key={name}>{name}</Th>
))}
</Tr>
</Tfoot>
</Table>
</OutlinedBox>
);
}

View File

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

View File

@ -13,7 +13,7 @@ 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";
import { UseFreeAgentsDispatch, UseFreeAgentsState } from "hooks/freeagents";
const regionOptions = [
{

View File

@ -21,11 +21,11 @@ import {
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { t, Trans } from "@lingui/macro";
import { PostsData } from "app/freeagents/service";
import MarkdownTextarea from "components/common/MarkdownTextarea";
import { Controller, useForm } from "react-hook-form";
import { FiTrash } from "react-icons/fi";
import { getToastOptions } from "utils/getToastOptions";
import { PostsData } from "services/freeagents";
import { getToastOptions } from "utils/objects";
import { trpc } from "utils/trpc";
import { Unpacked } from "utils/types";
import {

View File

@ -18,10 +18,10 @@ import {
RiPaintLine,
RiSwordLine,
} from "react-icons/ri";
import { getToastOptions } from "utils/getToastOptions";
import { PostsData } from "services/freeagents";
import { getToastOptions } from "utils/objects";
import { trpc } from "utils/trpc";
import { Unpacked } from "utils/types";
import { PostsData } from "../service";
const playstyleToEmoji = {
FRONTLINE: RiSwordLine,
@ -153,11 +153,12 @@ const FreeAgentSection = ({
isOpenByDefault
mt={4}
my={6}
wordBreak="break-word"
>
<Markdown value={post.content} smallHeaders />
</SubTextCollapse>
{post.user.profile?.bio && (
<SubTextCollapse title={t`Bio`} mt={4}>
<SubTextCollapse title={t`Bio`} mt={4} wordBreak="break-word">
<Markdown value={post.user.profile.bio} smallHeaders />
</SubTextCollapse>
)}

View File

@ -2,8 +2,8 @@ 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 { PostsData } from "services/freeagents";
import { Unpacked } from "utils/types";
import { PostsData } from "../service";
const MatchesInfo = ({
matchedPosts,

View File

@ -1,34 +0,0 @@
import { Box, Skeleton } from "@chakra-ui/react";
const event = {
bg: "yellow",
logoUrl: "https://abload.de/img/screenshot2021-01-29agyjt6.png",
staleAfter: "2020-03-08", // always +1 from when the event ends?,
link: "https://twitter.com/NineWholeGrains/status/1353110481721040897",
content:
"Grand Graining Grounds is happening on February 6th and 7th. Starting prize pool of $2500! ",
} as const;
const Banner = () => {
if (new Date().getTime() > new Date(event.staleAfter).getTime()) return null;
return (
<a href={event.link}>
<Skeleton borderRadius="md" isLoaded={true}>
<Box
bg={event.bg}
color="black"
p={2}
mt={4}
fontWeight="bold"
textAlign="center"
borderRadius="md"
>
{event.content}
</Box>
</Skeleton>
</a>
);
};
export default Banner;

View File

@ -0,0 +1,27 @@
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", color: "black" }
: { bg: "black", color: "white" }
}
borderRadius={isMobile ? "50%" : "0"}
size={isMobile ? "lg" : "sm"}
mx={2}
display={isMobile ? "flex" : ["none", null, null, "flex"]}
/>
);
};
export default ColorModeSwitcher;

View File

@ -0,0 +1,93 @@
import {
Box,
Button,
Flex,
IconButton,
useColorMode,
useMediaQuery,
} from "@chakra-ui/react";
import { useActiveNavItem, useMyTheme, useUser } from "hooks/common";
import { signIn, signOut } from "next-auth/client";
import Image from "next/image";
import Link from "next/link";
import { FiLogIn, FiLogOut, FiMenu } from "react-icons/fi";
import ColorModeSwitcher from "./ColorModeSwitcher";
import LanguageSwitcher from "./LanguageSwitcher";
const Header = ({ openNav }: { openNav: () => void }) => {
const [isSmall] = useMediaQuery("(max-width: 400px)");
const [user] = useUser();
const { secondaryBgColor } = useMyTheme();
const { colorMode } = useColorMode();
const activeNavItem = useActiveNavItem();
return (
<Box
display="flex"
as="header"
justifySelf="center"
fontWeight="bold"
letterSpacing={1}
justifyContent="space-between"
alignItems="center"
mb={1}
bg={secondaryBgColor}
>
<Flex justify="space-between" align="center" width="6rem" px={2}>
<ColorModeSwitcher /> <LanguageSwitcher />
<IconButton
aria-label="Open menu"
variant="ghost"
color="current"
onClick={openNav}
icon={<FiMenu />}
_hover={
colorMode === "dark"
? { bg: "white", color: "black" }
: { bg: "black", color: "white" }
}
borderRadius="0"
size="sm"
display={["flex", null, null, "none"]}
/>
</Flex>
<Flex justify="center" align="center" fontSize="sm" color="white.300">
<Link href="/">{isSmall ? "s.ink" : "sendou.ink"}</Link>{" "}
{activeNavItem && (
<>
<Box mx={1}>-</Box>{" "}
<Image
src={`/layout/${activeNavItem.code}.png`}
height={24}
width={24}
priority
/>
<Box ml={1}>{activeNavItem.name}</Box>
</>
)}
</Flex>
<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", color: "black" }
: { bg: "black", color: "white" }
}
borderRadius="0"
size="xs"
px={2}
height="30px"
>
{user ? "Log out" : "Log in"}
</Button>
</Box>
);
};
export default Header;

View File

@ -1,45 +0,0 @@
import { Box, Flex } from "@chakra-ui/layout";
import { useMyTheme } from "hooks/common";
import Image from "next/image";
const HeaderBanner = ({
icon,
title,
subtitle,
}: {
icon: string;
title: string;
subtitle: string;
}) => {
const { secondaryBgColor, gray } = useMyTheme();
return (
<Flex
bg={secondaryBgColor}
flexWrap="wrap"
boxShadow="lg"
justify={["flex-start", null, "center"]}
mt={4}
mb={2}
h={12}
>
<Box mt="-1rem" ml={[3, null, 0]}>
<Image src={`/layout/${icon}.png`} height={80} width={80} priority />
</Box>
<Flex align="center" mb={6}>
<Box mx={2} fontWeight="bold" fontSize={["1.25rem", null, "1rem"]}>
{title}
</Box>
<Box
mt="1px"
display={["none", null, "block"]}
fontSize="sm"
color={gray}
>
{subtitle}
</Box>
</Flex>
</Flex>
);
};
export default HeaderBanner;

View File

@ -5,6 +5,7 @@ import {
MenuItemOption,
MenuList,
MenuOptionGroup,
useColorMode,
} from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { useLingui } from "@lingui/react";
@ -30,7 +31,8 @@ export const languages = [
{ code: "he", name: "עברית" },
] as const;
export const LanguageSelector = () => {
export const LanguageSwitcher = ({ isMobile }: { isMobile?: boolean }) => {
const { colorMode } = useColorMode();
const { i18n } = useLingui();
const { secondaryBgColor, textColor } = useMyTheme();
@ -38,12 +40,19 @@ export const LanguageSelector = () => {
<Menu>
<MenuButton
as={IconButton}
data-cy="color-mode-toggle"
aria-label="Switch language"
variant="ghost"
fontSize="20px"
color="current"
icon={<FiGlobe />}
isRound
color={textColor}
_hover={
colorMode === "dark"
? { bg: "white", color: "black" }
: { bg: "black", color: "white" }
}
borderRadius={isMobile ? "50%" : "0"}
size={isMobile ? "lg" : "sm"}
display={isMobile ? "flex" : ["none", null, null, "flex"]}
/>
<MenuList bg={secondaryBgColor} color={textColor}>
<MenuOptionGroup
@ -64,3 +73,5 @@ export const LanguageSelector = () => {
</Menu>
);
};
export default LanguageSwitcher;

View File

@ -0,0 +1,42 @@
import { CloseButton } from "@chakra-ui/close-button";
import { Flex } from "@chakra-ui/layout";
import {
Drawer,
DrawerBody,
DrawerContent,
DrawerOverlay,
} from "@chakra-ui/modal";
import { useMyTheme } from "hooks/common";
import ColorModeSwitcher from "./ColorModeSwitcher";
import LanguageSwitcher from "./LanguageSwitcher";
import NavButtons from "./NavButtons";
const MobileNav = ({
isOpen,
onClose,
}: {
isOpen: boolean;
onClose: () => void;
}) => {
const { bgColor } = useMyTheme();
return (
<Drawer isOpen={isOpen} onClose={onClose} size="full">
<DrawerOverlay>
<DrawerContent bg={bgColor}>
<DrawerBody>
<Flex mb={4} align="center" justifyContent="space-between">
<Flex>
<ColorModeSwitcher isMobile />
<LanguageSwitcher isMobile />
</Flex>
<CloseButton onClick={onClose} />
</Flex>
<NavButtons onButtonClick={onClose} />
</DrawerBody>
</DrawerContent>
</DrawerOverlay>
</Drawer>
);
};
export default MobileNav;

64
components/layout/Nav.tsx Normal file
View File

@ -0,0 +1,64 @@
import { Box, Flex } from "@chakra-ui/react";
import MyLink from "components/common/MyLink";
import { useActiveNavItem, useMyTheme } from "hooks/common";
import Image from "next/image";
import { useRouter } from "next/router";
import { navItems } from "utils/constants";
import UserItem from "./UserItem";
const Nav = () => {
const router = useRouter();
const navItem = useActiveNavItem();
const { bgColor, secondaryBgColor, themeColorHex } = useMyTheme();
if (router.pathname === "/") return null;
return (
<Box
as="nav"
flexShrink={0}
position="sticky"
alignSelf="flex-start"
display={["none", null, null, "block"]}
>
{navItems.map(({ code, name }) => {
const isActive =
code === "u" ? router.pathname === "/u" : navItem?.code === code;
return (
<Box
key={code}
borderLeft="4px solid"
borderColor={isActive ? themeColorHex : 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: secondaryBgColor,
}}
>
<Image
src={`/layout/${code}.png`}
height={32}
width={32}
priority
/>
<Box ml={2}>{name}</Box>
</Flex>
</MyLink>
</Box>
);
})}
<UserItem />
</Box>
);
};
export default Nav;

View File

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

View File

@ -1,128 +0,0 @@
import {
Avatar,
Box,
Button,
Flex,
Grid,
IconButton,
Menu,
MenuButton,
MenuGroup,
MenuItem,
MenuList,
useColorMode,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import { useUser } from "hooks/common";
import { signIn, signOut } from "next-auth/client";
import Link from "next/link";
import { FaHeart } from "react-icons/fa";
import { FiMoon, FiSun } from "react-icons/fi";
import { DiscordIcon } from "utils/assets/icons";
import { LanguageSelector } from "./LanguageSelector";
const TopNav = () => {
const { colorMode, toggleColorMode } = useColorMode();
const UserItem = () => {
const [user, loading] = useUser();
if (loading) return <Box />;
if (!user) {
return (
<Button
onClick={() => signIn("discord")}
leftIcon={<DiscordIcon />}
variant="ghost"
size="sm"
>
<Trans>Log in via Discord</Trans>
</Button>
);
}
return (
<Menu>
<MenuButton>
<Avatar
src={
user.discordAvatar
? `https://cdn.discordapp.com/avatars/${user.discordId}/${user.discordAvatar}.png`
: undefined
}
name={user.username}
size="sm"
m={1}
cursor="pointer"
/>
</MenuButton>
<MenuList>
<MenuGroup title={`${user.username}#${user.discriminator}`}>
<Link href={`/u/${user.discordId}`}>
<MenuItem>
<Trans>Profile</Trans>
</MenuItem>
</Link>
<MenuItem onClick={() => signOut()}>
<Trans>Log out</Trans>
</MenuItem>
</MenuGroup>
</MenuList>
</Menu>
);
};
return (
<Grid
as="header"
templateColumns={["1fr 1fr", null, "1fr 1fr 1fr"]}
w="100%"
alignItems="center"
justifyContent="space-between"
p={1}
>
<Flex alignItems="center">
<IconButton
data-cy="color-mode-toggle"
aria-label={`Switch to ${
colorMode === "light" ? "dark" : "light"
} mode`}
variant="ghost"
color="current"
fontSize="20px"
onClick={toggleColorMode}
icon={colorMode === "light" ? <FiSun /> : <FiMoon />}
mr="5px"
isRound
/>
<LanguageSelector />
<a href="https://patreon.com/sendou">
<Button
size="xs"
bg={colorMode === "dark" ? "white" : "black"}
leftIcon={<FaHeart />}
ml={4}
>
Sponsor
</Button>
</a>
</Flex>
<Box
justifySelf="center"
color="gray.600"
fontWeight="bold"
letterSpacing={1}
display={["none", null, "block"]}
>
{" "}
<Link href="/">sendou.ink </Link>
</Box>
<Box justifySelf="flex-end">
<UserItem />
</Box>
</Grid>
);
};
export default TopNav;

View File

@ -0,0 +1,46 @@
import { Box, Flex } from "@chakra-ui/layout";
import MyLink from "components/common/MyLink";
import UserAvatar from "components/common/UserAvatar";
import { useActiveNavItem, useMyTheme, useUser } from "hooks/common";
import { useRouter } from "next/router";
export const UserItem = () => {
const { secondaryBgColor, themeColorHex, bgColor } = useMyTheme();
const [user] = useUser();
const navItem = useActiveNavItem();
const router = useRouter();
if (!user) return null;
const isActive = navItem?.code === "u" && router.pathname !== "/u";
return (
<Box
borderLeft="4px solid"
borderColor={isActive ? themeColorHex : 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: secondaryBgColor,
}}
>
<>
<UserAvatar user={user} size="sm" />
<Box ml={2}>My Page</Box>
</>
</Flex>
</MyLink>
</Box>
);
};
export default UserItem;

View File

@ -1,13 +1,12 @@
import { Flex, useToast } from "@chakra-ui/react";
import { useToast } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import MyContainer from "components/common/MyContainer";
import { useRouter } from "next/router";
import { useState } from "react";
import { SWRConfig } from "swr";
import Banner from "./Banner";
import Footer from "./Footer";
import IconNavBar from "./IconNavBar";
import TopNav from "./TopNav";
import Header from "./Header";
import MobileNav from "./MobileNav";
import Nav from "./Nav";
const DATE_KEYS = ["createdAt", "updatedAt"];
@ -20,21 +19,12 @@ const WIDE = [
"plus/history",
];
const Layout = ({
children,
header,
}: {
header: React.ReactNode;
children: React.ReactNode;
}) => {
const Layout = ({ children }: { children: React.ReactNode }) => {
const [navIsOpen, setNavIsOpen] = useState(false);
const router = useRouter();
const [errors, setErrors] = useState(new Set<string>());
const toast = useToast();
const isWide = WIDE.some((widePage) =>
router.pathname.startsWith("/" + widePage)
);
return (
<SWRConfig
value={{
@ -64,16 +54,11 @@ const Layout = ({
},
}}
>
<TopNav />
<IconNavBar />
<Banner />
{header}
<Flex flexDirection="column" minH="100vh" pt={4}>
<MyContainer wide={isWide} mt={2}>
{children}
</MyContainer>
<Footer />
</Flex>
<Header openNav={() => setNavIsOpen(true)} />
<Nav />
<MobileNav isOpen={navIsOpen} onClose={() => setNavIsOpen(false)} />
<main>{children}</main>
<Footer />
</SWRConfig>
);
};

View File

@ -35,7 +35,7 @@ const DraggableImageAdder: React.FC<DraggableImageAdderProps> = ({
bg={bgColor}
textAlign="center"
width="119px"
ml="950px"
ml="850px"
>
<strong style={{ cursor: "move" }}>
<div

View File

@ -128,7 +128,9 @@ const StageSelector: React.FC<StageSelectorProps> = ({
<ModeSelector
mode={currentBackground.mode as RankedMode}
setMode={changeMode}
justify="center"
mt={2}
display="flex"
justifyContent="center"
/>
<RadioGroup value={currentBackground.view} onChange={changeView}>
<HStack justifyContent="center" spacing={6}>

View File

@ -12,7 +12,7 @@ import { useUser } from "hooks/common";
import { useLadderTeams } from "hooks/play";
import { useEffect, useState } from "react";
import { FiCheck, FiTrash } from "react-icons/fi";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { sendData } from "utils/postData";
interface Props {}

View File

@ -10,14 +10,14 @@ import {
} from "@chakra-ui/react";
import { zodResolver } from "@hookform/resolvers/zod";
import { Trans } from "@lingui/macro";
import { Suggestions } from "app/plus/service";
import MyLink from "components/common/MyLink";
import SubText from "components/common/SubText";
import UserAvatar from "components/common/UserAvatar";
import { useMyTheme } from "hooks/common";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { getToastOptions } from "utils/getToastOptions";
import { Suggestions } from "services/plus";
import { getToastOptions } from "utils/objects";
import { getVotingRange } from "utils/plus";
import { getFullUsername } from "utils/strings";
import { trpc } from "utils/trpc";

View File

@ -19,7 +19,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import UserSelector from "components/common/UserSelector";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { trpc } from "utils/trpc";
import {
suggestionFullSchema,

View File

@ -18,7 +18,7 @@ import { zodResolver } from "@hookform/resolvers/zod";
import UserSelector from "components/common/UserSelector";
import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { trpc } from "utils/trpc";
import { vouchSchema } from "utils/validators/vouch";
import * as z from "zod";

View File

@ -21,7 +21,7 @@ import { GetTeamData } from "prisma/queries/getTeam";
import { Fragment, useEffect, useState } from "react";
import { FiCheck, FiTrash, FiUsers } from "react-icons/fi";
import { mutate } from "swr";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { sendData } from "utils/postData";
interface Props {

View File

@ -26,7 +26,7 @@ import { useState } from "react";
import { useForm } from "react-hook-form";
import { FaTwitter } from "react-icons/fa";
import { mutate } from "swr";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { sendData } from "utils/postData";
import {
teamSchema,

View File

@ -31,8 +31,8 @@ import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FiTrash } from "react-icons/fi";
import { mutate } from "swr";
import { getToastOptions } from "utils/getToastOptions";
import { weapons } from "utils/lists/weapons";
import { getToastOptions } from "utils/objects";
import { sendData } from "utils/postData";
import { Unpacked } from "utils/types";
import {

View File

@ -28,7 +28,7 @@ import { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { FaGamepad, FaTwitch, FaTwitter, FaYoutube } from "react-icons/fa";
import { mutate } from "swr";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { sendData } from "utils/postData";
import {
profileSchemaFrontend,

View File

@ -0,0 +1,40 @@
import MyLink from "components/common/MyLink";
import NewTable from "components/common/NewTable";
import WeaponImage from "components/common/WeaponImage";
import { Top500PlacementsByMonth } from "services/xsearch";
import { getRankingString } from "utils/strings";
interface Props {
placements: Top500PlacementsByMonth;
}
const Top500Table: React.FC<Props> = ({ placements }) => {
return (
<NewTable
headers={[
{ name: "rank", dataKey: "ranking" },
{ name: "name", dataKey: "name" },
{ name: "x power", dataKey: "xPower" },
{ name: "weapon", dataKey: "weapon" },
]}
data={placements.map((placement) => {
return {
id: placement.ranking,
name: (
<MyLink
href={`/player/${placement.player.switchAccountId}`}
isColored={false}
>
{placement.playerName}
</MyLink>
),
ranking: getRankingString(placement.ranking),
xPower: placement.xPower,
weapon: <WeaponImage size={32} name={placement.weapon} />,
};
})}
/>
);
};
export default Top500Table;

View File

@ -0,0 +1,84 @@
import { Box, Flex } from "@chakra-ui/react";
import OutlinedBox from "components/common/OutlinedBox";
import SubText from "components/common/SubText";
import WeaponImage from "components/common/WeaponImage";
import { useMyTheme } from "hooks/common";
import { IoChevronDownOutline, IoChevronUpOutline } from "react-icons/io5";
import { XTrends } from "services/xtrends";
const TrendTier = ({
tier,
data,
}: {
tier: { label: string; criteria: number; color: string };
data: XTrends["SZ"];
}) => {
const { gray } = useMyTheme();
if (!data.length) return null;
return (
<OutlinedBox key={tier.label} mb={4}>
<Flex>
<Flex
flexDir="column"
w="80px"
minH="100px"
px="10px"
borderRight="5px solid"
borderColor={tier.color}
marginRight="1em"
justifyContent="center"
>
<Box fontSize="2em" fontWeight="bolder">
{tier.label}
</Box>
<Box color={gray}>
{tier.criteria === 0.002 ? ">0%" : `${tier.criteria}%`}
</Box>
</Flex>
<Flex
flexDir="row"
flex={1}
flexWrap="wrap"
alignItems="center"
py="1em"
>
{data.map((weaponObj) => (
<Flex key={weaponObj.weapon} m={4} flexDir="column" align="center">
<WeaponImage name={weaponObj.weapon} size={64} />
<SubText display="flex" alignItems="center" mt={2}>
{weaponObj.count} / {weaponObj.averageXp} /{" "}
{
{
UP: (
<Box
fontSize="lg"
color="green.500"
as={IoChevronUpOutline}
/>
),
SAME: (
<Box fontSize="3xl" color="gray.500" mb={1}>
-
</Box>
),
DOWN: (
<Box
fontSize="lg"
color="red.500"
as={IoChevronDownOutline}
/>
),
}[weaponObj.progress]
}
</SubText>
</Flex>
))}
</Flex>
</Flex>
</OutlinedBox>
);
};
export default TrendTier;

View File

@ -3,8 +3,10 @@
import { useColorMode } from "@chakra-ui/react";
import { User as PrismaUser } from "@prisma/client";
import { useSession } from "next-auth/client";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import { theme } from "theme";
import { navItems } from "utils/constants";
export function useDebounce(value: string, delay: number = 500) {
const [debouncedValue, setDebouncedValue] = useState(value);
@ -31,3 +33,17 @@ export const useUser = (): [PrismaUser | undefined | null, boolean] => {
// @ts-ignore
return useSession();
};
export const useActiveNavItem = () => {
const [navItem, setNavItem] = useState<
undefined | { code: string; name: string }
>(undefined);
const router = useRouter();
const firstPath = router.pathname.split("/")[1];
useEffect(() => {
setNavItem(navItems.find(({ code }) => code === firstPath));
}, [firstPath]);
return navItem;
};

View File

@ -1,12 +1,59 @@
import { useToast } from "@chakra-ui/toast";
import { useUser } from "hooks/common";
import { useState } from "react";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { getVotingRange } from "utils/plus";
import { trpc } from "utils/trpc";
import { Unpacked } from "utils/types";
import { votesSchema } from "utils/validators/votes";
import * as z from "zod";
export function usePlusHomePage() {
const [user] = useUser();
const [suggestionsFilter, setSuggestionsFilter] = useState<
number | undefined
>(undefined);
const { data: suggestionsData } = trpc.useQuery(["plus.suggestions"], {
enabled: !getVotingRange().isHappening,
});
const { data: plusStatusData } = trpc.useQuery(["plus.statuses"]);
const { data: votingProgress } = trpc.useQuery(["plus.votingProgress"], {
enabled: getVotingRange().isHappening,
});
const suggestions = suggestionsData ?? [];
return {
plusStatusData: plusStatusData?.find(
(status) => status.user.id === user?.id
),
vouchedPlusStatusData: plusStatusData?.find(
(status) => status.voucher?.id === user?.id
),
suggestionsData: suggestions.filter(
(suggestion) =>
!suggestionsFilter || suggestion.tier === suggestionsFilter
),
suggestionCounts: suggestions.reduce(
(counts, suggestion) => {
const tierString = [null, "ONE", "TWO", "THREE"][
suggestion.tier
] as keyof typeof counts;
counts[tierString]++;
return counts;
},
{ ONE: 0, TWO: 0, THREE: 0 }
),
ownSuggestion: suggestions.find(
(suggestion) => suggestion.suggesterUser.id === user?.id
),
setSuggestionsFilter,
votingProgress,
};
}
export default function usePlusVoting() {
const [currentIndex, setCurrentIndex] = useState(0);
const [votes, setVotes] = useState<z.infer<typeof votesSchema>>([]);

View File

@ -197,10 +197,7 @@ const MyApp = ({ Component, pageProps }: AppProps) => {
<NextAuthProvider session={pageProps.session}>
<ChakraProvider theme={extendedTheme}>
<I18nProvider i18n={i18n}>
<Layout
// @ts-expect-error
header={Component.header}
>
<Layout>
<QueryClientProvider client={queryClient}>
<Hydrate
state={trpc.useDehydratedState(pageProps.dehydratedState)}

View File

@ -3,7 +3,7 @@ import SubText from "components/common/SubText";
import { useUser } from "hooks/common";
import { useState } from "react";
import { ADMIN_DISCORD_ID } from "utils/constants";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { sendData } from "utils/postData";
import { trpc } from "utils/trpc";

View File

@ -1,18 +1,9 @@
import {
Badge,
Box,
Button,
Flex,
FormLabel,
Switch,
Wrap,
} from "@chakra-ui/react";
import { Badge, Box, Button, FormLabel, Switch, Wrap } from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import BuildStats from "components/analyzer/BuildStats";
import EditableBuilds from "components/analyzer/EditableBuilds";
import { ViewSlotsAbilities } from "components/builds/ViewSlots";
import WeaponSelector from "components/common/WeaponSelector";
import HeaderBanner from "components/layout/HeaderBanner";
import { useMyTheme } from "hooks/common";
import useAbilityEffects from "hooks/useAbilityEffects";
import { useRouter } from "next/router";
@ -75,14 +66,9 @@ const BuildAnalyzerPage = () => {
return (
<>
<MyHead title={t`Build Analyzer`} />
<Flex justifyContent="space-between">
<Badge>
<Trans>Patch {CURRENT_PATCH}</Trans>
</Badge>
<Box color={gray} fontSize="0.75em">
<Trans>AP = Ability Point = Mains * 10 + Subs * 3</Trans>
</Box>
</Flex>
<Badge>
<Trans>Patch {CURRENT_PATCH}</Trans>
</Badge>
<Box my={4} maxW={80} mx="auto">
<WeaponSelector value={weapon} setValue={setWeapon} isMulti={false} />
@ -218,12 +204,4 @@ const BuildAnalyzerPage = () => {
}
};
BuildAnalyzerPage.header = (
<HeaderBanner
icon="analyzer"
title="Build Analyzer"
subtitle="Discover what your builds are actually doing"
/>
);
export default BuildAnalyzerPage;

View File

@ -1,9 +1,9 @@
import * as trpc from "@trpc/server";
import { inferAsyncReturnType, inferProcedureOutput } from "@trpc/server";
import * as trpcNext from "@trpc/server/adapters/next";
import calendarApi from "app/calendar/api";
import freeAgentsApi from "app/freeagents/api";
import plusApi from "app/plus/api";
import calendarApi from "api/calendar";
import freeAgentsApi from "api/freeagents";
import plusApi from "api/plus";
import superjson from "superjson";
import { getMySession } from "utils/api";
import { trpc as trcpReactQuery } from "utils/trpc";

View File

@ -8,7 +8,6 @@ import MyInfiniteScroller from "components/common/MyInfiniteScroller";
import MyLink from "components/common/MyLink";
import WeaponImage from "components/common/WeaponImage";
import WeaponSelector from "components/common/WeaponSelector";
import HeaderBanner from "components/layout/HeaderBanner";
import { useBuildsByWeapon } from "hooks/builds";
import { useMyTheme, useUser } from "hooks/common";
import { useState } from "react";
@ -132,7 +131,8 @@ const BuildsPage = () => {
onShowAllByUser={() =>
dispatch({ type: "EXPAND_USER", id: buildArray[0].userId })
}
m={2}
my={2}
mx={[0, 2]}
/>
)
)}
@ -141,12 +141,4 @@ const BuildsPage = () => {
);
};
BuildsPage.header = (
<HeaderBanner
icon="builds"
title="Builds"
subtitle="Find what people are running on that weapon you picked up"
/>
);
export default BuildsPage;

View File

@ -1,6 +1,138 @@
import CalendarPage from "app/calendar/components/CalendarPage";
import HeaderBanner from "components/layout/HeaderBanner";
import { Button } from "@chakra-ui/button";
import { Input, InputGroup, InputLeftElement } from "@chakra-ui/input";
import { Box } from "@chakra-ui/layout";
import { t, Trans } from "@lingui/macro";
import EventInfo from "components/calendar/EventInfo";
import { EventModal, FormData } from "components/calendar/EventModal";
import MyHead from "components/common/MyHead";
import SubText from "components/common/SubText";
import { useMyTheme } from "hooks/common";
import { ssr } from "pages/api/trpc/[trpc]";
import { Fragment, useState } from "react";
import { FiSearch } from "react-icons/fi";
import { trpc } from "utils/trpc";
const CalendarPage = () => {
const { gray } = useMyTheme();
const events = trpc.useQuery(["calendar.events"], { enabled: false });
const [eventToEdit, setEventToEdit] = useState<
boolean | (FormData & { id: number })
>(false);
const [filter, setFilter] = useState("");
let lastPrintedDate: [number, number, Date] | null = null;
return (
<>
<MyHead title={t`Calendar`} />
{eventToEdit && (
<EventModal
onClose={() => setEventToEdit(false)}
event={typeof eventToEdit === "boolean" ? undefined : eventToEdit}
refetchQuery={events.refetch}
/>
)}
<div>
<Button
size="sm"
onClick={() => setEventToEdit(true)}
data-cy="add-event-button"
>
<Trans>Add event</Trans>
</Button>
</div>
<InputGroup my={8} maxW="24rem" mx="auto">
<InputLeftElement
pointerEvents="none"
children={<FiSearch color={gray} />}
/>
<Input value={filter} onChange={(e) => setFilter(e.target.value)} />
</InputGroup>
{(events.data ?? [])
.filter((event) =>
event.name.toLowerCase().includes(filter.toLowerCase().trim())
)
.map((event, i) => {
const printDateHeader =
!lastPrintedDate ||
lastPrintedDate[0] !== event.date.getDate() ||
lastPrintedDate[1] !== event.date.getMonth();
if (printDateHeader) {
lastPrintedDate = [
event.date.getDate(),
event.date.getMonth(),
event.date,
];
}
const now = new Date();
const isToday =
lastPrintedDate![2].getDate() === now.getDate() &&
lastPrintedDate![2].getMonth() === now.getMonth();
return (
<Fragment key={event.id}>
{printDateHeader && (
<Box mt={i === 0 ? 0 : 10}>
<SubText>
{/* TODO */}
{event.date.toLocaleDateString("en", {
month: "long",
day: "numeric",
weekday: "long",
})}{" "}
{isToday && <Trans>(Today)</Trans>}
</SubText>
</Box>
)}
<div>
<EventInfo
event={event}
edit={() =>
setEventToEdit({
...event,
date: event.date.toISOString(),
// TODO: remove this if later other event types than tournament are allowed
// currently in the validator we accept the properties as if you can only submit
// tournaments but database is prepared to accept other kind of events
// this makes TS freak out a bit
discordInviteUrl: event.discordInviteUrl!,
tags: event.tags as any,
format: event.format as any,
})
}
/>
</div>
</Fragment>
);
})}
<Box color={gray} mt={10}>
All events listed in your local time:{" "}
{Intl.DateTimeFormat().resolvedOptions().timeZone}
</Box>
{/* <RightSidebar>
<div>
<Button
size="sm"
onClick={() => setEventToEdit(true)}
data-cy="add-event-button"
>
<Trans>Add event</Trans>
</Button>
</div>
<InputGroup mt={8} maxW="24rem" mx="auto">
<InputLeftElement
pointerEvents="none"
children={<FiSearch color={gray} />}
/>
<Input value={filter} onChange={(e) => setFilter(e.target.value)} />
</InputGroup>
</RightSidebar> */}
</>
);
};
export const getStaticProps = async () => {
await ssr.prefetchQuery("calendar.events");
@ -13,13 +145,4 @@ export const getStaticProps = async () => {
};
};
// @ts-expect-error
CalendarPage.header = (
<HeaderBanner
icon="calendar"
title="Calendar"
subtitle="Upcoming tournaments and other events."
/>
);
export default CalendarPage;

View File

@ -1,7 +1,174 @@
import FreeAgentsPage from "app/freeagents/components/FreeAgentsPage";
import HeaderBanner from "components/layout/HeaderBanner";
import {
Alert,
AlertDescription,
AlertIcon,
AlertTitle,
Box,
Button,
Flex,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import FAFilters from "components/freeagents/FAFilters";
import FAModal from "components/freeagents/FAModal";
import FreeAgentSection from "components/freeagents/FreeAgentSection";
import MatchesInfo from "components/freeagents/MatchesInfo";
import { useMyTheme, useUser } from "hooks/common";
import { useFreeAgents } from "hooks/freeagents";
import { useRouter } from "next/router";
import { useEffect, useRef, useState } from "react";
import { mutate } from "swr";
import { sendData } from "utils/postData";
import { ssr } from "./api/trpc/[trpc]";
const FreeAgentsPage = () => {
const {
postsData,
refetchPosts,
likesData,
isLoading,
usersPost,
matchedPosts,
allPostsCount,
state,
dispatch,
} = useFreeAgents();
const [user] = useUser();
const router = useRouter();
const [postIdToScrollTo, setPostIdToScrollTo] = useState<undefined | number>(
undefined
);
const { gray } = useMyTheme();
const [sending, setSending] = useState(false);
const postRef = useRef<HTMLDivElement>(null);
const [modalIsOpen, setModalIsOpen] = useState(false);
useEffect(() => {
if (!postRef.current) return;
postRef.current.scrollIntoView();
}, [postRef.current]);
const dateThreeWeeksAgo = new Date();
dateThreeWeeksAgo.setDate(dateThreeWeeksAgo.getDate() - 7 * 3);
const onPostRefresh = async () => {
setSending(true);
const success = await sendData("PUT", "/api/freeagents", {
canVC: usersPost!.canVC,
playstyles: usersPost!.playstyles,
content: usersPost!.content,
});
setSending(false);
if (!success) return;
mutate("/api/freeagents");
};
return (
<>
{modalIsOpen && (
<FAModal
post={usersPost}
onClose={() => setModalIsOpen(false)}
refetchQuery={refetchPosts}
/>
)}
{user && (
<Button size="sm" onClick={() => setModalIsOpen(true)}>
{usersPost ? (
<Trans>Edit free agent post</Trans>
) : (
<Trans>New free agent post</Trans>
)}
</Button>
)}
{usersPost &&
usersPost.updatedAt.getTime() < dateThreeWeeksAgo.getTime() && (
<Alert
status="warning"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
mx="auto"
mt={6}
>
<AlertIcon boxSize="40px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="lg">
<Trans>Your free agent post is about to expire</Trans>
</AlertTitle>
<AlertDescription maxWidth="sm">
<Trans>
Free agent posts that haven't been updated in over a month will
be hidden. Please press the button below if you are still a free
agent.
</Trans>
<Box>
<Button
mt={4}
variant="outline"
onClick={onPostRefresh}
isLoading={sending}
>
<Trans>I'm still a free agent</Trans>
</Button>
</Box>
</AlertDescription>
</Alert>
)}
{usersPost && likesData ? (
<MatchesInfo
matchedPosts={matchedPosts}
focusOnMatch={(id) => setPostIdToScrollTo(id)}
/>
) : null}
{!isLoading && <FAFilters state={state} dispatch={dispatch} />}
{allPostsCount > 0 && (
<Flex align="center" fontSize="small" color={gray} mt={4}>
Showing {postsData.length} posts out of {allPostsCount}{" "}
<Button
onClick={() => dispatch({ type: "RESET_FILTERS" })}
visibility={
postsData.length === allPostsCount ? "hidden" : "visible"
}
ml={2}
size="sm"
variant="ghost"
>
Reset filters
</Button>
</Flex>
)}
{postsData.map((post) => (
<FreeAgentSection
key={post.id}
post={post}
isLiked={!!likesData?.likedPostIds.includes(post.id)}
canLike={
!!user && post.user.discordId !== user.discordId && !!usersPost
}
postRef={post.id === getIdToScrollTo() ? postRef : undefined}
showXp={state.xp}
showPlusServerMembership={state.plusServer}
/>
))}
</>
);
function getIdToScrollTo() {
if (postIdToScrollTo) return postIdToScrollTo;
return Number.isNaN(parseInt(router.query.id as any))
? undefined
: parseInt(router.query.id as any);
}
};
export const getStaticProps = async () => {
await ssr.prefetchQuery("freeAgents.posts");
@ -13,13 +180,4 @@ export const getStaticProps = async () => {
};
};
// @ts-expect-error
FreeAgentsPage.header = (
<HeaderBanner
icon="freeagents"
title="Free Agents"
subtitle="Meet your next teammates"
/>
);
export default FreeAgentsPage;

View File

@ -1,88 +1,36 @@
import { Box, Flex, Heading, useColorMode } from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import { Trans } from "@lingui/macro";
import MyLink from "components/common/MyLink";
import { useMyTheme } from "hooks/common";
import NavButtons from "components/layout/NavButtons";
import { useMyTheme, useUser } from "hooks/common";
import Image from "next/image";
import Link from "next/link";
import { useState } from "react";
const Home = () => {
const { gray } = useMyTheme();
const HomePage = () => {
const { bgColor, secondaryBgColor, gray } = useMyTheme();
const { colorMode } = useColorMode();
const [rgb, setRgb] = useState(false);
const [user] = useUser();
return (
<>
{/* <SEO
title="sendou.ink"
appendTitle={false}
description="Competitive Splatoon hub featuring several tools and resources."
imageSrc="https://sendou.ink/seo/home.png"
/> */}
<Box textAlign="center">
<Heading
color={gray}
letterSpacing="0.25rem"
fontSize="xl"
textAlign="center"
>
Competitive Splatoon Hub
</Heading>
<Flex justify="center">
<Image
className={rgb ? "rgb" : undefined}
className="rgb"
src={`/layout/posterGirl_${colorMode}.png`}
width={481}
height={400}
priority
onLoad={() => setRgb(true)}
/>
<Heading size="2xl">Sendou.ink</Heading>
<Box fontWeight="semibold" letterSpacing="wide" color={gray}>
<Trans>Competitive Splatoon Hub</Trans>
</Box>
</Box>
<>
<PageInfoSection location="xsearch" title={t`Top 500 Browser`}>
<Trans>
Conveniently browse Top 500 results. There are also tier lists to
see which weapons are reigning in each mode.
</Trans>
</PageInfoSection>
<PageInfoSection location="sr" title={t`Salmon Run`}>
<Trans>
Get into Overfishing and climb up the leaderboards. Guides included
to get you started.
</Trans>
</PageInfoSection>
<PageInfoSection location="builds" title={t`Builds`}>
<Trans>
View builds by some of the best players in the world and submit your
own.
</Trans>
</PageInfoSection>
<PageInfoSection location="analyzer" title={t`Build Analyzer`}>
<Trans>
What exactly is the effect of your builds? Guess no more.
</Trans>
</PageInfoSection>
<PageInfoSection location="calendar" title={t`Calendar`}>
<Trans>View all the upcoming events in the community.</Trans>
</PageInfoSection>
<PageInfoSection location="u" title={t`User Search`}>
<Trans>
You can make your own page. Use this tool to find other users'
pages.
</Trans>
</PageInfoSection>
<PageInfoSection location="freeagents" title={t`Free Agents`}>
<Trans>Find your next teammates.</Trans>
</PageInfoSection>
<PageInfoSection location="t" title={t`Teams`}>
<Trans>Make a page for your team.</Trans>
</PageInfoSection>
<PageInfoSection location="plans" title={t`Map Planner`}>
<Trans>Make your battle plans by drawing on over 100 maps.</Trans>
</PageInfoSection>
<PageInfoSection location="links" title={t`External links`}>
<Trans>
There are a lot of useful Splatoon resouces. This page collects them
together.
</Trans>
</PageInfoSection>
</>
<Box textAlign="center" mt={24} fontSize="sm" color={gray}>
</Flex>
<Box fontSize="sm" textAlign="right">
<Trans>
All art by{" "}
<MyLink href="https://twitter.com/borzoic_" isExternal>
@ -90,34 +38,26 @@ const Home = () => {
</MyLink>
</Trans>
</Box>
<NavButtons />
<Box textAlign="center" mt={6}>
The goal of sendou.ink is to provide useful tools and resources for
Splatoon players. It's an{" "}
<MyLink isExternal href="https://github.com/Sendouc/sendou.ink">
open source project
</MyLink>{" "}
by{" "}
<MyLink isExternal href="https://sendou.cc/">
Sendou
</MyLink>{" "}
and <MyLink href="/about">contributors</MyLink>. To explore what you can
do on the site you can check out a{" "}
<MyLink isExternal href="https://www.youtube.com/watch?v=kQbvez9QnHc">
tour video made by Chara
</MyLink>
.
</Box>
</>
);
function PageInfoSection({
location,
title,
children,
}: {
location: string;
title: string;
children: React.ReactNode;
}) {
return (
<Flex mt={12} alignItems="center" flexDir="column" textAlign="center">
<Link href={`/${location}`}>
<a>
<Image src={`/layout/${location}.png`} width={128} height={128} />
</a>
</Link>
<Heading mb={2}>
<Link href={`/${location}`}>
<a>{title}</a>
</Link>
</Heading>
{children}
</Flex>
);
}
};
export default Home;
export default HomePage;

View File

@ -1,7 +1,6 @@
import { Box, Heading, Link, Stack } from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import MyHead from "components/common/MyHead";
import HeaderBanner from "components/layout/HeaderBanner";
import { useMyTheme } from "hooks/common";
import links from "utils/data/links.json";
@ -46,12 +45,4 @@ const LinksPage = () => {
);
};
LinksPage.header = (
<HeaderBanner
icon="links"
title="Links"
subtitle="All the useful resources around the community in one place"
/>
);
export default LinksPage;

View File

@ -24,9 +24,8 @@ import { t, Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import { RankedMode } from "@prisma/client";
import ModeImage from "components/common/ModeImage";
import SubText from "components/common/SubText";
import HeaderBanner from "components/layout/HeaderBanner";
import MultipleModeSelector from "components/common/MultipleModeSelector";
import SubText from "components/common/SubText";
import { useRouter } from "next/router";
import { ChangeEvent, Fragment, useEffect, useState } from "react";
import { FiCheck, FiFilter, FiRotateCw } from "react-icons/fi";
@ -44,12 +43,12 @@ const MapsGeneratorPage = () => {
>(getInitialStages());
const [generationMode, setGenerationMode] = useState<
"EQUAL" | "SZ_EVERY_OTHER" | "CUSTOM_ORDER"
>("EQUAL");
>("SZ_EVERY_OTHER");
const [maplist, setMaplist] = useState("");
const [modes, setModes] = useState<
{ label: string; value: number; data?: string }[]
>([]);
const [count, setCount] = useState(9);
const [count, setCount] = useState(25);
const [editing, setEditing] = useState(false);
const [copied, setCopied] = useState<null | "URL" | "LIST">(null);
@ -359,12 +358,12 @@ const MapsGeneratorPage = () => {
value={generationMode}
>
<Stack direction="row" mb={4}>
<Radio value="EQUAL">
<Trans>All modes equally</Trans>
</Radio>
<Radio value="SZ_EVERY_OTHER">
<Trans>SZ every other</Trans>
</Radio>
<Radio value="EQUAL">
<Trans>All modes equally</Trans>
</Radio>
<Radio value="CUSTOM_ORDER">
<Trans>Custom order</Trans>
</Radio>
@ -441,12 +440,4 @@ const MapsGeneratorPage = () => {
}
};
MapsGeneratorPage.header = (
<HeaderBanner
icon="plans"
title="Maplist Generator"
subtitle="Get a list of maps to play in a scrim"
/>
);
export default MapsGeneratorPage;

View File

@ -1,6 +1,5 @@
import { Button, ButtonGroup, Flex } from "@chakra-ui/react";
import { t, Trans } from "@lingui/macro";
import HeaderBanner from "components/layout/HeaderBanner";
import DraggableImageAdder from "components/plans/DraggableImageAdder";
import DraggableToolsSelector from "components/plans/DraggableToolsSelector";
import StageSelector from "components/plans/StageSelector";
@ -374,12 +373,4 @@ const MapPlannerPage = () => {
);
};
MapPlannerPage.header = (
<HeaderBanner
icon="plans"
title="Map Planner"
subtitle="Draw on 200 different maps and make your plans"
/>
);
export default MapPlannerPage;

View File

@ -13,7 +13,7 @@ import { getAllLadderRegisteredTeamsForMatches } from "prisma/queries/getAllLadd
import { getLadderDay, GetLadderDayData } from "prisma/queries/getLadderDay";
import { Fragment } from "react";
import { shuffleArray } from "utils/arrays";
import { getLadderRounds } from "utils/playFunctions";
import { getLadderRounds } from "utils/play";
interface Props {
ladderDay: GetLadderDayData;

View File

@ -1,9 +1,130 @@
import PlusVotingHistoryPage, {
PlusVotingHistoryPageProps,
} from "app/plus/components/PlusVotingHistoryPage";
import plusService from "app/plus/service";
import HeaderBanner from "components/layout/HeaderBanner";
import { Box } from "@chakra-ui/layout";
import { Select } from "@chakra-ui/select";
import { PlusRegion } from "@prisma/client";
import MyHead from "components/common/MyHead";
import NewTable from "components/common/NewTable";
import { useMyTheme } from "hooks/common";
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { Fragment } from "react";
import plusService, {
DistinctSummaryMonths,
VotingSummariesByMonthAndTier,
} from "services/plus";
import { getFullUsername, getLocalizedMonthYearString } from "utils/strings";
export interface PlusVotingHistoryPageProps {
summaries: VotingSummariesByMonthAndTier;
monthsWithData: DistinctSummaryMonths;
}
const PlusVotingHistoryPage = ({
summaries,
monthsWithData,
}: PlusVotingHistoryPageProps) => {
const { gray } = useMyTheme();
const router = useRouter();
return (
<>
<MyHead title="Voting History" />
<Select
size="sm"
borderRadius="lg"
onChange={(e) => {
router.replace(`/plus/history/${e.target.value}`);
}}
maxW={64}
mb={4}
data-cy="tier-selector"
>
{monthsWithData.map(({ month, year, tier }) => (
<option
key={`${month}${year}${tier}`}
value={`${tier}/${year}/${month}`}
>
+{tier} - {getLocalizedMonthYearString(month, year, "en")}
</option>
))}
</Select>
<NewTable
smallAtPx="700"
headers={[
{ name: "name", dataKey: "name" },
{ name: "percentage", dataKey: "percentage" },
{ name: "count (na)", dataKey: "countNa" },
{ name: "count (eu)", dataKey: "countEu" },
{ name: "region", dataKey: "region" },
]}
data={summaries.map((summary) => {
const getCount = (region: PlusRegion, counts: number[]) => {
if (region === summary.regionForVoting) return counts;
return counts.slice(1, 3);
};
return {
id: summary.user.id,
name: (
<Box>
{getFullUsername(summary.user)}{" "}
{summary.wasSuggested && (
<Box as="span" fontWeight="bold" color="theme.500">
(S)
</Box>
)}
{summary.wasVouched && (
<Box as="span" fontWeight="bold" color="theme.500">
(V)
</Box>
)}
</Box>
),
percentage: (
<Box color={summary.percentage >= 50 ? "green.500" : "red.500"}>
{summary.percentage}%
</Box>
),
countNa: getCount("NA", summary.countsNA).map((count, i, arr) => (
<Fragment key={i}>
<Box
as="span"
color={i + 1 <= arr.length / 2 ? "red.500" : "green.500"}
>
{count}
</Box>
{i !== arr.length - 1 && <>/</>}
</Fragment>
)),
countEu: getCount("EU", summary.countsEU).map((count, i, arr) => (
<Fragment key={i}>
<Box
as="span"
color={i + 1 <= arr.length / 2 ? "red.500" : "green.500"}
>
{count}
</Box>
{i !== arr.length - 1 && <>/</>}
</Fragment>
)),
region: summary.regionForVoting,
};
})}
/>
<Box mt={6} fontSize="sm" color={gray}>
<Box as="span" fontWeight="bold" color="theme.500">
(S)
</Box>{" "}
= was a suggestion
<Box as="span" fontWeight="bold" color="theme.500" ml={4}>
(V)
</Box>{" "}
= was a vouch
</Box>
</>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
return {
@ -49,13 +170,4 @@ export const getStaticProps: GetStaticProps<PlusVotingHistoryPageProps> = async
};
};
// @ts-expect-error
PlusVotingHistoryPage.header = (
<HeaderBanner
icon="plus"
title="Voting History"
subtitle="+1, +2 and +3 voting history since early 2020"
/>
);
export default PlusVotingHistoryPage;

View File

@ -1,6 +1,232 @@
import PlusHomePage from "app/plus/components/PlusHomePage";
import HeaderBanner from "components/layout/HeaderBanner";
import { Box, Center, Divider, Flex, Heading, Stack } from "@chakra-ui/layout";
import {
Alert,
AlertDescription,
AlertTitle,
Progress,
Radio,
RadioGroup,
} from "@chakra-ui/react";
import { Trans } from "@lingui/macro";
import MyHead from "components/common/MyHead";
import SubText from "components/common/SubText";
import Suggestion from "components/plus/Suggestion";
import SuggestionModal from "components/plus/SuggestionModal";
import VotingInfoHeader from "components/plus/VotingInfoHeader";
import VouchModal from "components/plus/VouchModal";
import { useUser } from "hooks/common";
import { usePlusHomePage } from "hooks/plus";
import { ssr } from "pages/api/trpc/[trpc]";
import { Fragment } from "react";
import { getVotingRange } from "utils/plus";
import { getFullUsername } from "utils/strings";
const PlusHomePage = () => {
const [user] = useUser();
const {
plusStatusData,
suggestionsData,
ownSuggestion,
suggestionCounts,
setSuggestionsFilter,
vouchedPlusStatusData,
votingProgress,
} = usePlusHomePage();
if (!plusStatusData?.membershipTier) {
return (
<Box>
<Box fontSize="sm" mb={4}>
<VotingInfoHeader isMember={!!plusStatusData?.membershipTier} />
</Box>
<Heading size="md">Suggested players this month:</Heading>
<Flex flexWrap="wrap">
{suggestionsData
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
.map((suggestion) => (
<Box
key={suggestion.tier + "+" + suggestion.suggestedUser.id}
m={1}
>
{getFullUsername(suggestion.suggestedUser)} (+{suggestion.tier})
</Box>
))}
</Flex>
</Box>
);
}
return (
<>
<MyHead title="Plus Server" />
<Box fontSize="sm" mb={4}>
<VotingInfoHeader isMember={!!plusStatusData?.membershipTier} />
</Box>
{votingProgress && (
<Box textAlign="center">
<SubText>
+1 ({votingProgress[1].voted}/{votingProgress[1].totalVoterCount})
</SubText>
<Progress
value={
(votingProgress[1].voted / votingProgress[1].totalVoterCount) *
100
}
size="xs"
colorScheme="pink"
mb={6}
/>
<SubText>
+2 ({votingProgress[2].voted}/{votingProgress[2].totalVoterCount})
</SubText>
<Progress
value={
(votingProgress[2].voted / votingProgress[2].totalVoterCount) *
100
}
size="xs"
colorScheme="blue"
mb={6}
/>
<SubText>
+3 ({votingProgress[3].voted}/{votingProgress[3].totalVoterCount})
</SubText>
<Progress
value={
(votingProgress[3].voted / votingProgress[3].totalVoterCount) *
100
}
size="xs"
colorScheme="yellow"
mb={6}
/>
</Box>
)}
{!getVotingRange().isHappening && (
<>
{plusStatusData &&
plusStatusData.membershipTier &&
!ownSuggestion && (
<SuggestionModal
userPlusMembershipTier={plusStatusData.membershipTier}
/>
)}
{plusStatusData &&
plusStatusData.canVouchFor &&
!plusStatusData.canVouchAgainAfter && (
<VouchModal canVouchFor={plusStatusData.canVouchFor} />
)}
</>
)}
{plusStatusData &&
(plusStatusData.canVouchAgainAfter ||
plusStatusData.voucher ||
vouchedPlusStatusData) && (
<Alert
status="success"
variant="subtle"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
mt={2}
mb={6}
rounded="lg"
>
<AlertDescription maxWidth="sm">
<AlertTitle mb={1} fontSize="lg">
Vouching status
</AlertTitle>
{plusStatusData?.canVouchAgainAfter && (
<Box>
Can vouch again after:{" "}
{plusStatusData.canVouchAgainAfter.toLocaleDateString()}
</Box>
)}
{plusStatusData?.voucher && (
<Box>
Vouched for <b>+{plusStatusData.vouchTier}</b> by{" "}
{getFullUsername(plusStatusData.voucher)}
</Box>
)}
{vouchedPlusStatusData && (
<Box>
Vouched {getFullUsername(vouchedPlusStatusData.user)} to{" "}
<b>+{vouchedPlusStatusData.vouchTier}</b>
</Box>
)}
</AlertDescription>
</Alert>
)}
<Center mt={2}>
<RadioGroup
defaultValue="ALL"
onChange={(value) => {
const tier = [null, "ONE", "TWO", "THREE"].indexOf(value as any);
setSuggestionsFilter(tier === -1 ? undefined : tier);
}}
>
<Stack spacing={4} direction={["column", "row"]}>
<Radio value="ALL">
<Trans>
All (
{suggestionCounts.ONE +
suggestionCounts.TWO +
suggestionCounts.THREE}
)
</Trans>
</Radio>
<Radio value="ONE">
<Flex align="center">
<SubText mr={2}>+1</SubText> ({suggestionCounts.ONE})
</Flex>
</Radio>
<Radio value="TWO">
<Flex align="center">
<SubText mr={2}>+2</SubText> ({suggestionCounts.TWO})
</Flex>
</Radio>
<Radio value="THREE" data-cy="plus-three-radio">
<Flex align="center">
<SubText mr={2}>+3</SubText> ({suggestionCounts.THREE})
</Flex>
</Radio>
</Stack>
</RadioGroup>
</Center>
{suggestionCounts.ONE + suggestionCounts.TWO + suggestionCounts.THREE ===
0 ? (
<Box mt={4}>No suggestions yet for this month</Box>
) : (
<>
{suggestionsData.map((suggestion, i) => {
const canSuggest = () => {
if (!plusStatusData?.membershipTier) return false;
if (plusStatusData.membershipTier > suggestion.tier) return false;
if (suggestion.suggesterUser.id === user?.id) return false;
if (
suggestion.resuggestions?.some(
(suggestion) => suggestion.suggesterUser.id === user?.id
)
)
return false;
return true;
};
return (
<Fragment
key={suggestion.suggestedUser.id + "-" + suggestion.tier}
>
<Suggestion suggestion={suggestion} canSuggest={canSuggest()} />
{i < suggestionsData.length - 1 && <Divider />}
</Fragment>
);
})}
</>
)}
</>
);
};
export const getStaticProps = async () => {
await Promise.all([
@ -16,13 +242,4 @@ export const getStaticProps = async () => {
};
};
// @ts-expect-error
PlusHomePage.header = (
<HeaderBanner
icon="plus"
title="Plus Server"
subtitle="View all the suggested players for this month"
/>
);
export default PlusHomePage;

View File

@ -1,3 +1,177 @@
import PlusVotingPage from "app/plus/components/PlusVotingPage";
import { Alert, AlertIcon } from "@chakra-ui/alert";
import { Button } from "@chakra-ui/button";
import { Box, Flex, Grid, Heading, HStack } from "@chakra-ui/layout";
import { Progress } from "@chakra-ui/progress";
import Markdown from "components/common/Markdown";
import SubText from "components/common/SubText";
import UserAvatar from "components/common/UserAvatar";
import { ChangeVoteButtons } from "components/plus/ChangeVoteButtons";
import { PlusVotingButton } from "components/plus/PlusVotingButton";
import usePlusVoting from "hooks/plus";
import { useRouter } from "next/router";
import { Fragment, useEffect } from "react";
import { getVotingRange } from "utils/plus";
import { getFullUsername } from "utils/strings";
export default PlusVotingPage;
const progressBarColor = ["theme", "pink", "blue", "yellow"];
export default function PlusVotingPage() {
const router = useRouter();
const {
isLoading,
shouldRedirect,
plusStatus,
currentUser,
handleVote,
progress,
previousUser,
goBack,
submit,
voteStatus,
votedUsers,
editVote,
} = usePlusVoting();
useEffect(() => {
if (shouldRedirect) router.push("/404");
}, [shouldRedirect]);
if (isLoading || !plusStatus?.membershipTier) return null;
if (votedUsers)
return (
<>
<Alert status="success" variant="subtle" rounded="lg">
<AlertIcon />
Votes succesfully recorded. Voting ends{" "}
{getVotingRange().endDate.toLocaleString()}.
</Alert>
<Grid
mt={6}
justify="center"
templateColumns={["1fr 1fr", "2fr 0.75fr 1fr 1fr"]}
gridRowGap={5}
gridColumnGap="0.5rem"
mx="auto"
maxW="500px"
>
{votedUsers.map((votedUser) => {
return (
<Fragment key={votedUser.userId}>
<Flex align="center">
<UserAvatar user={votedUser} size="sm" mr={4} />{" "}
{getFullUsername(votedUser)}
</Flex>
<ChangeVoteButtons
score={votedUser.score}
isSameRegion={votedUser.region === plusStatus.region}
editVote={(score) =>
editVote({ userId: votedUser.userId, score })
}
/>
</Fragment>
);
})}
</Grid>
</>
);
return (
<>
<Progress
value={progress}
size="xs"
colorScheme={progressBarColor[plusStatus.membershipTier]}
mb={6}
/>
{previousUser ? (
<Box textAlign="center" mb={6}>
<UserAvatar user={previousUser} size="sm" />
<Box my={2} fontSize="sm">
{getFullUsername(previousUser)}
</Box>
<Button
borderRadius="50%"
height={10}
width={10}
variant="outline"
colorScheme={previousUser.score < 0 ? "red" : "theme"}
onClick={goBack}
>
{previousUser.score > 0 ? "+" : ""}
{previousUser.score}
</Button>
</Box>
) : (
<Flex align="center" justify="center" height="6.8rem">
<Heading>+{plusStatus.membershipTier} Voting</Heading>
</Flex>
)}
{currentUser && (
<>
<Box mt={6} textAlign="center">
<UserAvatar user={currentUser} size="2xl" mx="auto" />
<Box fontSize="2rem" fontWeight="bold" mt={2}>
{getFullUsername(currentUser)}
</Box>
</Box>
<HStack justify="center" spacing={4} mt={2}>
{currentUser.region === plusStatus.region && (
<PlusVotingButton
number={-2}
onClick={() =>
handleVote({ userId: currentUser.userId, score: -2 })
}
/>
)}
<PlusVotingButton
number={-1}
onClick={() =>
handleVote({ userId: currentUser.userId, score: -1 })
}
/>
<PlusVotingButton
number={1}
onClick={() =>
handleVote({ userId: currentUser.userId, score: 1 })
}
/>
{currentUser.region === plusStatus.region && (
<PlusVotingButton
number={2}
onClick={() =>
handleVote({ userId: currentUser.userId, score: 2 })
}
/>
)}
</HStack>
{currentUser.suggestions && (
<Box mt={5}>
<SubText>Suggestions</SubText>
{currentUser.suggestions.map((suggestion) => {
return (
<Box key={suggestion.suggesterUser.id} mt={4} fontSize="sm">
"{suggestion.description}" -{" "}
{getFullUsername(suggestion.suggesterUser)}
</Box>
);
})}
</Box>
)}
{currentUser.bio && (
<Box mt={4}>
<SubText mb={4}>Bio</SubText>
<Markdown value={currentUser.bio} smallHeaders />
</Box>
)}
</>
)}
{previousUser && !currentUser && (
<Box onClick={submit} mt={6} textAlign="center">
{" "}
<Button isLoading={voteStatus === "loading"}>Submit</Button>
</Box>
)}
</>
);
}

View File

@ -6,7 +6,6 @@ import {
} from "@chakra-ui/react";
import Markdown from "components/common/Markdown";
import SubText from "components/common/SubText";
import HeaderBanner from "components/layout/HeaderBanner";
import fs from "fs";
import { GetStaticPaths, GetStaticProps } from "next";
import { join } from "path";
@ -85,8 +84,4 @@ export const getStaticPaths: GetStaticPaths = async () => {
};
};
SalmonRunGuidePage.header = (
<HeaderBanner icon="sr" title="Salmon Run" subtitle="Learn how to overfish" />
);
export default SalmonRunGuidePage;

View File

@ -27,14 +27,13 @@ import {
} from "components/common/Table";
import UserAvatar from "components/common/UserAvatar";
import WeaponImage from "components/common/WeaponImage";
import HeaderBanner from "components/layout/HeaderBanner";
import { useSalmonRunRecords, WeaponsFilter } from "hooks/sr";
import Image from "next/image";
import Link from "next/link";
import { salmonRunStages } from "utils/lists/stages";
import { getRankingString } from "utils/strings";
import { salmonRunCategoryToNatural } from "./new";
import MyHead from "../../../components/common/MyHead";
import { salmonRunCategoryToNatural } from "./new";
const SalmonRunLeaderboardsPage = ({}) => {
const { i18n } = useLingui();
@ -252,12 +251,4 @@ const SalmonRunLeaderboardsPage = ({}) => {
);
};
SalmonRunLeaderboardsPage.header = (
<HeaderBanner
icon="sr"
title="Salmon Run"
subtitle="Overfishing leaderboards"
/>
);
export default SalmonRunLeaderboardsPage;

View File

@ -17,7 +17,6 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { t, Trans } from "@lingui/macro";
import { useLingui } from "@lingui/react";
import UserSelector from "components/common/UserSelector";
import HeaderBanner from "components/layout/HeaderBanner";
import RotationSelector from "components/sr/RotationSelector";
import { useUser } from "hooks/common";
import Image from "next/image";
@ -214,12 +213,4 @@ const AddRecordModal = () => {
);
};
AddRecordModal.header = (
<HeaderBanner
icon="sr"
title="Salmon Run"
subtitle="Overfishing leaderboards"
/>
);
export default AddRecordModal;

View File

@ -121,7 +121,7 @@ const SalmonRunPlayerPage = (props: Props) => {
)}
<Flex mt={2}>
{record.links.map((link) => (
<LinkButton link={link} />
<LinkButton key={link} link={link} />
))}
</Flex>
</Box>

View File

@ -1,13 +1,47 @@
html,
body {
margin: 0;
padding: 0;
}
header, footer {
grid-column-end: span 3;
}
main {
grid-column-start: 2;
width: 100%;
overflow: auto;
font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica, Arial,
sans-serif, Apple Color Emoji, Segoe UI Emoji;
font-size: 16px;
line-height: 1.5;
max-width: 64rem;
margin-inline-end: auto;
margin-inline-start: auto;
}
nav {
grid-row-start: 2;
position: sticky;
top: 0;
}
aside {
grid-column-start: 3;
}
header {
width: 100%;
}
#__next {
display: grid;
row-gap: 1.5rem;
column-gap: 1.5rem;
grid-template-rows: 37px 1fr 100px;
grid-template-columns: 175px 1fr 175px;
min-height: 100vh;
}
@media (max-width: 1200px) {
#__next {
grid-template-columns: 175px 1fr 0px;
}
}
@media (max-width: 991px) {
#__next {
grid-template-columns: 0px 1fr 0px;
}
}
.rgb {
@ -83,3 +117,7 @@ For Next.JS image
.rounded {
border-radius: 50%;
}
a.nounderline {
text-decoration: none !important;
}

View File

@ -32,7 +32,7 @@ import { Fragment, useEffect, useState } from "react";
import { FaTwitter } from "react-icons/fa";
import { FiEdit } from "react-icons/fi";
import useSWR, { mutate } from "swr";
import { getToastOptions } from "utils/getToastOptions";
import { getToastOptions } from "utils/objects";
import { sendData } from "utils/postData";
import MyHead from "../../components/common/MyHead";

View File

@ -17,7 +17,6 @@ import MyLink from "components/common/MyLink";
import SubText from "components/common/SubText";
import SubTextCollapse from "components/common/SubTextCollapse";
import TwitterAvatar from "components/common/TwitterAvatar";
import HeaderBanner from "components/layout/HeaderBanner";
import CreateNewTeamModal from "components/t/CreateNewTeamModal";
import { countries } from "countries-list";
import { useMyTheme, useUser } from "hooks/common";
@ -180,12 +179,4 @@ export const getStaticProps: GetStaticProps<Props> = async () => {
};
};
TeamsPage.header = (
<HeaderBanner
icon="t"
title="Teams"
subtitle="Because Splatoon is a team game after all"
/>
);
export default TeamsPage;

View File

@ -4,7 +4,6 @@ import { useLingui } from "@lingui/react";
import { Build, LeagueType, RankedMode } from "@prisma/client";
import BuildCard from "components/builds/BuildCard";
import Markdown from "components/common/Markdown";
import MyContainer from "components/common/MyContainer";
import MyInfiniteScroller from "components/common/MyInfiniteScroller";
import AvatarWithInfo from "components/u/AvatarWithInfo";
import BuildModal from "components/u/BuildModal";
@ -122,9 +121,7 @@ const ProfilePage = (props: Props) => {
{user.profile?.bio && user.profile?.bio.trim().length > 0 && (
<>
<Divider my={6} />
<MyContainer>
<Markdown value={user.profile.bio} smallHeaders />
</MyContainer>
<Markdown value={user.profile.bio} smallHeaders />
</>
)}
{buildCount > 0 && (
@ -135,9 +132,9 @@ const ProfilePage = (props: Props) => {
onChange={(e) =>
setWeapon(e.target.value === "ALL" ? null : e.target.value)
}
mx="auto"
maxWidth={80}
size="lg"
maxWidth={64}
size="sm"
rounded="lg"
>
<option value="ALL">
{t`All weapons`} ({buildCount})

View File

@ -5,10 +5,10 @@ import {
InputGroup,
InputLeftElement,
} from "@chakra-ui/react";
import { t } from "@lingui/macro";
import Flag from "components/common/Flag";
import MyLink from "components/common/MyLink";
import UserAvatar from "components/common/UserAvatar";
import HeaderBanner from "components/layout/HeaderBanner";
import { useDebounce, useMyTheme } from "hooks/common";
import { GetStaticProps } from "next";
import Head from "next/head";
@ -20,7 +20,6 @@ import { FaTwitter } from "react-icons/fa";
import { FiSearch } from "react-icons/fi";
import { setSearchParams } from "utils/setSearchParams";
import { Unpacked } from "utils/types";
import { t } from "@lingui/macro";
import MyHead from "../../components/common/MyHead";
interface Props {
@ -143,12 +142,4 @@ export const getStaticProps: GetStaticProps<Props> = async () => {
return { props: { users: await getAllUsers() }, revalidate: 3600 };
};
UserSearchPage.header = (
<HeaderBanner
icon="u"
title="Users"
subtitle="Find an user's page by their username or Twitter"
/>
);
export default UserSearchPage;

View File

@ -1,11 +1,79 @@
import { Flex, Select } from "@chakra-ui/react";
import { t } from "@lingui/macro";
import { RankedMode } from "@prisma/client";
import XSearchPage, {
XSearchPageProps,
} from "app/xrank/components/XSearchPage";
import xRankService from "app/xrank/service";
import ModeSelector from "components/common/ModeSelector";
import MyHead from "components/common/MyHead";
import Top500Table from "components/xsearch/Top500Table";
import { GetStaticPaths, GetStaticProps } from "next";
import { useRouter } from "next/router";
import { useEffect, useState } from "react";
import xRankService, { Top500PlacementsByMonth } from "services/xsearch";
import { getLocalizedMonthYearString } from "utils/strings";
export interface XSearchPageProps {
placements: Top500PlacementsByMonth;
monthOptions: { label: string; value: string }[];
}
const XSearchPage = ({ placements, monthOptions }: XSearchPageProps) => {
const [variables, setVariables] = useState<{
month: number;
year: number;
mode: RankedMode;
}>({
month: Number(monthOptions[0].value.split(",")[0]),
year: Number(monthOptions[0].value.split(",")[1]),
mode: "SZ" as RankedMode,
});
const router = useRouter();
useEffect(() => {
router.replace(
`/xsearch/${variables.year}/${variables.month}/${variables.mode}`
);
}, [variables]);
//TODO: layout can be persistent between route changes
return (
<>
<MyHead title={t`Top 500 Browser`} />
<Flex flexDir={["column", null, "row"]} justify="space-between">
<Select
value={`${variables.month},${variables.year}`}
onChange={(e) => {
const [month, year] = e.target.value.split(",");
setVariables({
...variables,
month: Number(month),
year: Number(year),
});
}}
mb={4}
maxW={64}
size="sm"
rounded="lg"
mx={["auto", null, "0"]}
>
{monthOptions.map((monthYear) => (
<option key={monthYear.value} value={monthYear.value}>
{monthYear.label}
</option>
))}
</Select>
<ModeSelector
mode={variables.mode}
setMode={(mode) => setVariables({ ...variables, mode })}
mx={["auto", null, "0"]}
mb={[4, null, 0]}
/>
</Flex>
<Top500Table placements={placements} />
</>
);
};
export const getStaticPaths: GetStaticPaths = async () => {
const mostRecentResult = await xRankService.getMostRecentResult();

View File

@ -1,8 +1,58 @@
import { XTrendsPageProps } from "app/xrank/components/XTrendsPage";
import xRankService from "app/xrank/service";
import { RankedMode } from ".prisma/client";
import { Box, Flex } from "@chakra-ui/react";
import ModeSelector from "components/common/ModeSelector";
import MyHead from "components/common/MyHead";
import TrendTier from "components/xtrends/TrendTier";
import { useMyTheme } from "hooks/common";
import { GetStaticProps } from "next";
import { useState } from "react";
import xRankService, { XTrends } from "services/xtrends";
import { xTrendsTiers } from "utils/constants";
export { default as default } from "app/xrank/components/XTrendsPage";
export interface XTrendsPageProps {
trends: XTrends;
}
const XTrendsPage = ({ trends }: XTrendsPageProps) => {
const { gray } = useMyTheme();
const [mode, setMode] = useState<RankedMode>("SZ");
return (
<>
<MyHead title="Top 500 Trends" />
<Flex flexDir={["column", null, "row"]} justify="space-between">
<ModeSelector
mode={mode}
setMode={setMode}
mx={["auto", null, "0"]}
mb={[4, null, 0]}
/>
</Flex>
{xTrendsTiers.map((tier, i) => (
<TrendTier
key={tier.label}
tier={tier}
data={trends[mode].filter((weapon, _, arr) => {
const targetCount = 1500 * (tier.criteria / 100);
const previousTargetCount =
i === 0 ? Infinity : 1500 * (xTrendsTiers[i - 1].criteria / 100);
return (
weapon.count >= targetCount && weapon.count < previousTargetCount
);
})}
/>
))}
<Box color={gray} fontSize="sm">
Weapons are ordered based on their appearance in the Top 500 of X Rank
in the last three months. Average X Power per weapon is also shown.
Progress icon describes the change with the latest month.
</Box>
</>
);
};
export default XTrendsPage;
export const getStaticProps: GetStaticProps<XTrendsPageProps> = async () => {
const trends = await xRankService.getXTrends();

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

BIN
public/layout/maps.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

BIN
public/layout/xtrends.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

View File

@ -1,11 +1,10 @@
import { Prisma } from "@prisma/client";
import { httpError } from "@trpc/server";
import prisma from "prisma/client";
import { ADMIN_ID } from "utils/constants";
import { ADMIN_ID, TAGS } from "utils/constants";
import { userBasicSelection } from "utils/prisma";
import { eventSchema } from "utils/validators/event";
import * as z from "zod";
import { TAGS } from "./utils";
export type Events = Prisma.PromiseReturnType<typeof events>;

56
services/xsearch.ts Normal file
View File

@ -0,0 +1,56 @@
import { Prisma, RankedMode } from "@prisma/client";
import prisma from "prisma/client";
export type Top500PlacementsByMonth = Prisma.PromiseReturnType<
typeof getTop500PlacementsByMonth
>;
const getTop500PlacementsByMonth = async ({
month,
year,
mode,
}: {
month: number;
year: number;
mode: RankedMode;
}) => {
return prisma.xRankPlacement.findMany({
where: { month, year, mode },
orderBy: { ranking: "asc" },
select: {
playerName: true,
xPower: true,
ranking: true,
switchAccountId: true,
weapon: true,
player: {
include: {
user: {
select: {
discordId: true,
discordAvatar: true,
discriminator: true,
username: true,
profile: { select: { customUrlPath: true } },
},
},
},
},
},
});
};
export type MostRecentResult = Prisma.PromiseReturnType<
typeof getMostRecentResult
>;
const getMostRecentResult = () => {
return prisma.xRankPlacement.findFirst({
orderBy: [{ year: "desc" }, { month: "desc" }],
});
};
export default {
getTop500PlacementsByMonth,
getMostRecentResult,
};

89
services/xtrends.ts Normal file
View File

@ -0,0 +1,89 @@
import { RankedMode } from ".prisma/client";
import prisma from "prisma/client";
import { truncuateFloat } from "utils/numbers";
import { Unpacked } from "utils/types";
export type XTrends = Record<
RankedMode,
{
weapon: string;
count: number;
percentage: number;
averageXp: number;
progress: "UP" | "DOWN" | "SAME";
}[]
>;
const getXTrends = async (): Promise<XTrends> => {
const placements = await prisma.xRankPlacement.findMany({
orderBy: [{ year: "desc" }, { month: "desc" }],
// amount of placements in top 500 * amount of months we want * amount of modes
take: 500 * 4 * 4,
});
const referenceMonths = placements.slice(0, 500 * 4 * 3);
const lastThreeMonths = placements.slice(500 * 4);
const weaponXPowersGrouped = (
acc: Record<RankedMode, Record<string, number[]>>,
placement: Unpacked<typeof placements>
) => {
if (!acc[placement.mode][placement.weapon]) {
acc[placement.mode][placement.weapon] = [];
}
acc[placement.mode][placement.weapon].push(placement.xPower);
return acc;
};
const referenceWeaponsCounts = referenceMonths.reduce(weaponXPowersGrouped, {
SZ: {},
TC: {},
RM: {},
CB: {},
});
const lastThreeMonthsWeaponCounts = lastThreeMonths.reduce(
weaponXPowersGrouped,
{
SZ: {},
TC: {},
RM: {},
CB: {},
}
);
return (["SZ", "TC", "RM", "CB"] as const).reduce(
(acc: XTrends, mode: RankedMode) => {
acc[mode] = Object.entries(lastThreeMonthsWeaponCounts[mode])
.map(([weapon, xPowers]) => {
const previousCount =
referenceWeaponsCounts[mode][weapon]?.length ?? 0;
return {
weapon,
count: xPowers.length,
percentage: truncuateFloat(
(xPowers.length / (lastThreeMonths.length / 4)) * 100
),
averageXp: truncuateFloat(
xPowers.reduce((a, b) => a + b) / xPowers.length
),
progress:
previousCount === xPowers.length
? ("SAME" as const)
: previousCount > xPowers.length
? ("DOWN" as const)
: ("UP" as const),
};
})
.sort((a, b) => b.count - a.count);
return acc;
},
{ SZ: [], TC: [], RM: [], CB: [] }
);
};
export default {
getXTrends,
};

View File

@ -1,3 +1,7 @@
//
// IDs
//
export const ADMIN_DISCORD_ID = "79237403620945920";
export const ADMIN_ID = 8;
export const GANBA_DISCORD_ID = "312082701865713665";
@ -8,5 +12,176 @@ export const SALMON_RUN_ADMIN_DISCORD_IDS = [
"78546869373906944", // Minaraii
];
//
// Limits
//
export const TEAM_ROSTER_LIMIT = 10;
export const LADDER_ROSTER_LIMIT = 4;
//
// Misc
//
export const navItems: {
code: string;
name: string;
}[] = [
{ code: "xsearch", name: "Browser" },
{ code: "xtrends", name: "Tier List" },
{
code: "sr",
name: "Salmon Run",
},
{
code: "builds",
name: "Builds",
},
{ code: "analyzer", name: "Analyzer" },
{ code: "calendar", name: "Calendar" },
{ code: "u", name: "Users" },
{ code: "freeagents", name: "Free Agents" },
{ code: "t", name: "Teams" },
{ code: "plans", name: "Plans" },
{ code: "maps", name: "Map Lists" },
{
code: "plus",
name: "Plus Server",
},
{ code: "links", name: "Links" },
];
export const xTrendsTiers = [
{
label: "X",
criteria: 6,
color: "purple.700",
},
{
label: "S+",
criteria: 5,
color: "red.700",
},
{
label: "S",
criteria: 4,
color: "red.700",
},
{
label: "A+",
criteria: 3,
color: "orange.700",
},
{
label: "A",
criteria: 2,
color: "orange.700",
},
{
label: "B+",
criteria: 1.5,
color: "yellow.700",
},
{
label: "B",
criteria: 1,
color: "yellow.700",
},
{
label: "C+",
criteria: 0.4,
color: "green.700",
},
{
label: "C",
criteria: 0.002, //1 in 500
color: "green.700",
},
] as const;
export const TAGS = [
{
code: "SZ",
name: "SZ Only",
description: "Splat Zones is the only mode played.",
color: "#F44336",
},
{
code: "TW",
name: "Includes TW",
description: "Turf War is played.",
color: "#D50000",
},
{
code: "SPECIAL",
name: "Special rules",
description:
"Ruleset that derives from standard e.g. limited what weapons can be used.",
color: "#CE93D8",
},
{
code: "ART",
name: "Art prizes",
description: "You can win art by playing in this tournament.",
color: "#AA00FF",
},
{
code: "MONEY",
name: "Money prizes",
description: "You can win money by playing in this tournament.",
color: "#673AB7",
},
{
code: "REGION",
name: "Region locked",
description: "Limited who can play in this tournament based on location.",
color: "#C5CAE9",
},
{
code: "LOW",
name: "Skill cap",
description: "Who can play in this tournament is limited by skill.",
color: "#BBDEFB",
},
{
code: "COUNT",
name: "Entry limi",
description: "Only limited amount of teams can register.",
color: "#1565C0",
},
{
code: "MULTIPLE",
name: "Multi-day",
description: "This tournament takes place over more than one day.",
color: "#0277BD",
},
{
code: "S1",
name: "Splatoon 1",
description: "The game played is Splatoon 1.",
color: "#81C784",
},
{
code: "LAN",
name: "LAN",
description: "This tournament is played locally.",
color: "#263238",
},
{
code: "QUALIFIER",
name: "Qualifier",
description: "This tournament is a qualifier for another event.",
color: "#FFC0CB",
},
] as const;
export const EVENT_FORMATS = [
{ code: "SE", name: "Single Elimination" },
{ code: "DE", name: "Double Elimination" },
{ code: "GROUPS2SE", name: "Groups to Single Elimination" },
{ code: "GROUPS2DE", name: "Groups to Double Elimination" },
{ code: "SWISS2SE", name: "Swiss to Single Elimination" },
{ code: "SWISS2DE", name: "Swiss to Double Elimination" },
{ code: "SWISS", name: "Swiss" },
{ code: "OTHER", name: "Other" },
] as const;

1
utils/numbers.ts Normal file
View File

@ -0,0 +1 @@
export const truncuateFloat = (float: number) => parseFloat(float.toFixed(1));

View File

@ -1,16 +0,0 @@
// this is a pretty random solution but basically some strings in /lists I want in the messages.po file and not game.po as they need to be
// manually translated so I use this file to make it so
import { t } from "@lingui/macro";
const translateThese = [
t`Shooters`,
t`Blasters`,
t`Rollers`,
t`Brushes`,
t`Chargers`,
t`Sloshers`,
t`Splatlings`,
t`Dualies`,
t`Brellas`,
];

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