Functioning proposal teams (pt 1 - the form) (#115)

* Team create flow step

* Show team on review step.

* Fix image types.

* Get team into ideal format. Properly post it to backend.

* Validate team forms and show errors.

* Adjust team member buttons.
This commit is contained in:
William O'Beirne 2018-09-27 16:25:49 -04:00 committed by Daniel Ternyak
parent 9e0ecaef02
commit 3b161f3476
16 changed files with 741 additions and 35 deletions

View File

@ -1,5 +1,7 @@
import axios from './axios';
import { Proposal } from 'modules/proposals/reducers';
import { TeamMember } from 'modules/create/types';
import { socialAccountsToUrls } from 'utils/social';
import { PROPOSAL_CATEGORY } from './constants';
export function getProposals(): Promise<{ data: Proposal[] }> {
@ -26,9 +28,20 @@ export function postProposal(payload: {
title: string;
category: PROPOSAL_CATEGORY;
milestones: object[];
team: TeamMember[];
}) {
return axios.post(`/api/v1/proposals/`, {
...payload,
team: [{ accountAddress: payload.accountAddress }],
// Team has a different shape for POST
team: payload.team.map(u => ({
displayName: u.name,
title: u.title,
accountAddress: u.ethAddress,
emailAddress: u.emailAddress,
avatar: { link: u.avatarUrl },
socialMedias: socialAccountsToUrls(u.socialAccounts).map(url => ({
link: url,
})),
})),
});
}

View File

@ -81,3 +81,30 @@
font-size: 1rem;
}
}
.ReviewTeam {
&-member {
display: flex;
align-items: center;
margin-bottom: 0.75rem;
&-avatar {
height: 4.2rem;
width: 4.2rem;
margin-right: 1rem;
border-radius: 4px;
}
&-info {
&-name {
font-size: 1.2rem;
margin-bottom: 0.2rem;
}
&-title {
font-size: 1rem;
opacity: 0.5;
}
}
}
}

View File

@ -2,12 +2,12 @@ import React from 'react';
import { connect } from 'react-redux';
import { Icon, Timeline } from 'antd';
import moment from 'moment';
import { Milestone } from 'modules/create/types';
import { getCreateErrors, KeyOfForm, FIELD_NAME_MAP } from 'modules/create/utils';
import Markdown from 'components/Markdown';
import { AppState } from 'store/reducers';
import { CREATE_STEP } from './index';
import { CATEGORY_UI } from 'api/constants';
import defaultUserImg from 'static/images/default-user.jpg';
import './Review.less';
interface OwnProps {
@ -68,11 +68,17 @@ class CreateReview extends React.Component<Props> {
},
],
},
// {
// step: CREATE_STEP.TEAM,
// name: 'Team',
// fields: [],
// },
{
step: CREATE_STEP.TEAM,
name: 'Team',
fields: [
{
key: 'team',
content: <ReviewTeam team={form.team} />,
error: errors.team && errors.team.join(' '),
},
],
},
{
step: CREATE_STEP.DETAILS,
name: 'Details',
@ -178,7 +184,11 @@ export default connect<StateProps, {}, OwnProps, AppState>(state => ({
form: state.create.form,
}))(CreateReview);
const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => (
const ReviewMilestones = ({
milestones,
}: {
milestones: AppState['create']['form']['milestones'];
}) => (
<Timeline>
{milestones.map(m => (
<Timeline.Item>
@ -195,3 +205,17 @@ const ReviewMilestones = ({ milestones }: { milestones: Milestone[] }) => (
))}
</Timeline>
);
const ReviewTeam = ({ team }: { team: AppState['create']['form']['team'] }) => (
<div className="ReviewTeam">
{team.map((u, idx) => (
<div className="ReviewTeam-member" key={idx}>
<img className="ReviewTeam-member-avatar" src={u.avatarUrl || defaultUserImg} />
<div className="ReviewTeam-member-info">
<div className="ReviewTeam-member-info-name">{u.name}</div>
<div className="ReviewTeam-member-info-title">{u.title}</div>
</div>
</div>
))}
</div>
);

View File

@ -0,0 +1,53 @@
.TeamForm {
max-width: 660px;
padding: 0 1rem;
width: 100%;
margin: 0 auto;
&-add {
display: flex;
width: 100%;
padding: 1rem;
align-items: center;
cursor: pointer;
opacity: 0.7;
transition: opacity 80ms ease, transform 80ms ease;
outline: none;
&:hover,
&:focus {
opacity: 1;
}
&:active {
transform: translateY(2px);
}
&-icon {
display: flex;
align-items: center;
justify-content: center;
margin-right: 1.25rem;
width: 7.4rem;
height: 7.4rem;
border: 2px dashed #2ecc71;
color: #2ecc71;
border-radius: 8px;
font-size: 2rem;
}
&-text {
text-align: left;
&-title {
font-size: 1.6rem;
font-weight: 300;
color: #2ecc71;
}
&-subtitle {
opacity: 0.7;
font-size: 1rem;
}
}
}
}

View File

@ -1,29 +1,101 @@
import React from 'react';
import Placeholder from 'components/Placeholder';
import { CreateFormState } from 'modules/create/types';
import { Icon } from 'antd';
import { CreateFormState, TeamMember } from 'modules/create/types';
import TeamMemberComponent from './TeamMember';
import './Team.less';
type State = object;
interface State {
team: TeamMember[];
}
interface Props {
initialState?: Partial<State>;
updateForm(form: Partial<CreateFormState>): void;
}
export default class CreateFlowTeam extends React.Component<Props, State> {
const MAX_TEAM_SIZE = 6;
const DEFAULT_STATE: State = {
team: [
{
name: '',
title: '',
avatarUrl: '',
ethAddress: '',
emailAddress: '',
socialAccounts: {},
},
],
};
export default class CreateFlowTeam extends React.PureComponent<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
...DEFAULT_STATE,
...(props.initialState || {}),
};
// Don't allow for empty team array
// TODO: Default first user to auth'd user
if (!this.state.team.length) {
this.state = {
...this.state,
team: [...DEFAULT_STATE.team],
};
}
}
render() {
const { team } = this.state;
return (
<Placeholder
style={{ maxWidth: 580, margin: '0 auto' }}
title="Team isnt implemented yet"
subtitle="We dont yet have users built out. Skip this step for now."
/>
<div className="TeamForm">
{team.map((user, idx) => (
<TeamMemberComponent
key={idx}
index={idx}
user={user}
initialEditingState={!user.name}
onChange={this.handleChange}
onRemove={this.removeMember}
/>
))}
{team.length < MAX_TEAM_SIZE && (
<button className="TeamForm-add" onClick={this.addMember}>
<div className="TeamForm-add-icon">
<Icon type="plus" />
</div>
<div className="TeamForm-add-text">
<div className="TeamForm-add-text-title">Add a team member</div>
<div className="TeamForm-add-text-subtitle">
Find an existing user, or fill out their info yourself
</div>
</div>
</button>
)}
</div>
);
}
private handleChange = (user: TeamMember, idx: number) => {
const team = [...this.state.team];
team[idx] = user;
this.setState({ team });
this.props.updateForm({ team });
};
private addMember = () => {
const team = [...this.state.team, { ...DEFAULT_STATE.team[0] }];
this.setState({ team });
this.props.updateForm({ team });
};
private removeMember = (index: number) => {
const team = [
...this.state.team.slice(0, index),
...this.state.team.slice(index + 1),
];
this.setState({ team });
this.props.updateForm({ team });
};
}

View File

@ -0,0 +1,122 @@
.TeamMember {
position: relative;
display: flex;
align-items: center;
padding: 1rem;
margin: 0 auto 1rem;
background: #FFF;
box-shadow: 0 1px 2px rgba(#000, 0.2);
&.is-editing {
align-items: flex-start;
}
&-avatar {
position: relative;
height: 7.5rem;
width: 7.5rem;
margin-right: 1.25rem;
img {
height: 100%;
width: 100%;
border-radius: 8px;
}
&-change {
position: absolute;
top: 100%;
left: 50%;
transform: translateY(1rem) translateX(-50%);
}
}
&-info {
flex: 1;
// Read only view
&-name {
font-size: 1.6rem;
font-weight: 300;
}
&-title {
font-size: 1rem;
opacity: 0.7;
margin-bottom: 0.5rem;
}
&-social {
display: flex;
&-icon {
position: relative;
height: 1.3rem;
font-size: 1.3rem;
margin-right: 1rem;
opacity: 0.2;
&.is-active {
opacity: 1;
}
&:last-child {
margin: 0;
}
&-check {
position: absolute;
font-size: 0.8rem;
bottom: 0;
right: 0;
transform: translate(50%, 50%);
color: #2ecc71;
background: #FFF;
border: 1px solid #FFF;
border-radius: 100%;
}
}
}
&-edit {
position: absolute;
bottom: 1.5rem;
right: 1rem;
opacity: 0.5;
cursor: pointer;
&:hover {
opacity: 1;
}
}
&-remove {
position: absolute;
top: 0.5rem;
right: 1rem;
font-size: 1rem;
opacity: 0.3;
cursor: pointer;
&:hover {
opacity: 1;
}
}
// Edit form
.ant-form-item {
margin-bottom: 0.25rem;
}
.ant-btn {
margin-right: 0.5rem;
&:last-child {
margin: 0;
}
}
}
}

View File

@ -0,0 +1,239 @@
import React from 'react';
import classnames from 'classnames';
import { Input, Form, Col, Row, Button, Icon, Alert } from 'antd';
import { SOCIAL_TYPE, SOCIAL_INFO } from 'utils/social';
import { TeamMember } from 'modules/create/types';
import { getCreateTeamMemberError } from 'modules/create/utils';
import defaultUserImg from 'static/images/default-user.jpg';
import './TeamMember.less';
interface Props {
index: number;
user: TeamMember;
initialEditingState?: boolean;
onChange(user: TeamMember, index: number): void;
onRemove(index: number): void;
}
interface State {
fields: TeamMember;
isEditing: boolean;
}
export default class CreateFlowTeamMember extends React.PureComponent<Props, State> {
state: State = {
fields: { ...this.props.user },
isEditing: this.props.initialEditingState || false,
};
render() {
const { user, index } = this.props;
const { fields, isEditing } = this.state;
const error = getCreateTeamMemberError(fields);
const isMissingField =
!fields.name || !fields.title || !fields.emailAddress || !fields.ethAddress;
const isDisabled = !!error || isMissingField;
return (
<div className={classnames('TeamMember', isEditing && 'is-editing')}>
<div className="TeamMember-avatar">
<img src={fields.avatarUrl || defaultUserImg} />
{isEditing && (
<Button className="TeamMember-avatar-change" onClick={this.handleChangePhoto}>
Change
</Button>
)}
</div>
<div className="TeamMember-info">
{isEditing ? (
<Form
className="TeamMember-info-form"
layout="vertical"
onSubmit={this.toggleEditing}
>
<Form.Item>
<Input
name="name"
autoComplete="off"
placeholder="Display name (Required)"
value={fields.name}
onChange={this.handleChangeField}
/>
</Form.Item>
<Form.Item>
<Input
name="title"
autoComplete="off"
placeholder="Title (Required)"
value={fields.title}
onChange={this.handleChangeField}
/>
</Form.Item>
<Row gutter={12}>
<Col xs={24} sm={12}>
<Form.Item>
<Input
name="ethAddress"
autoComplete="ethAddress"
placeholder="Ethereum address (Required)"
value={fields.ethAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
</Col>
<Col xs={24} sm={12}>
<Form.Item>
<Input
name="emailAddress"
placeholder="Email address (Required)"
type="email"
autoComplete="email"
value={fields.emailAddress}
onChange={this.handleChangeField}
/>
</Form.Item>
</Col>
</Row>
<Row gutter={12}>
{Object.values(SOCIAL_INFO).map(s => (
<Col xs={24} sm={12} key={s.type}>
<Form.Item>
<Input
placeholder={`${s.name} account`}
autoComplete="off"
value={fields.socialAccounts[s.type]}
onChange={ev => this.handleSocialChange(ev, s.type)}
addonBefore={s.icon}
/>
</Form.Item>
</Col>
))}
</Row>
{!isMissingField &&
error && (
<Alert
type="error"
message={error}
showIcon
style={{ marginBottom: '0.75rem' }}
/>
)}
<Row>
<Button type="primary" htmlType="submit" disabled={isDisabled}>
Save changes
</Button>
<Button type="ghost" htmlType="button" onClick={this.cancelEditing}>
Cancel
</Button>
</Row>
</Form>
) : (
<>
<div className="TeamMember-info-name">{user.name || <em>No name</em>}</div>
<div className="TeamMember-info-title">
{user.title || <em>No title</em>}
</div>
<div className="TeamMember-info-social">
{Object.values(SOCIAL_INFO).map(s => {
const account = user.socialAccounts[s.type];
const cn = classnames(
'TeamMember-info-social-icon',
account && 'is-active',
);
return (
<div key={s.name} className={cn}>
{s.icon}
{account && (
<Icon
className="TeamMember-info-social-icon-check"
type="check-circle"
theme="filled"
/>
)}
</div>
);
})}
</div>
<button className="TeamMember-info-edit" onClick={this.toggleEditing}>
<Icon type="form" /> Edit
</button>
{index !== 0 && (
<button className="TeamMember-info-remove" onClick={this.removeMember}>
<Icon type="close-circle" theme="filled" />
</button>
)}
</>
)}
</div>
</div>
);
}
private toggleEditing = (ev?: React.SyntheticEvent<any>) => {
if (ev) {
ev.preventDefault();
}
const { isEditing, fields } = this.state;
if (isEditing) {
// TODO: Check if valid first
this.props.onChange(fields, this.props.index);
}
this.setState({ isEditing: !isEditing });
};
private cancelEditing = () => {
this.setState({
isEditing: false,
fields: { ...this.props.user },
});
};
private handleChangeField = (ev: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = ev.currentTarget;
this.setState({
fields: {
...this.state.fields,
[name as any]: value,
},
});
};
private handleSocialChange = (
ev: React.ChangeEvent<HTMLInputElement>,
type: SOCIAL_TYPE,
) => {
const { value } = ev.currentTarget;
this.setState({
fields: {
...this.state.fields,
socialAccounts: {
...this.state.fields.socialAccounts,
[type]: value,
},
},
});
};
private handleChangePhoto = () => {
// TODO: Actual file uploading
const gender = ['men', 'women'][Math.floor(Math.random() * 2)];
const num = Math.floor(Math.random() * 80);
this.setState({
fields: {
...this.state.fields,
avatarUrl: `https://randomuser.me/api/portraits/${gender}/${num}.jpg`,
},
});
};
private removeMember = () => {
this.props.onRemove(this.props.index);
};
}

View File

@ -1,10 +1,36 @@
import { PROPOSAL_CATEGORY } from 'api/constants';
import { CreateFormState } from 'modules/create/types';
const createExampleProposal = (payOutAddress: string, trustees: string[]) => {
const createExampleProposal = (
payOutAddress: string,
trustees: string[],
): CreateFormState => {
return {
title: 'Grant.io T-Shirts',
brief: "The most stylish wear, sporting your favorite brand's logo",
category: PROPOSAL_CATEGORY.COMMUNITY,
team: [
{
name: 'John Smith',
title: 'CEO of Grant.io',
avatarUrl: `https://randomuser.me/api/portraits/men/${Math.floor(
Math.random() * 80,
)}.jpg`,
ethAddress: payOutAddress,
emailAddress: 'test@grant.io',
socialAccounts: {},
},
{
name: 'Jane Smith',
title: 'T-Shirt Designer',
avatarUrl: `https://randomuser.me/api/portraits/women/${Math.floor(
Math.random() * 80,
)}.jpg`,
ethAddress: '0x4bbeEB066eD09B7AEd07bF39EEe0460DFa261520',
emailAddress: 'designer@tshirt.com',
socialAccounts: {},
},
],
details:
'![](https://i.imgur.com/aQagS0D.png)\n\nWe all know it, Grant.io is the bee\'s knees. But wouldn\'t it be great if you could show all your friends and family how much you love it? Well that\'s what we\'re here to offer today.\n\n# What We\'re Building\n\nWhy, T-Shirts of course! These beautiful shirts made out of 100% cotton and laser printed for long lasting goodness come from American Apparel. We\'ll be offering them in 4 styles:\n\n* Crew neck (wrinkled)\n* Crew neck (straight)\n* Scoop neck (fitted)\n* V neck (fitted)\n\nShirt sizings will be as follows:\n\n| Size | S | M | L | XL |\n|--------|-----|-----|-----|------|\n| **Width** | 18" | 20" | 22" | 24" |\n| **Length** | 28" | 29" | 30" | 31" |\n\n# Who We Are\n\nWe are the team behind grant.io. In addition to our software engineering experience, we have over 78 years of T-Shirt printing expertise combined. Sometimes I wake up at night and realize I was printing shirts in my dreams. Weird, man.\n\n# Expense Breakdown\n\n* $1,000 - A professional designer will hand-craft each letter on the shirt.\n* $500 - We\'ll get the shirt printed from 5 different factories and choose the best quality one.\n* $3,000 - The full run of prints, with 20 smalls, 20 mediums, and 20 larges.\n* $500 - Pizza. Lots of pizza.\n\n**Total**: $5,000',
amountToRaise: '5',

View File

@ -6,7 +6,7 @@ import qs from 'query-string';
import { withRouter, RouteComponentProps } from 'react-router';
import { debounce } from 'underscore';
import Basics from './Basics';
// import Team from './Team';
import Team from './Team';
import Details from './Details';
import Milestones from './Milestones';
import Governance from './Governance';
@ -25,7 +25,7 @@ import './index.less';
export enum CREATE_STEP {
BASICS = 'BASICS',
// TEAM = 'TEAM',
TEAM = 'TEAM',
DETAILS = 'DETAILS',
MILESTONES = 'MILESTONES',
GOVERNANCE = 'GOVERNANCE',
@ -34,7 +34,7 @@ export enum CREATE_STEP {
const STEP_ORDER = [
CREATE_STEP.BASICS,
// CREATE_STEP.TEAM,
CREATE_STEP.TEAM,
CREATE_STEP.DETAILS,
CREATE_STEP.MILESTONES,
CREATE_STEP.GOVERNANCE,
@ -57,14 +57,14 @@ const STEP_INFO: { [key in CREATE_STEP]: StepInfo } = {
'You dont have to fill out everything at once right now, you can come back later.',
component: Basics,
},
// [CREATE_STEP.TEAM]: {
// short: 'Team',
// title: 'Assemble your team',
// subtitle: 'Let everyone know if youre flying solo, or who youre working with',
// help:
// 'More team members, real names, and linked social accounts adds legitimacy to your proposal',
// component: Team,
// },
[CREATE_STEP.TEAM]: {
short: 'Team',
title: 'Assemble your team',
subtitle: 'Let everyone know if youre flying solo, or who youre working with',
help:
'More team members, real names, and linked social accounts adds legitimacy to your proposal',
component: Team,
},
[CREATE_STEP.DETAILS]: {
short: 'Details',
title: 'Dive into the details',
@ -198,7 +198,7 @@ class CreateFlow extends React.Component<Props, State> {
<div className="CreateFlow">
<div className="CreateFlow-header">
<Steps current={currentIndex}>
{STEP_ORDER.slice(0, 4).map(s => (
{STEP_ORDER.slice(0, 5).map(s => (
<Steps.Step
key={s}
title={STEP_INFO[s].short}

View File

@ -23,6 +23,7 @@ export const INITIAL_STATE: CreateState = {
payOutAddress: '',
trustees: [],
milestones: [],
team: [],
deadline: ONE_DAY * 60,
milestoneDeadline: ONE_DAY * 7,
},
@ -91,7 +92,7 @@ export default function createReducer(state: CreateState = INITIAL_STATE, action
form: action.payload
? {
...state.form,
...(action.payload || {}),
...action.payload,
}
: state.form,
};

View File

@ -1,4 +1,5 @@
import { PROPOSAL_CATEGORY } from 'api/constants';
import { SocialAccountMap } from 'utils/social';
enum CreateTypes {
UPDATE_FORM = 'UPDATE_FORM',
@ -31,6 +32,15 @@ export interface Milestone {
immediatePayout: boolean;
}
export interface TeamMember {
name: string;
title: string;
avatarUrl: string;
ethAddress: string;
emailAddress: string;
socialAccounts: SocialAccountMap;
}
export interface CreateFormState {
title: string;
brief: string;
@ -40,6 +50,7 @@ export interface CreateFormState {
payOutAddress: string;
trustees: string[];
milestones: Milestone[];
team: TeamMember[];
deadline: number | null;
milestoneDeadline: number | null;
}

View File

@ -1,4 +1,4 @@
import { CreateFormState, Milestone } from './types';
import { CreateFormState, Milestone, TeamMember } from './types';
import { isValidEthAddress, getAmountError } from 'utils/validators';
import { ProposalWithCrowdFund } from 'modules/proposals/reducers';
import { MILESTONE_STATE } from 'modules/proposals/reducers';
@ -13,6 +13,7 @@ interface CreateFormErrors {
brief?: string;
category?: string;
amountToRaise?: string;
team?: string[];
details?: string;
payOutAddress?: string;
trustees?: string[];
@ -27,6 +28,7 @@ export const FIELD_NAME_MAP: { [key in KeyOfForm]: string } = {
brief: 'Brief',
category: 'Category',
amountToRaise: 'Target amount',
team: 'Team',
details: 'Details',
payOutAddress: 'Payout address',
trustees: 'Trustees',
@ -40,7 +42,7 @@ export function getCreateErrors(
skipRequired?: boolean,
): CreateFormErrors {
const errors: CreateFormErrors = {};
const { title, milestones, amountToRaise, payOutAddress, trustees } = form;
const { title, team, milestones, amountToRaise, payOutAddress, trustees } = form;
// Required fields with no extra validation
if (!skipRequired) {
@ -53,6 +55,9 @@ export function getCreateErrors(
if (!milestones || !milestones.length) {
errors.milestones = ['Must have at least one milestone'];
}
if (!team || !team.length) {
errors.team = ['Must have at least one team member'];
}
}
// Title
@ -126,9 +131,39 @@ export function getCreateErrors(
errors.milestones = milestoneErrors;
}
// Team
let didTeamError = false;
const teamErrors = team.map(u => {
if (!u.name || !u.title || !u.emailAddress || !u.ethAddress) {
didTeamError = true;
return '';
}
const err = getCreateTeamMemberError(u);
didTeamError = didTeamError || !!err;
return err;
});
if (didTeamError) {
errors.team = teamErrors;
}
return errors;
}
export function getCreateTeamMemberError(user: TeamMember) {
if (user.name.length > 30) {
return 'Display name can only be 30 characters maximum';
} else if (user.title.length > 30) {
return 'Title can only be 30 characters maximum';
} else if (!/.+\@.+\..+/.test(user.emailAddress)) {
return 'That doesnt look like a valid email address';
} else if (!isValidEthAddress(user.ethAddress)) {
return 'That doesnt look like a valid ETH address';
}
return '';
}
function milestoneToMilestoneAmount(milestone: Milestone, raiseGoal: Wei) {
return raiseGoal.divn(100).mul(Wei(milestone.payoutPercent.toString()));
}
@ -157,6 +192,7 @@ export function formToBackendData(form: CreateFormState): ProposalBackendData {
title: form.title,
category: form.category,
content: form.details,
team: form.team,
};
}

View File

@ -8,6 +8,7 @@ import { fetchProposal, fetchProposals } from 'modules/proposals/actions';
import { PROPOSAL_CATEGORY } from 'api/constants';
import { AppState } from 'store/reducers';
import { Wei } from 'utils/units';
import { TeamMember } from 'modules/create/types';
type GetState = () => AppState;
@ -112,6 +113,7 @@ export interface ProposalBackendData {
title: string;
content: string;
category: PROPOSAL_CATEGORY;
team: TeamMember[];
}
export type TCreateCrowdFund = typeof createCrowdFund;
@ -136,7 +138,7 @@ export function createCrowdFund(
immediateFirstMilestonePayout,
} = contractData;
const { content, title, category } = backendData;
const { content, title, category, team } = backendData;
const state = getState();
const accounts = state.web3.accounts;
@ -163,6 +165,7 @@ export function createCrowdFund(
title,
milestones,
category,
team,
});
dispatch({
type: types.CROWD_FUND_CREATED,

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 412 512">
<path fill="currentColor" d="M177.2 430.9c0 9.8-8 17.8-17.8 17.8s-17.8-8-17.8-17.8 8-17.8 17.8-17.8c9.8-.1 17.8 7.9 17.8 17.8zM270 413c-9.8 0-17.8 8-17.8 17.8s8 17.8 17.8 17.8 17.8-8 17.8-17.8-8-17.8-17.8-17.8zm142.3-36c0 38.9-7.6 73.9-22.2 103h-27.3c23.5-38.7 30.5-94.8 22.4-134.3-16.1 29.5-52.1 38.6-85.9 28.8-127.8-37.5-192.5 19.7-234.6 50.3l18.9-59.3-39.9 42.3c4.8 26.7 15.7 51.3 31.2 72.3H46.1c-9.7-15.8-17.2-33-22.2-51.3L.1 454c0-74.9-5.5-147.6 61.5-215.2 20.2-20.4 43.7-36.2 69.1-46.7-6.8-13.5-9.5-29.2-7.8-46l-19.9-1.2c-17.9-1.1-31.6-16.5-30.6-34.4v-.1L74 84.2c1.1-17.1 15.4-30.6 32.5-30.6 1.3 0-.3-.1 28.2 1.7 13.9.8 21.5 9.8 22.8 11.4 7.1-10.4 14.5-20.5 24.6-34.5l20.6 12.1c-13.6 29-9.1 36.2-9 36.3 3.9 0 13.9-.5 32.4 5.7C246 92.9 262 107 271 126c.4.9 15.5 29 1.2 62.6 19 6.1 51.3 19.9 82.4 51.8 36.6 37.6 57.7 87.4 57.7 136.6zM128 122.3c3.2-10 7.7-19.7 13.1-29.4.1-2 2.2-13.1-7.8-13.8-28.5-1.8-26.3-1.6-26.7-1.6-4.6 0-8.3 3.5-8.6 8.1l-1.6 26.2c-.3 4.7 3.4 8.8 8.1 9.1l23.5 1.4zm25.8 61.8c5.6 9.4 14.1 16.1 22.3 20 0-21.2 28.5-41.9 52.8-17.5l8.4 10.3c20.8-18.8 19.4-45.3 12.1-60.9-13.8-29.1-46.9-32-54.3-31.7-10.3.4-19.7-5.4-23.7-15.3-13.7 21.2-37.2 62.5-17.6 95.1zm82.9 68.4L217 268.6c-1.9 1.6-2.2 4.4-.6 6.3l8.9 10.9c1 1.2 3.8 2.7 6.3.6l19.6-16 5.5 6.8c4.9 6 13.8-1.4 9-7.3-63.6-78.3-41.5-51.1-55.3-68.1-4.7-6-13.9 1.4-9 7.3 1.9 2.3 18.4 22.6 19.8 24.3l-9.6 7.9c-4.6 3.8 2.6 13.3 7.4 9.4l9.7-8 8 9.8zm118.4 25.7c-16.9-23.7-42.6-46.7-73.4-60.4-7.9-3.5-15-6.1-22.9-8.6-2 2.2-4.1 4.3-6.4 6.2l31.9 39.2c10.4 12.7 8.5 31.5-4.2 41.9-1.3 1.1-13.1 10.7-29 4.9-2.9 2.3-10.1 9.9-22.2 9.9-8.6 0-16.6-3.8-22.1-10.5l-8.9-10.9c-6.3-7.8-7.9-17.9-5-26.8-8.2-9.9-8.3-21.3-4.6-30-7.2-1.3-26.7-6.2-42.7-21.4-55.8 20.7-88 64.4-101.3 91.2-14.9 30.2-18.8 60.9-19.9 90.2 8.2-8.7-3.9 4.1 114-120.9l-29.9 93.6c57.8-31.1 124-36 197.4-14.4 23.6 6.9 45.1 1.6 56-13.9 11.1-15.6 8.5-37.7-6.8-59.3zM110.6 107.3l15.6 1 1-15.6-15.6-1-1 15.6z"></path>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1,5 +1,6 @@
declare module '*.svg' {
const content: string;
import React from 'react';
const content: React.ReactComponent<React.SVGProps<any>>;
export default content;
}

View File

@ -0,0 +1,75 @@
import React from 'react';
import { Icon } from 'antd';
import keybaseIcon from 'static/images/keybase.svg';
export enum SOCIAL_TYPE {
GITHUB = 'GITHUB',
TWITTER = 'TWITTER',
LINKEDIN = 'LINKEDIN',
KEYBASE = 'KEYBASE',
}
export interface SocialInfo {
type: SOCIAL_TYPE;
name: string;
format: string;
icon: React.ReactNode;
}
const accountNameRegex = '([a-zA-Z0-9-_]*)';
export const SOCIAL_INFO: { [key in SOCIAL_TYPE]: SocialInfo } = {
[SOCIAL_TYPE.GITHUB]: {
type: SOCIAL_TYPE.GITHUB,
name: 'Github',
format: `https://github.com/${accountNameRegex}`,
icon: <Icon type="github" />,
},
[SOCIAL_TYPE.TWITTER]: {
type: SOCIAL_TYPE.TWITTER,
name: 'Twitter',
format: `https://twitter.com/${accountNameRegex}`,
icon: <Icon type="twitter" />,
},
[SOCIAL_TYPE.LINKEDIN]: {
type: SOCIAL_TYPE.LINKEDIN,
name: 'LinkedIn',
format: `https://linkedin.com/in/${accountNameRegex}`,
icon: <Icon type="linkedin" />,
},
[SOCIAL_TYPE.KEYBASE]: {
type: SOCIAL_TYPE.KEYBASE,
name: 'KeyBase',
format: `https://keybase.io/${accountNameRegex}`,
icon: <Icon component={keybaseIcon} />,
},
};
export type SocialAccountMap = Partial<{ [key in SOCIAL_TYPE]: string }>;
function urlToAccount(format: string, url: string): string | false {
const matches = url.match(new RegExp(format));
return matches && matches[1] ? matches[1] : false;
}
export function socialAccountToUrl(account: string, type: SOCIAL_TYPE): string {
return SOCIAL_INFO[type].format.replace(accountNameRegex, account);
}
export function socialUrlsToAccounts(urls: string[]): SocialAccountMap {
const accounts: SocialAccountMap = {};
urls.forEach(url => {
Object.values(SOCIAL_INFO).forEach(s => {
const account = urlToAccount(s.format, url);
if (account) {
accounts[s.type] = account;
}
});
});
return accounts;
}
export function socialAccountsToUrls(accounts: SocialAccountMap): string[] {
return Object.keys(accounts).map((key: SOCIAL_TYPE) => {
return socialAccountToUrl(accounts[key], key);
});
}