initial commit for creator analytics

This commit is contained in:
seanyesmunt 2020-03-18 13:11:37 -04:00 committed by Sean Yesmunt
parent 2367052a31
commit bc32341aab
14 changed files with 295 additions and 7 deletions

View file

@ -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);

View file

@ -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;

View file

@ -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;
}

View file

@ -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} />

View file

@ -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} />}

View file

@ -33,3 +33,4 @@ exports.CHANNELS = 'channels';
exports.EMBED = 'embed';
exports.TOP = 'top';
exports.WELCOME = 'welcome';
exports.CREATOR_DASHBOARD = 'dashboard';

View file

@ -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);

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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);
}
}

View file

@ -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);
}

View file

@ -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);

View file

@ -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;

View file

@ -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);