diff --git a/static/app-strings.json b/static/app-strings.json index e12e8ba5e..06dd56c6c 100644 --- a/static/app-strings.json +++ b/static/app-strings.json @@ -2216,5 +2216,8 @@ "Enable Data Hosting": "Enable Data Hosting", "Data over the limit will be deleted within 30 minutes. This will make the Yrbl cry a little bit.": "Data over the limit will be deleted within 30 minutes. This will make the Yrbl cry a little bit.", "Choose %asset%": "Choose %asset%", + "Showing %filtered% results of %total%": "Showing %filtered% results of %total%", + "filtered": "filtered", + "View All Playlists": "View All Playlists", "--end--": "--end--" } diff --git a/ui/component/collectionsListMine/view.jsx b/ui/component/collectionsListMine/view.jsx index 5b8104c49..abad00e0b 100644 --- a/ui/component/collectionsListMine/view.jsx +++ b/ui/component/collectionsListMine/view.jsx @@ -25,6 +25,7 @@ const ALL = 'All'; const PRIVATE = 'Private'; const PUBLIC = 'Public'; const COLLECTION_FILTERS = [ALL, PRIVATE, PUBLIC]; +const COLLECTION_SHOW_COUNT = 12; export default function CollectionsListMine(props: Props) { const { @@ -53,18 +54,24 @@ export default function CollectionsListMine(props: Props) { 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())) - ); - }); + filteredCollections = collectionsToShow + .filter((id) => { + return ( + (unpublishedCollections[id] && + unpublishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) || + (publishedCollections[id] && + publishedCollections[id].name.toLocaleLowerCase().includes(searchText.toLocaleLowerCase())) + ); + }) + .slice(0, COLLECTION_SHOW_COUNT); } else { - filteredCollections = collectionsToShow || []; + 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]; @@ -130,7 +137,19 @@ export default function CollectionsListMine(props: Props) {

- {__('Playlists')} +

-
-
-
- {COLLECTION_FILTERS.map((value) => ( -
+
{}} className="wunderbar--inline"> + + setSearchText(e.target.value)} + type="text" + placeholder={__('Search')} + /> +
-
{}} className="wunderbar--inline"> - - setSearchText(e.target.value)} - type="text" - placeholder={__('Search')} - /> - + {isTruncated && ( +

+ {__(`Showing %filtered% results of %total%`, { + filtered: filteredLength, + total: totalLength, + })} + {`${searchText ? ' (' + __('filtered') + ') ' : ' '}`} +

{Boolean(hasCollections) && (
- {/* TODO: fix above spacing hack */}
{filteredCollections && filteredCollections.length > 0 && diff --git a/ui/component/playlistsMine/index.js b/ui/component/playlistsMine/index.js new file mode 100644 index 000000000..f69176961 --- /dev/null +++ b/ui/component/playlistsMine/index.js @@ -0,0 +1,29 @@ +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'; +import { PAGE_PARAM, PAGE_SIZE_PARAM } from 'constants/claim'; +const COLLECTIONS_PAGE_SIZE = 12; + +const select = (state, props) => { + const { search } = props.location; + const urlParams = new URLSearchParams(search); + const page = Number(urlParams.get(PAGE_PARAM)) || '1'; + const pageSize = urlParams.get(PAGE_SIZE_PARAM) || String(COLLECTIONS_PAGE_SIZE); + + return { + page, + pageSize, + publishedCollections: selectMyPublishedPlaylistCollections(state), + unpublishedCollections: selectMyUnpublishedCollections(state), + // savedCollections: selectSavedCollections(state), + fetchingCollections: selectFetchingMyCollections(state), + }; +}; + +export default withRouter(connect(select)(PlaylistsMine)); diff --git a/ui/component/playlistsMine/view.jsx b/ui/component/playlistsMine/view.jsx new file mode 100644 index 000000000..e61fd12d3 --- /dev/null +++ b/ui/component/playlistsMine/view.jsx @@ -0,0 +1,161 @@ +// @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, +}; + +const ALL = 'All'; +const PRIVATE = 'Private'; +const PUBLIC = 'Public'; +const COLLECTION_FILTERS = [ALL, PRIVATE, PUBLIC]; + +export default function PlaylistsMine(props: Props) { + const { + publishedCollections, + unpublishedCollections, + // savedCollections, these are resolved on startup from sync'd claimIds or urls + fetchingCollections, + page = 0, + pageSize, + } = props; + + const unpublishedCollectionsList = (Object.keys(unpublishedCollections || {}): any); + const publishedList = (Object.keys(publishedCollections || {}): any); + const hasCollections = unpublishedCollectionsList.length || publishedList.length; + const [filterType, setFilterType] = React.useState(ALL); + 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 paginateStart = shouldPaginate ? (page - 1) * pageSize : 0; + const paginatedCollections = filteredCollections.slice(paginateStart, paginateStart + pageSize); + + function escapeListener(e: SyntheticKeyboardEvent<*>) { + if (e.keyCode === KEYCODES.ESCAPE) { + e.preventDefault(); + setSearchText(''); + } + } + + function onTextareaFocus() { + window.addEventListener('keydown', escapeListener); + } + + function onTextareaBlur() { + window.removeEventListener('keydown', escapeListener); + } + + return ( + <> +
+
+

+ {__('Playlists')} + {!hasCollections && !fetchingCollections && ( +
{__('(Empty) --[indicates empty playlist]--')}
+ )} + {!hasCollections && fetchingCollections && ( +
{__('(Empty) --[indicates empty playlist]--')}
+ )} +

+
+
+
+
+ {COLLECTION_FILTERS.map((value) => ( +
+
+
{}} className="wunderbar--inline"> + + setSearchText(e.target.value)} + type="text" + placeholder={__('Search')} + /> + +
+ {Boolean(hasCollections) && ( +
+ {/* TODO: fix above spacing hack */} + +
+ {paginatedCollections && + paginatedCollections.length > 0 && + paginatedCollections.map((key) => )} + {!paginatedCollections.length &&
{__('No matching playlists')}
} +
+ {shouldPaginate && ( + 0 ? Math.ceil(filteredCollections.length / Number(pageSize)) : 1 + } + /> + )} +
+ )} + {!hasCollections && !fetchingCollections && ( +
+ +
+ )} + {!hasCollections && fetchingCollections && ( +
+

{__('Loading...')}

+
+ )} +
+ + ); +} diff --git a/ui/component/router/view.jsx b/ui/component/router/view.jsx index 8a5e3b80d..257a00108 100644 --- a/ui/component/router/view.jsx +++ b/ui/component/router/view.jsx @@ -45,6 +45,7 @@ import InvitedPage from 'page/invited'; import LibraryPage from 'page/library'; import ListBlockedPage from 'page/listBlocked'; import ListsPage from 'page/lists'; +import PlaylistsPage from 'page/playlists'; import OwnComments from 'page/ownComments'; import PasswordResetPage from 'page/passwordReset'; import PasswordSetPage from 'page/passwordSet'; @@ -294,6 +295,7 @@ function AppRouter(props: Props) { + diff --git a/ui/constants/pages.js b/ui/constants/pages.js index 770514ac7..d0910d80d 100644 --- a/ui/constants/pages.js +++ b/ui/constants/pages.js @@ -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'; diff --git a/ui/page/lists/index.js b/ui/page/lists/index.js index ef574d5ba..fdfcc6e45 100644 --- a/ui/page/lists/index.js +++ b/ui/page/lists/index.js @@ -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; diff --git a/ui/page/playlists/index.js b/ui/page/playlists/index.js new file mode 100644 index 000000000..9ac3421a7 --- /dev/null +++ b/ui/page/playlists/index.js @@ -0,0 +1,3 @@ +import PlaylistsPage from './view'; + +export default PlaylistsPage; diff --git a/ui/page/playlists/view.jsx b/ui/page/playlists/view.jsx new file mode 100644 index 000000000..3242a8bb1 --- /dev/null +++ b/ui/page/playlists/view.jsx @@ -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 ( + + + + + ); +} + +export default PlaylistsPage; diff --git a/ui/scss/component/_collection.scss b/ui/scss/component/_collection.scss index f67883022..eac2afc07 100644 --- a/ui/scss/component/_collection.scss +++ b/ui/scss/component/_collection.scss @@ -136,3 +136,9 @@ } } } + +.collection-grid__results-summary { + padding: 0; + padding-bottom: var(--spacing-m); + color: var(--color-text-help); +} diff --git a/ui/scss/component/_form-field.scss b/ui/scss/component/_form-field.scss index 0933a87be..010d4eb05 100644 --- a/ui/scss/component/_form-field.scss +++ b/ui/scss/component/_form-field.scss @@ -313,14 +313,16 @@ fieldset-group { } &:nth-of-type(2) { - input, - select { - border-top-left-radius: 0; - border-bottom-left-radius: 0; - } - - label { - margin-left: var(--spacing-s); + &:not(input.paginate-channel) { + // yuck + input, + select { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + label { + margin-left: var(--spacing-s); + } } } } @@ -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 diff --git a/ui/scss/component/section.scss b/ui/scss/component/section.scss index 9d77d8a8c..37e75f347 100644 --- a/ui/scss/component/section.scss +++ b/ui/scss/component/section.scss @@ -48,6 +48,11 @@ flex-wrap: wrap; } +.section__header-action-stack { + display: flex; + flex-direction: column; +} + .section__flex { display: flex; align-items: flex-start;