initial commit for creator analytics
This commit is contained in:
parent
2367052a31
commit
bc32341aab
14 changed files with 295 additions and 7 deletions
24
ui/component/channelSelector/index.js
Normal file
24
ui/component/channelSelector/index.js
Normal 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);
|
59
ui/component/channelSelector/view.jsx
Normal file
59
ui/component/channelSelector/view.jsx
Normal 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;
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
|
|
|
@ -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} />}
|
||||
|
|
|
@ -33,3 +33,4 @@ exports.CHANNELS = 'channels';
|
|||
exports.EMBED = 'embed';
|
||||
exports.TOP = 'top';
|
||||
exports.WELCOME = 'welcome';
|
||||
exports.CREATOR_DASHBOARD = 'dashboard';
|
||||
|
|
10
ui/page/creatorDashboard/index.js
Normal file
10
ui/page/creatorDashboard/index.js
Normal 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);
|
125
ui/page/creatorDashboard/view.jsx
Normal file
125
ui/page/creatorDashboard/view.jsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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);
|
||||
|
|
Loading…
Reference in a new issue