mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-07 19:55:46 -05:00
team page stuff
This commit is contained in:
881
frontend-react/package-lock.json
generated
881
frontend-react/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,7 @@
|
||||
"@types/node": "^13.7.0",
|
||||
"@types/reach__router": "^1.2.6",
|
||||
"@types/react": "^16.9.19",
|
||||
"@types/react-datepicker": "^2.11.0",
|
||||
"@types/react-dom": "^16.9.5",
|
||||
"@types/react-infinite-scroller": "^1.2.1",
|
||||
"@types/react-select": "^3.0.10",
|
||||
@@ -23,7 +24,9 @@
|
||||
"emotion-theming": "^10.0.27",
|
||||
"graphql": "^14.6.0",
|
||||
"jstz": "^2.1.1",
|
||||
"node-sass": "^4.13.1",
|
||||
"react": "^16.12.0",
|
||||
"react-datepicker": "^2.12.0",
|
||||
"react-dom": "^16.12.0",
|
||||
"react-draggable": "^4.2.0",
|
||||
"react-helmet-async": "^1.0.4",
|
||||
@@ -32,6 +35,7 @@
|
||||
"react-infinite-scroller": "^1.2.4",
|
||||
"react-scripts": "^3.3.1",
|
||||
"react-select": "^3.0.8",
|
||||
"react-tweet-embed": "^1.2.2",
|
||||
"typescript": "^3.7.5"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
30
frontend-react/src/components/elements/DatePicker.tsx
Normal file
30
frontend-react/src/components/elements/DatePicker.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import React from "react"
|
||||
import HackerOneDatePicker from "react-datepicker"
|
||||
import Button from "./Button"
|
||||
import "react-datepicker/src/stylesheets/datepicker.scss"
|
||||
|
||||
interface DatePickerProps {
|
||||
date: Date
|
||||
setDate: (date: Date | null) => void
|
||||
}
|
||||
|
||||
interface CustomInputProps {
|
||||
value?: string
|
||||
onClick?: () => void
|
||||
}
|
||||
|
||||
const CustomInput: React.FC<CustomInputProps> = ({ value, onClick }) => (
|
||||
<Button onClick={onClick as () => void}>{value as string}</Button>
|
||||
)
|
||||
|
||||
const DatePicker: React.FC<DatePickerProps> = ({ date, setDate }) => {
|
||||
return (
|
||||
<HackerOneDatePicker
|
||||
selected={date}
|
||||
onChange={date => setDate(date)}
|
||||
customInput={<CustomInput />}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default DatePicker
|
||||
@@ -8,6 +8,7 @@ const BuildsPage = lazy(() => import("../builds/BuildsPage"))
|
||||
const CalendarPage = lazy(() => import("../calendar/CalendarPage"))
|
||||
const MapPlannerPage = lazy(() => import("../plans/MapPlannerPage"))
|
||||
const FreeAgentsPage = lazy(() => import("../freeagents/FreeAgentsPage"))
|
||||
const TeamPage = lazy(() => import("../team/TeamPage"))
|
||||
|
||||
const Routes: React.FC = () => {
|
||||
return (
|
||||
@@ -15,6 +16,7 @@ const Routes: React.FC = () => {
|
||||
<Router>
|
||||
<HomePage path="/" />
|
||||
<UserPage path="/u/:id" />
|
||||
<TeamPage path="/t/:name" />
|
||||
<BuildsPage path="/builds" />
|
||||
<MapPlannerPage path="/plans" />
|
||||
<CalendarPage path="/calendar" />
|
||||
|
||||
90
frontend-react/src/components/team/AddResultModal.tsx
Normal file
90
frontend-react/src/components/team/AddResultModal.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import React, { useState } from "react"
|
||||
import Modal from "../elements/Modal"
|
||||
import Box from "../elements/Box"
|
||||
import { TournamentResult } from "../../types"
|
||||
import Input from "../elements/Input"
|
||||
import { useMutation } from "@apollo/react-hooks"
|
||||
import { useToast } from "@chakra-ui/core"
|
||||
import { ADD_RESULT } from "../../graphql/mutations/addResult"
|
||||
import TweetEmbed from "react-tweet-embed"
|
||||
import { useContext } from "react"
|
||||
import MyThemeContext from "../../themeContext"
|
||||
import DatePicker from "../elements/DatePicker"
|
||||
import Label from "../elements/Label"
|
||||
|
||||
interface AddResultModalProps {
|
||||
closeModal: () => void
|
||||
}
|
||||
|
||||
const AddResultModal: React.FC<AddResultModalProps> = ({ closeModal }) => {
|
||||
const { colorMode } = useContext(MyThemeContext)
|
||||
const [result, setResult] = useState<Partial<TournamentResult>>({})
|
||||
const toast = useToast()
|
||||
|
||||
const [addResult] = useMutation<boolean, TournamentResult>(ADD_RESULT, {
|
||||
variables: result as TournamentResult,
|
||||
onCompleted: () => {
|
||||
closeModal()
|
||||
toast({
|
||||
description: "Result added",
|
||||
position: "top-right",
|
||||
status: "success",
|
||||
duration: 10000,
|
||||
})
|
||||
},
|
||||
onError: error => {
|
||||
toast({
|
||||
title: "An error occurred",
|
||||
description: error.message,
|
||||
position: "top-right",
|
||||
status: "success",
|
||||
duration: 10000,
|
||||
})
|
||||
},
|
||||
refetchQueries: ["searchForUser"],
|
||||
})
|
||||
|
||||
const handleChange = (newValueObject: Partial<TournamentResult>) => {
|
||||
console.log("newV", newValueObject)
|
||||
setResult({ ...result, ...newValueObject })
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal title="Adding a new tournament result" closeModal={closeModal}>
|
||||
<Box display="flex" flexDirection="column">
|
||||
<Box>
|
||||
<Input
|
||||
label="Tournament name"
|
||||
value={result.tournament_name}
|
||||
setValue={value => handleChange({ tournament_name: value })}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt="1em">
|
||||
<Label>Date</Label>
|
||||
<DatePicker
|
||||
date={result.date ? new Date(result.date) : new Date()}
|
||||
setDate={value => handleChange({ date: value?.toString() })}
|
||||
/>
|
||||
</Box>
|
||||
<Box mt="1em">
|
||||
<Input
|
||||
label="Tweet id"
|
||||
value={result.tweet_id}
|
||||
setValue={value => handleChange({ tweet_id: value })}
|
||||
textLeft="https://twitter.com/.../status/"
|
||||
/>
|
||||
</Box>
|
||||
{result.tweet_id && (
|
||||
<Box mt="1em">
|
||||
<TweetEmbed
|
||||
id={result.tweet_id}
|
||||
options={{ theme: "dark", dnt: "true", conversation: "none" }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddResultModal
|
||||
32
frontend-react/src/components/team/LogoHeader.tsx
Normal file
32
frontend-react/src/components/team/LogoHeader.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import React from "react"
|
||||
import UserAvatar from "../common/UserAvatar"
|
||||
import Box from "../elements/Box"
|
||||
import { useContext } from "react"
|
||||
import MyThemeContext from "../../themeContext"
|
||||
|
||||
interface LogoHeaderProps {
|
||||
name: string
|
||||
twitter_name?: string
|
||||
}
|
||||
|
||||
const LogoHeader: React.FC<LogoHeaderProps> = ({ name, twitter_name }) => {
|
||||
const { themeColorWithShade } = useContext(MyThemeContext)
|
||||
return (
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<UserAvatar name={name} twitterName={twitter_name} size="2xl" />
|
||||
<Box
|
||||
fontFamily="'Pacifico', cursive"
|
||||
fontWeight="light"
|
||||
fontSize="48px"
|
||||
borderBottomColor={themeColorWithShade}
|
||||
borderBottomWidth="5px"
|
||||
w="100%"
|
||||
textAlign="center"
|
||||
>
|
||||
{name}
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogoHeader
|
||||
72
frontend-react/src/components/team/MemberCard.tsx
Normal file
72
frontend-react/src/components/team/MemberCard.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React from "react"
|
||||
import { Weapon, CountryCode } from "../../types"
|
||||
import Box from "../elements/Box"
|
||||
import { useContext } from "react"
|
||||
import MyThemeContext from "../../themeContext"
|
||||
import { Link } from "@reach/router"
|
||||
import UserAvatar from "../common/UserAvatar"
|
||||
import WeaponImage from "../common/WeaponImage"
|
||||
import Flag from "../common/Flag"
|
||||
import { countries } from "../../utils/lists"
|
||||
|
||||
interface MemberCardProps {
|
||||
member: {
|
||||
discord_id: string
|
||||
username: string
|
||||
discriminator: string
|
||||
twitch_name?: string
|
||||
twitter_name?: string
|
||||
country?: CountryCode
|
||||
weapons: Weapon[]
|
||||
custom_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
const MemberCard: React.FC<MemberCardProps> = ({ member }) => {
|
||||
const { borderStyle, grayWithShade } = useContext(MyThemeContext)
|
||||
return (
|
||||
<Box
|
||||
as="fieldset"
|
||||
display="block"
|
||||
borderWidth="1px"
|
||||
border={borderStyle}
|
||||
//w="300px"
|
||||
rounded="lg"
|
||||
overflow="hidden"
|
||||
//pb={showUser && build.discord_user ? "20px" : "15px"}
|
||||
p="15px"
|
||||
w="240px"
|
||||
h="180px"
|
||||
>
|
||||
<Box
|
||||
as="legend"
|
||||
color={grayWithShade}
|
||||
fontWeight="semibold"
|
||||
letterSpacing="wide"
|
||||
fontSize="s"
|
||||
>
|
||||
<Link to={`/u/${member.custom_url ?? member.discord_id}`}>
|
||||
{member.username}#{member.discriminator}
|
||||
</Link>
|
||||
</Box>
|
||||
<Box display="flex" flexDirection="column" alignItems="center">
|
||||
<UserAvatar name={member.username} twitterName={member.twitter_name} />
|
||||
{member.country && (
|
||||
<Box mt="0.5em" display="flex" alignItems="center">
|
||||
<Flag code={member.country} />
|
||||
{countries.find(obj => obj.code === member.country)?.name}
|
||||
</Box>
|
||||
)}
|
||||
<Box display="flex" mt="0.5em">
|
||||
{member.weapons.map(wpn => (
|
||||
<Box mx="0.3em" key={wpn}>
|
||||
<WeaponImage englishName={wpn} size="SMALL" />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default MemberCard
|
||||
24
frontend-react/src/components/team/Results.tsx
Normal file
24
frontend-react/src/components/team/Results.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import React from "react"
|
||||
import Button from "../elements/Button"
|
||||
import { useState } from "react"
|
||||
import AddResultModal from "./AddResultModal"
|
||||
|
||||
interface ResultsProps {
|
||||
canAddResults: boolean
|
||||
}
|
||||
|
||||
const Results: React.FC<ResultsProps> = ({ canAddResults }) => {
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
return (
|
||||
<>
|
||||
{showModal && <AddResultModal closeModal={() => setShowModal(false)} />}
|
||||
{canAddResults && (
|
||||
<Button onClick={() => setShowModal(true)}>
|
||||
Add tournament result
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Results
|
||||
74
frontend-react/src/components/team/TeamPage.tsx
Normal file
74
frontend-react/src/components/team/TeamPage.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import React from "react"
|
||||
import { RouteComponentProps, Redirect } from "@reach/router"
|
||||
import { useQuery } from "@apollo/react-hooks"
|
||||
import { SEARCH_FOR_TEAM } from "../../graphql/queries/searchForTeam"
|
||||
import { Team, UserData } from "../../types"
|
||||
import Loading from "../common/Loading"
|
||||
import Error from "../common/Error"
|
||||
import LogoHeader from "./LogoHeader"
|
||||
import MemberCard from "./MemberCard"
|
||||
import Box from "../elements/Box"
|
||||
import { Helmet } from "react-helmet-async"
|
||||
import Results from "./Results"
|
||||
import { USER } from "../../graphql/queries/user"
|
||||
|
||||
interface SearchForTeamData {
|
||||
searchForTeam: Team
|
||||
}
|
||||
|
||||
interface SearchForTeamVars {
|
||||
name: string
|
||||
}
|
||||
|
||||
interface TeamPageProps {
|
||||
name?: string
|
||||
}
|
||||
|
||||
const TeamPage: React.FC<RouteComponentProps & TeamPageProps> = ({ name }) => {
|
||||
const { data, error, loading } = useQuery<
|
||||
SearchForTeamData,
|
||||
SearchForTeamVars
|
||||
>(SEARCH_FOR_TEAM, {
|
||||
variables: { name: name as string },
|
||||
skip: !name,
|
||||
})
|
||||
const { data: userData, error: userError, loading: userLoading } = useQuery<
|
||||
UserData
|
||||
>(USER)
|
||||
|
||||
if (!name) return <Redirect to="/404" />
|
||||
if (loading || userLoading) return <Loading />
|
||||
if (error) return <Error errorMessage={error.message} />
|
||||
if (userError) return <Error errorMessage={userError.message} />
|
||||
if (!data || !data.searchForTeam || !userData) return <Redirect to="/404" />
|
||||
|
||||
const team = data.searchForTeam
|
||||
const user = userData.user
|
||||
console.log("team", team)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
<title>{team.name} | sendou.ink</title>
|
||||
</Helmet>
|
||||
<LogoHeader name={team.name} twitter_name={team.twitter_name} />
|
||||
<Box display="flex" flexWrap="wrap" justifyContent="center">
|
||||
{team.member_users.map(member => (
|
||||
<Box key={member.discord_id} p="0.5em">
|
||||
<MemberCard member={member} />
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Box mt="1em">
|
||||
<Results
|
||||
canAddResults={
|
||||
team.captain_discord_id === user?.discord_id &&
|
||||
team.tournament_results.length < 100
|
||||
}
|
||||
/>
|
||||
</Box>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default TeamPage
|
||||
@@ -26,6 +26,7 @@ import BuildTab from "./BuildTab"
|
||||
import MyThemeContext from "../../themeContext"
|
||||
import { PLAYER_INFO } from "../../graphql/queries/playerInfo"
|
||||
import XRankTab from "./XRankTab"
|
||||
import { weapons } from "../../utils/lists"
|
||||
|
||||
interface Tab {
|
||||
id: number
|
||||
@@ -95,7 +96,9 @@ const UserPage: React.FC<RouteComponentProps & UserPageProps> = ({ id }) => {
|
||||
content: (
|
||||
<TabPanel key={1}>
|
||||
<BuildTab
|
||||
builds={builds}
|
||||
builds={builds.sort(
|
||||
(a, b) => weapons.indexOf(a.weapon) - weapons.indexOf(b.weapon)
|
||||
)}
|
||||
canModifyBuilds={userLean?.discord_id === user.discord_id}
|
||||
/>
|
||||
</TabPanel>
|
||||
|
||||
17
frontend-react/src/graphql/mutations/addResult.ts
Normal file
17
frontend-react/src/graphql/mutations/addResult.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { gql, DocumentNode } from "apollo-boost"
|
||||
|
||||
export const ADD_RESULT: DocumentNode = gql`
|
||||
mutation addResult(
|
||||
$date: String!
|
||||
$tweet_id: String
|
||||
$tournament_name: String!
|
||||
$placement: Int!
|
||||
) {
|
||||
updateUser(
|
||||
date: $date
|
||||
tweet_id: $tweet_id
|
||||
tournament_name: $tournament_name
|
||||
placement: $placement
|
||||
)
|
||||
}
|
||||
`
|
||||
31
frontend-react/src/graphql/queries/searchForTeam.ts
Normal file
31
frontend-react/src/graphql/queries/searchForTeam.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { gql, DocumentNode } from "apollo-boost"
|
||||
|
||||
export const SEARCH_FOR_TEAM: DocumentNode = gql`
|
||||
query searchForTeam($name: String!) {
|
||||
searchForTeam(name: $name) {
|
||||
name
|
||||
twitter_name
|
||||
captain_discord_id
|
||||
member_discord_ids
|
||||
member_users {
|
||||
discord_id
|
||||
username
|
||||
discriminator
|
||||
twitch_name
|
||||
twitter_name
|
||||
country
|
||||
weapons
|
||||
custom_url
|
||||
}
|
||||
countries
|
||||
tag
|
||||
lf_post
|
||||
tournament_results {
|
||||
date
|
||||
tweet_id
|
||||
tournament_name
|
||||
placement
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
@@ -150,6 +150,34 @@ export interface FreeAgentPost {
|
||||
}
|
||||
}
|
||||
|
||||
export interface TournamentResult {
|
||||
date: string
|
||||
tweet_id?: string
|
||||
tournament_name: string
|
||||
placement: number
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
name: string
|
||||
twitter_name?: string
|
||||
captain_discord_id: string
|
||||
member_discord_ids: string[]
|
||||
member_users: {
|
||||
discord_id: string
|
||||
username: string
|
||||
discriminator: string
|
||||
twitch_name?: string
|
||||
twitter_name?: string
|
||||
country?: CountryCode
|
||||
weapons: Weapon[]
|
||||
custom_url?: string
|
||||
}[]
|
||||
countries: string[]
|
||||
tag?: String
|
||||
lf_post?: String
|
||||
tournament_results: TournamentResult[]
|
||||
}
|
||||
|
||||
//==============================================================================
|
||||
// Apollo
|
||||
//==============================================================================
|
||||
|
||||
@@ -4,11 +4,18 @@ const User = require("../mongoose-models/user")
|
||||
|
||||
const typeDef = gql`
|
||||
extend type Query {
|
||||
searchForTeam(name: String!): Team
|
||||
teams: [Team!]!
|
||||
}
|
||||
|
||||
extend type Mutation {
|
||||
createTeam(name: String!): Team!
|
||||
addTeam(name: String!): Team!
|
||||
addResult(
|
||||
date: String!
|
||||
tweet_id: String
|
||||
tournament_name: String!
|
||||
placement: Int!
|
||||
): Boolean!
|
||||
}
|
||||
|
||||
extend type User {
|
||||
@@ -16,8 +23,8 @@ const typeDef = gql`
|
||||
}
|
||||
|
||||
type Result {
|
||||
date: String
|
||||
tweet_url: String
|
||||
date: String!
|
||||
tweet_id: String
|
||||
tournament_name: String!
|
||||
placement: Int!
|
||||
}
|
||||
@@ -30,7 +37,6 @@ const typeDef = gql`
|
||||
member_users: [User!]!
|
||||
countries: [String!]!
|
||||
tag: String
|
||||
invite_code: String
|
||||
lf_post: String
|
||||
tournament_results: [Result!]!
|
||||
}
|
||||
@@ -38,9 +44,15 @@ const typeDef = gql`
|
||||
const resolvers = {
|
||||
Query: {
|
||||
teams: (root, args) => Team.find({}),
|
||||
searchForTeam: (root, { name }) => {
|
||||
const name_regex = `^${name.replace("_", " ")}$`
|
||||
return Team.findOne({
|
||||
name: { $regex: new RegExp(name_regex, "i") },
|
||||
}).populate("member_users")
|
||||
},
|
||||
},
|
||||
Mutation: {
|
||||
createTeam: async (root, args, { user }) => {
|
||||
addTeam: async (root, args, { user }) => {
|
||||
if (!user) throw new UserInputError("Must be logged in to create a team")
|
||||
if (user.team)
|
||||
throw new UserInputError(
|
||||
@@ -49,7 +61,7 @@ const resolvers = {
|
||||
|
||||
const name = args.name.replace(/\s\s+/g, " ").trim()
|
||||
|
||||
if (name.length < 2 || name.length > 32 || !/^[a-z0-9␣]+$/i.test(name)) {
|
||||
if (name.length < 2 || name.length > 32 || !/^[a-z0-9 ]+$/i.test(name)) {
|
||||
throw new UserInputError("Invalid team name provided", {
|
||||
invalidArgs: args,
|
||||
})
|
||||
@@ -62,10 +74,59 @@ const resolvers = {
|
||||
if (existing_team)
|
||||
throw new UserInputError("Team with this name already exists")
|
||||
|
||||
const team = new Team({ name, captain_discord_id: user.discord_id })
|
||||
const team = new Team({
|
||||
name,
|
||||
captain_discord_id: user.discord_id,
|
||||
member_discord_ids: [user.discord_id],
|
||||
})
|
||||
await User.findByIdAndUpdate(user._id, { $set: { team: team._id } })
|
||||
return team.save()
|
||||
},
|
||||
addResult: async (root, args, { user }) => {
|
||||
if (!user) {
|
||||
throw new UserInputError("Must be logged in")
|
||||
}
|
||||
|
||||
if (user.team.captain_discord_id !== user.discord_id) {
|
||||
//??
|
||||
throw new UserInputError("Must be a captain to add a result")
|
||||
}
|
||||
|
||||
if (user.team.tournament_results.length >= 100) {
|
||||
throw new UserInputError("Can't have more than 100 tournament results")
|
||||
}
|
||||
|
||||
if (Date.parse(args.date) === NaN) {
|
||||
throw new UserInputError("Invalid date")
|
||||
}
|
||||
|
||||
if (args.tweet_id && !isNaN(args.tweet_id)) {
|
||||
throw new UserInputError("Tweet ID can only contain numbers")
|
||||
}
|
||||
|
||||
if (
|
||||
args.tournament_name.length < 2 ||
|
||||
args.tournament_name.length > 100
|
||||
) {
|
||||
throw new UserInputError(
|
||||
"Tournament name has to be between 2 and 100 characters long"
|
||||
)
|
||||
}
|
||||
|
||||
if (args.placement < 1 || args.placement > 500) {
|
||||
throw new UserInputError("Placement has to be between 1 and 500")
|
||||
}
|
||||
|
||||
const team = await Team.findById(user.team)
|
||||
team.push({
|
||||
date: args.date,
|
||||
tournament_name: args.tournament_name,
|
||||
placement: args.placement,
|
||||
tweet_id: args.tweet_id ? args.tweet_id : undefined,
|
||||
})
|
||||
await team.save()
|
||||
return true
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -23,7 +23,6 @@ teamSchema.virtual("member_users", {
|
||||
ref: "User",
|
||||
localField: "member_discord_ids",
|
||||
foreignField: "discord_id",
|
||||
justOne: true,
|
||||
})
|
||||
|
||||
module.exports = mongoose.model("Team", teamSchema)
|
||||
|
||||
Reference in New Issue
Block a user