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:
parent
fc2e2d2cfc
commit
238a64bca9
13 changed files with 344 additions and 66 deletions
|
@ -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.",
|
||||
"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.",
|
||||
"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--"
|
||||
}
|
||||
|
|
|
@ -25,7 +25,7 @@ const ALL = 'All';
|
|||
const PRIVATE = 'Private';
|
||||
const PUBLIC = 'Public';
|
||||
const COLLECTION_FILTERS = [ALL, PRIVATE, PUBLIC];
|
||||
const COLLECTION_SHOW_COUNT = 24;
|
||||
const COLLECTION_SHOW_COUNT = 12;
|
||||
|
||||
export default function CollectionsListMine(props: Props) {
|
||||
const {
|
||||
|
@ -43,6 +43,7 @@ export default function CollectionsListMine(props: Props) {
|
|||
const [filterType, setFilterType] = React.useState(ALL);
|
||||
const [searchText, setSearchText] = React.useState('');
|
||||
|
||||
const playlistPageUrl = `/$/${PAGES.PLAYLISTS}?type=${filterType}`;
|
||||
let collectionsToShow = [];
|
||||
if (filterType === ALL) {
|
||||
collectionsToShow = unpublishedCollectionsList.concat(publishedList);
|
||||
|
@ -68,6 +69,10 @@ export default function CollectionsListMine(props: Props) {
|
|||
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 favorites = builtinCollectionsList.find((list) => list.id === COLLECTIONS_CONSTS.FAVORITES_ID);
|
||||
const builtin = [watchLater, favorites];
|
||||
|
@ -133,7 +138,19 @@ export default function CollectionsListMine(props: Props) {
|
|||
<div className="claim-grid__wrapper">
|
||||
<div className="claim-grid__header section">
|
||||
<h1 className="claim-grid__title">
|
||||
<Button
|
||||
className="claim-grid__title"
|
||||
button="link"
|
||||
navigate={playlistPageUrl}
|
||||
label={
|
||||
<span className="claim-grid__title-span">
|
||||
{__('Playlists')}
|
||||
<div className="claim-grid__title--empty">
|
||||
<Icon className="icon--margin-right" icon={ICONS.STACK} />
|
||||
</div>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
{!hasCollections && !fetchingCollections && (
|
||||
<div className="claim-grid__title--empty">{__('(Empty) --[indicates empty playlist]--')}</div>
|
||||
)}
|
||||
|
@ -142,6 +159,7 @@ export default function CollectionsListMine(props: Props) {
|
|||
)}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="section__header-action-stack">
|
||||
<div className="section__header--actions">
|
||||
<div className="claim-search__wrapper">
|
||||
<div className="claim-search__menu-group">
|
||||
|
@ -171,9 +189,25 @@ export default function CollectionsListMine(props: Props) {
|
|||
/>
|
||||
</Form>
|
||||
</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) && (
|
||||
<div>
|
||||
{/* TODO: fix above spacing hack */}
|
||||
<div className="claim-grid">
|
||||
{filteredCollections &&
|
||||
filteredCollections.length > 0 &&
|
||||
|
|
|
@ -20,7 +20,8 @@ function Paginate(props: Props) {
|
|||
const { search } = location;
|
||||
const [textValue, setTextValue] = React.useState('');
|
||||
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 isMobile = useIsMobile();
|
||||
|
||||
|
@ -47,6 +48,12 @@ function Paginate(props: Props) {
|
|||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (urlParamPage) {
|
||||
setCurrentPage(urlParamPage);
|
||||
}
|
||||
}, [urlParamPage, setCurrentPage]);
|
||||
|
||||
return (
|
||||
// Hide the paginate controls if we are loading or there is only one page
|
||||
// It should still be rendered to trigger the onPageChange callback
|
||||
|
|
20
ui/component/playlistsMine/index.js
Normal file
20
ui/component/playlistsMine/index.js
Normal 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));
|
182
ui/component/playlistsMine/view.jsx
Normal file
182
ui/component/playlistsMine/view.jsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -61,6 +61,7 @@ const InvitedPage = lazyImport(() => import('page/invited' /* webpackChunkName:
|
|||
const LibraryPage = lazyImport(() => import('page/library' /* webpackChunkName: "library" */));
|
||||
const ListBlockedPage = lazyImport(() => import('page/listBlocked' /* webpackChunkName: "listBlocked" */));
|
||||
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 LivestreamCurrentPage = lazyImport(() =>
|
||||
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.LIBRARY}`} component={LibraryPage} />
|
||||
<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.SETTINGS_BLOCKED_MUTED}`} component={ListBlockedPage} />
|
||||
<PrivateRoute {...props} path={`/$/${PAGES.SETTINGS_CREATOR}`} component={SettingsCreatorPage} />
|
||||
|
|
|
@ -27,6 +27,7 @@ exports.HOME = 'home';
|
|||
exports.HELP = 'help';
|
||||
exports.LIBRARY = 'library';
|
||||
exports.LISTS = 'lists';
|
||||
exports.PLAYLISTS = 'playlists';
|
||||
exports.INVITE = 'invite';
|
||||
exports.DEPRECATED__PUBLISH = 'publish';
|
||||
exports.DEPRECATED__PUBLISHED = 'published';
|
||||
|
|
|
@ -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';
|
||||
|
||||
const select = (state) => ({
|
||||
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);
|
||||
export default ListsPage;
|
||||
|
|
3
ui/page/playlists/index.js
Normal file
3
ui/page/playlists/index.js
Normal file
|
@ -0,0 +1,3 @@
|
|||
import PlaylistsPage from './view';
|
||||
|
||||
export default PlaylistsPage;
|
22
ui/page/playlists/view.jsx
Normal file
22
ui/page/playlists/view.jsx
Normal 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;
|
|
@ -136,3 +136,9 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.collection-grid__results-summary {
|
||||
padding: 0;
|
||||
padding-bottom: var(--spacing-m);
|
||||
color: var(--color-text-help);
|
||||
}
|
||||
|
|
|
@ -313,17 +313,19 @@ fieldset-group {
|
|||
}
|
||||
|
||||
&:nth-of-type(2) {
|
||||
&:not(input.paginate-channel) {
|
||||
// yuck
|
||||
input,
|
||||
select {
|
||||
border-top-left-radius: 0;
|
||||
border-bottom-left-radius: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-left: var(--spacing-s);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&.fieldgroup--paginate {
|
||||
padding-bottom: var(--spacing-l);
|
||||
|
@ -331,6 +333,12 @@ fieldset-group {
|
|||
align-items: flex-end;
|
||||
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
|
||||
|
|
|
@ -48,6 +48,11 @@
|
|||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.section__header-action-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.section__flex {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
Loading…
Reference in a new issue