free agent feature initial commit

This commit is contained in:
Sendou 2019-12-12 14:00:17 +02:00
parent 287a62853a
commit b29754191a
21 changed files with 780 additions and 91 deletions

24
models/fapost.js Normal file
View File

@ -0,0 +1,24 @@
const mongoose = require("mongoose")
const faPostSchema = new mongoose.Schema(
{
discord_id: { type: String, required: true },
can_vc: { type: String, required: true },
playstyles: { type: [String], required: true },
activity: String,
past_experience: String,
looking_for: String,
description: String,
hidden: { type: Boolean, default: false },
},
{ timestamps: true }
)
faPostSchema.virtual("discord_user", {
ref: "User",
localField: "discord_id",
foreignField: "discord_id",
justOne: true,
})
module.exports = mongoose.model("FAPost", faPostSchema)

View File

@ -12,6 +12,7 @@ const userSchema = new mongoose.Schema({
motion: { type: Number, min: -5, max: 5 },
},
weapons: [String],
top500: Boolean,
})
module.exports = mongoose.model("User", userSchema)

View File

@ -1,19 +0,0 @@
const mongoose = require('mongoose')
const videoSchema = new mongoose.Schema({
youtube_id: {type: String, required: true},
video_title: {type: String, required: true},
match_begin_timestamp: {type: Number, required: true},
upload_timestamp: {type: Number, required: true},
map: {type: String, required: true},
mode: {type: String, required: true},
weapon: {type: String, required: false},
unique_id: {type: String, required: false},
status: {type: String, required: true},
submitter_id: {type: String, required: true},
match_type: {type: String, required: true},
top500: {type: Boolean, required: false},
spec_pov: {type: Boolean, default: false, required: false}
})
module.exports = mongoose.model('Video', videoSchema)

View File

@ -0,0 +1,20 @@
import React from "react"
import { TextArea } from "semantic-ui-react"
const TextAreaWithLimit = ({ value, setValue, limit, style = {} }) => {
return (
<>
<TextArea
value={value}
onChange={e =>
e.target.value.length <= limit && setValue(e.target.value)
}
rows={5}
style={style}
/>
{value.length}/{limit}
</>
)
}
export default TextAreaWithLimit

View File

@ -0,0 +1,105 @@
import React, { useState } from "react"
import { Table, Image, Icon, Flag } from "semantic-ui-react"
import { Link } from "react-router-dom"
import { countries } from "../../utils/lists"
import top500 from "../../assets/xleaderboardIcons/all.png"
import WpnImage from "../common/WpnImage"
import RoleIcons from "./RoleIcons"
import VCIcon from "./VCIcon"
import TextRows from "./TextRows"
const FATableRows = ({ freeAgent }) => {
const [expanded, setExpanded] = useState(false)
const twitter = freeAgent.discord_user.twitter_name
const playstyles = freeAgent.playstyles.reduce((acc, cur) => {
acc[cur] = true
return acc
}, {})
return (
<>
<Table.Row>
<Table.Cell rowSpan={2}>
<Icon
name={expanded ? "angle down" : "angle right"}
size="big"
onClick={() => setExpanded(!expanded)}
style={{ cursor: "pointer" }}
/>
</Table.Cell>
<Table.Cell>
{twitter ? (
<>
<Image src={`https://avatars.io/twitter/${twitter}`} avatar />
<span>
<Link to={`/u/${freeAgent.discord_user.discord_id}`}>
{freeAgent.discord_user.username}#
{freeAgent.discord_user.discriminator}
</Link>
</span>
</>
) : (
<Link to={`/u/${freeAgent.discord_user.discord_id}`}>
{freeAgent.discord_user.username}#
{freeAgent.discord_user.discriminator}
</Link>
)}
</Table.Cell>
<Table.Cell>
{freeAgent.discord_user.country && (
<>
<Flag name={freeAgent.discord_user.country} />
{countries.reduce(
(acc, cur) =>
cur.code === freeAgent.discord_user.country ? cur.name : acc,
""
)}
</>
)}
</Table.Cell>
<Table.Cell>
<Icon name="twitter" size="large" style={{ color: "#1da1f2" }} />
<a href={`https://twitter.com/${twitter}`}>{twitter}</a>
</Table.Cell>
<Table.Cell>
<span style={{ color: "#999999" }}>
{new Date(parseInt(freeAgent.createdAt)).toLocaleDateString()}
</span>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell style={{ borderStyle: "hidden" }}>
{freeAgent.discord_user.weapons.map(weapon => (
<WpnImage
key={weapon}
weapon={weapon}
size="small"
style={{ float: "left" }}
/>
))}
</Table.Cell>
<Table.Cell style={{ borderStyle: "hidden" }}>
<RoleIcons playstyles={playstyles} />
</Table.Cell>
<Table.Cell style={{ borderStyle: "hidden" }}>
<VCIcon canVC={freeAgent.can_vc} />
</Table.Cell>
<Table.Cell style={{ borderStyle: "hidden" }}>
{freeAgent.discord_user.top500 && (
<Link to={`/u/${freeAgent.discord_user.discord_id}?tab=1`}>
<img
src={top500}
style={{ width: "30%", height: "auto" }}
alt="top 500"
/>
</Link>
)}
</Table.Cell>
</Table.Row>
{expanded && <TextRows freeAgent={freeAgent} />}
</>
)
}
export default FATableRows

View File

@ -0,0 +1,57 @@
import React, { useState } from "react"
import NewFAPostForm from "./NewFAPostForm"
import { Message, Button } from "semantic-ui-react"
import { freeAgentPosts } from "../../graphql/queries/freeAgentPosts"
import Loading from "../common/Loading"
import Error from "../common/Error"
import { useQuery } from "@apollo/react-hooks"
import FreeAgentTable from "./FreeAgentTable"
const FreeAgentBrowser = () => {
const [successMsg, setSuccessMsg] = useState(null)
const [showForm, setShowForm] = useState(false)
const { data, error, loading } = useQuery(freeAgentPosts)
const handleSuccess = () => {
setSuccessMsg(
"New free agent post successfully created! Good luck finding a team 😎"
)
setShowForm(false)
setTimeout(() => {
setSuccessMsg(null)
}, 10000)
}
const showButton = () => {
if (showForm) return false
return true
}
if (loading) return <Loading />
if (error) return <Error errorMessage={error.message} />
console.log("data", data)
return (
<>
{successMsg && <Message success>{successMsg}</Message>}
{showButton() && (
<Button onClick={() => setShowForm(true)}>New free agent post</Button>
)}
{showForm && (
<NewFAPostForm
handleSuccess={handleSuccess}
hideForm={() => setShowForm(false)}
/>
)}
{data.freeAgentPosts.length > 0 ? (
<FreeAgentTable FAArray={data.freeAgentPosts} />
) : (
<>No free agents at the moment</>
)}
</>
)
}
export default FreeAgentBrowser

View File

@ -0,0 +1,17 @@
import React from "react"
import { Table } from "semantic-ui-react"
import FATableRows from "./FATableRows"
const FreeAgentTable = ({ FAArray }) => {
return (
<Table basic="very">
<Table.Body>
{FAArray.map(fa => {
return <FATableRows key={fa.id} freeAgent={fa} />
})}
</Table.Body>
</Table>
)
}
export default FreeAgentTable

View File

@ -0,0 +1,185 @@
import React, { useState } from "react"
import {
Form,
Checkbox,
Radio,
Message,
Button,
Header,
} from "semantic-ui-react"
import { useMutation } from "@apollo/react-hooks"
import TextAreaWithLimit from "../common/TextAreaWithLimit"
import { addFreeAgentPost } from "../../graphql/mutations/addFreeAgentPost"
import { freeAgentPosts } from "../../graphql/queries/freeAgentPosts"
const NewFAPostForm = ({ handleSuccess, hideForm }) => {
const [form, setForm] = useState({
can_vc: "",
playstyles: [],
activity: "",
past_experience: "",
looking_for: "",
description: "",
})
const [errorMsg, setErrorMsg] = useState(null)
const handleError = error => {
setErrorMsg(error.message)
setTimeout(() => {
setErrorMsg(null)
}, 10000)
}
const [addFAPostMutation] = useMutation(addFreeAgentPost, {
onError: handleError,
onCompleted: handleSuccess,
refetchQueries: [
{
query: freeAgentPosts,
},
],
})
const handlePlaystyleChange = (e, { value }) => {
if (form.playstyles.indexOf(value) !== -1) {
setForm({
...form,
playstyles: form.playstyles.filter(playstyle => playstyle !== value),
})
} else {
setForm({ ...form, playstyles: [...form.playstyles, value] })
}
}
const handleSubmit = async event => {
event.preventDefault()
const postToAdd = { ...form }
//https://stackoverflow.com/a/38340730
Object.keys(postToAdd).forEach(
key => !postToAdd[key] && delete postToAdd[key]
)
await addFAPostMutation({ variables: postToAdd })
}
return (
<>
<Header>Make a new free agent post</Header>
<Message>
Discord name, Twitter user, weapon pool and Top 500 history are
automatically synced up with your profile.
</Message>
{errorMsg && <Message error>{errorMsg}</Message>}
<Form onSubmit={handleSubmit}>
<Form.Field required>
<label>Playstyles</label>
<Form.Field>
<Checkbox
label="Frontline/Slayer"
value="FRONTLINE"
checked={form.playstyles.indexOf("FRONTLINE") !== -1}
onChange={handlePlaystyleChange}
/>
</Form.Field>
<Form.Field>
<Checkbox
label="Midline/Support"
value="MIDLINE"
checked={form.playstyles.indexOf("MIDLINE") !== -1}
onChange={handlePlaystyleChange}
/>
</Form.Field>
<Form.Field>
<Checkbox
label="Backline/Anchor"
value="BACKLINE"
checked={form.playstyles.indexOf("BACKLINE") !== -1}
onChange={handlePlaystyleChange}
/>
</Form.Field>
</Form.Field>
<Form.Field required>
<label>Can you voice chat?</label>
<Form.Field>
<Radio
label="Yes"
value="YES"
checked={form.can_vc === "YES"}
onChange={() => setForm({ ...form, can_vc: "YES" })}
/>
</Form.Field>
<Form.Field>
<Radio
label="Usually"
value="USUALLY"
checked={form.can_vc === "USUALLY"}
onChange={() => setForm({ ...form, can_vc: "USUALLY" })}
/>
</Form.Field>
<Form.Field>
<Radio
label="Sometimes"
value="SOMETIMES"
checked={form.can_vc === "SOMETIMES"}
onChange={() => setForm({ ...form, can_vc: "SOMETIMES" })}
/>
</Form.Field>
<Form.Field>
<Radio
label="No"
value="NO"
checked={form.can_vc === "NO"}
onChange={() => setForm({ ...form, can_vc: "NO" })}
/>
</Form.Field>
</Form.Field>
<Form.Field>
<label>Past competitive experience</label>
<TextAreaWithLimit
value={form.past_experience}
setValue={value => setForm({ ...form, past_experience: value })}
limit={100}
style={{ height: "75px" }}
/>
</Form.Field>
<Form.Field>
<label>What is your activity like on a typical week?</label>
<TextAreaWithLimit
value={form.activity}
setValue={value => setForm({ ...form, activity: value })}
limit={100}
style={{ height: "75px" }}
/>
</Form.Field>
<Form.Field>
<label>What are you looking from a team?</label>
<TextAreaWithLimit
value={form.looking_for}
setValue={value => setForm({ ...form, looking_for: value })}
limit={100}
style={{ height: "75px" }}
/>
</Form.Field>
<Form.Field>
<label>Free word</label>
<TextAreaWithLimit
value={form.description}
setValue={value => setForm({ ...form, description: value })}
limit={1000}
/>
</Form.Field>
<Form.Field>
<Button type="submit">Submit</Button>
<span style={{ marginLeft: "0.3em" }}>
<Button type="button" negative onClick={hideForm}>
Cancel
</Button>
</span>
</Form.Field>
</Form>
</>
)
}
export default NewFAPostForm

View File

@ -0,0 +1,71 @@
import React from "react"
import { Popup, Icon } from "semantic-ui-react"
const RoleIcons = ({ playstyles }) => {
return (
<>
<Popup
content={
<>
Frontline/Slayer{" "}
{playstyles.FRONTLINE ? (
<Icon name="checkmark" color="green" />
) : (
<Icon name="close" color="red" />
)}
</>
}
trigger={
<Icon
name="crosshairs"
size="big"
color={playstyles.FRONTLINE ? "green" : null}
disabled={!playstyles.FRONTLINE}
/>
}
/>
<Popup
content={
<>
Midline/Support{" "}
{playstyles.MIDLINE ? (
<Icon name="checkmark" color="green" />
) : (
<Icon name="close" color="red" />
)}
</>
}
trigger={
<Icon
name="medkit"
size="big"
color={playstyles.MIDLINE ? "green" : null}
disabled={!playstyles.MIDLINE}
/>
}
/>
<Popup
content={
<>
Backline/Anchor{" "}
{playstyles.BACKLINE ? (
<Icon name="checkmark" color="green" />
) : (
<Icon name="close" color="red" />
)}
</>
}
trigger={
<Icon
name="anchor"
size="big"
color={playstyles.BACKLINE ? "green" : null}
disabled={!playstyles.BACKLINE}
/>
}
/>
</>
)
}
export default RoleIcons

View File

@ -0,0 +1,72 @@
import React from "react"
import { Table } from "semantic-ui-react"
const TextRows = ({ freeAgent }) => {
const { activity, description, looking_for, past_experience } = freeAgent
const hiddenBorder = { borderStyle: "hidden" }
return (
<>
{activity && (
<>
<Table.Row>
<Table.Cell style={hiddenBorder}></Table.Cell>
<Table.Cell colSpan={4} style={hiddenBorder}>
<h4>Activity</h4>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell></Table.Cell>
<Table.Cell colSpan={4}>{activity}</Table.Cell>
</Table.Row>
</>
)}
{past_experience && (
<>
<Table.Row style={hiddenBorder}>
<Table.Cell style={hiddenBorder}></Table.Cell>
<Table.Cell colSpan={4} style={{ borderStyle: "hidden" }}>
<h4>Past experience</h4>
</Table.Cell>
</Table.Row>
<Table.Row style={{ border: "0" }}>
<Table.Cell></Table.Cell>
<Table.Cell colSpan={4}>{past_experience}</Table.Cell>
</Table.Row>
</>
)}
{looking_for && (
<>
<Table.Row>
<Table.Cell style={hiddenBorder}></Table.Cell>
<Table.Cell colSpan={4} style={hiddenBorder}>
<h4>Looking for</h4>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell></Table.Cell>
<Table.Cell colSpan={4}>{looking_for}</Table.Cell>
</Table.Row>
</>
)}
{description && (
<>
<Table.Row>
<Table.Cell style={hiddenBorder}></Table.Cell>
<Table.Cell colSpan={4} style={hiddenBorder}>
<h4>Description</h4>
</Table.Cell>
</Table.Row>
<Table.Row>
<Table.Cell></Table.Cell>
<Table.Cell colSpan={4} style={{ whiteSpace: "pre-wrap" }}>
{description}
</Table.Cell>
</Table.Row>
</>
)}
</>
)
}
export default TextRows

View File

@ -0,0 +1,21 @@
import React from "react"
import { Popup, Icon } from "semantic-ui-react"
const VCIcon = ({ canVC }) => {
const iconColor = () => {
if (canVC === "YES") return "green"
else if (canVC === "NO") return "red"
return "yellow"
}
const canVCCapitalized = canVC.charAt(0) + canVC.toLowerCase().slice(1)
return (
<Popup
content={`Voice chat: ${canVCCapitalized}`}
trigger={<Icon name="microphone" size="big" color={iconColor()} />}
/>
)
}
export default VCIcon

View File

@ -12,7 +12,7 @@ const App = () => {
<div
style={{
background: "white",
padding: "2% 5%",
padding: "2em 3em",
margin: "0 -2em 0 -2em",
borderRadius: "7px",
}}

View File

@ -20,6 +20,7 @@ const TournamentSearchPage = lazy(() =>
import("../tournament/TournamentSearchPage")
)
const BuildsBrowser = lazy(() => import("../builds/BuildsBrowser"))
const FreeAgentBrowser = lazy(() => import("../freeagents/FreeAgentBrowser"))
const UserPage = lazy(() => import("../user/UserPage"))
const InfoPage = lazy(() => import("./InfoPage"))
@ -66,6 +67,9 @@ const Routes = () => {
<Route path="/builds">
<BuildsBrowser />
</Route>
<Route path="/freeagents">
<FreeAgentBrowser />
</Route>
<Route path="/u/:id">
<UserPage />
</Route>

View File

@ -1,9 +1,10 @@
import React, { useState, useEffect, useRef } from "react"
import { Form, Message, Button, TextArea } from "semantic-ui-react"
import { Form, Message, Button } from "semantic-ui-react"
import BuildCard from "../common/BuildCard"
import AbilityButtons from "./AbilityButtons"
import WeaponDropdown from "../common/WeaponDropdown"
import GearSearch from "./GearSearch"
import TextAreaWithLimit from "../common/TextAreaWithLimit"
const AddBuildForm = ({
addBuild,
@ -145,14 +146,11 @@ const AddBuildForm = ({
</Form.Field>
<Form.Field>
<label>Description</label>
<TextArea
<TextAreaWithLimit
value={description}
onChange={e =>
e.target.value.length < 1000 && setDescription(e.target.value)
}
rows={5}
setValue={setDescription}
limit={1000}
/>
{`${description.length}/1000`}
</Form.Field>
<Form.Group widths="equal">
<Form.Field>

View File

@ -20,8 +20,7 @@ const BuildTab = ({ user, userViewed }) => {
const [showForm, setShowForm] = useState(false)
const handleError = error => {
console.log("error", error)
setErrorMsg(error.graphQLErrors[0].message)
setErrorMsg(error.message)
setTimeout(() => {
setErrorMsg(null)
}, 10000)

View File

@ -0,0 +1,21 @@
import { gql } from "apollo-boost"
export const addFreeAgentPost = gql`
mutation addFreeAgentPost(
$can_vc: CanVC!
$playstyles: [Playstyle!]
$activity: String
$looking_for: String
$past_experience: String
$description: String
) {
addFreeAgentPost(
can_vc: $can_vc
playstyles: $playstyles
activity: $activity
looking_for: $looking_for
past_experience: $past_experience
description: $description
)
}
`

View File

@ -0,0 +1,26 @@
import { gql } from "apollo-boost"
export const freeAgentPosts = gql`
{
freeAgentPosts {
id
can_vc
playstyles
activity
looking_for
past_experience
description
hidden
createdAt
discord_user {
username
discriminator
discord_id
twitter_name
country
weapons
top500
}
}
}
`

View File

@ -11,6 +11,7 @@ const { User, userResolvers } = require("./schemas/user")
const { Link, linkResolvers } = require("./schemas/link")
const { Trend, trendResolvers } = require("./schemas/trend")
const { Tournament, tournamentResolvers } = require("./schemas/tournament")
const { FAPost, faPostResolvers } = require("./schemas/fapost")
const Query = gql`
type Query {
@ -38,7 +39,8 @@ const schema = makeExecutableSchema({
User,
Link,
Trend,
Tournament
Tournament,
FAPost,
],
resolvers: merge(
resolvers,
@ -50,8 +52,9 @@ const schema = makeExecutableSchema({
userResolvers,
linkResolvers,
trendResolvers,
tournamentResolvers
)
tournamentResolvers,
faPostResolvers
),
})
module.exports = schema

119
schemas/fapost.js Normal file
View File

@ -0,0 +1,119 @@
const { UserInputError, gql } = require("apollo-server-express")
const FAPost = require("../models/fapost")
const canVCValues = ["YES", "USUALLY", "SOMETIMES", "NO"]
const playstyleValues = ["FRONTLINE", "MIDLINE", "BACKLINE"]
const typeDef = gql`
extend type Query {
freeAgentPosts: [FAPost!]!
}
extend type Mutation {
addFreeAgentPost(
can_vc: CanVC!
playstyles: [Playstyle!]
activity: String
looking_for: String
past_experience: String
description: String
): Boolean!
}
enum CanVC {
YES
USUALLY
SOMETIMES
NO
}
enum Playstyle {
FRONTLINE
MIDLINE
BACKLINE
}
"Represents a free agent post of a player looking for a team"
type FAPost {
id: ID!
discord_id: String!
can_vc: CanVC!
playstyles: [Playstyle!]
"How active is the free agent"
activity: String
"What kind of team they are looking for"
looking_for: String
"Teams or other past experience in competitive"
past_experience: String
"Free word about anything else"
description: String
discord_user: User!
hidden: Boolean!
createdAt: String!
}
`
const resolvers = {
Query: {
freeAgentPosts: (root, args) => {
return FAPost.find({})
.populate("discord_user")
.sort({ createdAt: "desc" })
.catch(e => {
throw new UserInputError(e.message, {
invalidArgs: args,
})
})
},
},
Mutation: {
addFreeAgentPost: async (root, args, ctx) => {
if (!ctx.user) throw new AuthenticationError("Not logged in.")
if (canVCValues.indexOf(args.can_vc) === -1) {
throw new UserInputError("Invalid 'can vc' value provided.")
}
const playstyles = args.playstyles ? [...new Set(args.playstyles)] : []
if (
playstyles.some(playstyle => playstyleValues.indexOf(playstyle) === -1)
) {
throw new UserInputError("Invalid 'playstyles' value provided.")
}
if (args.activity && args.activity.length > 100) {
throw new UserInputError("'activity' value too long.")
}
if (args.looking_for && args.looking_for.length > 100) {
throw new UserInputError("'looking_for' value too long.")
}
if (args.past_experience && args.past_experience.length > 100) {
throw new UserInputError("'past_experience' value too long.")
}
if (args.past_experience && args.past_experience.length > 100) {
throw new UserInputError("'past_experience' value too long.")
}
if (args.description && args.description.length > 1000) {
throw new UserInputError("'description' value too long.")
}
const faPost = new FAPost({ ...args, discord_id: ctx.user.discord_id })
await faPost.save().catch(e => {
throw (new UserInputError(),
{
invalidArgs: args,
})
})
return true
},
},
}
module.exports = {
FAPost: typeDef,
faPostResolvers: resolvers,
}

View File

@ -1,5 +1,6 @@
const { UserInputError, gql } = require("apollo-server-express")
const User = require("../models/user")
const Player = require("../models/player")
const countries = require("../utils/countries")
const weapons = require("../utils/weapons")
require("dotenv").config()
@ -38,9 +39,32 @@ const typeDef = gql`
country: String
sens: Sens
weapons: [String]!
top500: Boolean!
}
`
const resolvers = {
User: {
top500: async root => {
if (typeof root.top500 === "boolean") return root.top500
const player = await Player.findOne({ twitter: root.twitter_name }).catch(
e => {
throw (new UserInputError(),
{
invalidArgs: args,
})
}
)
if (!player) {
await User.findByIdAndUpdate(root._id, { top500: false })
return false
}
await User.findByIdAndUpdate(root._id, { top500: true })
return true
},
},
Query: {
user: (root, args, ctx) => {
if (process.env.NODE_ENV === "development") {

View File

@ -1,59 +0,0 @@
const { UserInputError, gql } = require('apollo-server-express')
const Video = require('../models/video')
const typeDef = gql`
extend type Query {
youtubeInfoById(youtube_id: String!): YouTubeInfo
}
enum MatchType {
RANK
XRANK
TURF
TOURNAMENT
SCRIM
SPEC
}
enum Status {
PENDING
REJECTED
APPROVED
}
"Represents single match (one video can have multiple)-"
type Video {
"ID of the video on YouTube"
youtube_id: String!
video_title: String!
"Timestamp in second of the match in the video"
match_begin_timestamp: Int!
"Timestamp when the match was uploaded to YouTube"
upload_timestamp: Int!
map: String!
mode: String!
weapon: String
"Only if the player in the video has reached Top 500."
unique_id: String
status: Status!
"User ID of the user who submitted the video."
submitter_id: String!
match_type: MatchType!
"True if the player in the match has reached Top 500 with the weapon in the match."
top500: Boolean
spec_pov: Boolean!
}
type YouTubeInfo {
}
`
const resolvers = {
Query: {
youtubeInfoById: (root, args) => {
const youtube_id = args.youtube_id
}
}
}
module.exports = {
Video: typeDef,
videoResolvers: resolvers
}