improve playlists display (#232)

* improve playlists display

* fix pagination

* reset page on filter button

* pagination updates if page param changes

* carry collection active tab to playlists page
This commit is contained in:
jessopb 2021-11-05 21:00:27 -04:00 committed by GitHub
parent fc2e2d2cfc
commit 238a64bca9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 344 additions and 66 deletions

View file

@ -2205,5 +2205,21 @@
"On Firefox, notifications won't function if cookies are set to clear on browser close. Please disable or add an exception for Odysee, then refresh.": "On Firefox, notifications won't function if cookies are set to clear on browser close. Please disable or add an exception for Odysee, then refresh.", "On Firefox, notifications won't function if cookies are set to clear on browser close. Please disable or add an exception for Odysee, then refresh.": "On Firefox, notifications won't function if cookies are set to clear on browser close. Please disable or add an exception for Odysee, then refresh.",
"For Brave, enable google push notifications in settings.": "For Brave, enable google push notifications in settings.", "For Brave, enable google push notifications in settings.": "For Brave, enable google push notifications in settings.",
"Check browser settings to see if notifications are disabled or otherwise restricted.": "Check browser settings to see if notifications are disabled or otherwise restricted.", "Check browser settings to see if notifications are disabled or otherwise restricted.": "Check browser settings to see if notifications are disabled or otherwise restricted.",
"No Reposts": "No Reposts",
"You haven't reposted anything yet. Do it.": "You haven't reposted anything yet. Do it.",
"Claiming credits...": "Claiming credits...",
"%totalComments% comments": "%totalComments% comments",
"(%lbc_balance% available)": "(%lbc_balance% available)",
"Sending...": "Sending...",
"You sent %lbc% as a tip, Mahalo!": "You sent %lbc% as a tip, Mahalo!",
"Export All": "Export All",
"Minimum time gap in seconds between comments.": "Minimum time gap in seconds between comments.",
"Enabling a minimum amount to comment will force all comments to have tips associated with them. This can help prevent spam.": "Enabling a minimum amount to comment will force all comments to have tips associated with them. This can help prevent spam.",
"Comments containing these words will be blocked.": "Comments containing these words will be blocked.",
"Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8": "Enter the full channel name or URL to search.\n\nExamples:\n - @channel\n - @channel#3\n - https://odysee.com/@Odysee:8\n - lbry://@Odysee#8",
"Choose %asset%": "Choose %asset%",
"Showing %filtered% results of %total%": "Showing %filtered% results of %total%",
"filtered": "filtered",
"View All Playlists": "View All Playlists",
"--end--": "--end--" "--end--": "--end--"
} }

View file

@ -25,7 +25,7 @@ const ALL = 'All';
const PRIVATE = 'Private'; const PRIVATE = 'Private';
const PUBLIC = 'Public'; const PUBLIC = 'Public';
const COLLECTION_FILTERS = [ALL, PRIVATE, PUBLIC]; const COLLECTION_FILTERS = [ALL, PRIVATE, PUBLIC];
const COLLECTION_SHOW_COUNT = 24; const COLLECTION_SHOW_COUNT = 12;
export default function CollectionsListMine(props: Props) { export default function CollectionsListMine(props: Props) {
const { const {
@ -43,6 +43,7 @@ export default function CollectionsListMine(props: Props) {
const [filterType, setFilterType] = React.useState(ALL); const [filterType, setFilterType] = React.useState(ALL);
const [searchText, setSearchText] = React.useState(''); const [searchText, setSearchText] = React.useState('');
const playlistPageUrl = `/$/${PAGES.PLAYLISTS}?type=${filterType}`;
let collectionsToShow = []; let collectionsToShow = [];
if (filterType === ALL) { if (filterType === ALL) {
collectionsToShow = unpublishedCollectionsList.concat(publishedList); collectionsToShow = unpublishedCollectionsList.concat(publishedList);
@ -68,6 +69,10 @@ export default function CollectionsListMine(props: Props) {
filteredCollections = collectionsToShow.slice(0, COLLECTION_SHOW_COUNT) || []; filteredCollections = collectionsToShow.slice(0, COLLECTION_SHOW_COUNT) || [];
} }
const totalLength = collectionsToShow ? collectionsToShow.length : 0;
const filteredLength = filteredCollections.length;
const isTruncated = totalLength > filteredLength;
const watchLater = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.WATCH_LATER_ID); const watchLater = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.WATCH_LATER_ID);
const favorites = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.FAVORITES_ID); const favorites = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.FAVORITES_ID);
const builtin = [watchLater, favorites]; const builtin = [watchLater, favorites];
@ -133,7 +138,19 @@ export default function CollectionsListMine(props: Props) {
<div className="claim-grid__wrapper"> <div className="claim-grid__wrapper">
<div className="claim-grid__header section"> <div className="claim-grid__header section">
<h1 className="claim-grid__title"> <h1 className="claim-grid__title">
<Button
className="claim-grid__title"
button="link"
navigate={playlistPageUrl}
label={
<span className="claim-grid__title-span">
{__('Playlists')} {__('Playlists')}
<div className="claim-grid__title--empty">
<Icon className="icon--margin-right" icon={ICONS.STACK} />
</div>
</span>
}
/>
{!hasCollections && !fetchingCollections && ( {!hasCollections && !fetchingCollections && (
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div> <div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
)} )}
@ -142,6 +159,7 @@ export default function CollectionsListMine(props: Props) {
)} )}
</h1> </h1>
</div> </div>
<div className="section__header-action-stack">
<div className="section__header--actions"> <div className="section__header--actions">
<div className="claim-search__wrapper"> <div className="claim-search__wrapper">
<div className="claim-search__menu-group"> <div className="claim-search__menu-group">
@ -171,9 +189,25 @@ export default function CollectionsListMine(props: Props) {
/> />
</Form> </Form>
</div> </div>
<p className="collection-grid__results-summary">
{isTruncated && (
<>
{__(`Showing %filtered% results of %total%`, {
filtered: filteredLength,
total: totalLength,
})}
{`${searchText ? ' (' + __('filtered') + ') ' : ' '}`}
</>
)}
<Button
button="link"
navigate={playlistPageUrl}
label={<span className="claim-grid__title-span">{__('View All Playlists')}</span>}
/>
</p>
</div>
{Boolean(hasCollections) && ( {Boolean(hasCollections) && (
<div> <div>
{/* TODO: fix above spacing hack */}
<div className="claim-grid"> <div className="claim-grid">
{filteredCollections && {filteredCollections &&
filteredCollections.length > 0 && filteredCollections.length > 0 &&

View file

@ -20,7 +20,8 @@ function Paginate(props: Props) {
const { search } = location; const { search } = location;
const [textValue, setTextValue] = React.useState(''); const [textValue, setTextValue] = React.useState('');
const urlParams = new URLSearchParams(search); const urlParams = new URLSearchParams(search);
const initialPage = disableHistory ? 1 : Number(urlParams.get(PAGINATE_PARAM)) || 1; const urlParamPage = Number(urlParams.get(PAGINATE_PARAM));
const initialPage = disableHistory ? 1 : urlParamPage || 1;
const [currentPage, setCurrentPage] = React.useState(initialPage); const [currentPage, setCurrentPage] = React.useState(initialPage);
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@ -47,6 +48,12 @@ function Paginate(props: Props) {
} }
} }
React.useEffect(() => {
if (urlParamPage) {
setCurrentPage(urlParamPage);
}
}, [urlParamPage, setCurrentPage]);
return ( return (
// Hide the paginate controls if we are loading or there is only one page // Hide the paginate controls if we are loading or there is only one page
// It should still be rendered to trigger the onPageChange callback // It should still be rendered to trigger the onPageChange callback

View file

@ -0,0 +1,20 @@
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import {
selectMyPublishedPlaylistCollections,
selectMyUnpublishedCollections, // should probably distinguish types
// selectSavedCollections,
} from 'redux/selectors/collections';
import { selectFetchingMyCollections } from 'redux/selectors/claims';
import PlaylistsMine from './view';
const select = (state) => {
return {
publishedCollections: selectMyPublishedPlaylistCollections(state),
unpublishedCollections: selectMyUnpublishedCollections(state),
// savedCollections: selectSavedCollections(state),
fetchingCollections: selectFetchingMyCollections(state),
};
};
export default withRouter(connect(select)(PlaylistsMine));

View file

@ -0,0 +1,182 @@
// @flow
import React from 'react';
import CollectionPreviewTile from 'component/collectionPreviewTile';
import Button from 'component/button';
import Icon from 'component/common/icon';
import * as ICONS from 'constants/icons';
import * as KEYCODES from 'constants/keycodes';
import Paginate from 'component/common/paginate';
import Yrbl from 'component/yrbl';
import classnames from 'classnames';
import { FormField, Form } from 'component/common/form';
type Props = {
publishedCollections: CollectionGroup,
unpublishedCollections: CollectionGroup,
// savedCollections: CollectionGroup,
fetchingCollections: boolean,
page: number,
pageSize: number,
history: { replace: (string) => void },
location: { search: string },
};
const ALL = 'All';
const PRIVATE = 'Private';
const PUBLIC = 'Public';
const COLLECTION_FILTERS = [ALL, PRIVATE, PUBLIC];
const FILTER_TYPE_PARAM = 'type';
const PAGE_PARAM = 'page';
export default function PlaylistsMine(props: Props) {
const {
publishedCollections,
unpublishedCollections,
// savedCollections, these are resolved on startup from sync'd claimIds or urls
fetchingCollections,
history,
location,
} = props;
const { search } = location;
const urlParams = new URLSearchParams(search);
const page = Number(urlParams.get(PAGE_PARAM)) || 1;
const type = urlParams.get(FILTER_TYPE_PARAM) || ALL;
const pageSize = 12;
const unpublishedCollectionsList = (Object.keys(unpublishedCollections || {}): any);
const publishedList = (Object.keys(publishedCollections || {}): any);
const hasCollections = unpublishedCollectionsList.length || publishedList.length;
const [filterType, setFilterType] = React.useState(type);
const [searchText, setSearchText] = React.useState('');
let collectionsToShow = [];
if (filterType === ALL) {
collectionsToShow = unpublishedCollectionsList.concat(publishedList);
} else if (filterType === PRIVATE) {
collectionsToShow = unpublishedCollectionsList;
} else if (filterType === PUBLIC) {
collectionsToShow = publishedList;
}
let filteredCollections;
if (searchText && collectionsToShow) {
filteredCollections = collectionsToShow.filter((id) => {
return (
(unpublishedCollections[id] &&
unpublishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) ||
(publishedCollections[id] &&
publishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase()))
);
});
} else {
filteredCollections = collectionsToShow || [];
}
const shouldPaginate = filteredCollections.length > pageSize;
const paginateStartIndex = shouldPaginate ? (page - 1) * pageSize : 0;
const paginateEndIndex = paginateStartIndex + pageSize;
const paginatedCollections = filteredCollections.slice(paginateStartIndex, paginateEndIndex);
function escapeListener(e: SyntheticKeyboardEvent<*>) {
if (e.keyCode === KEYCODES.ESCAPE) {
e.preventDefault();
setSearchText('');
}
}
function onTextareaFocus() {
window.addEventListener('keydown', escapeListener);
}
function onTextareaBlur() {
window.removeEventListener('keydown', escapeListener);
}
function handleFilterType(val) {
const newParams = new URLSearchParams();
if (val) {
newParams.set(FILTER_TYPE_PARAM, val);
}
newParams.set(PAGE_PARAM, '1');
history.replace(`?${newParams.toString()}`);
setFilterType(val);
}
return (
<>
<div className="claim-grid__wrapper">
<div className="claim-grid__header section">
<h1 className="claim-grid__title">
{__('Playlists')}
{!hasCollections && !fetchingCollections && (
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
)}
{!hasCollections && fetchingCollections && (
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
)}
</h1>
</div>
<div className="section__header--actions">
<div className="claim-search__wrapper">
<div className="claim-search__menu-group">
{COLLECTION_FILTERS.map((value) => (
<Button
label={__(value)}
key={value}
button="alt"
onClick={() => handleFilterType(value)}
className={classnames('button-toggle', {
'button-toggle--active': filterType === value,
})}
/>
))}
</div>
</div>
<Form onSubmit={() => {}} className="wunderbar--inline">
<Icon icon={ICONS.SEARCH} />
<FormField
onFocus={onTextareaFocus}
onBlur={onTextareaBlur}
className="wunderbar__input--inline"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
type="text"
placeholder={__('Search')}
/>
</Form>
</div>
{Boolean(hasCollections) && (
<div>
{/* TODO: fix above spacing hack */}
<div className="claim-grid">
{paginatedCollections &&
paginatedCollections.length > 0 &&
paginatedCollections.map((key) => <CollectionPreviewTile tileLayout collectionId={key} key={key} />)}
{!paginatedCollections.length && <div className="empty main--empty">{__('No matching playlists')}</div>}
</div>
{shouldPaginate && (
<Paginate
totalPages={
filteredCollections.length > 0 ? Math.ceil(filteredCollections.length / Number(pageSize)) : 1
}
/>
)}
</div>
)}
{!hasCollections && !fetchingCollections && (
<div className="main--empty">
<Yrbl type={'sad'} title={__('You have no lists yet. Better start hoarding!')} />
</div>
)}
{!hasCollections && fetchingCollections && (
<div className="main--empty">
<h2 className="main--empty empty">{__('Loading...')}</h2>
</div>
)}
</div>
</>
);
}

View file

@ -61,6 +61,7 @@ const InvitedPage = lazyImport(() => import('page/invited' /* webpackChunkName:
const LibraryPage = lazyImport(() => import('page/library' /* webpackChunkName: "library" */)); const LibraryPage = lazyImport(() => import('page/library' /* webpackChunkName: "library" */));
const ListBlockedPage = lazyImport(() => import('page/listBlocked' /* webpackChunkName: "listBlocked" */)); const ListBlockedPage = lazyImport(() => import('page/listBlocked' /* webpackChunkName: "listBlocked" */));
const ListsPage = lazyImport(() => import('page/lists' /* webpackChunkName: "lists" */)); const ListsPage = lazyImport(() => import('page/lists' /* webpackChunkName: "lists" */));
const PlaylistsPage = lazyImport(() => import('page/playlists' /* webpackChunkName: "lists" */));
const LiveStreamSetupPage = lazyImport(() => import('page/livestreamSetup' /* webpackChunkName: "livestreamSetup" */)); const LiveStreamSetupPage = lazyImport(() => import('page/livestreamSetup' /* webpackChunkName: "livestreamSetup" */));
const LivestreamCurrentPage = lazyImport(() => const LivestreamCurrentPage = lazyImport(() =>
import('page/livestreamCurrent' /* webpackChunkName: "livestreamCurrent" */) import('page/livestreamCurrent' /* webpackChunkName: "livestreamCurrent" */)
@ -328,6 +329,7 @@ function AppRouter(props: Props) {
<PrivateRoute {...props} path={`/$/${PAGES.REWARDS_VERIFY}`} component={RewardsVerifyPage} /> <PrivateRoute {...props} path={`/$/${PAGES.REWARDS_VERIFY}`} component={RewardsVerifyPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LIBRARY}`} component={LibraryPage} /> <PrivateRoute {...props} path={`/$/${PAGES.LIBRARY}`} component={LibraryPage} />
<PrivateRoute {...props} path={`/$/${PAGES.LISTS}`} component={ListsPage} /> <PrivateRoute {...props} path={`/$/${PAGES.LISTS}`} component={ListsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.PLAYLISTS}`} component={PlaylistsPage} />
<PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} /> <PrivateRoute {...props} path={`/$/${PAGES.TAGS_FOLLOWING_MANAGE}`} component={TagsFollowingManagePage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_BLOCKED_MUTED}`} component={ListBlockedPage} /> <PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_BLOCKED_MUTED}`} component={ListBlockedPage} />
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_CREATOR}`} component={SettingsCreatorPage} /> <PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_CREATOR}`} component={SettingsCreatorPage} />

View file

@ -27,6 +27,7 @@ exports.HOME = 'home';
exports.HELP = 'help'; exports.HELP = 'help';
exports.LIBRARY = 'library'; exports.LIBRARY = 'library';
exports.LISTS = 'lists'; exports.LISTS = 'lists';
exports.PLAYLISTS = 'playlists';
exports.INVITE = 'invite'; exports.INVITE = 'invite';
exports.DEPRECATED__PUBLISH = 'publish'; exports.DEPRECATED__PUBLISH = 'publish';
exports.DEPRECATED__PUBLISHED = 'published'; exports.DEPRECATED__PUBLISHED = 'published';

View file

@ -1,31 +1,3 @@
import { connect } from 'react-redux';
import { doPurchaseList } from 'redux/actions/claims';
import { selectMyPurchases, selectIsFetchingMyPurchases } from 'redux/selectors/claims';
import { selectDownloadUrlsCount, selectIsFetchingFileList } from 'redux/selectors/file_info';
import {
selectBuiltinCollections,
selectMyPublishedMixedCollections,
selectMyPublishedPlaylistCollections,
selectMyUnpublishedCollections, // should probably distinguish types
// selectSavedCollections, // TODO: implement saving and copying collections
} from 'redux/selectors/collections';
import ListsPage from './view'; import ListsPage from './view';
const select = (state) => ({ export default ListsPage;
allDownloadedUrlsCount: selectDownloadUrlsCount(state),
fetchingFileList: selectIsFetchingFileList(state),
myPurchases: selectMyPurchases(state),
fetchingMyPurchases: selectIsFetchingMyPurchases(state),
builtinCollections: selectBuiltinCollections(state),
publishedCollections: selectMyPublishedMixedCollections(state),
publishedPlaylists: selectMyPublishedPlaylistCollections(state),
unpublishedCollections: selectMyUnpublishedCollections(state),
// savedCollections: selectSavedCollections(state),
});
export default connect(select, {
doPurchaseList,
})(ListsPage);

View file

@ -0,0 +1,3 @@
import PlaylistsPage from './view';
export default PlaylistsPage;

View file

@ -0,0 +1,22 @@
// @flow
import * as ICONS from 'constants/icons';
import React from 'react';
import Page from 'component/page';
import PlaylistsMine from 'component/playlistsMine';
import Icon from 'component/common/icon';
function PlaylistsPage() {
return (
<Page>
<label className="claim-list__header-label">
<span>
<Icon icon={ICONS.STACK} size={10} />
{__('Playlists')}
</span>
</label>
<PlaylistsMine />
</Page>
);
}
export default PlaylistsPage;

View file

@ -136,3 +136,9 @@
} }
} }
} }
.collection-grid__results-summary {
padding: 0;
padding-bottom: var(--spacing-m);
color: var(--color-text-help);
}

View file

@ -313,17 +313,19 @@ fieldset-group {
} }
&:nth-of-type(2) { &:nth-of-type(2) {
&:not(input.paginate-channel) {
// yuck
input, input,
select { select {
border-top-left-radius: 0; border-top-left-radius: 0;
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
} }
label { label {
margin-left: var(--spacing-s); margin-left: var(--spacing-s);
} }
} }
} }
}
&.fieldgroup--paginate { &.fieldgroup--paginate {
padding-bottom: var(--spacing-l); padding-bottom: var(--spacing-l);
@ -331,6 +333,12 @@ fieldset-group {
align-items: flex-end; align-items: flex-end;
justify-content: center; justify-content: center;
} }
&.fieldgroup--paginate-top {
padding-bottom: var(--spacing-m);
align-items: flex-end;
justify-content: center;
}
} }
// This is a special case where the prefix appears "inside" the input // This is a special case where the prefix appears "inside" the input

View file

@ -48,6 +48,11 @@
flex-wrap: wrap; flex-wrap: wrap;
} }
.section__header-action-stack {
display: flex;
flex-direction: column;
}
.section__flex { .section__flex {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;