diff options
Diffstat (limited to 'packages/react-shared/src')
13 files changed, 680 insertions, 0 deletions
diff --git a/packages/react-shared/src/ts/components/anchor_title.tsx b/packages/react-shared/src/ts/components/anchor_title.tsx new file mode 100644 index 000000000..f44354097 --- /dev/null +++ b/packages/react-shared/src/ts/components/anchor_title.tsx @@ -0,0 +1,87 @@ +import * as React from 'react'; +import { Link as ScrollLink } from 'react-scroll'; + +import { HeaderSizes, Styles } from '../types'; +import { constants } from '../utils/constants'; +import { utils } from '../utils/utils'; + +const headerSizeToScrollOffset: { [headerSize: string]: number } = { + h2: -20, + h3: 0, +}; + +export interface AnchorTitleProps { + title: string | React.ReactNode; + id: string; + headerSize: HeaderSizes; + shouldShowAnchor: boolean; +} + +export interface AnchorTitleState { + isHovering: boolean; +} + +const styles: Styles = { + anchor: { + fontSize: 20, + transform: 'rotate(45deg)', + cursor: 'pointer', + }, + headers: { + WebkitMarginStart: 0, + WebkitMarginEnd: 0, + fontWeight: 'bold', + display: 'block', + }, + h1: { + fontSize: '1.8em', + }, + h2: { + fontSize: '1.5em', + fontWeight: 400, + }, + h3: { + fontSize: '1.17em', + }, +}; + +export class AnchorTitle extends React.Component<AnchorTitleProps, AnchorTitleState> { + constructor(props: AnchorTitleProps) { + super(props); + this.state = { + isHovering: false, + }; + } + public render() { + let opacity = 0; + if (this.props.shouldShowAnchor) { + opacity = this.state.isHovering ? 0.6 : 1; + } + return ( + <div className="relative flex" style={{ ...styles[this.props.headerSize], ...styles.headers }}> + <div className="inline-block" style={{ paddingRight: 4 }}> + {this.props.title} + </div> + <ScrollLink + to={this.props.id} + offset={headerSizeToScrollOffset[this.props.headerSize]} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + > + <i + className="zmdi zmdi-link" + onClick={utils.setUrlHash.bind(utils, this.props.id)} + style={{ ...styles.anchor, opacity }} + onMouseOver={this._setHoverState.bind(this, true)} + onMouseOut={this._setHoverState.bind(this, false)} + /> + </ScrollLink> + </div> + ); + } + private _setHoverState(isHovering: boolean) { + this.setState({ + isHovering, + }); + } +} diff --git a/packages/react-shared/src/ts/components/markdown_code_block.tsx b/packages/react-shared/src/ts/components/markdown_code_block.tsx new file mode 100644 index 000000000..2070bb8e1 --- /dev/null +++ b/packages/react-shared/src/ts/components/markdown_code_block.tsx @@ -0,0 +1,25 @@ +import * as _ from 'lodash'; +import * as React from 'react'; +import * as HighLight from 'react-highlight'; + +export interface MarkdownCodeBlockProps { + value: string; + language: string; +} + +export interface MarkdownCodeBlockState {} + +export class MarkdownCodeBlock extends React.Component<MarkdownCodeBlockProps, MarkdownCodeBlockState> { + // Re-rendering a codeblock causes any use selection to become de-selected. This is annoying when trying + // to copy-paste code examples. We therefore noop re-renders on this component if it's props haven't changed. + public shouldComponentUpdate(nextProps: MarkdownCodeBlockProps, nextState: MarkdownCodeBlockState) { + return nextProps.value !== this.props.value || nextProps.language !== this.props.language; + } + public render() { + return ( + <span style={{ fontSize: 14 }}> + <HighLight className={this.props.language || 'javascript'}>{this.props.value}</HighLight> + </span> + ); + } +} diff --git a/packages/react-shared/src/ts/components/markdown_link_block.tsx b/packages/react-shared/src/ts/components/markdown_link_block.tsx new file mode 100644 index 000000000..8f5862249 --- /dev/null +++ b/packages/react-shared/src/ts/components/markdown_link_block.tsx @@ -0,0 +1,47 @@ +import * as _ from 'lodash'; +import * as React from 'react'; + +import { constants } from '../utils/constants'; +import { utils } from '../utils/utils'; + +export interface MarkdownLinkBlockProps { + href: string; +} + +export interface MarkdownLinkBlockState {} + +export class MarkdownLinkBlock extends React.Component<MarkdownLinkBlockProps, MarkdownLinkBlockState> { + // Re-rendering a linkBlock causes it to remain unclickable. + // We therefore noop re-renders on this component if it's props haven't changed. + public shouldComponentUpdate(nextProps: MarkdownLinkBlockProps, nextState: MarkdownLinkBlockState) { + return nextProps.href !== this.props.href; + } + public render() { + const href = this.props.href; + const isLinkToSection = _.startsWith(href, '#'); + // If protocol is http or https, we can open in a new tab, otherwise don't for security reasons + if (_.startsWith(href, 'http') || _.startsWith(href, 'https')) { + return ( + <a href={href} target="_blank" rel="nofollow noreferrer noopener"> + {this.props.children} + </a> + ); + } else if (isLinkToSection) { + return ( + <a + style={{ cursor: 'pointer', textDecoration: 'underline' }} + onClick={this._onHashUrlClick.bind(this, href)} + > + {this.props.children} + </a> + ); + } else { + return <a href={href}>{this.props.children}</a>; + } + } + private _onHashUrlClick(href: string) { + const hash = href.split('#')[1]; + utils.scrollToHash(hash, constants.SCROLL_CONTAINER_ID); + utils.setUrlHash(hash); + } +} diff --git a/packages/react-shared/src/ts/components/markdown_section.tsx b/packages/react-shared/src/ts/components/markdown_section.tsx new file mode 100644 index 000000000..d24a43dcb --- /dev/null +++ b/packages/react-shared/src/ts/components/markdown_section.tsx @@ -0,0 +1,94 @@ +import * as _ from 'lodash'; +import RaisedButton from 'material-ui/RaisedButton'; +import * as React from 'react'; +import * as ReactMarkdown from 'react-markdown'; +import { Element as ScrollElement } from 'react-scroll'; + +import { HeaderSizes } from '../types'; +import { colors } from '../utils/colors'; +import { utils } from '../utils/utils'; + +import { AnchorTitle } from './anchor_title'; +import { MarkdownCodeBlock } from './markdown_code_block'; +import { MarkdownLinkBlock } from './markdown_link_block'; + +export interface MarkdownSectionProps { + sectionName: string; + markdownContent: string; + headerSize?: HeaderSizes; + githubLink?: string; +} + +interface DefaultMarkdownSectionProps { + headerSize: HeaderSizes; +} + +type PropsWithDefaults = MarkdownSectionProps & DefaultMarkdownSectionProps; + +export interface MarkdownSectionState { + shouldShowAnchor: boolean; +} + +export class MarkdownSection extends React.Component<MarkdownSectionProps, MarkdownSectionState> { + public static defaultProps: Partial<MarkdownSectionProps> = { + headerSize: HeaderSizes.H3, + }; + constructor(props: MarkdownSectionProps) { + super(props); + this.state = { + shouldShowAnchor: false, + }; + } + public render() { + const { sectionName, markdownContent, headerSize, githubLink } = this.props as PropsWithDefaults; + + const id = utils.getIdFromName(sectionName); + return ( + <div + className="md-px1 sm-px2 overflow-hidden" + onMouseOver={this._setAnchorVisibility.bind(this, true)} + onMouseOut={this._setAnchorVisibility.bind(this, false)} + > + <ScrollElement name={id}> + <div className="clearfix pt3"> + <div className="col lg-col-8 md-col-8 sm-col-12"> + <span style={{ textTransform: 'capitalize', color: colors.grey700 }}> + <AnchorTitle + headerSize={headerSize} + title={sectionName} + id={id} + shouldShowAnchor={this.state.shouldShowAnchor} + /> + </span> + </div> + <div className="col col-4 sm-hide xs-hide right-align pr3" style={{ height: 28 }}> + {!_.isUndefined(githubLink) && ( + <a + href={githubLink} + target="_blank" + style={{ color: colors.linkBlue, textDecoration: 'none', lineHeight: 2.1 }} + > + Edit on Github + </a> + )} + </div> + </div> + <hr style={{ border: `1px solid ${colors.lightestGrey}` }} /> + <ReactMarkdown + source={markdownContent} + escapeHtml={false} + renderers={{ + code: MarkdownCodeBlock, + link: MarkdownLinkBlock, + }} + /> + </ScrollElement> + </div> + ); + } + private _setAnchorVisibility(shouldShowAnchor: boolean) { + this.setState({ + shouldShowAnchor, + }); + } +} diff --git a/packages/react-shared/src/ts/components/nested_sidebar_menu.tsx b/packages/react-shared/src/ts/components/nested_sidebar_menu.tsx new file mode 100644 index 000000000..2225bd197 --- /dev/null +++ b/packages/react-shared/src/ts/components/nested_sidebar_menu.tsx @@ -0,0 +1,158 @@ +import * as _ from 'lodash'; +import MenuItem from 'material-ui/MenuItem'; +import * as React from 'react'; +import { Link as ScrollLink } from 'react-scroll'; + +import { MenuSubsectionsBySection, Styles } from '../types'; +import { colors } from '../utils/colors'; +import { constants } from '../utils/constants'; +import { utils } from '../utils/utils'; + +import { VersionDropDown } from './version_drop_down'; + +export interface NestedSidebarMenuProps { + topLevelMenu: { [topLevel: string]: string[] }; + menuSubsectionsBySection: MenuSubsectionsBySection; + sidebarHeader?: React.ReactNode; + shouldDisplaySectionHeaders?: boolean; + onMenuItemClick?: () => void; + selectedVersion?: string; + versions?: string[]; + onVersionSelected?: (semver: string) => void; +} + +export interface NestedSidebarMenuState {} + +const styles: Styles = { + menuItemWithHeaders: { + minHeight: 0, + }, + menuItemWithoutHeaders: { + minHeight: 48, + }, + menuItemInnerDivWithHeaders: { + color: colors.grey800, + fontSize: 14, + lineHeight: 2, + padding: 0, + }, +}; + +export class NestedSidebarMenu extends React.Component<NestedSidebarMenuProps, NestedSidebarMenuState> { + public static defaultProps: Partial<NestedSidebarMenuProps> = { + shouldDisplaySectionHeaders: true, + onMenuItemClick: _.noop, + }; + public render() { + const navigation = _.map(this.props.topLevelMenu, (menuItems: string[], sectionName: string) => { + const finalSectionName = sectionName.replace(/-/g, ' '); + if (this.props.shouldDisplaySectionHeaders) { + const id = utils.getIdFromName(sectionName); + return ( + <div key={`section-${sectionName}`} className="py1" style={{ color: colors.grey800 }}> + <div style={{ fontWeight: 'bold', fontSize: 15 }} className="py1"> + {finalSectionName.toUpperCase()} + </div> + {this._renderMenuItems(menuItems)} + </div> + ); + } else { + return <div key={`section-${sectionName}`}>{this._renderMenuItems(menuItems)}</div>; + } + }); + const maxWidthWithScrollbar = 307; + return ( + <div> + {this.props.sidebarHeader} + {!_.isUndefined(this.props.versions) && + !_.isUndefined(this.props.selectedVersion) && + !_.isUndefined(this.props.onVersionSelected) && ( + <div style={{ maxWidth: maxWidthWithScrollbar }}> + <VersionDropDown + selectedVersion={this.props.selectedVersion} + versions={this.props.versions} + onVersionSelected={this.props.onVersionSelected} + /> + </div> + )} + <div className="pl1">{navigation}</div> + </div> + ); + } + private _renderMenuItems(menuItemNames: string[]): React.ReactNode[] { + const menuItemStyles = this.props.shouldDisplaySectionHeaders + ? styles.menuItemWithHeaders + : styles.menuItemWithoutHeaders; + const menuItemInnerDivStyles = this.props.shouldDisplaySectionHeaders ? styles.menuItemInnerDivWithHeaders : {}; + const menuItems = _.map(menuItemNames, menuItemName => { + const id = utils.getIdFromName(menuItemName); + return ( + <div key={menuItemName}> + <ScrollLink + key={`menuItem-${menuItemName}`} + to={id} + offset={-10} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + > + <MenuItem + onTouchTap={this._onMenuItemClick.bind(this, menuItemName)} + style={menuItemStyles} + innerDivStyle={menuItemInnerDivStyles} + > + <span style={{ textTransform: 'capitalize' }}>{menuItemName}</span> + </MenuItem> + </ScrollLink> + {this._renderMenuItemSubsections(menuItemName)} + </div> + ); + }); + return menuItems; + } + private _renderMenuItemSubsections(menuItemName: string): React.ReactNode { + if (_.isUndefined(this.props.menuSubsectionsBySection[menuItemName])) { + return null; + } + return this._renderMenuSubsectionsBySection(menuItemName, this.props.menuSubsectionsBySection[menuItemName]); + } + private _renderMenuSubsectionsBySection(menuItemName: string, entityNames: string[]): React.ReactNode { + return ( + <ul style={{ margin: 0, listStyleType: 'none', paddingLeft: 0 }} key={menuItemName}> + {_.map(entityNames, entityName => { + const name = `${menuItemName}-${entityName}`; + const id = utils.getIdFromName(name); + return ( + <li key={`menuItem-${entityName}`}> + <ScrollLink + to={id} + offset={0} + duration={constants.DOCS_SCROLL_DURATION_MS} + containerId={constants.DOCS_CONTAINER_ID} + onTouchTap={this._onMenuItemClick.bind(this, name)} + > + <MenuItem + onTouchTap={this._onMenuItemClick.bind(this, name)} + style={{ minHeight: 35 }} + innerDivStyle={{ + paddingLeft: 16, + fontSize: 14, + lineHeight: '35px', + }} + > + {entityName} + </MenuItem> + </ScrollLink> + </li> + ); + })} + </ul> + ); + } + private _onMenuItemClick(name: string): void { + const id = utils.getIdFromName(name); + utils.setUrlHash(id); + if (!_.isUndefined(this.props.onMenuItemClick)) { + this.props.onMenuItemClick(); + } + } +} diff --git a/packages/react-shared/src/ts/components/section_header.tsx b/packages/react-shared/src/ts/components/section_header.tsx new file mode 100644 index 000000000..ee34a6c09 --- /dev/null +++ b/packages/react-shared/src/ts/components/section_header.tsx @@ -0,0 +1,73 @@ +import * as React from 'react'; +import { Element as ScrollElement } from 'react-scroll'; + +import { HeaderSizes } from '../types'; +import { colors } from '../utils/colors'; +import { utils } from '../utils/utils'; + +import { AnchorTitle } from './anchor_title'; + +export interface SectionHeaderProps { + sectionName: string; + headerSize?: HeaderSizes; +} + +interface DefaultSectionHeaderProps { + headerSize: HeaderSizes; +} + +type PropsWithDefaults = SectionHeaderProps & DefaultSectionHeaderProps; + +export interface SectionHeaderState { + shouldShowAnchor: boolean; +} + +export class SectionHeader extends React.Component<SectionHeaderProps, SectionHeaderState> { + public static defaultProps: Partial<SectionHeaderProps> = { + headerSize: HeaderSizes.H2, + }; + constructor(props: SectionHeaderProps) { + super(props); + this.state = { + shouldShowAnchor: false, + }; + } + public render() { + const { sectionName, headerSize } = this.props as PropsWithDefaults; + + const finalSectionName = this.props.sectionName.replace(/-/g, ' '); + const id = utils.getIdFromName(finalSectionName); + return ( + <div + onMouseOver={this._setAnchorVisibility.bind(this, true)} + onMouseOut={this._setAnchorVisibility.bind(this, false)} + > + <ScrollElement name={id}> + <AnchorTitle + headerSize={headerSize} + title={ + <span + style={{ + textTransform: 'uppercase', + color: colors.grey, + fontFamily: 'Roboto Mono', + fontWeight: 300, + fontSize: 27, + }} + > + {finalSectionName} + </span> + } + id={id} + shouldShowAnchor={this.state.shouldShowAnchor} + /> + </ScrollElement> + </div> + ); + } + private _setAnchorVisibility(shouldShowAnchor: boolean) { + this.setState({ + shouldShowAnchor, + }); + } +} diff --git a/packages/react-shared/src/ts/components/version_drop_down.tsx b/packages/react-shared/src/ts/components/version_drop_down.tsx new file mode 100644 index 000000000..d9e49b205 --- /dev/null +++ b/packages/react-shared/src/ts/components/version_drop_down.tsx @@ -0,0 +1,39 @@ +import * as _ from 'lodash'; +import DropDownMenu from 'material-ui/DropDownMenu'; +import MenuItem from 'material-ui/MenuItem'; +import * as React from 'react'; + +import { utils } from '../utils/utils'; + +export interface VersionDropDownProps { + selectedVersion: string; + versions: string[]; + onVersionSelected: (semver: string) => void; +} + +export interface VersionDropDownState {} + +export class VersionDropDown extends React.Component<VersionDropDownProps, VersionDropDownState> { + public render() { + return ( + <div className="mx-auto" style={{ width: 120 }}> + <DropDownMenu + maxHeight={300} + value={this.props.selectedVersion} + onChange={this._updateSelectedVersion.bind(this)} + > + {this._renderDropDownItems()} + </DropDownMenu> + </div> + ); + } + private _renderDropDownItems() { + const items = _.map(this.props.versions, version => { + return <MenuItem key={version} value={version} primaryText={`v${version}`} />; + }); + return items; + } + private _updateSelectedVersion(e: any, index: number, semver: string) { + this.props.onVersionSelected(semver); + } +} diff --git a/packages/react-shared/src/ts/globals.d.ts b/packages/react-shared/src/ts/globals.d.ts new file mode 100644 index 000000000..9b0bcf845 --- /dev/null +++ b/packages/react-shared/src/ts/globals.d.ts @@ -0,0 +1,7 @@ +declare module 'react-highlight'; + +// is-mobile declarations +declare function isMobile(): boolean; +declare module 'is-mobile' { + export = isMobile; +} diff --git a/packages/react-shared/src/ts/index.ts b/packages/react-shared/src/ts/index.ts new file mode 100644 index 000000000..3b50c0117 --- /dev/null +++ b/packages/react-shared/src/ts/index.ts @@ -0,0 +1,12 @@ +export { AnchorTitle } from './components/anchor_title'; +export { MarkdownLinkBlock } from './components/markdown_link_block'; +export { MarkdownCodeBlock } from './components/markdown_code_block'; +export { MarkdownSection } from './components/markdown_section'; +export { NestedSidebarMenu } from './components/nested_sidebar_menu'; +export { SectionHeader } from './components/section_header'; + +export { HeaderSizes, Styles, MenuSubsectionsBySection, EtherscanLinkSuffixes, Networks } from './types'; + +export { utils } from './utils/utils'; +export { constants } from './utils/constants'; +export { colors } from './utils/colors'; diff --git a/packages/react-shared/src/ts/types.ts b/packages/react-shared/src/ts/types.ts new file mode 100644 index 000000000..88fadcc09 --- /dev/null +++ b/packages/react-shared/src/ts/types.ts @@ -0,0 +1,25 @@ +export interface Styles { + [name: string]: React.CSSProperties; +} + +export enum HeaderSizes { + H1 = 'h1', + H2 = 'h2', + H3 = 'h3', +} + +export interface MenuSubsectionsBySection { + [section: string]: string[]; +} + +export enum EtherscanLinkSuffixes { + Address = 'address', + Tx = 'tx', +} + +export enum Networks { + Mainnet = 'Mainnet', + Kovan = 'Kovan', + Ropsten = 'Ropsten', + Rinkeby = 'Rinkeby', +} diff --git a/packages/react-shared/src/ts/utils/colors.ts b/packages/react-shared/src/ts/utils/colors.ts new file mode 100644 index 000000000..2eead95c7 --- /dev/null +++ b/packages/react-shared/src/ts/utils/colors.ts @@ -0,0 +1,48 @@ +import { colors as materialUiColors } from 'material-ui/styles'; + +export const colors = { + ...materialUiColors, + gray40: '#F8F8F8', + grey50: '#FAFAFA', + grey100: '#F5F5F5', + lightestGrey: '#F0F0F0', + greyishPink: '#E6E5E5', + grey300: '#E0E0E0', + beigeWhite: '#E4E4E4', + grey350: '#cacaca', + grey400: '#BDBDBD', + lightGrey: '#BBBBBB', + grey500: '#9E9E9E', + grey: '#A5A5A5', + darkGrey: '#818181', + landingLinkGrey: '#919191', + grey700: '#616161', + grey750: '#515151', + grey800: '#424242', + darkerGrey: '#393939', + heroGrey: '#404040', + projectsGrey: '#343333', + darkestGrey: '#272727', + dharmaDarkGrey: '#252525', + lightBlue: '#60A4F4', + lightBlueA700: '#0091EA', + linkBlue: '#1D5CDE', + darkBlue: '#4D5481', + turquois: '#058789', + lightPurple: '#A81CA6', + purple: '#690596', + red200: '#EF9A9A', + red: '#E91751', + red500: '#F44336', + red600: '#E53935', + limeGreen: '#66DE75', + lightGreen: '#4DC55C', + lightestGreen: '#89C774', + brightGreen: '#00C33E', + green400: '#66BB6A', + green: '#4DA24B', + amber600: '#FFB300', + orange: '#E69D00', + amber800: '#FF8F00', + darkYellow: '#caca03', +}; diff --git a/packages/react-shared/src/ts/utils/constants.ts b/packages/react-shared/src/ts/utils/constants.ts new file mode 100644 index 000000000..562ab776b --- /dev/null +++ b/packages/react-shared/src/ts/utils/constants.ts @@ -0,0 +1,20 @@ +import { Networks } from '../types'; + +export const constants = { + DOCS_SCROLL_DURATION_MS: 0, + DOCS_CONTAINER_ID: 'documentation', + SCROLL_CONTAINER_ID: 'documentation', + SCROLL_TOP_ID: 'pageScrollTop', + NETWORK_NAME_BY_ID: { + 1: Networks.Mainnet, + 3: Networks.Ropsten, + 4: Networks.Rinkeby, + 42: Networks.Kovan, + } as { [symbol: number]: string }, + NETWORK_ID_BY_NAME: { + [Networks.Mainnet]: 1, + [Networks.Ropsten]: 3, + [Networks.Rinkeby]: 4, + [Networks.Kovan]: 42, + } as { [networkName: string]: number }, +}; diff --git a/packages/react-shared/src/ts/utils/utils.ts b/packages/react-shared/src/ts/utils/utils.ts new file mode 100644 index 000000000..b3acb081e --- /dev/null +++ b/packages/react-shared/src/ts/utils/utils.ts @@ -0,0 +1,45 @@ +import isMobile = require('is-mobile'); +import * as _ from 'lodash'; +import { scroller } from 'react-scroll'; + +import { EtherscanLinkSuffixes, Networks } from '../types'; + +import { constants } from './constants'; + +export const utils = { + setUrlHash(anchorId: string) { + window.location.hash = anchorId; + }, + scrollToHash(hash: string, containerId: string): void { + let finalHash = hash; + if (_.isEmpty(hash)) { + finalHash = constants.SCROLL_TOP_ID; // scroll to the top + } + + scroller.scrollTo(finalHash, { + duration: 0, + offset: 0, + containerId, + }); + }, + isUserOnMobile(): boolean { + const isUserOnMobile = isMobile(); + return isUserOnMobile; + }, + getIdFromName(name: string) { + const id = name.replace(/ /g, '-'); + return id; + }, + getEtherScanLinkIfExists( + addressOrTxHash: string, + networkId: number, + suffix: EtherscanLinkSuffixes, + ): string | undefined { + const networkName = constants.NETWORK_NAME_BY_ID[networkId]; + if (_.isUndefined(networkName)) { + return undefined; + } + const etherScanPrefix = networkName === Networks.Mainnet ? '' : `${networkName.toLowerCase()}.`; + return `https://${etherScanPrefix}etherscan.io/${suffix}/${addressOrTxHash}`; + }, +}; |