improve empty states
This commit is contained in:
parent
2bb1e42abb
commit
73b7b45b73
11 changed files with 230 additions and 117 deletions
|
@ -4,7 +4,7 @@ import classnames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import ChannelThumbnail from 'component/channelThumbnail';
|
import ChannelThumbnail from 'component/channelThumbnail';
|
||||||
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
|
import { Menu, MenuList, MenuButton, MenuItem } from '@reach/menu-button';
|
||||||
import ClaimPreviewTitle from 'component/claimPreviewTitle';
|
import ChannelTitle from 'component/channelTitle';
|
||||||
import Icon from 'component/common/icon';
|
import Icon from 'component/common/icon';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
|
@ -14,17 +14,17 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type ListItemProps = {
|
type ListItemProps = {
|
||||||
url: string,
|
uri: string,
|
||||||
isSelected?: boolean,
|
isSelected?: boolean,
|
||||||
};
|
};
|
||||||
|
|
||||||
function ChannelListItem(props: ListItemProps) {
|
function ChannelListItem(props: ListItemProps) {
|
||||||
const { url, isSelected = false } = props;
|
const { uri, isSelected = false } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}>
|
<div className={classnames('channel__list-item', { 'channel__list-item--selected': isSelected })}>
|
||||||
<ChannelThumbnail uri={url} />
|
<ChannelThumbnail uri={uri} />
|
||||||
<ClaimPreviewTitle uri={url} />
|
<ChannelTitle uri={uri} />
|
||||||
{isSelected && <Icon icon={ICONS.DOWN} />}
|
{isSelected && <Icon icon={ICONS.DOWN} />}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -33,7 +33,7 @@ function ChannelListItem(props: ListItemProps) {
|
||||||
function ChannelSelector(props: Props) {
|
function ChannelSelector(props: Props) {
|
||||||
const { channels, selectedChannelUrl, onChannelSelect } = props;
|
const { channels, selectedChannelUrl, onChannelSelect } = props;
|
||||||
|
|
||||||
if (!selectedChannelUrl) {
|
if (!channels || !selectedChannelUrl) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -41,13 +41,13 @@ function ChannelSelector(props: Props) {
|
||||||
<div>
|
<div>
|
||||||
<Menu>
|
<Menu>
|
||||||
<MenuButton className="">
|
<MenuButton className="">
|
||||||
<ChannelListItem url={selectedChannelUrl} isSelected />
|
<ChannelListItem uri={selectedChannelUrl} isSelected />
|
||||||
</MenuButton>
|
</MenuButton>
|
||||||
<MenuList className="menu__list">
|
<MenuList className="menu__list">
|
||||||
{channels &&
|
{channels &&
|
||||||
channels.map(channel => (
|
channels.map(channel => (
|
||||||
<MenuItem key={channel.canonical_url} onSelect={() => onChannelSelect(channel.canonical_url)}>
|
<MenuItem key={channel.canonical_url} onSelect={() => onChannelSelect(channel.canonical_url)}>
|
||||||
<ChannelListItem url={channel.canonical_url} />
|
<ChannelListItem uri={channel.canonical_url} />
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
))}
|
))}
|
||||||
</MenuList>
|
</MenuList>
|
||||||
|
|
10
ui/component/channelTitle/index.js
Normal file
10
ui/component/channelTitle/index.js
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { makeSelectClaimForUri, makeSelectTitleForUri } from 'lbry-redux';
|
||||||
|
import ChannelTitle from './view';
|
||||||
|
|
||||||
|
const select = (state, props) => ({
|
||||||
|
title: makeSelectTitleForUri(props.uri)(state),
|
||||||
|
claim: makeSelectClaimForUri(props.uri)(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(select)(ChannelTitle);
|
19
ui/component/channelTitle/view.jsx
Normal file
19
ui/component/channelTitle/view.jsx
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
// @flow
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
claim: ?ChannelClaim,
|
||||||
|
title: ?string,
|
||||||
|
};
|
||||||
|
|
||||||
|
function ChannelTitle(props: Props) {
|
||||||
|
const { title, claim } = props;
|
||||||
|
|
||||||
|
if (!claim) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="claim-preview__title">{title || claim.name}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ChannelTitle;
|
|
@ -1,10 +1,11 @@
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { makeSelectClaimForUri, makeSelectTitleForUri } from 'lbry-redux';
|
import { makeSelectClaimForUri, makeSelectTitleForUri, makeSelectIsUriResolving } from 'lbry-redux';
|
||||||
import ClaimPreviewTitle from './view';
|
import ClaimPreviewTitle from './view';
|
||||||
|
|
||||||
const select = (state, props) => ({
|
const select = (state, props) => ({
|
||||||
claim: makeSelectClaimForUri(props.uri)(state),
|
claim: makeSelectClaimForUri(props.uri)(state),
|
||||||
title: makeSelectTitleForUri(props.uri)(state),
|
title: makeSelectTitleForUri(props.uri)(state),
|
||||||
|
isResolvingUri: makeSelectIsUriResolving(props.uri)(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(select)(ClaimPreviewTitle);
|
export default connect(select)(ClaimPreviewTitle);
|
||||||
|
|
|
@ -10,7 +10,6 @@ type Props = {
|
||||||
|
|
||||||
function ClaimPreviewTitle(props: Props) {
|
function ClaimPreviewTitle(props: Props) {
|
||||||
const { title, claim } = props;
|
const { title, claim } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="claim-preview__title">
|
<div className="claim-preview__title">
|
||||||
{claim ? <TruncatedText text={title || claim.name} lines={2} /> : <span>{__('Nothing here')}</span>}
|
{claim ? <TruncatedText text={title || claim.name} lines={2} /> : <span>{__('Nothing here')}</span>}
|
||||||
|
|
14
ui/component/creatorAnalytics/index.js
Normal file
14
ui/component/creatorAnalytics/index.js
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import { connect } from 'react-redux';
|
||||||
|
import { selectMyChannelClaims, selectFetchingMyChannels, doPrepareEdit } from 'lbry-redux';
|
||||||
|
import CreatorAnalytics from './view';
|
||||||
|
|
||||||
|
const select = state => ({
|
||||||
|
channels: selectMyChannelClaims(state),
|
||||||
|
fetchingChannels: selectFetchingMyChannels(state),
|
||||||
|
});
|
||||||
|
|
||||||
|
const perform = dispatch => ({
|
||||||
|
prepareEdit: channelName => dispatch(doPrepareEdit({ signing_channel: { name: channelName } })),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(select, perform)(CreatorAnalytics);
|
150
ui/component/creatorAnalytics/view.jsx
Normal file
150
ui/component/creatorAnalytics/view.jsx
Normal file
|
@ -0,0 +1,150 @@
|
||||||
|
// @flow
|
||||||
|
import * as ICONS from 'constants/icons';
|
||||||
|
import * as PAGES from 'constants/pages';
|
||||||
|
import React from 'react';
|
||||||
|
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';
|
||||||
|
import Button from 'component/button';
|
||||||
|
import Yrbl from 'component/yrbl';
|
||||||
|
import { useHistory } from 'react-router-dom';
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
channels: Array<ChannelClaim>,
|
||||||
|
fetchingChannels: boolean,
|
||||||
|
prepareEdit: string => void,
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function CreatorDashboardPage(props: Props) {
|
||||||
|
const { channels, prepareEdit } = props;
|
||||||
|
const history = useHistory();
|
||||||
|
const [stats, setStats] = React.useState();
|
||||||
|
const [selectedChannelUrl, setSelectedChannelUrl] = usePersistedState('analytics-selected-channel');
|
||||||
|
const [fetchingStats, setFetchingStats] = React.useState(false);
|
||||||
|
const hasChannels = channels && channels.length > 0;
|
||||||
|
const firstChannel = hasChannels && channels[0];
|
||||||
|
const firstChannelUrl = firstChannel && (firstChannel.canonical_url || firstChannel.permanent_url); // permanent_url is needed for pending publishes
|
||||||
|
const selectedChannelClaim =
|
||||||
|
channels &&
|
||||||
|
channels.find(claim => claim.canonical_url === selectedChannelUrl || claim.permanent_url === selectedChannelUrl);
|
||||||
|
const selectedChannelClaimId = selectedChannelClaim && selectedChannelClaim.claim_id;
|
||||||
|
const channelFoundForSelectedChannelUrl =
|
||||||
|
channels &&
|
||||||
|
channels.find(channel => {
|
||||||
|
return selectedChannelUrl === channel.canonical_url || selectedChannelUrl === channel.permanent_url;
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
// set default channel
|
||||||
|
if ((!selectedChannelUrl || !channelFoundForSelectedChannelUrl) && firstChannelUrl) {
|
||||||
|
setSelectedChannelUrl(firstChannelUrl);
|
||||||
|
}
|
||||||
|
}, [selectedChannelUrl, firstChannelUrl, channelFoundForSelectedChannelUrl]);
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (selectedChannelClaimId) {
|
||||||
|
setFetchingStats(true);
|
||||||
|
Lbryio.call('reports', 'content', { claim_id: selectedChannelClaimId })
|
||||||
|
.then(res => {
|
||||||
|
setFetchingStats(false);
|
||||||
|
setStats(res);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setFetchingStats(false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [selectedChannelClaimId, setFetchingStats, setStats]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<React.Fragment>
|
||||||
|
<div className="section">
|
||||||
|
<ChannelSelector
|
||||||
|
selectedChannelUrl={selectedChannelUrl}
|
||||||
|
onChannelSelect={newChannelUrl => {
|
||||||
|
setStats(null);
|
||||||
|
setSelectedChannelUrl(newChannelUrl);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{fetchingStats && !stats && (
|
||||||
|
<div className="main--empty">
|
||||||
|
<Spinner delayed />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!fetchingStats && !stats && (
|
||||||
|
<section className="main--empty">
|
||||||
|
<Yrbl
|
||||||
|
title={__("You haven't published anything with this channel yet!")}
|
||||||
|
subtitle={
|
||||||
|
<Button
|
||||||
|
button="primary"
|
||||||
|
label={__('Publish Something')}
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedChannelClaim) {
|
||||||
|
prepareEdit(selectedChannelClaim.name);
|
||||||
|
history.push(`/$/${PAGES.PUBLISH}`);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
{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} this week
|
||||||
|
</span>
|
||||||
|
{stats.VideoViewChangeTopAllTime > 0 && (
|
||||||
|
<Icon icon={ICONS.SUPPORT} iconColor="green" size={18} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</React.Fragment>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,10 +1,16 @@
|
||||||
|
import * as MODALS from 'constants/modal_types';
|
||||||
import { connect } from 'react-redux';
|
import { connect } from 'react-redux';
|
||||||
import { selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux';
|
import { selectMyChannelClaims, selectFetchingMyChannels } from 'lbry-redux';
|
||||||
import Welcome from './view';
|
import { doOpenModal } from 'redux/actions/app';
|
||||||
|
import CreatorDashboardPage from './view';
|
||||||
|
|
||||||
const select = state => ({
|
const select = state => ({
|
||||||
channels: selectMyChannelClaims(state),
|
channels: selectMyChannelClaims(state),
|
||||||
fetchingChannels: selectFetchingMyChannels(state),
|
fetchingChannels: selectFetchingMyChannels(state),
|
||||||
});
|
});
|
||||||
|
|
||||||
export default connect(select)(Welcome);
|
const perform = dispatch => ({
|
||||||
|
openChannelCreateModal: () => dispatch(doOpenModal(MODALS.CREATE_CHANNEL)),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default connect(select, perform)(CreatorDashboardPage);
|
||||||
|
|
|
@ -1,125 +1,39 @@
|
||||||
// @flow
|
// @flow
|
||||||
import * as ICONS from 'constants/icons';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Page from 'component/page';
|
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 Spinner from 'component/spinner';
|
||||||
import Icon from 'component/common/icon';
|
import Button from 'component/button';
|
||||||
import usePersistedState from 'effects/use-persisted-state';
|
import CreatorAnalytics from 'component/creatorAnalytics';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
channels: Array<ChannelClaim>,
|
channels: Array<ChannelClaim>,
|
||||||
|
fetchingChannels: boolean,
|
||||||
|
openChannelCreateModal: () => void,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function CreatorDashboardPage(props: Props) {
|
export default function CreatorDashboardPage(props: Props) {
|
||||||
const { channels } = props;
|
const { channels, fetchingChannels, openChannelCreateModal } = 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 (
|
return (
|
||||||
<Page>
|
<Page>
|
||||||
<div className="section">
|
{fetchingChannels && (
|
||||||
<ChannelSelector
|
|
||||||
selectedChannelUrl={selectedChannelUrl}
|
|
||||||
onChannelSelect={newChannelUrl => {
|
|
||||||
setStats(null);
|
|
||||||
setSelectedChannelUrl(newChannelUrl);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{fetchingStats && !stats && (
|
|
||||||
<div className="main--empty">
|
<div className="main--empty">
|
||||||
<Spinner delayed />
|
<Spinner delayed />
|
||||||
</div>
|
</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
|
{!fetchingChannels && (!channels || !channels.length) && (
|
||||||
title={
|
<section className="main--empty">
|
||||||
<div className="card__data-subtitle">
|
<div className=" section--small">
|
||||||
<span>Most Viewed Claim</span>
|
<h2 className="section__title--large">{__("You haven't created a channel yet, let's fix that!")}</h2>
|
||||||
</div>
|
<div className="section__actions">
|
||||||
}
|
<Button button="primary" onClick={openChannelCreateModal} label={__('Create A Channel')} />
|
||||||
body={
|
</div>
|
||||||
<React.Fragment>
|
</div>
|
||||||
<div className="card--inline">
|
</section>
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{!fetchingChannels && channels && channels.length && <CreatorAnalytics />}
|
||||||
</Page>
|
</Page>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -9,7 +9,7 @@
|
||||||
|
|
||||||
.yrbl {
|
.yrbl {
|
||||||
height: 20rem;
|
height: 20rem;
|
||||||
margin-right: var(--spacing-large);
|
margin-right: calc(var(--spacing-xlarge) * 2);
|
||||||
|
|
||||||
@media (max-width: $breakpoint-small) {
|
@media (max-width: $breakpoint-small) {
|
||||||
height: 10rem;
|
height: 10rem;
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.section--small {
|
.section--small {
|
||||||
max-width: 35rem;
|
max-width: 40rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section__header {
|
.section__header {
|
||||||
|
|
Loading…
Add table
Reference in a new issue