mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-04-25 07:32:19 -05:00
* 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:
parent
12bcf83532
commit
1589b84c4b
|
|
@ -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,
|
||||
|
|
@ -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";
|
||||
|
|
@ -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", {
|
||||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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({
|
||||
|
|
|
|||
185
components/calendar/EventInfo.tsx
Normal file
185
components/calendar/EventInfo.tsx
Normal 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;
|
||||
|
|
@ -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>;
|
||||
|
|
@ -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[];
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -93,8 +93,6 @@ const MySelect: React.FC<SelectProps> = ({
|
|||
return undefined;
|
||||
};
|
||||
|
||||
console.log("value", value);
|
||||
|
||||
return (
|
||||
<ReactSelect
|
||||
className="basic-single"
|
||||
|
|
|
|||
108
components/common/NewTable.tsx
Normal file
108
components/common/NewTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
components/common/OutlinedBox.tsx
Normal file
21
components/common/OutlinedBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
@ -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 {
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
@ -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,
|
||||
|
|
@ -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;
|
||||
27
components/layout/ColorModeSwitcher.tsx
Normal file
27
components/layout/ColorModeSwitcher.tsx
Normal 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;
|
||||
93
components/layout/Header.tsx
Normal file
93
components/layout/Header.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
42
components/layout/MobileNav.tsx
Normal file
42
components/layout/MobileNav.tsx
Normal 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
64
components/layout/Nav.tsx
Normal 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;
|
||||
71
components/layout/NavButtons.tsx
Normal file
71
components/layout/NavButtons.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
46
components/layout/UserItem.tsx
Normal file
46
components/layout/UserItem.tsx
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ const DraggableImageAdder: React.FC<DraggableImageAdderProps> = ({
|
|||
bg={bgColor}
|
||||
textAlign="center"
|
||||
width="119px"
|
||||
ml="950px"
|
||||
ml="850px"
|
||||
>
|
||||
<strong style={{ cursor: "move" }}>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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 {}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
@ -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,
|
||||
|
|
@ -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";
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
40
components/xsearch/Top500Table.tsx
Normal file
40
components/xsearch/Top500Table.tsx
Normal 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;
|
||||
84
components/xtrends/TrendTier.tsx
Normal file
84
components/xtrends/TrendTier.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>>([]);
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
136
pages/index.tsx
136
pages/index.tsx
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
BIN
public/layout/discord-mascot.png
Normal file
BIN
public/layout/discord-mascot.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 6.9 KiB |
BIN
public/layout/maps.png
Normal file
BIN
public/layout/maps.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 18 KiB |
BIN
public/layout/xtrends.png
Normal file
BIN
public/layout/xtrends.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 28 KiB |
|
|
@ -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
56
services/xsearch.ts
Normal 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
89
services/xtrends.ts
Normal 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,
|
||||
};
|
||||
|
|
@ -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
1
utils/numbers.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export const truncuateFloat = (float: number) => parseFloat(float.toFixed(1));
|
||||
|
|
@ -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
Loading…
Reference in New Issue
Block a user