diff --git a/ui/component/channelSelector/index.js b/ui/component/channelSelector/index.js new file mode 100644 index 000000000..9652ddd95 --- /dev/null +++ b/ui/component/channelSelector/index.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import SelectChannel from './view'; +import { + selectBalance, + selectMyChannelClaims, + selectFetchingMyChannels, + doFetchChannelListMine, + doCreateChannel, +} from 'lbry-redux'; +import { selectUserVerifiedEmail } from 'lbryinc'; + +const select = state => ({ + channels: selectMyChannelClaims(state), + fetchingChannels: selectFetchingMyChannels(state), + balance: selectBalance(state), + emailVerified: selectUserVerifiedEmail(state), +}); + +const perform = dispatch => ({ + createChannel: (name, amount) => dispatch(doCreateChannel(name, amount)), + fetchChannelListMine: () => dispatch(doFetchChannelListMine()), +}); + +export default connect(select, perform)(SelectChannel); diff --git a/ui/component/channelSelector/view.jsx b/ui/component/channelSelector/view.jsx new file mode 100644 index 000000000..fa6303cca --- /dev/null +++ b/ui/component/channelSelector/view.jsx @@ -0,0 +1,59 @@ +// @flow +import * as ICONS from 'constants/icons'; +import classnames from 'classnames'; +import React from 'react'; +import ChannelThumbnail from 'component/channelThumbnail'; +import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button'; +import ClaimPreviewTitle from 'component/claimPreviewTitle'; +import Icon from 'component/common/icon'; + +type Props = { + selectedChannelUrl: string, // currently selected channel + channels: ?Array<ChannelClaim>, + onChannelSelect: (url: string) => void, +}; + +type ListItemProps = { + url: string, + isSelected?: boolean, +}; + +function ChannelListItem(props: ListItemProps) { + const { url, isSelected = false } = props; + + return ( + <div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}> + <ChannelThumbnail uri={url} /> + <ClaimPreviewTitle uri={url} /> + {isSelected && <Icon icon={ICONS.DOWN} />} + </div> + ); +} + +function ChannelSelector(props: Props) { + const { channels, selectedChannelUrl, onChannelSelect } = props; + + if (!selectedChannelUrl) { + return null; + } + + return ( + <div> + <Menu> + <MenuButton className=""> + <ChannelListItem url={selectedChannelUrl} isSelected /> + </MenuButton> + <MenuList className=""> + {channels && + channels.map(channel => ( + <MenuItem key={channel.canonical_url} onSelect={() => onChannelSelect(channel.canonical_url)}> + <ChannelListItem url={channel.canonical_url} /> + </MenuItem> + ))} + </MenuList> + </Menu> + </div> + ); +} + +export default ChannelSelector; diff --git a/ui/component/common/icon.jsx b/ui/component/common/icon.jsx index 7a7eee3f7..90931ebd7 100644 --- a/ui/component/common/icon.jsx +++ b/ui/component/common/icon.jsx @@ -74,7 +74,7 @@ class IconComponent extends React.PureComponent<Props> { <Icon size={sectionIcon ? 20 : iconSize} className={classnames(`icon icon--${icon}`, className)} color={color} /> ); - const inner = sectionIcon ? <span className="icon__wrapper">{component}</span> : component; + const inner = sectionIcon ? <span className={`icon__wrapper icon__wrapper--${icon}`}>{component}</span> : component; return tooltipText ? <Tooltip label={tooltipText}>{inner}</Tooltip> : inner; } diff --git a/ui/component/router/view.jsx b/ui/component/router/view.jsx index ea8e5091a..ea72be5a7 100644 --- a/ui/component/router/view.jsx +++ b/ui/component/router/view.jsx @@ -32,9 +32,10 @@ import SignInVerifyPage from 'page/signInVerify'; import ChannelsPage from 'page/channels'; import EmbedWrapperPage from 'page/embedWrapper'; import TopPage from 'page/top'; +import Welcome from 'page/welcome'; +import CreatorDashboard from 'page/creatorDashboard'; import { parseURI } from 'lbry-redux'; import { SITE_TITLE, WELCOME_VERSION } from 'config'; -import Welcome from 'page/welcome'; // Tell the browser we are handling scroll restoration if ('scrollRestoration' in history) { @@ -149,7 +150,7 @@ function AppRouter(props: Props) { <Redirect from={`/$/${PAGES.DEPRECATED__TAGS_FOLLOWING}`} to={`/$/${PAGES.TAGS_FOLLOWING}`} /> <Redirect from={`/$/${PAGES.DEPRECATED__TAGS_FOLLOWING_MANAGE}`} to={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} /> - <Route path={`/`} exact component={HomePage} /> + <Route path={`/`} exact component={CreatorDashboard || HomePage} /> <Route path={`/$/${PAGES.DISCOVER}`} exact component={DiscoverPage} /> <Route path={`/$/${PAGES.AUTH}`} exact component={SignInPage} /> <Route path={`/$/${PAGES.AUTH}/*`} exact component={SignInPage} /> @@ -174,6 +175,7 @@ function AppRouter(props: Props) { <PrivateRoute {...props} path={`/$/${PAGES.INVITE}`} component={InvitePage} /> <PrivateRoute {...props} path={`/$/${PAGES.DOWNLOADED}`} component={FileListDownloaded} /> <PrivateRoute {...props} path={`/$/${PAGES.PUBLISHED}`} component={FileListPublished} /> + <PrivateRoute {...props} path={`/$/${PAGES.CREATOR_DASHBOARD}`} component={CreatorDashboard} /> <PrivateRoute {...props} path={`/$/${PAGES.PUBLISH}`} component={PublishPage} /> <PrivateRoute {...props} path={`/$/${PAGES.REPORT}`} component={ReportPage} /> <PrivateRoute {...props} path={`/$/${PAGES.REWARDS}`} component={RewardsPage} /> diff --git a/ui/component/selectChannel/view.jsx b/ui/component/selectChannel/view.jsx index 875e61b75..15aa530b6 100644 --- a/ui/component/selectChannel/view.jsx +++ b/ui/component/selectChannel/view.jsx @@ -13,7 +13,7 @@ type Props = { fetchChannelListMine: () => void, fetchingChannels: boolean, hideAnon: boolean, - includeNew?: boolean, + hideNew: boolean, label?: string, injected?: Array<string>, emailVerified: boolean, @@ -71,7 +71,7 @@ class ChannelSection extends React.PureComponent<Props, State> { render() { const channel = this.state.addingChannel ? 'new' : this.props.channel; - const { fetchingChannels, channels = [], hideAnon, label, injected = [] } = this.props; + const { fetchingChannels, channels = [], hideAnon, hideNew, label, injected = [] } = this.props; const { addingChannel } = this.state; return ( @@ -96,7 +96,7 @@ class ChannelSection extends React.PureComponent<Props, State> { {item} </option> ))} - {!fetchingChannels && <option value={CHANNEL_NEW}>{__('New channel...')}</option>} + {!fetchingChannels && !hideNew && <option value={CHANNEL_NEW}>{__('New channel...')}</option>} </FormField> {addingChannel && <ChannelCreate onSuccess={this.handleChangeToNewChannel} />} diff --git a/ui/constants/pages.js b/ui/constants/pages.js index 5ad175a65..00bbcf6fa 100644 --- a/ui/constants/pages.js +++ b/ui/constants/pages.js @@ -33,3 +33,4 @@ exports.CHANNELS = 'channels'; exports.EMBED = 'embed'; exports.TOP = 'top'; exports.WELCOME = 'welcome'; +exports.CREATOR_DASHBOARD = 'dashboard'; diff --git a/ui/page/creatorDashboard/index.js b/ui/page/creatorDashboard/index.js new file mode 100644 index 000000000..35d76bc8c --- /dev/null +++ b/ui/page/creatorDashboard/index.js @@ -0,0 +1,10 @@ +import { connect } from 'react-redux'; +import { selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux'; +import Welcome from './view'; + +const select = state => ({ + channels: selectMyChannelClaims(state), + fetchingChannels: selectFetchingMyChannels(state), +}); + +export default connect(select)(Welcome); diff --git a/ui/page/creatorDashboard/view.jsx b/ui/page/creatorDashboard/view.jsx new file mode 100644 index 000000000..f0178182f --- /dev/null +++ b/ui/page/creatorDashboard/view.jsx @@ -0,0 +1,125 @@ +// @flow +import * as ICONS from 'constants/icons'; +import React from 'react'; +import Page from 'component/page'; +import { Lbryio } from 'lbryinc'; +import ChannelSelector from 'component/channelSelector'; +import ClaimPreview from 'component/claimPreview'; +import Card from 'component/common/card'; +import Spinner from 'component/spinner'; +import Icon from 'component/common/icon'; +import usePersistedState from 'effects/use-persisted-state'; + +type Props = { + channels: Array<ChannelClaim>, +}; + +export default function CreatorDashboardPage(props: Props) { + const { channels } = props; + const [stats, setStats] = React.useState(); + const [selectedChannelUrl, setSelectedChannelUrl] = usePersistedState('analytics-selected-channel'); + const [fetchingStats, setFetchingStats] = React.useState(false); + const topChannel = + channels && + channels.reduce((top, channel) => { + const topClaimCount = (top && top.meta && top.meta.claims_in_channel) || 0; + const currentClaimCount = (channel && channel.meta && channel.meta.claims_in_channel) || 0; + // $FlowFixMe + return topClaimCount >= currentClaimCount ? top : channel.canonical_url; + }); + + const selectedChannelClaim = channels && channels.find(claim => claim.canonical_url === selectedChannelUrl); + const selectedChannelClaimId = selectedChannelClaim && selectedChannelClaim.claim_id; + + React.useEffect(() => { + // set default channel + if (!selectedChannelUrl && topChannel) { + setSelectedChannelUrl(topChannel); + } + }, [selectedChannelUrl, topChannel]); + + React.useEffect(() => { + if (selectedChannelClaimId && !fetchingStats) { + setFetchingStats(true); + Lbryio.call('reports', 'content', { claim_id: selectedChannelClaimId }) + .then(res => { + setFetchingStats(false); + setStats(res); + }) + .catch(() => { + setFetchingStats(false); + }); + } + }, [selectedChannelClaimId, setFetchingStats]); + + return ( + <Page> + <div className="section"> + <ChannelSelector + selectedChannelUrl={selectedChannelUrl} + onChannelSelect={newChannelUrl => { + setStats(null); + setSelectedChannelUrl(newChannelUrl); + }} + /> + </div> + {fetchingStats && !stats && ( + <div className="main--empty"> + <Spinner delayed /> + </div> + )} + {!fetchingStats && !stats && <div className="main--empty">This channel doesn't have any publishes</div>} + {stats && ( + <div className="section"> + <div className="columns"> + <Card + iconColor + title={<span>{stats.ChannelSubs} followers</span>} + icon={ICONS.SUBSCRIBE} + subtitle={ + <div className="card__data-subtitle"> + <span> + {stats.ChannelSubChange > 0 ? '+' : '-'} {stats.ChannelSubChange || 0} this week + </span> + {stats.ChannelSubChange > 0 && <Icon icon={ICONS.SUPPORT} iconColor="green" size={18} />} + </div> + } + /> + <Card + icon={ICONS.EYE} + title={<span>{stats.AllContentViews} views</span>} + subtitle={<span>{stats.AllContentViewsChange || 0} this week</span>} + /> + </div> + + <Card + title={ + <div className="card__data-subtitle"> + <span>Most Viewed Claim</span> + </div> + } + body={ + <React.Fragment> + <div className="card--inline"> + <ClaimPreview + uri={stats.VideoURITopAllTime} + properties={ + <div className="section__subtitle card__data-subtitle"> + <span> + {stats.VideoViewsTopAllTime} views - {stats.VideoViewChangeTopAllTime} new + </span> + {stats.VideoViewChangeTopAllTime > 0 && ( + <Icon icon={ICONS.SUPPORT} iconColor="green" size={18} /> + )} + </div> + } + /> + </div> + </React.Fragment> + } + /> + </div> + )} + </Page> + ); +} diff --git a/ui/page/fileListPublished/view.jsx b/ui/page/fileListPublished/view.jsx index 51631abfb..79258631f 100644 --- a/ui/page/fileListPublished/view.jsx +++ b/ui/page/fileListPublished/view.jsx @@ -1,4 +1,5 @@ // @flow +import * as PAGES from 'constants/pages'; import React, { useEffect } from 'react'; import Button from 'component/button'; import ClaimList from 'component/claimList'; @@ -28,6 +29,7 @@ function FileListPublished(props: Props) { return ( <Page> + <Button button="link" label={'Creator Dashboard'} navigate={`/$/${PAGES.CREATOR_DASHBOARD}`} /> <WebUploadList /> {urls && Boolean(urls.length) && ( <React.Fragment> diff --git a/ui/scss/component/_card.scss b/ui/scss/component/_card.scss index fb55055fc..128a6fff4 100644 --- a/ui/scss/component/_card.scss +++ b/ui/scss/component/_card.scss @@ -5,6 +5,7 @@ border-radius: var(--card-radius); box-shadow: var(--card-box-shadow); overflow: hidden; + border: 1px solid var(--color-border); @media (max-width: $breakpoint-small) { margin-bottom: var(--spacing-medium); @@ -45,6 +46,11 @@ min-width: 35rem; } +.card--data { + display: flex; + align-items: center; +} + .card__actions { display: flex; align-items: center; @@ -163,3 +169,12 @@ .card__main-actions--with-icon { padding-left: 7.5rem; } + +.card__data-subtitle { + display: flex; + align-items: center; + + :not(:first-child) { + margin-left: var(--spacing-small); + } +} diff --git a/ui/scss/component/_channel.scss b/ui/scss/component/_channel.scss index 8b006ee86..cc357d92b 100644 --- a/ui/scss/component/_channel.scss +++ b/ui/scss/component/_channel.scss @@ -157,3 +157,31 @@ $metadata-z-index: 1; .channel-name--inline { margin-left: var(--spacing-xsmall); } + +.channel__list-item { + display: flex; + background-color: var(--color-card-background); + padding: var(--spacing-small); + align-items: center; + + border-bottom: 1px solid var(--color-border); + + .channel-thumbnail { + height: 2rem; + width: 2rem; + } + + .icon { + margin-left: var(--spacing-large); + margin-right: var(--spacing-small); + } + + &:hover { + background-color: var(--color-card-background-highlighted); + } +} + +.channel__list-item--selected { + border-radius: var(--border-radius); + border: 1px solid var(--color-border); +} diff --git a/ui/scss/component/_icon.scss b/ui/scss/component/_icon.scss index c9db92086..4a84bdf1f 100644 --- a/ui/scss/component/_icon.scss +++ b/ui/scss/component/_icon.scss @@ -17,6 +17,22 @@ } } +.icon__wrapper--Heart { + background-color: var(--color-follow-bg); + + .icon { + stroke: var(--color-follow-icon); + } +} + +.icon__wrapper--Eye { + background-color: var(--color-view-bg); + + .icon { + stroke: var(--color-view-icon); + } +} + .icon--help { color: var(--color-subtitle); margin-left: var(--spacing-xsmall); diff --git a/ui/scss/init/_vars.scss b/ui/scss/init/_vars.scss index 10ca9c0d2..eea471fd9 100644 --- a/ui/scss/init/_vars.scss +++ b/ui/scss/init/_vars.scss @@ -44,7 +44,7 @@ $breakpoint-medium: 1150px; // Card --card-radius: var(--border-radius); --card-max-width: 1000px; - --card-box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + // --card-box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); // Modal --modal-width: 550px; diff --git a/ui/scss/themes/light.scss b/ui/scss/themes/light.scss index ae0ecccb1..f2d94f9e6 100644 --- a/ui/scss/themes/light.scss +++ b/ui/scss/themes/light.scss @@ -16,6 +16,12 @@ --color-comment-menu-hovering: #6a6a6a; --color-notice: #fef3ca; + // Icons + --color-follow-bg: #ffd4da; + --color-follow-icon: #e2495e; + --color-view-bg: var(--color-secondary-alt); + --color-view-icon: var(--color-secondary); + // Text --color-text-selection-bg: var(--color-secondary-alt); --color-text-selection: var(--color-secondary);