From f509ca0f7270b2338083585779d494765c6b27b5 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Thu, 5 Jan 2023 18:20:49 -0800 Subject: [PATCH] Refactor Artist index to use react-window (cherry picked from commit d022679b7dcbce3cec98e6a1fd0879e3c0d92523) Fixed: Restoring scroll position when going back/forward to artist list (cherry picked from commit 5aad84dba453c42b4b5a9eac43deecf91a98f4f6) --- frontend/src/Album/Album.ts | 21 + frontend/src/App/AppRoutes.js | 4 +- frontend/src/App/State/AppState.ts | 3 + frontend/src/App/State/ArtistAppState.ts | 72 +++ frontend/src/App/State/SettingsAppState.ts | 13 + frontend/src/Artist/Artist.ts | 51 ++ frontend/src/Artist/ArtistBanner.js | 4 + frontend/src/Artist/ArtistPoster.js | 4 + .../src/Artist/Delete/DeleteArtistModal.js | 1 + frontend/src/Artist/Edit/EditArtistModal.js | 1 + .../Artist/Edit/EditArtistModalConnector.js | 1 + frontend/src/Artist/Index/ArtistIndex.js | 407 --------------- frontend/src/Artist/Index/ArtistIndex.tsx | 333 ++++++++++++ .../src/Artist/Index/ArtistIndexConnector.js | 106 ---- .../Artist/Index/ArtistIndexFilterModal.tsx | 49 ++ .../Index/ArtistIndexFilterModalConnector.js | 24 - .../src/Artist/Index/ArtistIndexFooter.js | 167 ------ .../src/Artist/Index/ArtistIndexFooter.tsx | 169 ++++++ .../Index/ArtistIndexFooterConnector.js | 46 -- .../Artist/Index/ArtistIndexItemConnector.js | 153 ------ .../Index/Banners/ArtistIndexBanner.css | 29 +- .../Index/Banners/ArtistIndexBanner.css.d.ts | 3 +- .../Artist/Index/Banners/ArtistIndexBanner.js | 271 ---------- .../Index/Banners/ArtistIndexBanner.tsx | 257 +++++++++ .../Index/Banners/ArtistIndexBannerInfo.js | 115 ----- .../Index/Banners/ArtistIndexBannerInfo.tsx | 187 +++++++ .../Index/Banners/ArtistIndexBanners.js | 327 ------------ .../Index/Banners/ArtistIndexBanners.tsx | 294 +++++++++++ .../Banners/ArtistIndexBannersConnector.js | 25 - .../Options/ArtistIndexBannerOptionsModal.js | 25 - .../Options/ArtistIndexBannerOptionsModal.tsx | 21 + .../ArtistIndexBannerOptionsModalContent.js | 226 -------- .../ArtistIndexBannerOptionsModalContent.tsx | 167 ++++++ ...IndexBannerOptionsModalContentConnector.js | 23 - .../Index/Banners/selectBannerOptions.ts | 9 + ...ilterMenu.js => ArtistIndexFilterMenu.tsx} | 13 +- ...dexSortMenu.js => ArtistIndexSortMenu.tsx} | 14 +- ...dexViewMenu.js => ArtistIndexViewMenu.tsx} | 40 +- .../Index/Overview/ArtistIndexOverview.css | 8 - .../Overview/ArtistIndexOverview.css.d.ts | 1 - .../Index/Overview/ArtistIndexOverview.js | 283 ---------- .../Index/Overview/ArtistIndexOverview.tsx | 240 +++++++++ .../Index/Overview/ArtistIndexOverviewInfo.js | 249 --------- .../Overview/ArtistIndexOverviewInfo.tsx | 228 ++++++++ .../Overview/ArtistIndexOverviewInfoRow.js | 35 -- .../Overview/ArtistIndexOverviewInfoRow.tsx | 23 + .../Index/Overview/ArtistIndexOverviews.js | 275 ---------- .../Index/Overview/ArtistIndexOverviews.tsx | 203 ++++++++ .../Overview/ArtistIndexOverviewsConnector.js | 25 - .../ArtistIndexOverviewOptionsModal.js | 25 - .../ArtistIndexOverviewOptionsModal.tsx | 25 + .../ArtistIndexOverviewOptionsModalContent.js | 308 ----------- ...ArtistIndexOverviewOptionsModalContent.tsx | 197 +++++++ ...dexOverviewOptionsModalContentConnector.js | 23 - .../Index/Overview/selectOverviewOptions.ts | 9 + .../Artist/Index/Posters/ArtistIndexPoster.js | 305 ----------- .../Index/Posters/ArtistIndexPoster.tsx | 257 +++++++++ ...osterInfo.js => ArtistIndexPosterInfo.tsx} | 116 +++-- .../Index/Posters/ArtistIndexPosters.js | 351 ------------- .../Index/Posters/ArtistIndexPosters.tsx | 294 +++++++++++ .../Posters/ArtistIndexPostersConnector.js | 25 - .../Options/ArtistIndexPosterOptionsModal.js | 25 - .../Options/ArtistIndexPosterOptionsModal.tsx | 21 + .../ArtistIndexPosterOptionsModalContent.js | 248 --------- .../ArtistIndexPosterOptionsModalContent.tsx | 167 ++++++ ...IndexPosterOptionsModalContentConnector.js | 23 - .../Index/Posters/selectPosterOptions.ts | 9 + .../ProgressBar/ArtistIndexProgressBar.css | 1 - ...gressBar.js => ArtistIndexProgressBar.tsx} | 33 +- .../Index/Table/ArtistIndexActionsCell.js | 103 ---- .../Artist/Index/Table/ArtistIndexHeader.js | 86 ---- .../Index/Table/ArtistIndexHeaderConnector.js | 13 - .../src/Artist/Index/Table/ArtistIndexRow.js | 487 ------------------ .../src/Artist/Index/Table/ArtistIndexRow.tsx | 392 ++++++++++++++ .../Artist/Index/Table/ArtistIndexTable.css | 6 +- .../Index/Table/ArtistIndexTable.css.d.ts | 2 +- .../Artist/Index/Table/ArtistIndexTable.js | 134 ----- .../Artist/Index/Table/ArtistIndexTable.tsx | 205 ++++++++ .../Index/Table/ArtistIndexTableConnector.js | 29 -- ...xHeader.css => ArtistIndexTableHeader.css} | 0 ...s.d.ts => ArtistIndexTableHeader.css.d.ts} | 0 .../Index/Table/ArtistIndexTableHeader.tsx | 98 ++++ .../Index/Table/ArtistIndexTableOptions.js | 105 ---- .../Index/Table/ArtistIndexTableOptions.tsx | 62 +++ .../Table/ArtistIndexTableOptionsConnector.js | 14 - ...tistStatusCell.js => ArtistStatusCell.tsx} | 50 +- .../Artist/Index/Table/hasGrowableColumns.js | 16 - .../Artist/Index/Table/hasGrowableColumns.ts | 11 + .../Artist/Index/Table/selectTableOptions.ts | 9 + .../Index/createArtistIndexItemSelector.ts | 48 ++ .../src/Components/Link/SpinnerIconButton.js | 2 + frontend/src/Components/Menu/SortMenu.js | 5 +- frontend/src/Components/Menu/ViewMenu.js | 5 +- .../src/Components/Page/PageContentBody.js | 61 --- .../src/Components/Page/PageContentBody.tsx | 51 ++ .../Page/Toolbar/PageToolbarButton.js | 3 +- frontend/src/Components/Scroller/Scroller.js | 95 ---- frontend/src/Components/Scroller/Scroller.tsx | 90 ++++ frontend/src/Components/withScrollPosition.js | 30 -- .../src/Components/withScrollPosition.tsx | 25 + frontend/src/Helpers/Hooks/useMeasure.ts | 21 + frontend/src/Helpers/Props/ScrollDirection.ts | 8 + frontend/src/Quality/Quality.ts | 17 + .../src/Store/Actions/artistIndexActions.js | 1 + .../createArtistMetadataProfileSelector.js | 16 - .../createArtistMetadataProfileSelector.ts | 18 + .../createArtistQualityProfileSelector.js | 16 - .../createArtistQualityProfileSelector.ts | 18 + .../Store/Selectors/createArtistSelector.js | 10 + .../src/UnmappedFiles/UnmappedFilesTable.js | 24 +- .../Array/getIndexOfFirstCharacter.js | 4 +- frontend/src/typings/CustomFormat.ts | 12 + frontend/src/typings/MetadataProfile.ts | 39 ++ frontend/src/typings/QualityProfile.ts | 23 + package.json | 3 + src/NzbDrone.Core/Localization/Core/en.json | 11 +- yarn.lock | 30 ++ 117 files changed, 4704 insertions(+), 5511 deletions(-) create mode 100644 frontend/src/Album/Album.ts create mode 100644 frontend/src/App/State/ArtistAppState.ts create mode 100644 frontend/src/Artist/Artist.ts delete mode 100644 frontend/src/Artist/Index/ArtistIndex.js create mode 100644 frontend/src/Artist/Index/ArtistIndex.tsx delete mode 100644 frontend/src/Artist/Index/ArtistIndexConnector.js create mode 100644 frontend/src/Artist/Index/ArtistIndexFilterModal.tsx delete mode 100644 frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js delete mode 100644 frontend/src/Artist/Index/ArtistIndexFooter.js create mode 100644 frontend/src/Artist/Index/ArtistIndexFooter.tsx delete mode 100644 frontend/src/Artist/Index/ArtistIndexFooterConnector.js delete mode 100644 frontend/src/Artist/Index/ArtistIndexItemConnector.js delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanner.js create mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js create mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanners.js create mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx delete mode 100644 frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js delete mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js create mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx delete mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js create mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx delete mode 100644 frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js create mode 100644 frontend/src/Artist/Index/Banners/selectBannerOptions.ts rename frontend/src/Artist/Index/Menus/{ArtistIndexFilterMenu.js => ArtistIndexFilterMenu.tsx} (76%) rename frontend/src/Artist/Index/Menus/{ArtistIndexSortMenu.js => ArtistIndexSortMenu.tsx} (94%) rename frontend/src/Artist/Index/Menus/{ArtistIndexViewMenu.js => ArtistIndexViewMenu.tsx} (54%) delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverview.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js create mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx delete mode 100644 frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js delete mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js create mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx delete mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js create mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx delete mode 100644 frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js create mode 100644 frontend/src/Artist/Index/Overview/selectOverviewOptions.ts delete mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPoster.js create mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx rename frontend/src/Artist/Index/Posters/{ArtistIndexPosterInfo.js => ArtistIndexPosterInfo.tsx} (63%) delete mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPosters.js create mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx delete mode 100644 frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js delete mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js create mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx delete mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js create mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx delete mode 100644 frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js create mode 100644 frontend/src/Artist/Index/Posters/selectPosterOptions.ts rename frontend/src/Artist/Index/ProgressBar/{ArtistIndexProgressBar.js => ArtistIndexProgressBar.tsx} (57%) delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexHeader.js delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexRow.js create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexRow.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTable.js create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTable.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js rename frontend/src/Artist/Index/Table/{ArtistIndexHeader.css => ArtistIndexTableHeader.css} (100%) rename frontend/src/Artist/Index/Table/{ArtistIndexHeader.css.d.ts => ArtistIndexTableHeader.css.d.ts} (100%) create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js create mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx delete mode 100644 frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js rename frontend/src/Artist/Index/Table/{ArtistStatusCell.js => ArtistStatusCell.tsx} (52%) delete mode 100644 frontend/src/Artist/Index/Table/hasGrowableColumns.js create mode 100644 frontend/src/Artist/Index/Table/hasGrowableColumns.ts create mode 100644 frontend/src/Artist/Index/Table/selectTableOptions.ts create mode 100644 frontend/src/Artist/Index/createArtistIndexItemSelector.ts delete mode 100644 frontend/src/Components/Page/PageContentBody.js create mode 100644 frontend/src/Components/Page/PageContentBody.tsx delete mode 100644 frontend/src/Components/Scroller/Scroller.js create mode 100644 frontend/src/Components/Scroller/Scroller.tsx delete mode 100644 frontend/src/Components/withScrollPosition.js create mode 100644 frontend/src/Components/withScrollPosition.tsx create mode 100644 frontend/src/Helpers/Hooks/useMeasure.ts create mode 100644 frontend/src/Helpers/Props/ScrollDirection.ts create mode 100644 frontend/src/Quality/Quality.ts delete mode 100644 frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts delete mode 100644 frontend/src/Store/Selectors/createArtistQualityProfileSelector.js create mode 100644 frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts create mode 100644 frontend/src/typings/CustomFormat.ts create mode 100644 frontend/src/typings/MetadataProfile.ts create mode 100644 frontend/src/typings/QualityProfile.ts diff --git a/frontend/src/Album/Album.ts b/frontend/src/Album/Album.ts new file mode 100644 index 000000000..03a129e06 --- /dev/null +++ b/frontend/src/Album/Album.ts @@ -0,0 +1,21 @@ +import ModelBase from 'App/ModelBase'; + +export interface Statistics { + trackCount: number; + trackFileCount: number; + percentOfTracks: number; + sizeOnDisk: number; + totalTrackCount: number; +} + +interface Album extends ModelBase { + foreignAlbumId: string; + title: string; + overview: string; + disambiguation?: string; + monitored: boolean; + releaseDate: string; + statistics: Statistics; +} + +export default Album; diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index 223e0f90e..54913f632 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -8,7 +8,7 @@ import AlbumDetailsPageConnector from 'Album/Details/AlbumDetailsPageConnector'; import AlbumStudioConnector from 'AlbumStudio/AlbumStudioConnector'; import ArtistDetailsPageConnector from 'Artist/Details/ArtistDetailsPageConnector'; import ArtistEditorConnector from 'Artist/Editor/ArtistEditorConnector'; -import ArtistIndexConnector from 'Artist/Index/ArtistIndexConnector'; +import ArtistIndex from 'Artist/Index/ArtistIndex'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; @@ -51,7 +51,7 @@ function AppRoutes(props) { { diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 8c8b99fba..e6b9d596c 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -1,3 +1,4 @@ +import ArtistAppState, { ArtistIndexAppState } from './ArtistAppState'; import SettingsAppState from './SettingsAppState'; import TagsAppState from './TagsAppState'; @@ -34,6 +35,8 @@ export interface CustomFilter { } interface AppState { + artist: ArtistAppState; + artistIndex: ArtistIndexAppState; settings: SettingsAppState; tags: TagsAppState; } diff --git a/frontend/src/App/State/ArtistAppState.ts b/frontend/src/App/State/ArtistAppState.ts new file mode 100644 index 000000000..9e0628df7 --- /dev/null +++ b/frontend/src/App/State/ArtistAppState.ts @@ -0,0 +1,72 @@ +import AppSectionState, { + AppSectionDeleteState, + AppSectionSaveState, +} from 'App/State/AppSectionState'; +import Artist from 'Artist/Artist'; +import Column from 'Components/Table/Column'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { Filter, FilterBuilderProp } from './AppState'; + +export interface ArtistIndexAppState { + sortKey: string; + sortDirection: SortDirection; + secondarySortKey: string; + secondarySortDirection: SortDirection; + view: string; + + posterOptions: { + detailedProgressBar: boolean; + size: string; + showTitle: boolean; + showMonitored: boolean; + showQualityProfile: boolean; + showNextAlbum: boolean; + showSearchAction: boolean; + }; + + bannerOptions: { + detailedProgressBar: boolean; + size: string; + showTitle: boolean; + showMonitored: boolean; + showQualityProfile: boolean; + showNextAlbum: boolean; + showSearchAction: boolean; + }; + + overviewOptions: { + detailedProgressBar: boolean; + size: string; + showMonitored: boolean; + showQualityProfile: boolean; + showLastAlbum: boolean; + showAdded: boolean; + showAlbumCount: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + showSearchAction: boolean; + }; + + tableOptions: { + showBanners: boolean; + showSearchAction: boolean; + }; + + selectedFilterKey: string; + filterBuilderProps: FilterBuilderProp[]; + filters: Filter[]; + columns: Column[]; +} + +interface ArtistAppState + extends AppSectionState, + AppSectionDeleteState, + AppSectionSaveState { + itemMap: Record; + + deleteOptions: { + addImportListExclusion: boolean; + }; +} + +export default ArtistAppState; diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts index 4c0680956..c5b974610 100644 --- a/frontend/src/App/State/SettingsAppState.ts +++ b/frontend/src/App/State/SettingsAppState.ts @@ -1,11 +1,14 @@ import AppSectionState, { AppSectionDeleteState, AppSectionSaveState, + AppSectionSchemaState, } from 'App/State/AppSectionState'; import DownloadClient from 'typings/DownloadClient'; import ImportList from 'typings/ImportList'; import Indexer from 'typings/Indexer'; +import MetadataProfile from 'typings/MetadataProfile'; import Notification from 'typings/Notification'; +import QualityProfile from 'typings/QualityProfile'; import { UiSettings } from 'typings/UiSettings'; export interface DownloadClientAppState @@ -27,13 +30,23 @@ export interface NotificationAppState extends AppSectionState, AppSectionDeleteState {} +export interface QualityProfilesAppState + extends AppSectionState, + AppSectionSchemaState {} + +export interface MetadataProfilesAppState + extends AppSectionState, + AppSectionSchemaState {} + export type UiSettingsAppState = AppSectionState; interface SettingsAppState { downloadClients: DownloadClientAppState; importLists: ImportListAppState; indexers: IndexerAppState; + metadataProfiles: MetadataProfilesAppState; notifications: NotificationAppState; + qualityProfiles: QualityProfilesAppState; uiSettings: UiSettingsAppState; } diff --git a/frontend/src/Artist/Artist.ts b/frontend/src/Artist/Artist.ts new file mode 100644 index 000000000..31b609d73 --- /dev/null +++ b/frontend/src/Artist/Artist.ts @@ -0,0 +1,51 @@ +import Album from 'Album/Album'; +import ModelBase from 'App/ModelBase'; + +export interface Image { + coverType: string; + url: string; + remoteUrl: string; +} + +export interface Statistics { + albumCount: number; + trackCount: number; + trackFileCount: number; + percentOfTracks: number; + sizeOnDisk: number; + totalTrackCount: number; +} + +export interface Ratings { + votes: number; + value: number; +} + +interface Artist extends ModelBase { + added: string; + foreignArtistId: string; + cleanName: string; + ended: boolean; + genres: string[]; + images: Image[]; + monitored: boolean; + overview: string; + path: string; + lastAlbum?: Album; + nextAlbum?: Album; + qualityProfileId: number; + metadataProfileId: number; + ratings: Ratings; + rootFolderPath: string; + albums: Album[]; + sortName: string; + statistics: Statistics; + status: string; + tags: number[]; + artistName: string; + artistType?: string; + disambiguation?: string; + isSaving?: boolean; +} + +export default Artist; diff --git a/frontend/src/Artist/ArtistBanner.js b/frontend/src/Artist/ArtistBanner.js index b409667b1..5483912e1 100644 --- a/frontend/src/Artist/ArtistBanner.js +++ b/frontend/src/Artist/ArtistBanner.js @@ -15,6 +15,10 @@ function ArtistBanner(props) { } ArtistBanner.propTypes = { + ...ArtistImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Artist/ArtistPoster.js b/frontend/src/Artist/ArtistPoster.js index 4eebd9ca4..de594e5b9 100644 --- a/frontend/src/Artist/ArtistPoster.js +++ b/frontend/src/Artist/ArtistPoster.js @@ -15,6 +15,10 @@ function ArtistPoster(props) { } ArtistPoster.propTypes = { + ...ArtistImage.propTypes, + coverType: PropTypes.string, + placeholder: PropTypes.string, + overflow: PropTypes.bool, size: PropTypes.number.isRequired }; diff --git a/frontend/src/Artist/Delete/DeleteArtistModal.js b/frontend/src/Artist/Delete/DeleteArtistModal.js index 8e0b87296..c647b7735 100644 --- a/frontend/src/Artist/Delete/DeleteArtistModal.js +++ b/frontend/src/Artist/Delete/DeleteArtistModal.js @@ -26,6 +26,7 @@ function DeleteArtistModal(props) { } DeleteArtistModal.propTypes = { + ...DeleteArtistModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Edit/EditArtistModal.js b/frontend/src/Artist/Edit/EditArtistModal.js index 6e99a2f53..f221e728c 100644 --- a/frontend/src/Artist/Edit/EditArtistModal.js +++ b/frontend/src/Artist/Edit/EditArtistModal.js @@ -18,6 +18,7 @@ function EditArtistModal({ isOpen, onModalClose, ...otherProps }) { } EditArtistModal.propTypes = { + ...EditArtistModalContentConnector.propTypes, isOpen: PropTypes.bool.isRequired, onModalClose: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Edit/EditArtistModalConnector.js b/frontend/src/Artist/Edit/EditArtistModalConnector.js index 56e336201..9c4e6325f 100644 --- a/frontend/src/Artist/Edit/EditArtistModalConnector.js +++ b/frontend/src/Artist/Edit/EditArtistModalConnector.js @@ -32,6 +32,7 @@ class EditArtistModalConnector extends Component { } EditArtistModalConnector.propTypes = { + ...EditArtistModal.propTypes, onModalClose: PropTypes.func.isRequired, clearPendingChanges: PropTypes.func.isRequired }; diff --git a/frontend/src/Artist/Index/ArtistIndex.js b/frontend/src/Artist/Index/ArtistIndex.js deleted file mode 100644 index 6f68f7fcd..000000000 --- a/frontend/src/Artist/Index/ArtistIndex.js +++ /dev/null @@ -1,407 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import NoArtist from 'Artist/NoArtist'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import PageContent from 'Components/Page/PageContent'; -import PageContentBody from 'Components/Page/PageContentBody'; -import PageJumpBar from 'Components/Page/PageJumpBar'; -import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; -import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; -import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; -import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import { align, icons, sortDirections } from 'Helpers/Props'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import translate from 'Utilities/String/translate'; -import ArtistIndexFooterConnector from './ArtistIndexFooterConnector'; -import ArtistIndexBannersConnector from './Banners/ArtistIndexBannersConnector'; -import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; -import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu'; -import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu'; -import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu'; -import ArtistIndexOverviewsConnector from './Overview/ArtistIndexOverviewsConnector'; -import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; -import ArtistIndexPostersConnector from './Posters/ArtistIndexPostersConnector'; -import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; -import ArtistIndexTableConnector from './Table/ArtistIndexTableConnector'; -import ArtistIndexTableOptionsConnector from './Table/ArtistIndexTableOptionsConnector'; -import styles from './ArtistIndex.css'; - -function getViewComponent(view) { - if (view === 'posters') { - return ArtistIndexPostersConnector; - } - - if (view === 'banners') { - return ArtistIndexBannersConnector; - } - - if (view === 'overview') { - return ArtistIndexOverviewsConnector; - } - - return ArtistIndexTableConnector; -} - -class ArtistIndex extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scroller: null, - jumpBarItems: { order: [] }, - jumpToCharacter: null, - isPosterOptionsModalOpen: false, - isBannerOptionsModalOpen: false, - isOverviewOptionsModalOpen: false - }; - } - - componentDidMount() { - this.setJumpBarItems(); - } - - componentDidUpdate(prevProps) { - const { - items, - sortKey, - sortDirection - } = this.props; - - if (sortKey !== prevProps.sortKey || - sortDirection !== prevProps.sortDirection || - hasDifferentItemsOrOrder(prevProps.items, items) - ) { - this.setJumpBarItems(); - } - - if (this.state.jumpToCharacter != null) { - this.setState({ jumpToCharacter: null }); - } - } - - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - - setJumpBarItems() { - const { - items, - sortKey, - sortDirection - } = this.props; - - // Reset if not sorting by sortName - if (sortKey !== 'sortName') { - this.setState({ jumpBarItems: { order: [] } }); - return; - } - - const characters = _.reduce(items, (acc, item) => { - let char = item.sortName.charAt(0); - - if (!isNaN(char)) { - char = '#'; - } - - if (char in acc) { - acc[char] = acc[char] + 1; - } else { - acc[char] = 1; - } - - return acc; - }, {}); - - const order = Object.keys(characters).sort(); - - // Reverse if sorting descending - if (sortDirection === sortDirections.DESCENDING) { - order.reverse(); - } - - const jumpBarItems = { - characters, - order - }; - - this.setState({ jumpBarItems }); - } - - // - // Listeners - - onPosterOptionsPress = () => { - this.setState({ isPosterOptionsModalOpen: true }); - }; - - onPosterOptionsModalClose = () => { - this.setState({ isPosterOptionsModalOpen: false }); - }; - - onBannerOptionsPress = () => { - this.setState({ isBannerOptionsModalOpen: true }); - }; - - onBannerOptionsModalClose = () => { - this.setState({ isBannerOptionsModalOpen: false }); - }; - - onOverviewOptionsPress = () => { - this.setState({ isOverviewOptionsModalOpen: true }); - }; - - onOverviewOptionsModalClose = () => { - this.setState({ isOverviewOptionsModalOpen: false }); - }; - - onJumpBarItemPress = (jumpToCharacter) => { - this.setState({ jumpToCharacter }); - }; - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - totalItems, - items, - columns, - selectedFilterKey, - filters, - customFilters, - sortKey, - sortDirection, - view, - isRefreshingArtist, - isRssSyncExecuting, - onScroll, - onSortSelect, - onFilterSelect, - onViewSelect, - onRefreshArtistPress, - onRssSyncPress, - ...otherProps - } = this.props; - - const { - scroller, - jumpBarItems, - jumpToCharacter, - isPosterOptionsModalOpen, - isBannerOptionsModalOpen, - isOverviewOptionsModalOpen - } = this.state; - - const ViewComponent = getViewComponent(view); - const isLoaded = !!(!error && isPopulated && items.length && scroller); - const hasNoArtist = !totalItems; - - return ( - - - - - - - - - - - { - view === 'table' ? - - - : - null - } - - { - view === 'posters' ? - : - null - } - - { - view === 'banners' ? - : - null - } - - { - view === 'overview' ? - : - null - } - - - - - - - - - - - -
- - { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && -
- {getErrorMessage(error, 'Failed to load artist from API')} -
- } - - { - isLoaded && -
- - - -
- } - - { - !error && isPopulated && !items.length && - - } -
- - { - isLoaded && !!jumpBarItems.order.length && - - } -
- - - - - - -
- ); - } -} - -ArtistIndex.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - totalItems: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - view: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onSortSelect: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired, - onViewSelect: PropTypes.func.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onScroll: PropTypes.func.isRequired -}; - -export default ArtistIndex; diff --git a/frontend/src/Artist/Index/ArtistIndex.tsx b/frontend/src/Artist/Index/ArtistIndex.tsx new file mode 100644 index 000000000..604b905a2 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndex.tsx @@ -0,0 +1,333 @@ +import React, { useCallback, useMemo, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import NoArtist from 'Artist/NoArtist'; +import { REFRESH_ARTIST, RSS_SYNC } from 'Commands/commandNames'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import PageContent from 'Components/Page/PageContent'; +import PageContentBody from 'Components/Page/PageContentBody'; +import PageJumpBar from 'Components/Page/PageJumpBar'; +import PageToolbar from 'Components/Page/Toolbar/PageToolbar'; +import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton'; +import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; +import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import withScrollPosition from 'Components/withScrollPosition'; +import { align, icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + setArtistFilter, + setArtistSort, + setArtistTableOption, + setArtistView, +} from 'Store/Actions/artistIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import scrollPositions from 'Store/scrollPositions'; +import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import getErrorMessage from 'Utilities/Object/getErrorMessage'; +import translate from 'Utilities/String/translate'; +import ArtistIndexFooter from './ArtistIndexFooter'; +import ArtistIndexBanners from './Banners/ArtistIndexBanners'; +import ArtistIndexBannerOptionsModal from './Banners/Options/ArtistIndexBannerOptionsModal'; +import ArtistIndexFilterMenu from './Menus/ArtistIndexFilterMenu'; +import ArtistIndexSortMenu from './Menus/ArtistIndexSortMenu'; +import ArtistIndexViewMenu from './Menus/ArtistIndexViewMenu'; +import ArtistIndexOverviews from './Overview/ArtistIndexOverviews'; +import ArtistIndexOverviewOptionsModal from './Overview/Options/ArtistIndexOverviewOptionsModal'; +import ArtistIndexPosters from './Posters/ArtistIndexPosters'; +import ArtistIndexPosterOptionsModal from './Posters/Options/ArtistIndexPosterOptionsModal'; +import ArtistIndexTable from './Table/ArtistIndexTable'; +import ArtistIndexTableOptions from './Table/ArtistIndexTableOptions'; +import styles from './ArtistIndex.css'; + +function getViewComponent(view) { + if (view === 'posters') { + return ArtistIndexPosters; + } + + if (view === 'banners') { + return ArtistIndexBanners; + } + + if (view === 'overview') { + return ArtistIndexOverviews; + } + + return ArtistIndexTable; +} + +interface ArtistIndexProps { + initialScrollTop?: number; +} + +const ArtistIndex = withScrollPosition((props: ArtistIndexProps) => { + const { + isFetching, + isPopulated, + error, + totalItems, + items, + columns, + selectedFilterKey, + filters, + customFilters, + sortKey, + sortDirection, + view, + } = useSelector(createArtistClientSideCollectionItemsSelector('artistIndex')); + + const isRefreshingArtist = useSelector( + createCommandExecutingSelector(REFRESH_ARTIST) + ); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(RSS_SYNC) + ); + const { isSmallScreen } = useSelector(createDimensionsSelector()); + const dispatch = useDispatch(); + const scrollerRef = useRef(); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [jumpToCharacter, setJumpToCharacter] = useState(null); + + const onRefreshArtistPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + }) + ); + }, [dispatch]); + + const onRssSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: RSS_SYNC, + }) + ); + }, [dispatch]); + + const onTableOptionChange = useCallback( + (payload) => { + dispatch(setArtistTableOption(payload)); + }, + [dispatch] + ); + + const onViewSelect = useCallback( + (value) => { + dispatch(setArtistView({ view: value })); + + if (scrollerRef.current) { + scrollerRef.current.scrollTo(0, 0); + } + }, + [scrollerRef, dispatch] + ); + + const onSortSelect = useCallback( + (value) => { + dispatch(setArtistSort({ sortKey: value })); + }, + [dispatch] + ); + + const onFilterSelect = useCallback( + (value) => { + dispatch(setArtistFilter({ selectedFilterKey: value })); + }, + [dispatch] + ); + + const onOptionsPress = useCallback(() => { + setIsOptionsModalOpen(true); + }, [setIsOptionsModalOpen]); + + const onOptionsModalClose = useCallback(() => { + setIsOptionsModalOpen(false); + }, [setIsOptionsModalOpen]); + + const onJumpBarItemPress = useCallback( + (character) => { + setJumpToCharacter(character); + }, + [setJumpToCharacter] + ); + + const onScroll = useCallback( + ({ scrollTop }) => { + setJumpToCharacter(null); + scrollPositions.artistIndex = scrollTop; + }, + [setJumpToCharacter] + ); + + const jumpBarItems = useMemo(() => { + // Reset if not sorting by sortName + if (sortKey !== 'sortName') { + return { + order: [], + }; + } + + const characters = items.reduce((acc, item) => { + let char = item.sortName.charAt(0); + + if (!isNaN(char)) { + char = '#'; + } + + if (char in acc) { + acc[char] = acc[char] + 1; + } else { + acc[char] = 1; + } + + return acc; + }, {}); + + const order = Object.keys(characters).sort(); + + // Reverse if sorting descending + if (sortDirection === SortDirection.Descending) { + order.reverse(); + } + + return { + characters, + order, + }; + }, [items, sortKey, sortDirection]); + const ViewComponent = useMemo(() => getViewComponent(view), [view]); + + const isLoaded = !!(!error && isPopulated && items.length); + const hasNoArtist = !totalItems; + + return ( + + + + + + + + + + {view === 'table' ? ( + + + + ) : ( + + )} + + + + + + + + + + +
+ + {isFetching && !isPopulated ? : null} + + {!isFetching && !!error ? ( +
+ {getErrorMessage(error, 'Failed to load artist from API')} +
+ ) : null} + + {isLoaded ? ( +
+ + + +
+ ) : null} + + {!error && isPopulated && !items.length ? ( + + ) : null} +
+ + {isLoaded && !!jumpBarItems.order.length ? ( + + ) : null} +
+ {view === 'posters' ? ( + + ) : null} + {view === 'banners' ? ( + + ) : null} + {view === 'overview' ? ( + + ) : null} +
+ ); +}, 'artistIndex'); + +export default ArtistIndex; diff --git a/frontend/src/Artist/Index/ArtistIndexConnector.js b/frontend/src/Artist/Index/ArtistIndexConnector.js deleted file mode 100644 index 541d9819e..000000000 --- a/frontend/src/Artist/Index/ArtistIndexConnector.js +++ /dev/null @@ -1,106 +0,0 @@ -/* eslint max-params: 0 */ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withScrollPosition from 'Components/withScrollPosition'; -import { setArtistFilter, setArtistSort, setArtistTableOption, setArtistView } from 'Store/Actions/artistIndexActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import scrollPositions from 'Store/scrollPositions'; -import createArtistClientSideCollectionItemsSelector from 'Store/Selectors/createArtistClientSideCollectionItemsSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import ArtistIndex from './ArtistIndex'; - -function createMapStateToProps() { - return createSelector( - createArtistClientSideCollectionItemsSelector('artistIndex'), - createCommandExecutingSelector(commandNames.REFRESH_ARTIST), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createDimensionsSelector(), - ( - artist, - isRefreshingArtist, - isRssSyncExecuting, - dimensionsState - ) => { - return { - ...artist, - isRefreshingArtist, - isRssSyncExecuting, - isSmallScreen: dimensionsState.isSmallScreen - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setArtistTableOption(payload)); - }, - - onSortSelect(sortKey) { - dispatch(setArtistSort({ sortKey })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setArtistFilter({ selectedFilterKey })); - }, - - dispatchSetArtistView(view) { - dispatch(setArtistView({ view })); - }, - - onRefreshArtistPress() { - dispatch(executeCommand({ - name: commandNames.REFRESH_ARTIST - })); - }, - - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - } - }; -} - -class ArtistIndexConnector extends Component { - - // - // Listeners - - onViewSelect = (view) => { - this.props.dispatchSetArtistView(view); - }; - - onScroll = ({ scrollTop }) => { - scrollPositions.artistIndex = scrollTop; - }; - - // - // Render - - render() { - return ( - - ); - } -} - -ArtistIndexConnector.propTypes = { - isSmallScreen: PropTypes.bool.isRequired, - view: PropTypes.string.isRequired, - dispatchSetArtistView: PropTypes.func.isRequired -}; - -export default withScrollPosition( - connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexConnector), - 'artistIndex' -); diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx b/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx new file mode 100644 index 000000000..ea3b99a81 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFilterModal.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setArtistFilter } from 'Store/Actions/artistIndexActions'; + +function createArtistSelector() { + return createSelector( + (state: AppState) => state.artist.items, + (artist) => { + return artist; + } + ); +} + +function createFilterBuilderPropsSelector() { + return createSelector( + (state: AppState) => state.artistIndex.filterBuilderProps, + (filterBuilderProps) => { + return filterBuilderProps; + } + ); +} + +export default function ArtistIndexFilterModal(props) { + const sectionItems = useSelector(createArtistSelector()); + const filterBuilderProps = useSelector(createFilterBuilderPropsSelector()); + const customFilterType = 'artist'; + + const dispatch = useDispatch(); + + const dispatchSetFilter = useCallback( + (payload) => { + dispatch(setArtistFilter(payload)); + }, + [dispatch] + ); + + return ( + + ); +} diff --git a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js b/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js deleted file mode 100644 index cf5ec33ea..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFilterModalConnector.js +++ /dev/null @@ -1,24 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import FilterModal from 'Components/Filter/FilterModal'; -import { setArtistFilter } from 'Store/Actions/artistIndexActions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artist.items, - (state) => state.artistIndex.filterBuilderProps, - (sectionItems, filterBuilderProps) => { - return { - sectionItems, - filterBuilderProps, - customFilterType: 'artistIndex' - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetFilter: setArtistFilter -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.js b/frontend/src/Artist/Index/ArtistIndexFooter.js deleted file mode 100644 index 5b0f1fc5a..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFooter.js +++ /dev/null @@ -1,167 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { PureComponent } from 'react'; -import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; -import DescriptionList from 'Components/DescriptionList/DescriptionList'; -import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import styles from './ArtistIndexFooter.css'; - -class ArtistIndexFooter extends PureComponent { - - // - // Render - - render() { - const { artist } = this.props; - const count = artist.length; - let tracks = 0; - let trackFiles = 0; - let ended = 0; - let continuing = 0; - let monitored = 0; - let totalFileSize = 0; - - artist.forEach((s) => { - const { statistics = {} } = s; - - const { - trackCount = 0, - trackFileCount = 0, - sizeOnDisk = 0 - } = statistics; - - tracks += trackCount; - trackFiles += trackFileCount; - - if (s.status === 'ended') { - ended++; - } else { - continuing++; - } - - if (s.monitored) { - monitored++; - } - - totalFileSize += sizeOnDisk; - }); - - return ( - - {(enableColorImpairedMode) => { - return ( -
-
-
-
-
- {translate('ContinuingAllTracksDownloaded')} -
-
- -
-
-
- {translate('EndedAllTracksDownloaded')} -
-
- -
-
-
- {translate('MissingTracksArtistMonitored')} -
-
- -
-
-
- {translate('MissingTracksArtistNotMonitored')} -
-
-
- -
- - - - - - - - - - - - - - - - - - - - - - - -
-
- ); - }} - - ); - } -} - -ArtistIndexFooter.propTypes = { - artist: PropTypes.arrayOf(PropTypes.object).isRequired -}; - -export default ArtistIndexFooter; diff --git a/frontend/src/Artist/Index/ArtistIndexFooter.tsx b/frontend/src/Artist/Index/ArtistIndexFooter.tsx new file mode 100644 index 000000000..4b4982055 --- /dev/null +++ b/frontend/src/Artist/Index/ArtistIndexFooter.tsx @@ -0,0 +1,169 @@ +import classNames from 'classnames'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import { ColorImpairedConsumer } from 'App/ColorImpairedContext'; +import ArtistAppState from 'App/State/ArtistAppState'; +import DescriptionList from 'Components/DescriptionList/DescriptionList'; +import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './ArtistIndexFooter.css'; + +function createUnoptimizedSelector() { + return createSelector( + createClientSideCollectionSelector('artist', 'artistIndex'), + (artist: ArtistAppState) => { + return artist.items.map((s) => { + const { monitored, status, statistics } = s; + + return { + monitored, + status, + statistics, + }; + }); + } + ); +} + +function createArtistSelector() { + return createDeepEqualSelector( + createUnoptimizedSelector(), + (artist) => artist + ); +} + +export default function ArtistIndexFooter() { + const artist = useSelector(createArtistSelector()); + const count = artist.length; + let tracks = 0; + let trackFiles = 0; + let ended = 0; + let continuing = 0; + let monitored = 0; + let totalFileSize = 0; + + artist.forEach((a) => { + const { statistics = { trackCount: 0, trackFileCount: 0, sizeOnDisk: 0 } } = + a; + + const { trackCount = 0, trackFileCount = 0, sizeOnDisk = 0 } = statistics; + + tracks += trackCount; + trackFiles += trackFileCount; + + if (a.status === 'ended') { + ended++; + } else { + continuing++; + } + + if (a.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( + + {(enableColorImpairedMode) => { + return ( +
+
+
+
+
{translate('ContinuingAllTracksDownloaded')}
+
+ +
+
+
{translate('EndedAllTracksDownloaded')}
+
+ +
+
+
{translate('MissingTracksArtistMonitored')}
+
+ +
+
+
{translate('MissingTracksArtistNotMonitored')}
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); + }} + + ); +} diff --git a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js b/frontend/src/Artist/Index/ArtistIndexFooterConnector.js deleted file mode 100644 index 2cb0e3e7d..000000000 --- a/frontend/src/Artist/Index/ArtistIndexFooterConnector.js +++ /dev/null @@ -1,46 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; -import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; -import ArtistIndexFooter from './ArtistIndexFooter'; - -function createUnoptimizedSelector() { - return createSelector( - createClientSideCollectionSelector('artist', 'artistIndex'), - (artist) => { - return artist.items.map((s) => { - const { - monitored, - status, - statistics - } = s; - - return { - monitored, - status, - statistics - }; - }); - } - ); -} - -function createArtistSelector() { - return createDeepEqualSelector( - createUnoptimizedSelector(), - (artist) => artist - ); -} - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - (artist) => { - return { - artist - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexFooter); diff --git a/frontend/src/Artist/Index/ArtistIndexItemConnector.js b/frontend/src/Artist/Index/ArtistIndexItemConnector.js deleted file mode 100644 index 43d92ef13..000000000 --- a/frontend/src/Artist/Index/ArtistIndexItemConnector.js +++ /dev/null @@ -1,153 +0,0 @@ -/* eslint max-params: 0 */ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import { toggleArtistMonitored } from 'Store/Actions/artistActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector'; -import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector'; -import createArtistSelector from 'Store/Selectors/createArtistSelector'; -import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; - -function selectShowSearchAction() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - const view = artistIndex.view; - - switch (view) { - case 'posters': - return artistIndex.posterOptions.showSearchAction; - case 'banners': - return artistIndex.bannerOptions.showSearchAction; - case 'overview': - return artistIndex.overviewOptions.showSearchAction; - default: - return artistIndex.tableOptions.showSearchAction; - } - } - ); -} - -function createMapStateToProps() { - return createSelector( - createArtistSelector(), - createArtistQualityProfileSelector(), - createArtistMetadataProfileSelector(), - selectShowSearchAction(), - createExecutingCommandsSelector(), - ( - artist, - qualityProfile, - metadataProfile, - showSearchAction, - executingCommands - ) => { - - // If an artist is deleted this selector may fire before the parent - // selectors, which will result in an undefined artist, if that happens - // we want to return early here and again in the render function to avoid - // trying to show an artist that has no information available. - - if (!artist) { - return {}; - } - - const isRefreshingArtist = executingCommands.some((command) => { - return ( - command.name === commandNames.REFRESH_ARTIST && - command.body.artistId === artist.id - ); - }); - - const isSearchingArtist = executingCommands.some((command) => { - return ( - command.name === commandNames.ARTIST_SEARCH && - command.body.artistId === artist.id - ); - }); - - const latestAlbum = _.maxBy(artist.albums, (album) => album.releaseDate); - - return { - ...artist, - qualityProfile, - metadataProfile, - latestAlbum, - showSearchAction, - isRefreshingArtist, - isSearchingArtist - }; - } - ); -} - -const mapDispatchToProps = { - dispatchExecuteCommand: executeCommand, - toggleArtistMonitored -}; - -class ArtistIndexItemConnector extends Component { - - // - // Listeners - - onRefreshArtistPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.REFRESH_ARTIST, - artistId: this.props.id - }); - }; - - onSearchPress = () => { - this.props.dispatchExecuteCommand({ - name: commandNames.ARTIST_SEARCH, - artistId: this.props.id - }); - }; - - onMonitoredPress = () => { - this.props.toggleArtistMonitored({ - artistId: this.props.id, - monitored: !this.props.monitored - }); - }; - - // - // Render - - render() { - const { - id, - component: ItemComponent, - ...otherProps - } = this.props; - - if (!id) { - return null; - } - - return ( - - ); - } -} - -ArtistIndexItemConnector.propTypes = { - id: PropTypes.number, - monitored: PropTypes.bool.isRequired, - component: PropTypes.elementType.isRequired, - dispatchExecuteCommand: PropTypes.func.isRequired, - toggleArtistMonitored: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(ArtistIndexItemConnector); diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css index e22472389..7f1fc71c6 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css @@ -1,9 +1,5 @@ $hoverScale: 1.05; -.container { - padding: 10px; -} - .content { transition: all 200ms ease-in; @@ -26,12 +22,29 @@ $hoverScale: 1.05; .link { composes: link from '~Components/Link/Link.css'; + position: relative; display: block; + height: 50px; background-color: var(--defaultColor); } -.nextAiring { - background-color: #fafbfc; +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: var(--offWhite); + text-align: center; + font-size: 20px; +} + +.nextAlbum { + background-color: var(--artistBackgroundColor); text-align: center; font-size: $smallFontSize; } @@ -39,8 +52,7 @@ $hoverScale: 1.05; .title { @add-mixin truncate; - background-color: var(--defaultColor); - color: var(--white); + background-color: var(--artistBackgroundColor); text-align: center; font-size: $smallFontSize; } @@ -49,6 +61,7 @@ $hoverScale: 1.05; position: absolute; top: 0; right: 0; + z-index: 1; width: 0; height: 0; border-width: 0 25px 25px 0; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts index 393757652..bd6cb4ac9 100644 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.css.d.ts @@ -8,7 +8,8 @@ interface CssExports { 'controls': string; 'ended': string; 'link': string; - 'nextAiring': string; + 'nextAlbum': string; + 'overlayTitle': string; 'title': string; } export const cssExports: CssExports; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js deleted file mode 100644 index 43c7ca22b..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.js +++ /dev/null @@ -1,271 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistBanner from 'Artist/ArtistBanner'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import translate from 'Utilities/String/translate'; -import ArtistIndexBannerInfo from './ArtistIndexBannerInfo'; -import styles from './ArtistIndexBanner.css'; - -class ArtistIndexBanner extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - artistName, - monitored, - status, - foreignArtistId, - nextAiring, - statistics, - images, - bannerWidth, - bannerHeight, - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction, - qualityProfile, - showRelativeDates, - shortDateFormat, - timeFormat, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${bannerWidth}px`, - height: `${bannerHeight}px` - }; - - return ( -
-
-
- - - { - status === 'ended' && -
- } - - - - -
- - - - { - showTitle && -
- {artistName} -
- } - - { - showMonitored && -
- {monitored ? 'Monitored' : 'Unmonitored'} -
- } - - { - showQualityProfile && -
- {qualityProfile.name} -
- } - { - nextAiring && -
- { - getRelativeDate( - nextAiring, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- } - - - - - - -
-
- ); - } -} - -ArtistIndexBanner.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAiring: PropTypes.string, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - bannerWidth: PropTypes.number.isRequired, - bannerHeight: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexBanner.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx new file mode 100644 index 000000000..1ab5171ad --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanner.tsx @@ -0,0 +1,257 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Statistics } from 'Artist/Artist'; +import ArtistBanner from 'Artist/ArtistBanner'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import ArtistIndexBannerInfo from 'Artist/Index/Banners/ArtistIndexBannerInfo'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import translate from 'Utilities/String/translate'; +import selectBannerOptions from './selectBannerOptions'; +import styles from './ArtistIndexBanner.css'; + +interface ArtistIndexBannerProps { + artistId: number; + sortKey: string; + bannerWidth: number; + bannerHeight: number; +} + +function ArtistIndexBanner(props: ArtistIndexBannerProps) { + const { artistId, sortKey, bannerWidth, bannerHeight } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = useSelector(selectBannerOptions); + + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + + const { + artistName, + artistType, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + added, + statistics = {} as Statistics, + images, + tags, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasBannerError, setHasBannerError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onBannerLoadError = useCallback(() => { + setHasBannerError(true); + }, [setHasBannerError]); + + const onBannerLoad = useCallback(() => { + setHasBannerError(false); + }, [setHasBannerError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${bannerWidth}px`, + height: `${bannerHeight}px`, + }; + + return ( +
+
+ + + {status === 'ended' ? ( +
+ ) : null} + + + + + {hasBannerError ? ( +
{artistName}
+ ) : null} + +
+ + + + {showTitle ? ( +
+ {artistName} +
+ ) : null} + + {showMonitored ? ( +
+ {monitored ? translate('Monitored') : translate('Unmonitored')} +
+ ) : null} + + {showQualityProfile ? ( +
+ {qualityProfile.name} +
+ ) : null} + + {showNextAlbum && !!nextAlbum?.releaseDate ? ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ) : null} + + + + + + +
+ ); +} + +export default ArtistIndexBanner; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js deleted file mode 100644 index f641de0e1..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.js +++ /dev/null @@ -1,115 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import styles from './ArtistIndexBannerInfo.css'; - -function ArtistIndexBannerInfo(props) { - const { - qualityProfile, - showQualityProfile, - previousAiring, - added, - albumCount, - path, - sizeOnDisk, - sortKey, - showRelativeDates, - shortDateFormat, - timeFormat - } = props; - - if (sortKey === 'qualityProfileId' && !showQualityProfile) { - return ( -
- {qualityProfile.name} -
- ); - } - - if (sortKey === 'previousAiring' && previousAiring) { - return ( -
- { - getRelativeDate( - previousAiring, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- ); - } - - if (sortKey === 'added' && added) { - const addedDate = getRelativeDate( - added, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: false - } - ); - - return ( -
- {`Added ${addedDate}`} -
- ); - } - - if (sortKey === 'albumCount') { - let albums = '1 album'; - - if (albumCount === 0) { - albums = 'No albums'; - } else if (albumCount > 1) { - albums = `${albumCount} albums`; - } - - return ( -
- {albums} -
- ); - } - - if (sortKey === 'path') { - return ( -
- {path} -
- ); - } - - if (sortKey === 'sizeOnDisk') { - return ( -
- {formatBytes(sizeOnDisk)} -
- ); - } - - return null; -} - -ArtistIndexBannerInfo.propTypes = { - qualityProfile: PropTypes.object.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - previousAiring: PropTypes.string, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexBannerInfo; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx new file mode 100644 index 000000000..a93b0bafc --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBannerInfo.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import Album from 'Album/Album'; +import TagListConnector from 'Components/TagListConnector'; +import MetadataProfile from 'typings/MetadataProfile'; +import QualityProfile from 'typings/QualityProfile'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './ArtistIndexBannerInfo.css'; + +interface ArtistIndexBannerInfoProps { + artistType?: string; + showQualityProfile: boolean; + qualityProfile?: QualityProfile; + metadataProfile?: MetadataProfile; + showNextAlbum: boolean; + nextAlbum?: Album; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + tags?: number[]; + sortKey: string; + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: string; +} + +function ArtistIndexBannerInfo(props: ArtistIndexBannerInfoProps) { + const { + artistType, + qualityProfile, + metadataProfile, + showQualityProfile, + showNextAlbum, + nextAlbum, + lastAlbum, + added, + albumCount, + path, + sizeOnDisk, + tags, + sortKey, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + } = props; + + if (sortKey === 'artistType' && artistType) { + return ( +
+ {artistType} +
+ ); + } + + if ( + sortKey === 'qualityProfileId' && + !showQualityProfile && + !!qualityProfile?.name + ) { + return ( +
+ {qualityProfile.name} +
+ ); + } + + if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) { + return ( +
+ {metadataProfile.name} +
+ ); + } + + if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) { + return ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ); + } + + if (sortKey === 'lastAlbum' && !!lastAlbum?.releaseDate) { + return ( +
+ {getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ); + } + + if (sortKey === 'added' && added) { + const addedDate = getRelativeDate( + added, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: false, + } + ); + + return ( +
+ {translate('Added')}: {addedDate} +
+ ); + } + + if (sortKey === 'albumCount') { + let albums = translate('OneAlbum'); + + if (albumCount === 0) { + albums = translate('NoAlbums'); + } else if (albumCount > 1) { + albums = translate('CountAlbums', { albumCount }); + } + + return
{albums}
; + } + + if (sortKey === 'path') { + return ( +
+ {path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + if (sortKey === 'tags' && tags) { + return ( +
+ +
+ ); + } + + return null; +} + +export default ArtistIndexBannerInfo; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js deleted file mode 100644 index be3cdb502..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.js +++ /dev/null @@ -1,327 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexBanner from './ArtistIndexBanner'; -import styles from './ArtistIndexBanners.css'; - -// container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -const additionalColumnCount = { - small: 3, - medium: 2, - large: 1 -}; - -function calculateColumnWidth(width, bannerSize, isSmallScreen) { - const maxiumColumnWidth = isSmallScreen ? 344 : 364; - const columns = Math.floor(width / maxiumColumnWidth); - const remainder = width % maxiumColumnWidth; - - if (remainder === 0 && bannerSize === 'large') { - return maxiumColumnWidth; - } - - return Math.floor(width / (columns + additionalColumnCount[bannerSize])); -} - -function calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions) { - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile - } = bannerOptions; - - const nextAiringHeight = 19; - - const heights = [ - bannerHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - nextAiringHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - if (showTitle) { - heights.push(19); - } - - if (showMonitored) { - heights.push(19); - } - - if (showQualityProfile) { - heights.push(19); - } - - switch (sortKey) { - case 'seasons': - case 'previousAiring': - case 'added': - case 'path': - case 'sizeOnDisk': - heights.push(19); - break; - case 'qualityProfileId': - if (!showQualityProfile) { - heights.push(19); - } - break; - default: - // No need to add a height of 0 - } - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculateHeight(bannerWidth) { - return Math.ceil((88/476) * bannerWidth); -} - -class ArtistIndexBanners extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnWidth: 364, - columnCount: 1, - bannerWidth: 476, - bannerHeight: 88, - rowHeight: calculateRowHeight(88, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._isInitialized = false; - this._grid = null; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - bannerOptions, - jumpToCharacter, - scrollTop - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.bannerOptions !== bannerOptions) { - this.calculateGrid(); - } - - if (this._grid && - (prevState.width !== width || - prevState.columnWidth !== columnWidth || - prevState.columnCount !== columnCount || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items))) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - const row = Math.floor(index / columnCount); - - this._grid.scrollToCell({ - rowIndex: row, - columnIndex: 0 - }); - } - } - - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - bannerOptions - } = this.props; - - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - const columnWidth = calculateColumnWidth(width, bannerOptions.size, isSmallScreen); - const columnCount = Math.max(Math.floor(width / columnWidth), 1); - const bannerWidth = columnWidth - padding; - const bannerHeight = calculateHeight(bannerWidth); - const rowHeight = calculateRowHeight(bannerHeight, sortKey, isSmallScreen, bannerOptions); - - this.setState({ - width, - columnWidth, - columnCount, - bannerWidth, - bannerHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, columnIndex, style }) => { - const { - items, - sortKey, - bannerOptions, - showRelativeDates, - shortDateFormat, - timeFormat - } = this.props; - - const { - bannerWidth, - bannerHeight, - columnCount - } = this.state; - - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile - } = bannerOptions; - - const artist = items[rowIndex * columnCount + columnIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - items, - isSmallScreen, - scroller - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight - } = this.state; - - const rowCount = Math.ceil(items.length / columnCount); - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( - - ); - } - } - - - ); - } -} - -ArtistIndexBanners.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - bannerOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexBanners; diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx new file mode 100644 index 000000000..3b8aec5a3 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/ArtistIndexBanners.tsx @@ -0,0 +1,294 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import ArtistIndexBanner from 'Artist/Index/Banners/ArtistIndexBanner'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const ADDITIONAL_COLUMN_COUNT = { + small: 3, + medium: 2, + large: 1, +}; + +interface CellItemData { + layout: { + columnCount: number; + padding: number; + bannerWidth: number; + bannerHeight: number; + }; + items: Artist[]; + sortKey: string; +} + +interface ArtistIndexBannersProps { + items: Artist[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const artistIndexSelector = createSelector( + (state) => state.artistIndex.bannerOptions, + (bannerOptions) => { + return { + bannerOptions, + }; + } +); + +const Cell: React.FC> = ({ + columnIndex, + rowIndex, + style, + data, +}) => { + const { layout, items, sortKey } = data; + + const { columnCount, padding, bannerWidth, bannerHeight } = layout; + + const index = rowIndex * columnCount + columnIndex; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +export default function ArtistIndexBanners(props: ArtistIndexBannersProps) { + const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; + + const { bannerOptions } = useSelector(artistIndexSelector); + const ref: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const columnWidth = useMemo(() => { + const { width } = size; + const maximumColumnWidth = isSmallScreen ? 344 : 364; + const columns = Math.floor(width / maximumColumnWidth); + const remainder = width % maximumColumnWidth; + return remainder === 0 + ? maximumColumnWidth + : Math.floor( + width / (columns + ADDITIONAL_COLUMN_COUNT[bannerOptions.size]) + ); + }, [isSmallScreen, bannerOptions, size]); + + const columnCount = useMemo( + () => Math.max(Math.floor(size.width / columnWidth), 1), + [size, columnWidth] + ); + const padding = props.isSmallScreen + ? columnPaddingSmallScreen + : columnPadding; + const bannerWidth = columnWidth - padding * 2; + const bannerHeight = Math.ceil((88 / 476) * bannerWidth); + + const rowHeight = useMemo(() => { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + } = bannerOptions; + + const nextAiringHeight = 19; + + const heights = [ + bannerHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + if (showNextAlbum) { + heights.push(19); + } + + switch (sortKey) { + case 'artistType': + case 'metadataProfileId': + case 'lastAlbum': + case 'added': + case 'albumCount': + case 'path': + case 'sizeOnDisk': + case 'tags': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + case 'nextAlbum': + if (!showNextAlbum) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); + }, [isSmallScreen, bannerOptions, sortKey, bannerHeight]); + + useEffect(() => { + const current = scrollerRef.current; + + if (isSmallScreen) { + const padding = bodyPaddingSmallScreen - 5; + + setSize({ + width: window.innerWidth - padding * 2, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = bodyPadding - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, ref, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const rowIndex = Math.floor(index / columnCount); + + const scrollTop = rowIndex * rowHeight + padding; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [ + jumpToCharacter, + rowHeight, + columnCount, + padding, + items, + scrollerRef, + ref, + ]); + + return ( +
+ + ref={ref} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + columnCount={columnCount} + columnWidth={columnWidth} + rowCount={Math.ceil(items.length / columnCount)} + rowHeight={rowHeight} + itemData={{ + layout: { + columnCount, + padding, + bannerWidth, + bannerHeight, + }, + items, + sortKey, + }} + > + {Cell} + +
+ ); +} diff --git a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js b/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js deleted file mode 100644 index 1cf68ba2b..000000000 --- a/frontend/src/Artist/Index/Banners/ArtistIndexBannersConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexBanners from './ArtistIndexBanners'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.bannerOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (bannerOptions, uiSettings, dimensions) => { - return { - bannerOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexBanners); diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js deleted file mode 100644 index 34c8abfcf..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexBannerOptionsModalContentConnector from './ArtistIndexBannerOptionsModalContentConnector'; - -function ArtistIndexBannerOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexBannerOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexBannerOptionsModal; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx new file mode 100644 index 000000000..156e06079 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent'; + +interface ArtistIndexBannerOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexBannerOptionsModal({ + isOpen, + onModalClose, +}: ArtistIndexBannerOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexBannerOptionsModal; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js deleted file mode 100644 index 8951a7b3d..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.js +++ /dev/null @@ -1,226 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const bannerSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexBannerOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showTitle: props.showTitle, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showTitle !== prevProps.showTitle) { - state.showTitle = showTitle; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangeBannerOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangeBannerOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showSearchAction - } = this.state; - - return ( - - - Options - - - -
- - - {translate('Size')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowName')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexBannerOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - showTitle: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangeBannerOption: PropTypes.func.isRequired, - showMonitored: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexBannerOptionsModalContent; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx new file mode 100644 index 000000000..f75311bca --- /dev/null +++ b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContent.tsx @@ -0,0 +1,167 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import selectBannerOptions from 'Artist/Index/Banners/selectBannerOptions'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import { setArtistBannerOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; + +const bannerSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexBannerOptionsModalContentProps { + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexBannerOptionsModalContent( + props: ArtistIndexBannerOptionsModalContentProps +) { + const { onModalClose } = props; + + const bannerOptions = useSelector(selectBannerOptions); + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = bannerOptions; + + const dispatch = useDispatch(); + + const onBannerOptionChange = useCallback( + ({ name, value }) => { + dispatch(setArtistBannerOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('BannerOptions')} + + +
+ + {translate('BannerSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowName')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowNextAlbum')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexBannerOptionsModalContent; diff --git a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js b/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js deleted file mode 100644 index 884edd05d..000000000 --- a/frontend/src/Artist/Index/Banners/Options/ArtistIndexBannerOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistBannerOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexBannerOptionsModalContent from './ArtistIndexBannerOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.bannerOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangeBannerOption(payload) { - dispatch(setArtistBannerOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexBannerOptionsModalContent); diff --git a/frontend/src/Artist/Index/Banners/selectBannerOptions.ts b/frontend/src/Artist/Index/Banners/selectBannerOptions.ts new file mode 100644 index 000000000..529c15e06 --- /dev/null +++ b/frontend/src/Artist/Index/Banners/selectBannerOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectBannerOptions = createSelector( + (state: AppState) => state.artistIndex.bannerOptions, + (bannerOptions) => bannerOptions +); + +export default selectBannerOptions; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx similarity index 76% rename from frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js rename to frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx index d146fdf7d..19be069e5 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexFilterMenu.tsx @@ -1,6 +1,6 @@ import PropTypes from 'prop-types'; import React from 'react'; -import ArtistIndexFilterModalConnector from 'Artist/Index/ArtistIndexFilterModalConnector'; +import ArtistIndexFilterModal from 'Artist/Index/ArtistIndexFilterModal'; import FilterMenu from 'Components/Menu/FilterMenu'; import { align } from 'Helpers/Props'; @@ -10,7 +10,7 @@ function ArtistIndexFilterMenu(props) { filters, customFilters, isDisabled, - onFilterSelect + onFilterSelect, } = props; return ( @@ -20,22 +20,23 @@ function ArtistIndexFilterMenu(props) { selectedFilterKey={selectedFilterKey} filters={filters} customFilters={customFilters} - filterModalConnectorComponent={ArtistIndexFilterModalConnector} + filterModalConnectorComponent={ArtistIndexFilterModal} onFilterSelect={onFilterSelect} /> ); } ArtistIndexFilterMenu.propTypes = { - selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired, + selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) + .isRequired, filters: PropTypes.arrayOf(PropTypes.object).isRequired, customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, isDisabled: PropTypes.bool.isRequired, - onFilterSelect: PropTypes.func.isRequired + onFilterSelect: PropTypes.func.isRequired, }; ArtistIndexFilterMenu.defaultProps = { - showCustomFilters: false + showCustomFilters: false, }; export default ArtistIndexFilterMenu; diff --git a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx similarity index 94% rename from frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js rename to frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx index 967b34d49..3d4b8ded0 100644 --- a/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.js +++ b/frontend/src/Artist/Index/Menus/ArtistIndexSortMenu.tsx @@ -6,18 +6,10 @@ import SortMenuItem from 'Components/Menu/SortMenuItem'; import { align, sortDirections } from 'Helpers/Props'; function ArtistIndexSortMenu(props) { - const { - sortKey, - sortDirection, - isDisabled, - onSortSelect - } = props; + const { sortKey, sortDirection, isDisabled, onSortSelect } = props; return ( - + + - - Table + + {translate('Table')} - - Posters + + {translate('Posters')} - - Banners + + {translate('Banners')} - Overview + {translate('Overview')} @@ -57,7 +39,7 @@ function ArtistIndexViewMenu(props) { ArtistIndexViewMenu.propTypes = { view: PropTypes.string.isRequired, isDisabled: PropTypes.bool.isRequired, - onViewSelect: PropTypes.func.isRequired + onViewSelect: PropTypes.func.isRequired, }; export default ArtistIndexViewMenu; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css index 3b1888228..1f482a2d6 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css @@ -1,13 +1,5 @@ $hoverScale: 1.05; -.container { - &:hover { - .content { - background-color: var(--tableRowHoverBackgroundColor); - } - } -} - .content { display: flex; flex-grow: 1; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts index 76a72536a..de94277cc 100644 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.css.d.ts @@ -2,7 +2,6 @@ // Please do not change this file! interface CssExports { 'actions': string; - 'container': string; 'content': string; 'details': string; 'ended': string; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js deleted file mode 100644 index 1baac838f..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.js +++ /dev/null @@ -1,283 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import TextTruncate from 'react-text-truncate'; -import ArtistPoster from 'Artist/ArtistPoster'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import fonts from 'Styles/Variables/fonts'; -import translate from 'Utilities/String/translate'; -import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo'; -import styles from './ArtistIndexOverview.css'; - -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const defaultFontSize = parseInt(fonts.defaultFontSize); -const lineHeight = parseFloat(fonts.lineHeight); - -// Hardcoded height beased on line-height of 32 + bottom margin of 10. -// Less side-effecty than using react-measure. -const titleRowHeight = 42; - -function getContentHeight(rowHeight, isSmallScreen) { - const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; - - return rowHeight - padding; -} - -class ArtistIndexOverview extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - artistName, - overview, - monitored, - status, - foreignArtistId, - nextAiring, - statistics, - images, - posterWidth, - posterHeight, - qualityProfile, - overviewOptions, - showSearchAction, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - rowHeight, - isSmallScreen, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - const contentHeight = getContentHeight(rowHeight, isSmallScreen); - const overviewHeight = contentHeight - titleRowHeight; - - return ( -
-
-
-
- { - status === 'ended' && -
- } - - - - -
- - -
- -
-
- - {artistName} - - -
- - - { - showSearchAction && - - } - - -
-
- -
- - - - - - -
-
-
- - - - -
- ); - } -} - -ArtistIndexOverview.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - overview: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAiring: PropTypes.string, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - rowHeight: PropTypes.number.isRequired, - qualityProfile: PropTypes.object.isRequired, - overviewOptions: PropTypes.object.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexOverview.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx new file mode 100644 index 000000000..afb06c380 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverview.tsx @@ -0,0 +1,240 @@ +import React, { useCallback, useMemo, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import TextTruncate from 'react-text-truncate'; +import { Statistics } from 'Artist/Artist'; +import ArtistPoster from 'Artist/ArtistPoster'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import dimensions from 'Styles/Variables/dimensions'; +import fonts from 'Styles/Variables/fonts'; +import translate from 'Utilities/String/translate'; +import createArtistIndexItemSelector from '../createArtistIndexItemSelector'; +import ArtistIndexOverviewInfo from './ArtistIndexOverviewInfo'; +import selectOverviewOptions from './selectOverviewOptions'; +import styles from './ArtistIndexOverview.css'; + +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +// Hardcoded height based on line-height of 32 + bottom margin of 10. +// Less side-effecty than using react-measure. +const TITLE_HEIGHT = 42; + +interface ArtistIndexOverviewProps { + artistId: number; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSmallScreen: boolean; +} + +function ArtistIndexOverview(props: ArtistIndexOverviewProps) { + const { + artistId, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSmallScreen, + } = props; + + const { artist, qualityProfile, isRefreshingArtist, isSearchingArtist } = + useSelector(createArtistIndexItemSelector(props.artistId)); + + const overviewOptions = useSelector(selectOverviewOptions); + + const { + artistName, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + lastAlbum, + added, + overview, + statistics = {} as Statistics, + images, + } = artist; + + const { + albumCount = 0, + sizeOnDisk = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + } = statistics; + + const dispatch = useDispatch(); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + const contentHeight = useMemo(() => { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; + }, [rowHeight, isSmallScreen]); + + const overviewHeight = contentHeight - TITLE_HEIGHT; + + return ( +
+
+
+
+ {status === 'ended' && ( +
+ )} + + + + +
+ + +
+ +
+
+ + {artistName} + + +
+ + + {overviewOptions.showSearchAction ? ( + + ) : null} + + +
+
+ +
+ + + + + +
+
+
+ + + + +
+ ); +} + +export default ArtistIndexOverview; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js deleted file mode 100644 index f7cda7916..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.js +++ /dev/null @@ -1,249 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import { icons } from 'Helpers/Props'; -import dimensions from 'Styles/Variables/dimensions'; -import formatDateTime from 'Utilities/Date/formatDateTime'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import formatBytes from 'Utilities/Number/formatBytes'; -import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow'; -import styles from './ArtistIndexOverviewInfo.css'; - -const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight); - -const rows = [ - { - name: 'monitored', - showProp: 'showMonitored', - valueProp: 'monitored' - }, - { - name: 'qualityProfileId', - showProp: 'showQualityProfile', - valueProp: 'qualityProfileId' - }, - { - name: 'lastAlbum', - showProp: 'showLastAlbum', - valueProp: 'lastAlbum' - }, - { - name: 'added', - showProp: 'showAdded', - valueProp: 'added' - }, - { - name: 'albumCount', - showProp: 'showAlbumCount', - valueProp: 'albumCount' - }, - { - name: 'path', - showProp: 'showPath', - valueProp: 'path' - }, - { - name: 'sizeOnDisk', - showProp: 'showSizeOnDisk', - valueProp: 'sizeOnDisk' - } -]; - -function isVisible(row, props) { - const { - name, - showProp, - valueProp - } = row; - - if (props[valueProp] == null) { - return false; - } - - return props[showProp] || props.sortKey === name; -} - -function getInfoRowProps(row, props) { - const { name } = row; - - if (name === 'monitored') { - const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; - - return { - title: monitoredText, - iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, - label: monitoredText - }; - } - - if (name === 'qualityProfileId') { - return { - title: 'Quality Profile', - iconName: icons.PROFILE, - label: props.qualityProfile.name - }; - } - - if (name === 'lastAlbum') { - const { - lastAlbum, - showRelativeDates, - shortDateFormat, - timeFormat - } = props; - - return { - title: `Last Album: ${lastAlbum.title}`, - iconName: icons.CALENDAR, - label: getRelativeDate( - lastAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - }; - } - - if (name === 'added') { - const { - added, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = props; - - return { - title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, - iconName: icons.ADD, - label: getRelativeDate( - added, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - }; - } - - if (name === 'albumCount') { - const { albumCount } = props; - let albums = '1 album'; - - if (albumCount === 0) { - albums = 'No albums'; - } else if (albumCount > 1) { - albums = `${albumCount} albums`; - } - - return { - title: 'Album Count', - iconName: icons.CIRCLE, - label: albums - }; - } - - if (name === 'path') { - return { - title: 'Path', - iconName: icons.FOLDER, - label: props.path - }; - } - - if (name === 'sizeOnDisk') { - return { - title: 'Size on Disk', - iconName: icons.DRIVE, - label: formatBytes(props.sizeOnDisk) - }; - } -} - -function ArtistIndexOverviewInfo(props) { - const { - height, - nextAiring, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = props; - - let shownRows = 1; - - const maxRows = Math.floor(height / (infoRowHeight + 4)); - - return ( -
- { - !!nextAiring && - - } - - { - rows.map((row) => { - if (!isVisible(row, props)) { - return null; - } - - if (shownRows >= maxRows) { - return null; - } - - shownRows++; - - const infoRowProps = getInfoRowProps(row, props); - - return ( - - ); - }) - } -
- ); -} - -ArtistIndexOverviewInfo.propTypes = { - height: PropTypes.number.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showAlbumCount: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - monitored: PropTypes.bool.isRequired, - nextAiring: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - lastAlbum: PropTypes.object, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviewInfo; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx new file mode 100644 index 000000000..c0c62ba84 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfo.tsx @@ -0,0 +1,228 @@ +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import Album from 'Album/Album'; +import { icons } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import dimensions from 'Styles/Variables/dimensions'; +import formatDateTime from 'Utilities/Date/formatDateTime'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import ArtistIndexOverviewInfoRow from './ArtistIndexOverviewInfoRow'; +import styles from './ArtistIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.artistIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored', + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfileId', + }, + { + name: 'lastAlbum', + showProp: 'showLastAlbum', + valueProp: 'lastAlbum', + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added', + }, + { + name: 'albumCount', + showProp: 'showAlbumCount', + valueProp: 'albumCount', + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'path', + }, + { + name: 'sizeOnDisk', + showProp: 'showSizeOnDisk', + valueProp: 'sizeOnDisk', + }, +]; + +function getInfoRowProps(row, props, uiSettings) { + const { name } = row; + + if (name === 'monitored') { + const monitoredText = props.monitored ? 'Monitored' : 'Unmonitored'; + + return { + title: monitoredText, + iconName: props.monitored ? icons.MONITORED : icons.UNMONITORED, + label: monitoredText, + }; + } + + if (name === 'qualityProfileId') { + return { + title: 'Quality Profile', + iconName: icons.PROFILE, + label: props.qualityProfile.name, + }; + } + + if (name === 'lastAlbum' && !!props.lastAlbum?.title) { + const lastAlbum = props.lastAlbum; + const { showRelativeDates, shortDateFormat, timeFormat } = uiSettings; + + return { + title: `Last Album: ${lastAlbum.title}`, + iconName: icons.CALENDAR, + label: getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + ), + }; + } + + if (name === 'added') { + const added = props.added; + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + uiSettings; + + return { + title: `Added: ${formatDateTime(added, longDateFormat, timeFormat)}`, + iconName: icons.ADD, + label: getRelativeDate(added, shortDateFormat, showRelativeDates, { + timeFormat, + timeForToday: true, + }), + }; + } + + if (name === 'albumCount') { + const { albumCount } = props; + let albums = '1 album'; + + if (albumCount === 0) { + albums = 'No albums'; + } else if (albumCount > 1) { + albums = `${albumCount} albums`; + } + + return { + title: 'Album Count', + iconName: icons.CIRCLE, + label: albums, + }; + } + + if (name === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.path, + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk), + }; + } +} + +interface ArtistIndexOverviewInfoProps { + height: number; + showMonitored: boolean; + showQualityProfile: boolean; + showLastAlbum: boolean; + showAdded: boolean; + showAlbumCount: boolean; + showPath: boolean; + showSizeOnDisk: boolean; + monitored: boolean; + nextAlbum?: Album; + qualityProfile: object; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + sortKey: string; +} + +function ArtistIndexOverviewInfo(props: ArtistIndexOverviewInfoProps) { + const { height, nextAlbum } = props; + + const uiSettings = useSelector(createUISettingsSelector()); + + const { shortDateFormat, showRelativeDates, longDateFormat, timeFormat } = + uiSettings; + + let shownRows = 1; + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + const rowInfo = useMemo(() => { + return rows.map((row) => { + const { name, showProp, valueProp } = row; + + const isVisible = + props[valueProp] != null && (props[showProp] || props.sortKey === name); + + return { + ...row, + isVisible, + }; + }); + }, [props]); + + return ( +
+ {!!nextAlbum?.releaseDate && ( + + )} + + {rowInfo.map((row) => { + if (!row.isVisible) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props, uiSettings); + + return ; + })} +
+ ); +} + +export default ArtistIndexOverviewInfo; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js deleted file mode 100644 index b04029b88..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.js +++ /dev/null @@ -1,35 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './ArtistIndexOverviewInfoRow.css'; - -function ArtistIndexOverviewInfoRow(props) { - const { - title, - iconName, - label - } = props; - - return ( -
- - - {label} -
- ); -} - -ArtistIndexOverviewInfoRow.propTypes = { - title: PropTypes.string, - iconName: PropTypes.object.isRequired, - label: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviewInfoRow; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx new file mode 100644 index 000000000..931d7053c --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewInfoRow.tsx @@ -0,0 +1,23 @@ +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './ArtistIndexOverviewInfoRow.css'; + +interface ArtistIndexOverviewInfoRowProps { + title?: string; + iconName: object; + label: string; +} + +function ArtistIndexOverviewInfoRow(props: ArtistIndexOverviewInfoRowProps) { + const { title, iconName, label } = props; + + return ( +
+ + + {label} +
+ ); +} + +export default ArtistIndexOverviewInfoRow; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js deleted file mode 100644 index 101092170..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.js +++ /dev/null @@ -1,275 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexOverview from './ArtistIndexOverview'; -import styles from './ArtistIndexOverviews.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -function calculatePosterWidth(posterSize, isSmallScreen) { - const maxiumPosterWidth = isSmallScreen ? 192 : 202; - - if (posterSize === 'large') { - return maxiumPosterWidth; - } - - if (posterSize === 'medium') { - return Math.floor(maxiumPosterWidth * 0.75); - } - - return Math.floor(maxiumPosterWidth * 0.5); -} - -function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) { - const { - detailedProgressBar - } = overviewOptions; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return posterWidth; -} - -class ArtistIndexOverviews extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnCount: 1, - posterWidth: 238, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._grid = null; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - overviewOptions, - jumpToCharacter, - scrollTop, - isSmallScreen - } = this.props; - - const { - width, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.overviewOptions !== overviewOptions) { - this.calculateGrid(this.state.width, isSmallScreen); - } - - if ( - this._grid && - (prevState.width !== width || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items) || - prevProps.overviewOptions !== overviewOptions - ) - ) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - - this._grid.scrollToCell({ - rowIndex: index, - columnIndex: 0 - }); - } - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - overviewOptions - } = this.props; - - const posterWidth = calculatePosterWidth(overviewOptions.size, isSmallScreen); - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions); - - this.setState({ - width, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, style }) => { - const { - items, - sortKey, - overviewOptions, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - isSmallScreen - } = this.props; - - const { - posterWidth, - posterHeight, - rowHeight - } = this.state; - - const artist = items[rowIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - items, - isSmallScreen, - scroller - } = this.props; - - const { - width, - rowHeight - } = this.state; - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -ArtistIndexOverviews.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - overviewOptions: PropTypes.object.isRequired, - scrollTop: PropTypes.number.isRequired, - jumpToCharacter: PropTypes.string, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired -}; - -export default ArtistIndexOverviews; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx new file mode 100644 index 000000000..acb56fe23 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/ArtistIndexOverviews.tsx @@ -0,0 +1,203 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import Artist from 'Artist/Artist'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import ArtistIndexOverview from './ArtistIndexOverview'; +import selectOverviewOptions from './selectOverviewOptions'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Artist[]; + sortKey: string; + posterWidth: number; + posterHeight: number; + rowHeight: number; + isSmallScreen: boolean; +} + +interface ArtistIndexOverviewsProps { + items: Artist[]; + sortKey?: string; + sortDirection?: string; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, ...otherData } = data; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function ArtistIndexOverviews(props: ArtistIndexOverviewsProps) { + const { items, sortKey, jumpToCharacter, isSmallScreen, scrollerRef } = props; + + const { size: posterSize, detailedProgressBar } = useSelector( + selectOverviewOptions + ); + const listRef: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const posterWidth = useMemo(() => { + const maxiumPosterWidth = isSmallScreen ? 192 : 202; + + if (posterSize === 'large') { + return maxiumPosterWidth; + } + + if (posterSize === 'medium') { + return Math.floor(maxiumPosterWidth * 0.75); + } + + return Math.floor(maxiumPosterWidth * 0.5); + }, [posterSize, isSmallScreen]); + + const posterHeight = useMemo(() => { + return posterWidth; + }, [posterWidth]); + + const rowHeight = useMemo(() => { + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + return heights.reduce((acc, height) => acc + height, 0); + }, [detailedProgressBar, posterHeight, isSmallScreen]); + + useEffect(() => { + const current = scrollerRef.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current.scrollTo(scrollTop); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + posterWidth, + posterHeight, + rowHeight, + isSmallScreen, + }} + > + {Row} + +
+ ); +} + +export default ArtistIndexOverviews; diff --git a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js b/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js deleted file mode 100644 index 030e8999b..000000000 --- a/frontend/src/Artist/Index/Overview/ArtistIndexOverviewsConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexOverviews from './ArtistIndexOverviews'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.overviewOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (overviewOptions, uiSettings, dimensions) => { - return { - overviewOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexOverviews); diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js deleted file mode 100644 index 9ca575185..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexOverviewOptionsModalContentConnector from './ArtistIndexOverviewOptionsModalContentConnector'; - -function ArtistIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexOverviewOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexOverviewOptionsModal; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx new file mode 100644 index 000000000..bc999cee4 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModal.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent'; + +interface ArtistIndexOverviewOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): void; +} + +function ArtistIndexOverviewOptionsModal({ + isOpen, + onModalClose, + ...otherProps +}: ArtistIndexOverviewOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexOverviewOptionsModal; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js deleted file mode 100644 index 226f46a1b..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.js +++ /dev/null @@ -1,308 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexOverviewOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showLastAlbum: props.showLastAlbum, - showAdded: props.showAdded, - showAlbumCount: props.showAlbumCount, - showPath: props.showPath, - showSizeOnDisk: props.showSizeOnDisk, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showMonitored, - showQualityProfile, - showLastAlbum, - showAdded, - showAlbumCount, - showPath, - showSizeOnDisk, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showLastAlbum !== prevProps.showLastAlbum) { - state.showLastAlbum = showLastAlbum; - } - - if (showAdded !== prevProps.showAdded) { - state.showAdded = showAdded; - } - - if (showAlbumCount !== prevProps.showAlbumCount) { - state.showAlbumCount = showAlbumCount; - } - - if (showPath !== prevProps.showPath) { - state.showPath = showPath; - } - - if (showSizeOnDisk !== prevProps.showSizeOnDisk) { - state.showSizeOnDisk = showSizeOnDisk; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangeOverviewOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangeOverviewOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showMonitored, - showQualityProfile, - showLastAlbum, - showAdded, - showAlbumCount, - showPath, - showSizeOnDisk, - showSearchAction - } = this.state; - - return ( - - - Overview Options - - - -
- - - {translate('PosterSize')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowLastAlbum')} - - - - - - - - {translate('ShowDateAdded')} - - - - - - - - {translate('ShowAlbumCount')} - - - - - - - - {translate('ShowPath')} - - - - - - - - {translate('ShowSizeOnDisk')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexOverviewOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showLastAlbum: PropTypes.bool.isRequired, - showAdded: PropTypes.bool.isRequired, - showAlbumCount: PropTypes.bool.isRequired, - showPath: PropTypes.bool.isRequired, - showSizeOnDisk: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangeOverviewOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexOverviewOptionsModalContent; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx new file mode 100644 index 000000000..e19692e41 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContent.tsx @@ -0,0 +1,197 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; +import selectOverviewOptions from '../selectOverviewOptions'; + +const posterSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexOverviewOptionsModalContentProps { + onModalClose(...args: unknown[]): void; +} + +function ArtistIndexOverviewOptionsModalContent( + props: ArtistIndexOverviewOptionsModalContentProps +) { + const { onModalClose } = props; + + const { + detailedProgressBar, + size, + showMonitored, + showQualityProfile, + showLastAlbum, + showAdded, + showAlbumCount, + showPath, + showSizeOnDisk, + showSearchAction, + } = useSelector(selectOverviewOptions); + + const dispatch = useDispatch(); + + const onOverviewOptionChange = useCallback( + ({ name, value }) => { + dispatch(setArtistOverviewOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('OverviewOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowLastAlbum')} + + + + + + {translate('ShowDateAdded')} + + + + + + {translate('ShowAlbumCount')} + + + + + + {translate('ShowPath')} + + + + + + {translate('ShowSizeOnDisk')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexOverviewOptionsModalContent; diff --git a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js b/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js deleted file mode 100644 index 70c30dba6..000000000 --- a/frontend/src/Artist/Index/Overview/Options/ArtistIndexOverviewOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistOverviewOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexOverviewOptionsModalContent from './ArtistIndexOverviewOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.overviewOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangeOverviewOption(payload) { - dispatch(setArtistOverviewOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexOverviewOptionsModalContent); diff --git a/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts b/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts new file mode 100644 index 000000000..5875163c8 --- /dev/null +++ b/frontend/src/Artist/Index/Overview/selectOverviewOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectOverviewOptions = createSelector( + (state: AppState) => state.artistIndex.overviewOptions, + (overviewOptions) => overviewOptions +); + +export default selectOverviewOptions; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js deleted file mode 100644 index 455736ff1..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.js +++ /dev/null @@ -1,305 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistPoster from 'Artist/ArtistPoster'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; -import Label from 'Components/Label'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import { icons } from 'Helpers/Props'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import translate from 'Utilities/String/translate'; -import ArtistIndexPosterInfo from './ArtistIndexPosterInfo'; -import styles from './ArtistIndexPoster.css'; - -class ArtistIndexPoster extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasPosterError: false, - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - onPosterLoad = () => { - if (this.state.hasPosterError) { - this.setState({ hasPosterError: false }); - } - }; - - onPosterLoadError = () => { - if (!this.state.hasPosterError) { - this.setState({ hasPosterError: true }); - } - }; - - // - // Render - - render() { - const { - id, - artistName, - monitored, - foreignArtistId, - status, - nextAlbum, - lastAlbum, - statistics, - images, - posterWidth, - posterHeight, - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - qualityProfile, - showNextAlbum, - showSearchAction, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - ...otherProps - } = this.props; - - const { - albumCount, - sizeOnDisk, - trackCount, - trackFileCount, - totalTrackCount - } = statistics; - - const { - hasPosterError, - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - const link = `/artist/${foreignArtistId}`; - - const elementStyle = { - width: `${posterWidth}px`, - height: `${posterHeight}px` - }; - - return ( -
-
-
- - - { - status === 'ended' && -
- } - - - - - { - hasPosterError && -
- {artistName} -
- } - - -
- - - - { - showTitle && -
- {artistName} -
- } - - { - showMonitored && -
- {monitored ? 'Monitored' : 'Unmonitored'} -
- } - - { - showQualityProfile && -
- {qualityProfile.name} -
- } - - { - showNextAlbum && !!nextAlbum?.releaseDate && -
- { - getRelativeDate( - nextAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } -
- } - - - - - -
-
- ); - } -} - -ArtistIndexPoster.propTypes = { - id: PropTypes.number.isRequired, - artistName: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - statistics: PropTypes.object.isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - posterWidth: PropTypes.number.isRequired, - posterHeight: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - qualityProfile: PropTypes.object.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired -}; - -ArtistIndexPoster.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - } -}; - -export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx new file mode 100644 index 000000000..46c637ab4 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPoster.tsx @@ -0,0 +1,257 @@ +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { Statistics } from 'Artist/Artist'; +import ArtistPoster from 'Artist/ArtistPoster'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistIndexPosterInfo from 'Artist/Index/Posters/ArtistIndexPosterInfo'; +import ArtistIndexProgressBar from 'Artist/Index/ProgressBar/ArtistIndexProgressBar'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import translate from 'Utilities/String/translate'; +import selectPosterOptions from './selectPosterOptions'; +import styles from './ArtistIndexPoster.css'; + +interface ArtistIndexPosterProps { + artistId: number; + sortKey: string; + posterWidth: number; + posterHeight: number; +} + +function ArtistIndexPoster(props: ArtistIndexPosterProps) { + const { artistId, sortKey, posterWidth, posterHeight } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = useSelector(selectPosterOptions); + + const { showRelativeDates, shortDateFormat, longDateFormat, timeFormat } = + useSelector(createUISettingsSelector()); + + const { + artistName, + artistType, + monitored, + status, + path, + foreignArtistId, + nextAlbum, + added, + statistics = {} as Statistics, + images, + tags, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasPosterError, setHasPosterError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onPosterLoadError = useCallback(() => { + setHasPosterError(true); + }, [setHasPosterError]); + + const onPosterLoad = useCallback(() => { + setHasPosterError(false); + }, [setHasPosterError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + const link = `/artist/${foreignArtistId}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + }; + + return ( +
+
+ + + {status === 'ended' ? ( +
+ ) : null} + + + + + {hasPosterError ? ( +
{artistName}
+ ) : null} + +
+ + + + {showTitle ? ( +
+ {artistName} +
+ ) : null} + + {showMonitored ? ( +
+ {monitored ? translate('Monitored') : translate('Unmonitored')} +
+ ) : null} + + {showQualityProfile ? ( +
+ {qualityProfile.name} +
+ ) : null} + + {showNextAlbum && !!nextAlbum?.releaseDate ? ( +
+ {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )} +
+ ) : null} + + + + + + +
+ ); +} + +export default ArtistIndexPoster; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx similarity index 63% rename from frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js rename to frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx index 20a34bffd..0d4ff9135 100644 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.js +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosterInfo.tsx @@ -1,16 +1,39 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import Album from 'Album/Album'; import TagListConnector from 'Components/TagListConnector'; +import MetadataProfile from 'typings/MetadataProfile'; +import QualityProfile from 'typings/QualityProfile'; import formatDateTime from 'Utilities/Date/formatDateTime'; import getRelativeDate from 'Utilities/Date/getRelativeDate'; import formatBytes from 'Utilities/Number/formatBytes'; import translate from 'Utilities/String/translate'; import styles from './ArtistIndexPosterInfo.css'; -function ArtistIndexPosterInfo(props) { +interface ArtistIndexPosterInfoProps { + artistType?: string; + showQualityProfile: boolean; + qualityProfile?: QualityProfile; + metadataProfile?: MetadataProfile; + showNextAlbum: boolean; + nextAlbum?: Album; + lastAlbum?: Album; + added?: string; + albumCount: number; + path: string; + sizeOnDisk?: number; + tags?: number[]; + sortKey: string; + showRelativeDates: boolean; + shortDateFormat: string; + longDateFormat: string; + timeFormat: string; +} + +function ArtistIndexPosterInfo(props: ArtistIndexPosterInfoProps) { const { artistType, qualityProfile, + metadataProfile, showQualityProfile, showNextAlbum, nextAlbum, @@ -24,7 +47,7 @@ function ArtistIndexPosterInfo(props) { showRelativeDates, shortDateFormat, longDateFormat, - timeFormat + timeFormat, } = props; if (sortKey === 'artistType' && artistType) { @@ -35,7 +58,11 @@ function ArtistIndexPosterInfo(props) { ); } - if (sortKey === 'qualityProfileId' && !showQualityProfile) { + if ( + sortKey === 'qualityProfileId' && + !showQualityProfile && + !!qualityProfile?.name + ) { return (
{qualityProfile.name} @@ -43,6 +70,14 @@ function ArtistIndexPosterInfo(props) { ); } + if (sortKey === 'metadataProfileId' && !!metadataProfile?.name) { + return ( +
+ {metadataProfile.name} +
+ ); + } + if (sortKey === 'nextAlbum' && !showNextAlbum && !!nextAlbum?.releaseDate) { return (
- { - getRelativeDate( - nextAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } + {getRelativeDate( + nextAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )}
); } @@ -78,17 +111,15 @@ function ArtistIndexPosterInfo(props) { timeFormat )}`} > - { - getRelativeDate( - lastAlbum.releaseDate, - shortDateFormat, - showRelativeDates, - { - timeFormat, - timeForToday: true - } - ) - } + {getRelativeDate( + lastAlbum.releaseDate, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true, + } + )}
); } @@ -100,7 +131,7 @@ function ArtistIndexPosterInfo(props) { showRelativeDates, { timeFormat, - timeForToday: false + timeForToday: false, } ); @@ -123,11 +154,7 @@ function ArtistIndexPosterInfo(props) { albums = translate('CountAlbums', { albumCount }); } - return ( -
- {albums} -
- ); + return
{albums}
; } if (sortKey === 'path') { @@ -146,12 +173,10 @@ function ArtistIndexPosterInfo(props) { ); } - if (sortKey === 'tags') { + if (sortKey === 'tags' && tags) { return (
- +
); } @@ -159,23 +184,4 @@ function ArtistIndexPosterInfo(props) { return null; } -ArtistIndexPosterInfo.propTypes = { - artistType: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - added: PropTypes.string, - albumCount: PropTypes.number.isRequired, - path: PropTypes.string.isRequired, - sizeOnDisk: PropTypes.number, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - sortKey: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired -}; - export default ArtistIndexPosterInfo; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js deleted file mode 100644 index 69df97c7c..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.js +++ /dev/null @@ -1,351 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { Grid, WindowScroller } from 'react-virtualized'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import Measure from 'Components/Measure'; -import dimensions from 'Styles/Variables/dimensions'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; -import ArtistIndexPoster from './ArtistIndexPoster'; -import styles from './ArtistIndexPosters.css'; - -// Poster container dimensions -const columnPadding = parseInt(dimensions.artistIndexColumnPadding); -const columnPaddingSmallScreen = parseInt(dimensions.artistIndexColumnPaddingSmallScreen); -const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); -const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); - -const additionalColumnCount = { - small: 3, - medium: 2, - large: 1 -}; - -function calculateColumnWidth(width, posterSize, isSmallScreen) { - const maxiumColumnWidth = isSmallScreen ? 172 : 182; - const columns = Math.floor(width / maxiumColumnWidth); - const remainder = width % maxiumColumnWidth; - - if (remainder === 0 && posterSize === 'large') { - return maxiumColumnWidth; - } - - return Math.floor(width / (columns + additionalColumnCount[posterSize])); -} - -function calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions) { - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum - } = posterOptions; - - const nextAiringHeight = 19; - - const heights = [ - posterHeight, - detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, - nextAiringHeight, - isSmallScreen ? columnPaddingSmallScreen : columnPadding - ]; - - if (showTitle) { - heights.push(19); - } - - if (showMonitored) { - heights.push(19); - } - - if (showQualityProfile) { - heights.push(19); - } - - if (showNextAlbum) { - heights.push(19); - } - - switch (sortKey) { - case 'artistType': - case 'lastAlbum': - case 'seasons': - case 'added': - case 'albumCount': - case 'path': - case 'sizeOnDisk': - case 'tags': - heights.push(19); - break; - case 'qualityProfileId': - if (!showQualityProfile) { - heights.push(19); - } - break; - case 'nextAlbum': - if (!showNextAlbum) { - heights.push(19); - } - break; - default: - // No need to add a height of 0 - } - - return heights.reduce((acc, height) => acc + height, 0); -} - -function calculatePosterHeight(posterWidth) { - return Math.ceil(posterWidth); -} - -class ArtistIndexPosters extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - width: 0, - columnWidth: 182, - columnCount: 1, - posterWidth: 238, - posterHeight: 238, - rowHeight: calculateRowHeight(238, null, props.isSmallScreen, {}), - scrollRestored: false - }; - - this._isInitialized = false; - this._grid = null; - this._padding = props.isSmallScreen ? columnPaddingSmallScreen : columnPadding; - } - - componentDidUpdate(prevProps, prevState) { - const { - items, - sortKey, - posterOptions, - jumpToCharacter, - scrollTop, - isSmallScreen - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight, - scrollRestored - } = this.state; - - if (prevProps.sortKey !== sortKey || - prevProps.posterOptions !== posterOptions) { - this.calculateGrid(width, isSmallScreen); - } - - if (this._grid && - (prevState.width !== width || - prevState.columnWidth !== columnWidth || - prevState.columnCount !== columnCount || - prevState.rowHeight !== rowHeight || - hasDifferentItemsOrOrder(prevProps.items, items))) { - // recomputeGridSize also forces Grid to discard its cache of rendered cells - this._grid.recomputeGridSize(); - } - - if (this._grid && scrollTop !== 0 && !scrollRestored) { - this.setState({ scrollRestored: true }); - this._grid.scrollToPosition({ scrollTop }); - } - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - const index = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (this._grid && index != null) { - const row = Math.floor(index / columnCount); - - this._grid.scrollToCell({ - rowIndex: row, - columnIndex: 0 - }); - } - } - } - - // - // Control - - setGridRef = (ref) => { - this._grid = ref; - }; - - calculateGrid = (width = this.state.width, isSmallScreen) => { - const { - sortKey, - posterOptions - } = this.props; - - const columnWidth = calculateColumnWidth(width, posterOptions.size, isSmallScreen); - const columnCount = Math.max(Math.floor(width / columnWidth), 1); - const posterWidth = columnWidth - this._padding * 2; - const posterHeight = calculatePosterHeight(posterWidth); - const rowHeight = calculateRowHeight(posterHeight, sortKey, isSmallScreen, posterOptions); - - this.setState({ - width, - columnWidth, - columnCount, - posterWidth, - posterHeight, - rowHeight - }); - }; - - cellRenderer = ({ key, rowIndex, columnIndex, style }) => { - const { - items, - sortKey, - posterOptions, - showRelativeDates, - shortDateFormat, - longDateFormat, - timeFormat - } = this.props; - - const { - posterWidth, - posterHeight, - columnCount - } = this.state; - - const { - detailedProgressBar, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum - } = posterOptions; - - const artist = items[rowIndex * columnCount + columnIndex]; - - if (!artist) { - return null; - } - - return ( -
- -
- ); - }; - - // - // Listeners - - onMeasure = ({ width }) => { - this.calculateGrid(width, this.props.isSmallScreen); - }; - - // - // Render - - render() { - const { - scroller, - items, - isSmallScreen - } = this.props; - - const { - width, - columnWidth, - columnCount, - rowHeight - } = this.state; - - const rowCount = Math.ceil(items.length / columnCount); - - return ( - - - {({ height, registerChild, onChildScroll, scrollTop }) => { - if (!height) { - return
; - } - - return ( -
- -
- ); - } - } - - - ); - } -} - -ArtistIndexPosters.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - posterOptions: PropTypes.object.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number.isRequired, - scroller: PropTypes.instanceOf(Element).isRequired, - showRelativeDates: PropTypes.bool.isRequired, - shortDateFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - isSmallScreen: PropTypes.bool.isRequired -}; - -export default ArtistIndexPosters; diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx new file mode 100644 index 000000000..8a7eec694 --- /dev/null +++ b/frontend/src/Artist/Index/Posters/ArtistIndexPosters.tsx @@ -0,0 +1,294 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import ArtistIndexPoster from 'Artist/Index/Posters/ArtistIndexPoster'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); +const columnPadding = parseInt(dimensions.artistIndexColumnPadding); +const columnPaddingSmallScreen = parseInt( + dimensions.artistIndexColumnPaddingSmallScreen +); +const progressBarHeight = parseInt(dimensions.progressBarSmallHeight); +const detailedProgressBarHeight = parseInt(dimensions.progressBarMediumHeight); + +const ADDITIONAL_COLUMN_COUNT = { + small: 3, + medium: 2, + large: 1, +}; + +interface CellItemData { + layout: { + columnCount: number; + padding: number; + posterWidth: number; + posterHeight: number; + }; + items: Artist[]; + sortKey: string; +} + +interface ArtistIndexPostersProps { + items: Artist[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const artistIndexSelector = createSelector( + (state) => state.artistIndex.posterOptions, + (posterOptions) => { + return { + posterOptions, + }; + } +); + +const Cell: React.FC> = ({ + columnIndex, + rowIndex, + style, + data, +}) => { + const { layout, items, sortKey } = data; + + const { columnCount, padding, posterWidth, posterHeight } = layout; + + const index = rowIndex * columnCount + columnIndex; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +export default function ArtistIndexPosters(props: ArtistIndexPostersProps) { + const { scrollerRef, items, sortKey, jumpToCharacter, isSmallScreen } = props; + + const { posterOptions } = useSelector(artistIndexSelector); + const ref: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const columnWidth = useMemo(() => { + const { width } = size; + const maximumColumnWidth = isSmallScreen ? 172 : 182; + const columns = Math.floor(width / maximumColumnWidth); + const remainder = width % maximumColumnWidth; + return remainder === 0 + ? maximumColumnWidth + : Math.floor( + width / (columns + ADDITIONAL_COLUMN_COUNT[posterOptions.size]) + ); + }, [isSmallScreen, posterOptions, size]); + + const columnCount = useMemo( + () => Math.max(Math.floor(size.width / columnWidth), 1), + [size, columnWidth] + ); + const padding = props.isSmallScreen + ? columnPaddingSmallScreen + : columnPadding; + const posterWidth = columnWidth - padding * 2; + const posterHeight = Math.ceil(posterWidth); + + const rowHeight = useMemo(() => { + const { + detailedProgressBar, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding, + ]; + + if (showTitle) { + heights.push(19); + } + + if (showMonitored) { + heights.push(19); + } + + if (showQualityProfile) { + heights.push(19); + } + + if (showNextAlbum) { + heights.push(19); + } + + switch (sortKey) { + case 'artistType': + case 'metadataProfileId': + case 'lastAlbum': + case 'added': + case 'albumCount': + case 'path': + case 'sizeOnDisk': + case 'tags': + heights.push(19); + break; + case 'qualityProfileId': + if (!showQualityProfile) { + heights.push(19); + } + break; + case 'nextAlbum': + if (!showNextAlbum) { + heights.push(19); + } + break; + default: + // No need to add a height of 0 + } + + return heights.reduce((acc, height) => acc + height, 0); + }, [isSmallScreen, posterOptions, sortKey, posterHeight]); + + useEffect(() => { + const current = scrollerRef.current; + + if (isSmallScreen) { + const padding = bodyPaddingSmallScreen - 5; + + setSize({ + width: window.innerWidth - padding * 2, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = bodyPadding - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, ref, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + const rowIndex = Math.floor(index / columnCount); + + const scrollTop = rowIndex * rowHeight + padding; + + ref.current.scrollTo({ scrollLeft: 0, scrollTop }); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [ + jumpToCharacter, + rowHeight, + columnCount, + padding, + items, + scrollerRef, + ref, + ]); + + return ( +
+ + ref={ref} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + columnCount={columnCount} + columnWidth={columnWidth} + rowCount={Math.ceil(items.length / columnCount)} + rowHeight={rowHeight} + itemData={{ + layout: { + columnCount, + padding, + posterWidth, + posterHeight, + }, + items, + sortKey, + }} + > + {Cell} + +
+ ); +} diff --git a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js b/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js deleted file mode 100644 index bff8bef81..000000000 --- a/frontend/src/Artist/Index/Posters/ArtistIndexPostersConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import ArtistIndexPosters from './ArtistIndexPosters'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.posterOptions, - createUISettingsSelector(), - createDimensionsSelector(), - (posterOptions, uiSettings, dimensions) => { - return { - posterOptions, - showRelativeDates: uiSettings.showRelativeDates, - shortDateFormat: uiSettings.shortDateFormat, - longDateFormat: uiSettings.longDateFormat, - timeFormat: uiSettings.timeFormat, - isSmallScreen: dimensions.isSmallScreen - }; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexPosters); diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js deleted file mode 100644 index e1b0a257a..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.js +++ /dev/null @@ -1,25 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import ArtistIndexPosterOptionsModalContentConnector from './ArtistIndexPosterOptionsModalContentConnector'; - -function ArtistIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { - return ( - - - - ); -} - -ArtistIndexPosterOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexPosterOptionsModal; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx new file mode 100644 index 000000000..69368807a --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent'; + +interface ArtistIndexPosterOptionsModalProps { + isOpen: boolean; + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexPosterOptionsModal({ + isOpen, + onModalClose, +}: ArtistIndexPosterOptionsModalProps) { + return ( + + + + ); +} + +export default ArtistIndexPosterOptionsModal; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js deleted file mode 100644 index d0bc50baa..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.js +++ /dev/null @@ -1,248 +0,0 @@ -import _ from 'lodash'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -const posterSizeOptions = [ - { key: 'small', value: 'Small' }, - { key: 'medium', value: 'Medium' }, - { key: 'large', value: 'Large' } -]; - -class ArtistIndexPosterOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - detailedProgressBar: props.detailedProgressBar, - size: props.size, - showTitle: props.showTitle, - showMonitored: props.showMonitored, - showQualityProfile: props.showQualityProfile, - showNextAlbum: props.showNextAlbum, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum, - showSearchAction - } = this.props; - - const state = {}; - - if (detailedProgressBar !== prevProps.detailedProgressBar) { - state.detailedProgressBar = detailedProgressBar; - } - - if (size !== prevProps.size) { - state.size = size; - } - - if (showTitle !== prevProps.showTitle) { - state.showTitle = showTitle; - } - - if (showMonitored !== prevProps.showMonitored) { - state.showMonitored = showMonitored; - } - - if (showQualityProfile !== prevProps.showQualityProfile) { - state.showQualityProfile = showQualityProfile; - } - - if (showNextAlbum !== prevProps.showNextAlbum) { - state.showNextAlbum = showNextAlbum; - } - - if (showSearchAction !== prevProps.showSearchAction) { - state.showSearchAction = showSearchAction; - } - - if (!_.isEmpty(state)) { - this.setState(state); - } - } - - // - // Listeners - - onChangePosterOption = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onChangePosterOption({ [name]: value }); - }); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - detailedProgressBar, - size, - showTitle, - showMonitored, - showQualityProfile, - showNextAlbum, - showSearchAction - } = this.state; - - return ( - - - Poster Options - - - -
- - - {translate('PosterSize')} - - - - - - - - {translate('DetailedProgressBar')} - - - - - - - - {translate('ShowName')} - - - - - - - - {translate('ShowMonitored')} - - - - - - - - {translate('ShowQualityProfile')} - - - - - - - - {translate('ShowNextAlbum')} - - - - - - - - {translate('ShowSearch')} - - - - -
-
- - - - -
- ); - } -} - -ArtistIndexPosterOptionsModalContent.propTypes = { - size: PropTypes.string.isRequired, - showTitle: PropTypes.bool.isRequired, - showMonitored: PropTypes.bool.isRequired, - showQualityProfile: PropTypes.bool.isRequired, - showNextAlbum: PropTypes.bool.isRequired, - detailedProgressBar: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onChangePosterOption: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default ArtistIndexPosterOptionsModalContent; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx new file mode 100644 index 000000000..e1e60801c --- /dev/null +++ b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContent.tsx @@ -0,0 +1,167 @@ +import React, { useCallback } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import selectPosterOptions from 'Artist/Index/Posters/selectPosterOptions'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import { setArtistPosterOption } from 'Store/Actions/artistIndexActions'; +import translate from 'Utilities/String/translate'; + +const posterSizeOptions = [ + { + key: 'small', + get value() { + return translate('Small'); + }, + }, + { + key: 'medium', + get value() { + return translate('Medium'); + }, + }, + { + key: 'large', + get value() { + return translate('Large'); + }, + }, +]; + +interface ArtistIndexPosterOptionsModalContentProps { + onModalClose(...args: unknown[]): unknown; +} + +function ArtistIndexPosterOptionsModalContent( + props: ArtistIndexPosterOptionsModalContentProps +) { + const { onModalClose } = props; + + const posterOptions = useSelector(selectPosterOptions); + + const { + detailedProgressBar, + size, + showTitle, + showMonitored, + showQualityProfile, + showNextAlbum, + showSearchAction, + } = posterOptions; + + const dispatch = useDispatch(); + + const onPosterOptionChange = useCallback( + ({ name, value }) => { + dispatch(setArtistPosterOption({ [name]: value })); + }, + [dispatch] + ); + + return ( + + {translate('PosterOptions')} + + +
+ + {translate('PosterSize')} + + + + + + {translate('DetailedProgressBar')} + + + + + + {translate('ShowName')} + + + + + + {translate('ShowMonitored')} + + + + + + {translate('ShowQualityProfile')} + + + + + + {translate('ShowNextAlbum')} + + + + + + {translate('ShowSearch')} + + + +
+
+ + + + +
+ ); +} + +export default ArtistIndexPosterOptionsModalContent; diff --git a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js b/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js deleted file mode 100644 index 72af268ad..000000000 --- a/frontend/src/Artist/Index/Posters/Options/ArtistIndexPosterOptionsModalContentConnector.js +++ /dev/null @@ -1,23 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistPosterOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexPosterOptionsModalContent from './ArtistIndexPosterOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex, - (artistIndex) => { - return artistIndex.posterOptions; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onChangePosterOption(payload) { - dispatch(setArtistPosterOption(payload)); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexPosterOptionsModalContent); diff --git a/frontend/src/Artist/Index/Posters/selectPosterOptions.ts b/frontend/src/Artist/Index/Posters/selectPosterOptions.ts new file mode 100644 index 000000000..1a53a0add --- /dev/null +++ b/frontend/src/Artist/Index/Posters/selectPosterOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectPosterOptions = createSelector( + (state: AppState) => state.artistIndex.posterOptions, + (posterOptions) => posterOptions +); + +export default selectPosterOptions; diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css index ce5313877..9b5777117 100644 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.css @@ -4,7 +4,6 @@ border-radius: 0; background-color: #5b5b5b; color: var(--white); - transition: width 200ms ease; } .progressBar { diff --git a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx similarity index 57% rename from frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js rename to frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx index 27d5c6f77..f76fa6588 100644 --- a/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.js +++ b/frontend/src/Artist/Index/ProgressBar/ArtistIndexProgressBar.tsx @@ -1,4 +1,3 @@ -import PropTypes from 'prop-types'; import React from 'react'; import ProgressBar from 'Components/ProgressBar'; import { sizes } from 'Helpers/Props'; @@ -6,7 +5,17 @@ import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; import translate from 'Utilities/String/translate'; import styles from './ArtistIndexProgressBar.css'; -function ArtistIndexProgressBar(props) { +interface ArtistIndexProgressBarProps { + monitored: boolean; + status: string; + trackCount: number; + trackFileCount: number; + totalTrackCount: number; + posterWidth: number; + detailedProgressBar: boolean; +} + +function ArtistIndexProgressBar(props: ArtistIndexProgressBarProps) { const { monitored, status, @@ -14,10 +23,10 @@ function ArtistIndexProgressBar(props) { trackFileCount, totalTrackCount, posterWidth, - detailedProgressBar + detailedProgressBar, } = props; - const progress = trackCount ? trackFileCount / trackCount * 100 : 100; + const progress = trackCount ? (trackFileCount / trackCount) * 100 : 100; const text = `${trackFileCount} / ${trackCount}`; return ( @@ -29,20 +38,14 @@ function ArtistIndexProgressBar(props) { size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL} showText={detailedProgressBar} text={text} - title={translate('TrackFileCountTrackCountTotalTotalTrackCountInterp', [trackFileCount, trackCount, totalTrackCount])} + title={translate('ArtistProgressBarText', { + trackFileCount, + trackCount, + totalTrackCount, + })} width={posterWidth} /> ); } -ArtistIndexProgressBar.propTypes = { - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - trackCount: PropTypes.number.isRequired, - trackFileCount: PropTypes.number.isRequired, - totalTrackCount: PropTypes.number.isRequired, - posterWidth: PropTypes.number.isRequired, - detailedProgressBar: PropTypes.bool.isRequired -}; - export default ArtistIndexProgressBar; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js b/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js deleted file mode 100644 index a2a3c8dab..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexActionsCell.js +++ /dev/null @@ -1,103 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import IconButton from 'Components/Link/IconButton'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import { icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class ArtistIndexActionsCell extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - // - // Listeners - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - // - // Render - - render() { - const { - id, - isRefreshingArtist, - onRefreshArtistPress, - ...otherProps - } = this.props; - - const { - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - return ( - - - - - - - - - - ); - } -} - -ArtistIndexActionsCell.propTypes = { - id: PropTypes.number.isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired -}; - -export default ArtistIndexActionsCell; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js b/frontend/src/Artist/Index/Table/ArtistIndexHeader.js deleted file mode 100644 index 7054bbaf3..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeader.js +++ /dev/null @@ -1,86 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import IconButton from 'Components/Link/IconButton'; -import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; -import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; -import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; -import { icons } from 'Helpers/Props'; -import ArtistIndexTableOptionsConnector from './ArtistIndexTableOptionsConnector'; -import hasGrowableColumns from './hasGrowableColumns'; -import styles from './ArtistIndexHeader.css'; - -function ArtistIndexHeader(props) { - const { - showBanners, - columns, - onTableOptionChange, - ...otherProps - } = props; - - return ( - - { - columns.map((column) => { - const { - name, - label, - isSortable, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'actions') { - return ( - - - - - - - ); - } - - return ( - - {typeof label === 'function' ? label() : label} - - ); - }) - } - - ); -} - -ArtistIndexHeader.propTypes = { - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - onTableOptionChange: PropTypes.func.isRequired, - showBanners: PropTypes.bool.isRequired -}; - -export default ArtistIndexHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js deleted file mode 100644 index 37ddd9ef3..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexHeaderConnector.js +++ /dev/null @@ -1,13 +0,0 @@ -import { connect } from 'react-redux'; -import { setArtistTableOption } from 'Store/Actions/artistIndexActions'; -import ArtistIndexHeader from './ArtistIndexHeader'; - -function createMapDispatchToProps(dispatch, props) { - return { - onTableOptionChange(payload) { - dispatch(setArtistTableOption(payload)); - } - }; -} - -export default connect(undefined, createMapDispatchToProps)(ArtistIndexHeader); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.js b/frontend/src/Artist/Index/Table/ArtistIndexRow.js deleted file mode 100644 index 0dc5585ca..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexRow.js +++ /dev/null @@ -1,487 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import AlbumTitleLink from 'Album/AlbumTitleLink'; -import ArtistBanner from 'Artist/ArtistBanner'; -import ArtistNameLink from 'Artist/ArtistNameLink'; -import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; -import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; -import HeartRating from 'Components/HeartRating'; -import IconButton from 'Components/Link/IconButton'; -import Link from 'Components/Link/Link'; -import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; -import ProgressBar from 'Components/ProgressBar'; -import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; -import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; -import TagListConnector from 'Components/TagListConnector'; -import { icons } from 'Helpers/Props'; -import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; -import formatBytes from 'Utilities/Number/formatBytes'; -import translate from 'Utilities/String/translate'; -import ArtistStatusCell from './ArtistStatusCell'; -import hasGrowableColumns from './hasGrowableColumns'; -import styles from './ArtistIndexRow.css'; - -class ArtistIndexRow extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - hasBannerError: false, - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: false - }; - } - - onEditArtistPress = () => { - this.setState({ isEditArtistModalOpen: true }); - }; - - onEditArtistModalClose = () => { - this.setState({ isEditArtistModalOpen: false }); - }; - - onDeleteArtistPress = () => { - this.setState({ - isEditArtistModalOpen: false, - isDeleteArtistModalOpen: true - }); - }; - - onDeleteArtistModalClose = () => { - this.setState({ isDeleteArtistModalOpen: false }); - }; - - onUseSceneNumberingChange = () => { - // Mock handler to satisfy `onChange` being required for `CheckInput`. - // - }; - - onBannerLoad = () => { - if (this.state.hasBannerError) { - this.setState({ hasBannerError: false }); - } - }; - - onBannerLoadError = () => { - if (!this.state.hasBannerError) { - this.setState({ hasBannerError: true }); - } - }; - - // - // Render - - render() { - const { - id, - monitored, - status, - artistName, - foreignArtistId, - artistType, - qualityProfile, - metadataProfile, - nextAlbum, - lastAlbum, - added, - statistics, - genres, - ratings, - path, - tags, - images, - isSaving, - showBanners, - showSearchAction, - columns, - isRefreshingArtist, - isSearchingArtist, - onRefreshArtistPress, - onSearchPress, - onMonitoredPress - } = this.props; - - const { - albumCount, - trackCount, - trackFileCount, - totalTrackCount, - sizeOnDisk - } = statistics; - - const { - hasBannerError, - isEditArtistModalOpen, - isDeleteArtistModalOpen - } = this.state; - - return ( - <> - { - columns.map((column) => { - const { - name, - isVisible - } = column; - - if (!isVisible) { - return null; - } - - if (name === 'status') { - return ( - - ); - } - - if (name === 'sortName') { - return ( - - { - showBanners ? - - - - { - hasBannerError && -
- {artistName} -
- } - : - - - } -
- ); - } - - if (name === 'artistType') { - return ( - - {artistType} - - ); - } - - if (name === 'qualityProfileId') { - return ( - - {qualityProfile.name} - - ); - } - - if (name === 'metadataProfileId') { - return ( - - {metadataProfile.name} - - ); - } - - if (name === 'nextAlbum') { - if (nextAlbum) { - return ( - - - - ); - } - return ( - - None - - ); - } - - if (name === 'lastAlbum') { - if (lastAlbum) { - return ( - - - - ); - } - return ( - - None - - ); - } - - if (name === 'added') { - return ( - - ); - } - - if (name === 'albumCount') { - return ( - - {albumCount} - - ); - } - - if (name === 'trackProgress') { - const progress = trackCount ? trackFileCount / trackCount * 100 : 100; - - return ( - - - - ); - } - - if (name === 'trackCount') { - return ( - - {totalTrackCount} - - ); - } - - if (name === 'path') { - return ( - - {path} - - ); - } - - if (name === 'sizeOnDisk') { - return ( - - {formatBytes(sizeOnDisk)} - - ); - } - - if (name === 'genres') { - const joinedGenres = genres.join(', '); - - return ( - - - {joinedGenres} - - - ); - } - - if (name === 'ratings') { - return ( - - - - ); - } - - if (name === 'tags') { - return ( - - - - ); - } - - if (name === 'actions') { - return ( - - - - { - showSearchAction && - - } - - - - ); - } - - return null; - }) - } - - - - - - ); - } -} - -ArtistIndexRow.propTypes = { - id: PropTypes.number.isRequired, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - artistName: PropTypes.string.isRequired, - foreignArtistId: PropTypes.string.isRequired, - artistType: PropTypes.string, - qualityProfile: PropTypes.object.isRequired, - metadataProfile: PropTypes.object.isRequired, - nextAlbum: PropTypes.object, - lastAlbum: PropTypes.object, - added: PropTypes.string, - statistics: PropTypes.object.isRequired, - latestAlbum: PropTypes.object, - path: PropTypes.string.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - ratings: PropTypes.object.isRequired, - tags: PropTypes.arrayOf(PropTypes.number).isRequired, - images: PropTypes.arrayOf(PropTypes.object).isRequired, - isSaving: PropTypes.bool.isRequired, - showBanners: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - isRefreshingArtist: PropTypes.bool.isRequired, - isSearchingArtist: PropTypes.bool.isRequired, - onRefreshArtistPress: PropTypes.func.isRequired, - onSearchPress: PropTypes.func.isRequired, - onMonitoredPress: PropTypes.func.isRequired -}; - -ArtistIndexRow.defaultProps = { - statistics: { - albumCount: 0, - trackCount: 0, - trackFileCount: 0, - totalTrackCount: 0 - }, - genres: [], - tags: [] -}; - -export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx new file mode 100644 index 000000000..de508a331 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexRow.tsx @@ -0,0 +1,392 @@ +import classNames from 'classnames'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AlbumTitleLink from 'Album/AlbumTitleLink'; +import { Statistics } from 'Artist/Artist'; +import ArtistBanner from 'Artist/ArtistBanner'; +import ArtistNameLink from 'Artist/ArtistNameLink'; +import DeleteArtistModal from 'Artist/Delete/DeleteArtistModal'; +import EditArtistModalConnector from 'Artist/Edit/EditArtistModalConnector'; +import createArtistIndexItemSelector from 'Artist/Index/createArtistIndexItemSelector'; +import ArtistStatusCell from 'Artist/Index/Table/ArtistStatusCell'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import HeartRating from 'Components/HeartRating'; +import IconButton from 'Components/Link/IconButton'; +import Link from 'Components/Link/Link'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import ProgressBar from 'Components/ProgressBar'; +import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; +import VirtualTableRowCell from 'Components/Table/Cells/VirtualTableRowCell'; +import Column from 'Components/Table/Column'; +import TagListConnector from 'Components/TagListConnector'; +import { icons } from 'Helpers/Props'; +import { executeCommand } from 'Store/Actions/commandActions'; +import getProgressBarKind from 'Utilities/Artist/getProgressBarKind'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import hasGrowableColumns from './hasGrowableColumns'; +import selectTableOptions from './selectTableOptions'; +import styles from './ArtistIndexRow.css'; + +interface ArtistIndexRowProps { + artistId: number; + sortKey: string; + columns: Column[]; +} + +function ArtistIndexRow(props: ArtistIndexRowProps) { + const { artistId, columns } = props; + + const { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + } = useSelector(createArtistIndexItemSelector(props.artistId)); + + const { showBanners, showSearchAction } = useSelector(selectTableOptions); + + const { + artistName, + foreignArtistId, + monitored, + status, + path, + nextAlbum, + lastAlbum, + added, + statistics = {} as Statistics, + images, + artistType, + genres = [], + ratings, + tags = [], + isSaving = false, + } = artist; + + const { + albumCount = 0, + trackCount = 0, + trackFileCount = 0, + totalTrackCount = 0, + sizeOnDisk = 0, + } = statistics; + + const dispatch = useDispatch(); + const [hasBannerError, setHasBannerError] = useState(false); + const [isEditArtistModalOpen, setIsEditArtistModalOpen] = useState(false); + const [isDeleteArtistModalOpen, setIsDeleteArtistModalOpen] = useState(false); + + const onRefreshPress = useCallback(() => { + dispatch( + executeCommand({ + name: REFRESH_ARTIST, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onSearchPress = useCallback(() => { + dispatch( + executeCommand({ + name: ARTIST_SEARCH, + artistId, + }) + ); + }, [artistId, dispatch]); + + const onBannerLoadError = useCallback(() => { + setHasBannerError(true); + }, [setHasBannerError]); + + const onBannerLoad = useCallback(() => { + setHasBannerError(false); + }, [setHasBannerError]); + + const onEditArtistPress = useCallback(() => { + setIsEditArtistModalOpen(true); + }, [setIsEditArtistModalOpen]); + + const onEditArtistModalClose = useCallback(() => { + setIsEditArtistModalOpen(false); + }, [setIsEditArtistModalOpen]); + + const onDeleteArtistPress = useCallback(() => { + setIsEditArtistModalOpen(false); + setIsDeleteArtistModalOpen(true); + }, [setIsDeleteArtistModalOpen]); + + const onDeleteArtistModalClose = useCallback(() => { + setIsDeleteArtistModalOpen(false); + }, [setIsDeleteArtistModalOpen]); + + return ( + <> + {columns.map((column) => { + const { name, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'sortName') { + return ( + + {showBanners ? ( + + + + {hasBannerError && ( +
{artistName}
+ )} + + ) : ( + + )} +
+ ); + } + + if (name === 'artistType') { + return ( + + {artistType} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'metadataProfileId') { + return ( + + {metadataProfile.name} + + ); + } + + if (name === 'nextAlbum') { + if (nextAlbum) { + return ( + + + + ); + } + return ( + + None + + ); + } + + if (name === 'lastAlbum') { + if (lastAlbum) { + return ( + + + + ); + } + return ( + + None + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'albumCount') { + return ( + + {albumCount} + + ); + } + + if (name === 'trackProgress') { + const progress = trackCount + ? (trackFileCount / trackCount) * 100 + : 100; + + return ( + + + + ); + } + + if (name === 'trackCount') { + return ( + + {totalTrackCount} + + ); + } + + if (name === 'path') { + return ( + + {path} + + ); + } + + if (name === 'sizeOnDisk') { + return ( + + {formatBytes(sizeOnDisk)} + + ); + } + + if (name === 'genres') { + const joinedGenres = genres.join(', '); + + return ( + + {joinedGenres} + + ); + } + + if (name === 'ratings') { + return ( + + + + ); + } + + if (name === 'tags') { + return ( + + + + ); + } + + if (name === 'actions') { + return ( + + + + {showSearchAction ? ( + + ) : null} + + + + ); + } + + return null; + })} + + + + + + ); +} + +export default ArtistIndexRow; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css b/frontend/src/Artist/Index/Table/ArtistIndexTable.css index 23ab127b5..455f0bc7c 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.css +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css @@ -1,5 +1,3 @@ -.tableContainer { - composes: tableContainer from '~Components/Table/VirtualTable.css'; - - flex: 1 0 auto; +.tableScroller { + position: relative; } diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts index fbc2e3b9a..712cb8f72 100644 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.css.d.ts @@ -1,7 +1,7 @@ // This file is automatically generated. // Please do not change this file! interface CssExports { - 'tableContainer': string; + 'tableScroller': string; } export const cssExports: CssExports; export default cssExports; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.js b/frontend/src/Artist/Index/Table/ArtistIndexTable.js deleted file mode 100644 index 00f6a80d1..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTable.js +++ /dev/null @@ -1,134 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import ArtistIndexItemConnector from 'Artist/Index/ArtistIndexItemConnector'; -import VirtualTable from 'Components/Table/VirtualTable'; -import VirtualTableRow from 'Components/Table/VirtualTableRow'; -import { sortDirections } from 'Helpers/Props'; -import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; -import ArtistIndexHeaderConnector from './ArtistIndexHeaderConnector'; -import ArtistIndexRow from './ArtistIndexRow'; -import styles from './ArtistIndexTable.css'; - -class ArtistIndexTable extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - scrollIndex: null - }; - } - - componentDidUpdate(prevProps) { - const { - items, - jumpToCharacter - } = this.props; - - if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { - - const scrollIndex = getIndexOfFirstCharacter(items, jumpToCharacter); - - if (scrollIndex != null) { - this.setState({ scrollIndex }); - } - } else if (jumpToCharacter == null && prevProps.jumpToCharacter != null) { - this.setState({ scrollIndex: null }); - } - } - - // - // Control - - rowRenderer = ({ key, rowIndex, style }) => { - const { - items, - columns, - showBanners, - isSaving - } = this.props; - - const artist = items[rowIndex]; - - return ( - - - - ); - }; - - // - // Render - - render() { - const { - items, - columns, - sortKey, - sortDirection, - showBanners, - isSmallScreen, - onSortPress, - scroller, - scrollTop - } = this.props; - - return ( - - } - columns={columns} - sortKey={sortKey} - sortDirection={sortDirection} - /> - ); - } -} - -ArtistIndexTable.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - columns: PropTypes.arrayOf(PropTypes.object).isRequired, - sortKey: PropTypes.string, - sortDirection: PropTypes.oneOf(sortDirections.all), - showBanners: PropTypes.bool.isRequired, - isSaving: PropTypes.bool.isRequired, - jumpToCharacter: PropTypes.string, - scrollTop: PropTypes.number, - scroller: PropTypes.instanceOf(Element).isRequired, - isSmallScreen: PropTypes.bool.isRequired, - onSortPress: PropTypes.func.isRequired -}; - -export default ArtistIndexTable; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx new file mode 100644 index 000000000..1c556384d --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTable.tsx @@ -0,0 +1,205 @@ +import { throttle } from 'lodash'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { FixedSizeList as List, ListChildComponentProps } from 'react-window'; +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import ArtistIndexRow from 'Artist/Index/Table/ArtistIndexRow'; +import ArtistIndexTableHeader from 'Artist/Index/Table/ArtistIndexTableHeader'; +import Scroller from 'Components/Scroller/Scroller'; +import Column from 'Components/Table/Column'; +import useMeasure from 'Helpers/Hooks/useMeasure'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import SortDirection from 'Helpers/Props/SortDirection'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import selectTableOptions from './selectTableOptions'; +import styles from './ArtistIndexTable.css'; + +const bodyPadding = parseInt(dimensions.pageContentBodyPadding); +const bodyPaddingSmallScreen = parseInt( + dimensions.pageContentBodyPaddingSmallScreen +); + +interface RowItemData { + items: Artist[]; + sortKey: string; + columns: Column[]; +} + +interface ArtistIndexTableProps { + items: Artist[]; + sortKey?: string; + sortDirection?: SortDirection; + jumpToCharacter?: string; + scrollTop?: number; + scrollerRef: React.MutableRefObject; + isSmallScreen: boolean; +} + +const columnsSelector = createSelector( + (state) => state.artistIndex.columns, + (columns) => columns +); + +const Row: React.FC> = ({ + index, + style, + data, +}) => { + const { items, sortKey, columns } = data; + + if (index >= items.length) { + return null; + } + + const artist = items[index]; + + return ( +
+ +
+ ); +}; + +function getWindowScrollTopPosition() { + return document.documentElement.scrollTop || document.body.scrollTop || 0; +} + +function ArtistIndexTable(props: ArtistIndexTableProps) { + const { + items, + sortKey, + sortDirection, + jumpToCharacter, + isSmallScreen, + scrollerRef, + } = props; + + const columns = useSelector(columnsSelector); + const { showBanners } = useSelector(selectTableOptions); + const listRef: React.MutableRefObject = useRef(); + const [measureRef, bounds] = useMeasure(); + const [size, setSize] = useState({ width: 0, height: 0 }); + + const rowHeight = useMemo(() => { + return showBanners ? 70 : 38; + }, [showBanners]); + + useEffect(() => { + const current = scrollerRef.current as HTMLElement; + + if (isSmallScreen) { + setSize({ + width: window.innerWidth, + height: window.innerHeight, + }); + + return; + } + + if (current) { + const width = current.clientWidth; + const padding = + (isSmallScreen ? bodyPaddingSmallScreen : bodyPadding) - 5; + + setSize({ + width: width - padding * 2, + height: window.innerHeight, + }); + } + }, [isSmallScreen, scrollerRef, bounds]); + + useEffect(() => { + const currentScrollListener = isSmallScreen ? window : scrollerRef.current; + const currentScrollerRef = scrollerRef.current; + + const handleScroll = throttle(() => { + const { offsetTop = 0 } = currentScrollerRef; + const scrollTop = + (isSmallScreen + ? getWindowScrollTopPosition() + : currentScrollerRef.scrollTop) - offsetTop; + + listRef.current.scrollTo(scrollTop); + }, 10); + + currentScrollListener.addEventListener('scroll', handleScroll); + + return () => { + handleScroll.cancel(); + + if (currentScrollListener) { + currentScrollListener.removeEventListener('scroll', handleScroll); + } + }; + }, [isSmallScreen, listRef, scrollerRef]); + + useEffect(() => { + if (jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (index != null) { + let scrollTop = index * rowHeight; + + // If the offset is zero go to the top, otherwise offset + // by the approximate size of the header + padding (37 + 20). + if (scrollTop > 0) { + const offset = 57; + + scrollTop += offset; + } + + listRef.current.scrollTo(scrollTop); + scrollerRef.current.scrollTo(0, scrollTop); + } + } + }, [jumpToCharacter, rowHeight, items, scrollerRef, listRef]); + + return ( +
+ + + + ref={listRef} + style={{ + width: '100%', + height: '100%', + overflow: 'none', + }} + width={size.width} + height={size.height} + itemCount={items.length} + itemSize={rowHeight} + itemData={{ + items, + sortKey, + columns, + }} + > + {Row} + + +
+ ); +} + +export default ArtistIndexTable; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js deleted file mode 100644 index 3a97425cc..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableConnector.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setArtistSort } from 'Store/Actions/artistIndexActions'; -import ArtistIndexTable from './ArtistIndexTable'; - -function createMapStateToProps() { - return createSelector( - (state) => state.app.dimensions, - (state) => state.artistIndex.tableOptions, - (state) => state.artistIndex.columns, - (dimensions, tableOptions, columns) => { - return { - isSmallScreen: dimensions.isSmallScreen, - showBanners: tableOptions.showBanners, - columns - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onSortPress(sortKey) { - dispatch(setArtistSort({ sortKey })); - } - }; -} - -export default connect(createMapStateToProps, createMapDispatchToProps)(ArtistIndexTable); diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css similarity index 100% rename from frontend/src/Artist/Index/Table/ArtistIndexHeader.css rename to frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css diff --git a/frontend/src/Artist/Index/Table/ArtistIndexHeader.css.d.ts b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts similarity index 100% rename from frontend/src/Artist/Index/Table/ArtistIndexHeader.css.d.ts rename to frontend/src/Artist/Index/Table/ArtistIndexTableHeader.css.d.ts diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx new file mode 100644 index 000000000..de4230024 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableHeader.tsx @@ -0,0 +1,98 @@ +import classNames from 'classnames'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; +import ArtistIndexTableOptions from 'Artist/Index/Table/ArtistIndexTableOptions'; +import IconButton from 'Components/Link/IconButton'; +import Column from 'Components/Table/Column'; +import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; +import VirtualTableHeader from 'Components/Table/VirtualTableHeader'; +import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell'; +import { icons } from 'Helpers/Props'; +import SortDirection from 'Helpers/Props/SortDirection'; +import { + setArtistSort, + setArtistTableOption, +} from 'Store/Actions/artistIndexActions'; +import hasGrowableColumns from './hasGrowableColumns'; +import styles from './ArtistIndexTableHeader.css'; + +interface ArtistIndexTableHeaderProps { + showBanners: boolean; + columns: Column[]; + sortKey?: string; + sortDirection?: SortDirection; +} + +function ArtistIndexTableHeader(props: ArtistIndexTableHeaderProps) { + const { showBanners, columns, sortKey, sortDirection } = props; + + const dispatch = useDispatch(); + + const onSortPress = useCallback( + (value) => { + dispatch(setArtistSort({ sortKey: value })); + }, + [dispatch] + ); + + const onTableOptionChange = useCallback( + (payload) => { + dispatch(setArtistTableOption(payload)); + }, + [dispatch] + ); + + return ( + + {columns.map((column) => { + const { name, label, isSortable, isVisible } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + ); + } + + return ( + + {typeof label === 'function' ? label() : label} + + ); + })} + + ); +} + +export default ArtistIndexTableHeader; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js deleted file mode 100644 index 6fd619ad0..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.js +++ /dev/null @@ -1,105 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component, Fragment } from 'react'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import { inputTypes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -class ArtistIndexTableOptions extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - showBanners: props.showBanners, - showSearchAction: props.showSearchAction - }; - } - - componentDidUpdate(prevProps) { - const { - showBanners, - showSearchAction - } = this.props; - - if ( - showBanners !== prevProps.showBanners || - showSearchAction !== prevProps.showSearchAction - ) { - this.setState({ - showBanners, - showSearchAction - }); - } - } - - // - // Listeners - - onTableOptionChange = ({ name, value }) => { - this.setState({ - [name]: value - }, () => { - this.props.onTableOptionChange({ - tableOptions: { - ...this.state, - [name]: value - } - }); - }); - }; - - // - // Render - - render() { - const { - showBanners, - showSearchAction - } = this.state; - - return ( - - - - {translate('ShowBanners')} - - - - - - - - {translate('ShowSearch')} - - - - - - ); - } -} - -ArtistIndexTableOptions.propTypes = { - showBanners: PropTypes.bool.isRequired, - showSearchAction: PropTypes.bool.isRequired, - onTableOptionChange: PropTypes.func.isRequired -}; - -export default ArtistIndexTableOptions; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx new file mode 100644 index 000000000..f7f2bbd20 --- /dev/null +++ b/frontend/src/Artist/Index/Table/ArtistIndexTableOptions.tsx @@ -0,0 +1,62 @@ +import React, { Fragment, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import { inputTypes } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import selectTableOptions from './selectTableOptions'; + +interface ArtistIndexTableOptionsProps { + onTableOptionChange(...args: unknown[]): unknown; +} + +function ArtistIndexTableOptions(props: ArtistIndexTableOptionsProps) { + const { onTableOptionChange } = props; + + const tableOptions = useSelector(selectTableOptions); + + const { showBanners, showSearchAction } = tableOptions; + + const onTableOptionChangeWrapper = useCallback( + ({ name, value }) => { + onTableOptionChange({ + tableOptions: { + ...tableOptions, + [name]: value, + }, + }); + }, + [tableOptions, onTableOptionChange] + ); + + return ( + + + {translate('ShowBanners')} + + + + + + {translate('ShowSearch')} + + + + + ); +} + +export default ArtistIndexTableOptions; diff --git a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js b/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js deleted file mode 100644 index 0a1607cf2..000000000 --- a/frontend/src/Artist/Index/Table/ArtistIndexTableOptionsConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import ArtistIndexTableOptions from './ArtistIndexTableOptions'; - -function createMapStateToProps() { - return createSelector( - (state) => state.artistIndex.tableOptions, - (tableOptions) => { - return tableOptions; - } - ); -} - -export default connect(createMapStateToProps)(ArtistIndexTableOptions); diff --git a/frontend/src/Artist/Index/Table/ArtistStatusCell.js b/frontend/src/Artist/Index/Table/ArtistStatusCell.tsx similarity index 52% rename from frontend/src/Artist/Index/Table/ArtistStatusCell.js rename to frontend/src/Artist/Index/Table/ArtistStatusCell.tsx index 1f163a473..05be1573e 100644 --- a/frontend/src/Artist/Index/Table/ArtistStatusCell.js +++ b/frontend/src/Artist/Index/Table/ArtistStatusCell.tsx @@ -1,31 +1,44 @@ -import PropTypes from 'prop-types'; -import React from 'react'; +import React, { useCallback } from 'react'; +import { useDispatch } from 'react-redux'; import Icon from 'Components/Icon'; import MonitorToggleButton from 'Components/MonitorToggleButton'; import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; import { icons } from 'Helpers/Props'; +import { toggleArtistMonitored } from 'Store/Actions/artistActions'; import translate from 'Utilities/String/translate'; import styles from './ArtistStatusCell.css'; -function ArtistStatusCell(props) { +interface ArtistStatusCellProps { + className: string; + artistId: number; + artistType?: string; + monitored: boolean; + status: string; + isSaving: boolean; + component?: React.ElementType; +} + +function ArtistStatusCell(props: ArtistStatusCellProps) { const { className, + artistId, artistType, monitored, status, isSaving, - onMonitoredPress, - component: Component, + component: Component = VirtualTableRowCell, ...otherProps } = props; const endedString = artistType === 'Person' ? 'Deceased' : 'Inactive'; + const dispatch = useDispatch(); + + const onMonitoredPress = useCallback(() => { + dispatch(toggleArtistMonitored({ artistId, monitored: !monitored })); + }, [artistId, monitored, dispatch]); return ( - + ); } -ArtistStatusCell.propTypes = { - className: PropTypes.string.isRequired, - artistType: PropTypes.string, - monitored: PropTypes.bool.isRequired, - status: PropTypes.string.isRequired, - isSaving: PropTypes.bool.isRequired, - onMonitoredPress: PropTypes.func.isRequired, - component: PropTypes.elementType -}; - -ArtistStatusCell.defaultProps = { - className: styles.status, - component: VirtualTableRowCell -}; - export default ArtistStatusCell; diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.js b/frontend/src/Artist/Index/Table/hasGrowableColumns.js deleted file mode 100644 index 994436d9f..000000000 --- a/frontend/src/Artist/Index/Table/hasGrowableColumns.js +++ /dev/null @@ -1,16 +0,0 @@ -const growableColumns = [ - 'qualityProfileId', - 'path', - 'tags' -]; - -export default function hasGrowableColumns(columns) { - return columns.some((column) => { - const { - name, - isVisible - } = column; - - return growableColumns.includes(name) && isVisible; - }); -} diff --git a/frontend/src/Artist/Index/Table/hasGrowableColumns.ts b/frontend/src/Artist/Index/Table/hasGrowableColumns.ts new file mode 100644 index 000000000..ed0cc6c58 --- /dev/null +++ b/frontend/src/Artist/Index/Table/hasGrowableColumns.ts @@ -0,0 +1,11 @@ +import Column from 'Components/Table/Column'; + +const growableColumns = ['qualityProfileId', 'path', 'tags']; + +export default function hasGrowableColumns(columns: Column[]) { + return columns.some((column) => { + const { name, isVisible } = column; + + return growableColumns.includes(name) && isVisible; + }); +} diff --git a/frontend/src/Artist/Index/Table/selectTableOptions.ts b/frontend/src/Artist/Index/Table/selectTableOptions.ts new file mode 100644 index 000000000..b6a2a6a94 --- /dev/null +++ b/frontend/src/Artist/Index/Table/selectTableOptions.ts @@ -0,0 +1,9 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; + +const selectTableOptions = createSelector( + (state: AppState) => state.artistIndex.tableOptions, + (tableOptions) => tableOptions +); + +export default selectTableOptions; diff --git a/frontend/src/Artist/Index/createArtistIndexItemSelector.ts b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts new file mode 100644 index 000000000..86ee8a560 --- /dev/null +++ b/frontend/src/Artist/Index/createArtistIndexItemSelector.ts @@ -0,0 +1,48 @@ +import { createSelector } from 'reselect'; +import Artist from 'Artist/Artist'; +import { ARTIST_SEARCH, REFRESH_ARTIST } from 'Commands/commandNames'; +import createArtistMetadataProfileSelector from 'Store/Selectors/createArtistMetadataProfileSelector'; +import createArtistQualityProfileSelector from 'Store/Selectors/createArtistQualityProfileSelector'; +import { createArtistSelectorForHook } from 'Store/Selectors/createArtistSelector'; +import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; + +function createArtistIndexItemSelector(artistId: number) { + return createSelector( + createArtistSelectorForHook(artistId), + createArtistQualityProfileSelector(artistId), + createArtistMetadataProfileSelector(artistId), + createExecutingCommandsSelector(), + (artist: Artist, qualityProfile, metadataProfile, executingCommands) => { + // If an artist is deleted this selector may fire before the parent + // selectors, which will result in an undefined artist, if that happens + // we want to return early here and again in the render function to avoid + // trying to show an artist that has no information available. + + if (!artist) { + return {}; + } + + const isRefreshingArtist = executingCommands.some((command) => { + return ( + command.name === REFRESH_ARTIST && command.body.artistId === artist.id + ); + }); + + const isSearchingArtist = executingCommands.some((command) => { + return ( + command.name === ARTIST_SEARCH && command.body.artistId === artist.id + ); + }); + + return { + artist, + qualityProfile, + metadataProfile, + isRefreshingArtist, + isSearchingArtist, + }; + } + ); +} + +export default createArtistIndexItemSelector; diff --git a/frontend/src/Components/Link/SpinnerIconButton.js b/frontend/src/Components/Link/SpinnerIconButton.js index a804fafc5..d36ebb24d 100644 --- a/frontend/src/Components/Link/SpinnerIconButton.js +++ b/frontend/src/Components/Link/SpinnerIconButton.js @@ -23,6 +23,8 @@ function SpinnerIconButton(props) { } SpinnerIconButton.propTypes = { + ...IconButton.propTypes, + className: PropTypes.string, name: PropTypes.object.isRequired, spinningName: PropTypes.object.isRequired, isDisabled: PropTypes.bool.isRequired, diff --git a/frontend/src/Components/Menu/SortMenu.js b/frontend/src/Components/Menu/SortMenu.js index df8bb2a36..ec068fdf9 100644 --- a/frontend/src/Components/Menu/SortMenu.js +++ b/frontend/src/Components/Menu/SortMenu.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Menu from 'Components/Menu/Menu'; import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; -import { icons } from 'Helpers/Props'; +import { align, icons } from 'Helpers/Props'; function SortMenu(props) { const { @@ -30,7 +30,8 @@ function SortMenu(props) { SortMenu.propTypes = { className: PropTypes.string, children: PropTypes.node.isRequired, - isDisabled: PropTypes.bool.isRequired + isDisabled: PropTypes.bool.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]) }; SortMenu.defaultProps = { diff --git a/frontend/src/Components/Menu/ViewMenu.js b/frontend/src/Components/Menu/ViewMenu.js index 0f87d53f8..7eb505b8f 100644 --- a/frontend/src/Components/Menu/ViewMenu.js +++ b/frontend/src/Components/Menu/ViewMenu.js @@ -2,7 +2,7 @@ import PropTypes from 'prop-types'; import React from 'react'; import Menu from 'Components/Menu/Menu'; import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton'; -import { icons } from 'Helpers/Props'; +import { align, icons } from 'Helpers/Props'; function ViewMenu(props) { const { @@ -27,7 +27,8 @@ function ViewMenu(props) { ViewMenu.propTypes = { children: PropTypes.node.isRequired, - isDisabled: PropTypes.bool.isRequired + isDisabled: PropTypes.bool.isRequired, + alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]) }; ViewMenu.defaultProps = { diff --git a/frontend/src/Components/Page/PageContentBody.js b/frontend/src/Components/Page/PageContentBody.js deleted file mode 100644 index 1c93e575b..000000000 --- a/frontend/src/Components/Page/PageContentBody.js +++ /dev/null @@ -1,61 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Scroller from 'Components/Scroller/Scroller'; -import { scrollDirections } from 'Helpers/Props'; -import { isLocked } from 'Utilities/scrollLock'; -import styles from './PageContentBody.css'; - -class PageContentBody extends Component { - - // - // Listeners - - onScroll = (props) => { - const { onScroll } = this.props; - - if (this.props.onScroll && !isLocked()) { - onScroll(props); - } - }; - - // - // Render - - render() { - const { - className, - innerClassName, - children, - dispatch, - ...otherProps - } = this.props; - - return ( - -
- {children} -
-
- ); - } -} - -PageContentBody.propTypes = { - className: PropTypes.string, - innerClassName: PropTypes.string, - children: PropTypes.node.isRequired, - onScroll: PropTypes.func, - dispatch: PropTypes.func -}; - -PageContentBody.defaultProps = { - className: styles.contentBody, - innerClassName: styles.innerContentBody -}; - -export default PageContentBody; diff --git a/frontend/src/Components/Page/PageContentBody.tsx b/frontend/src/Components/Page/PageContentBody.tsx new file mode 100644 index 000000000..972a9bade --- /dev/null +++ b/frontend/src/Components/Page/PageContentBody.tsx @@ -0,0 +1,51 @@ +import React, { forwardRef, ReactNode, useCallback } from 'react'; +import Scroller from 'Components/Scroller/Scroller'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import { isLocked } from 'Utilities/scrollLock'; +import styles from './PageContentBody.css'; + +interface PageContentBodyProps { + className?: string; + innerClassName?: string; + children: ReactNode; + initialScrollTop?: number; + onScroll?: (payload) => void; +} + +const PageContentBody = forwardRef( + ( + props: PageContentBodyProps, + ref: React.MutableRefObject + ) => { + const { + className = styles.contentBody, + innerClassName = styles.innerContentBody, + children, + onScroll, + ...otherProps + } = props; + + const onScrollWrapper = useCallback( + (payload) => { + if (onScroll && !isLocked()) { + onScroll(payload); + } + }, + [onScroll] + ); + + return ( + +
{children}
+
+ ); + } +); + +export default PageContentBody; diff --git a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js index 2d179396a..c93603aa9 100644 --- a/frontend/src/Components/Page/Toolbar/PageToolbarButton.js +++ b/frontend/src/Components/Page/Toolbar/PageToolbarButton.js @@ -45,7 +45,8 @@ PageToolbarButton.propTypes = { iconName: PropTypes.object.isRequired, spinningName: PropTypes.object, isSpinning: PropTypes.bool, - isDisabled: PropTypes.bool + isDisabled: PropTypes.bool, + onPress: PropTypes.func }; PageToolbarButton.defaultProps = { diff --git a/frontend/src/Components/Scroller/Scroller.js b/frontend/src/Components/Scroller/Scroller.js deleted file mode 100644 index 205f1aadd..000000000 --- a/frontend/src/Components/Scroller/Scroller.js +++ /dev/null @@ -1,95 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { scrollDirections } from 'Helpers/Props'; -import styles from './Scroller.css'; - -class Scroller extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._scroller = null; - } - - componentDidMount() { - const { - scrollDirection, - autoFocus, - scrollTop - } = this.props; - - if (this.props.scrollTop != null) { - this._scroller.scrollTop = scrollTop; - } - - if (autoFocus && scrollDirection !== scrollDirections.NONE) { - this._scroller.focus({ preventScroll: true }); - } - } - - // - // Control - - _setScrollerRef = (ref) => { - this._scroller = ref; - - this.props.registerScroller(ref); - }; - - // - // Render - - render() { - const { - className, - scrollDirection, - autoScroll, - children, - scrollTop, - onScroll, - registerScroller, - ...otherProps - } = this.props; - - return ( -
- {children} -
- ); - } - -} - -Scroller.propTypes = { - className: PropTypes.string, - scrollDirection: PropTypes.oneOf(scrollDirections.all).isRequired, - autoFocus: PropTypes.bool.isRequired, - autoScroll: PropTypes.bool.isRequired, - scrollTop: PropTypes.number, - children: PropTypes.node, - onScroll: PropTypes.func, - registerScroller: PropTypes.func -}; - -Scroller.defaultProps = { - scrollDirection: scrollDirections.VERTICAL, - autoFocus: true, - autoScroll: true, - registerScroller: () => { /* no-op */ } -}; - -export default Scroller; diff --git a/frontend/src/Components/Scroller/Scroller.tsx b/frontend/src/Components/Scroller/Scroller.tsx new file mode 100644 index 000000000..2bcb899aa --- /dev/null +++ b/frontend/src/Components/Scroller/Scroller.tsx @@ -0,0 +1,90 @@ +import classNames from 'classnames'; +import { throttle } from 'lodash'; +import React, { forwardRef, ReactNode, useEffect, useRef } from 'react'; +import ScrollDirection from 'Helpers/Props/ScrollDirection'; +import styles from './Scroller.css'; + +interface ScrollerProps { + className?: string; + scrollDirection?: ScrollDirection; + autoFocus?: boolean; + autoScroll?: boolean; + scrollTop?: number; + initialScrollTop?: number; + children?: ReactNode; + onScroll?: (payload) => void; +} + +const Scroller = forwardRef( + (props: ScrollerProps, ref: React.MutableRefObject) => { + const { + className, + autoFocus = false, + autoScroll = true, + scrollDirection = ScrollDirection.Vertical, + children, + scrollTop, + initialScrollTop, + onScroll, + ...otherProps + } = props; + + const internalRef = useRef(); + const currentRef = ref ?? internalRef; + + useEffect( + () => { + if (initialScrollTop != null) { + currentRef.current.scrollTop = initialScrollTop; + } + }, + // eslint-disable-next-line react-hooks/exhaustive-deps + [] + ); + + useEffect(() => { + if (scrollTop != null) { + currentRef.current.scrollTop = scrollTop; + } + + if (autoFocus && scrollDirection !== ScrollDirection.None) { + currentRef.current.focus({ preventScroll: true }); + } + }, [autoFocus, currentRef, scrollDirection, scrollTop]); + + useEffect(() => { + const div = currentRef.current; + + const handleScroll = throttle(() => { + const scrollLeft = div.scrollLeft; + const scrollTop = div.scrollTop; + + onScroll?.({ scrollLeft, scrollTop }); + }, 10); + + div.addEventListener('scroll', handleScroll); + + return () => { + div.removeEventListener('scroll', handleScroll); + }; + }, [currentRef, onScroll]); + + return ( +
+ {children} +
+ ); + } +); + +export default Scroller; diff --git a/frontend/src/Components/withScrollPosition.js b/frontend/src/Components/withScrollPosition.js deleted file mode 100644 index bb089b8b0..000000000 --- a/frontend/src/Components/withScrollPosition.js +++ /dev/null @@ -1,30 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import scrollPositions from 'Store/scrollPositions'; - -function withScrollPosition(WrappedComponent, scrollPositionKey) { - function ScrollPosition(props) { - const { - history - } = props; - - const scrollTop = history.action === 'POP' || (history.location.state && history.location.state.restoreScrollPosition) ? - scrollPositions[scrollPositionKey] : - 0; - - return ( - - ); - } - - ScrollPosition.propTypes = { - history: PropTypes.object.isRequired - }; - - return ScrollPosition; -} - -export default withScrollPosition; diff --git a/frontend/src/Components/withScrollPosition.tsx b/frontend/src/Components/withScrollPosition.tsx new file mode 100644 index 000000000..ec13c6ab8 --- /dev/null +++ b/frontend/src/Components/withScrollPosition.tsx @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import scrollPositions from 'Store/scrollPositions'; + +function withScrollPosition(WrappedComponent, scrollPositionKey) { + function ScrollPosition(props) { + const { history } = props; + + const initialScrollTop = + history.action === 'POP' || + (history.location.state && history.location.state.restoreScrollPosition) + ? scrollPositions[scrollPositionKey] + : 0; + + return ; + } + + ScrollPosition.propTypes = { + history: PropTypes.object.isRequired, + }; + + return ScrollPosition; +} + +export default withScrollPosition; diff --git a/frontend/src/Helpers/Hooks/useMeasure.ts b/frontend/src/Helpers/Hooks/useMeasure.ts new file mode 100644 index 000000000..7b36b2844 --- /dev/null +++ b/frontend/src/Helpers/Hooks/useMeasure.ts @@ -0,0 +1,21 @@ +import { ResizeObserver as ResizeObserverPolyfill } from '@juggle/resize-observer'; +import { + default as useMeasureHook, + Options, + RectReadOnly, +} from 'react-use-measure'; + +const ResizeObserver = window.ResizeObserver || ResizeObserverPolyfill; + +export type Measurements = RectReadOnly; + +function useMeasure( + options?: Omit +): ReturnType { + return useMeasureHook({ + polyfill: ResizeObserver, + ...options, + }); +} + +export default useMeasure; diff --git a/frontend/src/Helpers/Props/ScrollDirection.ts b/frontend/src/Helpers/Props/ScrollDirection.ts new file mode 100644 index 000000000..0da932d22 --- /dev/null +++ b/frontend/src/Helpers/Props/ScrollDirection.ts @@ -0,0 +1,8 @@ +enum ScrollDirection { + Horizontal = 'horizontal', + Vertical = 'vertical', + None = 'none', + Both = 'both', +} + +export default ScrollDirection; diff --git a/frontend/src/Quality/Quality.ts b/frontend/src/Quality/Quality.ts new file mode 100644 index 000000000..5be1475fc --- /dev/null +++ b/frontend/src/Quality/Quality.ts @@ -0,0 +1,17 @@ +export interface Revision { + version: number; + real: number; + isRepack: boolean; +} + +interface Quality { + id: number; + name: string; +} + +export interface QualityModel { + quality: Quality; + revision: Revision; +} + +export default Quality; diff --git a/frontend/src/Store/Actions/artistIndexActions.js b/frontend/src/Store/Actions/artistIndexActions.js index e54b38df6..aa78daf40 100644 --- a/frontend/src/Store/Actions/artistIndexActions.js +++ b/frontend/src/Store/Actions/artistIndexActions.js @@ -39,6 +39,7 @@ export const defaultState = { showTitle: false, showMonitored: true, showQualityProfile: true, + showNextAlbum: true, showSearchAction: false }, diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js deleted file mode 100644 index de5205948..000000000 --- a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import createArtistSelector from './createArtistSelector'; - -function createArtistMetadataProfileSelector() { - return createSelector( - (state) => state.settings.metadataProfiles.items, - createArtistSelector(), - (metadataProfiles, artist = {}) => { - return metadataProfiles.find((profile) => { - return profile.id === artist.metadataProfileId; - }); - } - ); -} - -export default createArtistMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts new file mode 100644 index 000000000..0acbd3997 --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistMetadataProfileSelector.ts @@ -0,0 +1,18 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Artist from 'Artist/Artist'; +import { createArtistSelectorForHook } from './createArtistSelector'; + +function createArtistMetadataProfileSelector(artistId: number) { + return createSelector( + (state: AppState) => state.settings.metadataProfiles.items, + createArtistSelectorForHook(artistId), + (metadataProfiles, artist = {} as Artist) => { + return metadataProfiles.find((profile) => { + return profile.id === artist.metadataProfileId; + }); + } + ); +} + +export default createArtistMetadataProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js deleted file mode 100644 index 5819eb080..000000000 --- a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.js +++ /dev/null @@ -1,16 +0,0 @@ -import { createSelector } from 'reselect'; -import createArtistSelector from './createArtistSelector'; - -function createArtistQualityProfileSelector() { - return createSelector( - (state) => state.settings.qualityProfiles.items, - createArtistSelector(), - (qualityProfiles, artist = {}) => { - return qualityProfiles.find((profile) => { - return profile.id === artist.qualityProfileId; - }); - } - ); -} - -export default createArtistQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts new file mode 100644 index 000000000..99325276f --- /dev/null +++ b/frontend/src/Store/Selectors/createArtistQualityProfileSelector.ts @@ -0,0 +1,18 @@ +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import Artist from 'Artist/Artist'; +import { createArtistSelectorForHook } from './createArtistSelector'; + +function createArtistQualityProfileSelector(artistId: number) { + return createSelector( + (state: AppState) => state.settings.qualityProfiles.items, + createArtistSelectorForHook(artistId), + (qualityProfiles, artist = {} as Artist) => { + return qualityProfiles.find( + (profile) => profile.id === artist.qualityProfileId + ); + } + ); +} + +export default createArtistQualityProfileSelector; diff --git a/frontend/src/Store/Selectors/createArtistSelector.js b/frontend/src/Store/Selectors/createArtistSelector.js index 104ef83e3..c335f37f5 100644 --- a/frontend/src/Store/Selectors/createArtistSelector.js +++ b/frontend/src/Store/Selectors/createArtistSelector.js @@ -1,5 +1,15 @@ import { createSelector } from 'reselect'; +export function createArtistSelectorForHook(artistId) { + return createSelector( + (state) => state.artist.itemMap, + (state) => state.artist.items, + (itemMap, allArtists) => { + return artistId ? allArtists[itemMap[artistId]]: undefined; + } + ); +} + function createArtistSelector() { return createSelector( (state, { artistId }) => artistId, diff --git a/frontend/src/UnmappedFiles/UnmappedFilesTable.js b/frontend/src/UnmappedFiles/UnmappedFilesTable.js index 817826cc1..75a4d1d06 100644 --- a/frontend/src/UnmappedFiles/UnmappedFilesTable.js +++ b/frontend/src/UnmappedFiles/UnmappedFilesTable.js @@ -27,6 +27,8 @@ class UnmappedFilesTable extends Component { constructor(props, context) { super(props, context); + this.scrollerRef = React.createRef(); + this.state = { scroller: null, allSelected: false, @@ -65,13 +67,6 @@ class UnmappedFilesTable extends Component { } } - // - // Control - - setScrollerRef = (ref) => { - this.setState({ scroller: ref }); - }; - getSelectedIds = () => { if (this.state.allUnselected) { return []; @@ -184,7 +179,6 @@ class UnmappedFilesTable extends Component { } = this.props; const { - scroller, allSelected, allUnselected, selectedState @@ -227,9 +221,7 @@ class UnmappedFilesTable extends Component { - + { isFetching && !isPopulated && @@ -243,11 +235,14 @@ class UnmappedFilesTable extends Component { } { - isPopulated && !error && !!items.length && scroller && + isPopulated && + !error && + !!items.length && + this.scrollerRef.current ? + /> : + null } diff --git a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js index d27cfd604..cec7fb09a 100644 --- a/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js +++ b/frontend/src/Utilities/Array/getIndexOfFirstCharacter.js @@ -1,7 +1,5 @@ -import _ from 'lodash'; - export default function getIndexOfFirstCharacter(items, character) { - return _.findIndex(items, (item) => { + return items.findIndex((item) => { const firstCharacter = item.sortName.charAt(0); if (character === '#') { diff --git a/frontend/src/typings/CustomFormat.ts b/frontend/src/typings/CustomFormat.ts new file mode 100644 index 000000000..7cef9f6ef --- /dev/null +++ b/frontend/src/typings/CustomFormat.ts @@ -0,0 +1,12 @@ +export interface QualityProfileFormatItem { + format: number; + name: string; + score: number; +} + +interface CustomFormat { + id: number; + name: string; +} + +export default CustomFormat; diff --git a/frontend/src/typings/MetadataProfile.ts b/frontend/src/typings/MetadataProfile.ts new file mode 100644 index 000000000..a02c99c5a --- /dev/null +++ b/frontend/src/typings/MetadataProfile.ts @@ -0,0 +1,39 @@ +interface PrimaryAlbumType { + id?: number; + name?: string; +} + +interface SecondaryAlbumType { + id?: number; + name?: string; +} + +interface ReleaseStatus { + id?: number; + name?: string; +} + +interface ProfilePrimaryAlbumTypeItem { + primaryAlbumType?: PrimaryAlbumType; + allowed: boolean; +} + +interface ProfileSecondaryAlbumTypeItem { + secondaryAlbumType?: SecondaryAlbumType; + allowed: boolean; +} + +interface ProfileReleaseStatusItem { + releaseStatus?: ReleaseStatus; + allowed: boolean; +} + +interface MetadataProfile { + name: string; + primaryAlbumTypes: ProfilePrimaryAlbumTypeItem[]; + secondaryAlbumTypes: ProfileSecondaryAlbumTypeItem[]; + ReleaseStatuses: ProfileReleaseStatusItem[]; + id: number; +} + +export default MetadataProfile; diff --git a/frontend/src/typings/QualityProfile.ts b/frontend/src/typings/QualityProfile.ts new file mode 100644 index 000000000..ec4e46648 --- /dev/null +++ b/frontend/src/typings/QualityProfile.ts @@ -0,0 +1,23 @@ +import Quality from 'Quality/Quality'; +import { QualityProfileFormatItem } from './CustomFormat'; + +export interface QualityProfileQualityItem { + id?: number; + quality?: Quality; + items: QualityProfileQualityItem[]; + allowed: boolean; + name?: string; +} + +interface QualityProfile { + name: string; + upgradeAllowed: boolean; + cutoff: number; + items: QualityProfileQualityItem[]; + minFormatScore: number; + cutoffFormatScore: number; + formatItems: QualityProfileFormatItem[]; + id: number; +} + +export default QualityProfile; diff --git a/package.json b/package.json index 0dc155f38..d0c9c9c1e 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ "@fortawesome/free-regular-svg-icons": "6.4.0", "@fortawesome/free-solid-svg-icons": "6.4.0", "@fortawesome/react-fontawesome": "0.2.0", + "@juggle/resize-observer": "3.4.0", "@microsoft/signalr": "6.0.21", "@sentry/browser": "7.51.2", "@sentry/integrations": "7.51.2", @@ -76,7 +77,9 @@ "react-slider": "1.1.4", "react-tabs": "3.2.2", "react-text-truncate": "0.19.0", + "react-use-measure": "2.1.1", "react-virtualized": "9.21.1", + "react-window": "1.8.9", "redux": "4.1.0", "redux-actions": "2.6.5", "redux-batched-actions": "0.5.0", diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 04a66a2d2..80e2c3184 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -100,6 +100,7 @@ "ArtistFolderFormat": "Artist Folder Format", "ArtistName": "Artist Name", "ArtistNameHelpText": "The name of the artist/album to exclude (can be anything meaningful)", + "ArtistProgressBarText": "{trackFileCount} / {trackCount} (Total: {totalTrackCount})", "ArtistType": "Artist Type", "Artists": "Artists", "AudioInfo": "Audio Info", @@ -127,6 +128,8 @@ "BackupNow": "Backup Now", "BackupRetentionHelpText": "Automatic backups older than the retention period will be cleaned up automatically", "Backups": "Backups", + "BannerOptions": "Banner Options", + "Banners": "Banners", "BeforeUpdate": "Before Update", "BindAddress": "Bind Address", "BindAddressHelpText": "Valid IP address, localhost or '*' for all interfaces", @@ -185,7 +188,7 @@ "ContinuingAllTracksDownloaded": "Continuing (All tracks downloaded)", "ContinuingMoreAlbumsAreExpected": "More albums are expected", "ContinuingNoAdditionalAlbumsAreExpected": "No additional albums are expected", - "ContinuingOnly": "ContinuingOnly", + "ContinuingOnly": "Continuing Only", "CopyToClipboard": "Copy to clipboard", "CopyUsingHardlinksHelpText": "Hardlinks allow Lidarr to import seeding torrents to the the series folder without taking extra disk space or copying the entire contents of the file. Hardlinks will only work if the source and destination are on the same volume", "CopyUsingHardlinksHelpTextWarning": "Occasionally, file locks may prevent renaming files that are being seeded. You may temporarily disable seeding and use Lidarr's rename function as a work around.", @@ -684,6 +687,8 @@ "Original": "Original", "Other": "Other", "OutputPath": "Output Path", + "Overview": "Overview", + "OverviewOptions": "Overview Options", "PackageVersion": "Package Version", "PageSize": "Page Size", "PageSizeHelpText": "Number of items to show on each page", @@ -698,7 +703,9 @@ "Playlist": "Playlist", "Port": "Port", "PortNumber": "Port Number", + "PosterOptions": "Poster Options", "PosterSize": "Poster Size", + "Posters": "Posters", "PreferAndUpgrade": "Prefer and Upgrade", "PreferProtocol": "Prefer {preferredProtocol}", "PreferTorrent": "Prefer Torrent", @@ -964,6 +971,7 @@ "System": "System", "SystemTimeCheckMessage": "System time is off by more than 1 day. Scheduled tasks may not run correctly until the time is corrected", "TBA": "TBA", + "Table": "Table", "TagAudioFilesWithMetadata": "Tag Audio Files with Metadata", "TagIsNotUsedAndCanBeDeleted": "Tag is not used and can be deleted", "Tags": "Tags", @@ -997,7 +1005,6 @@ "TrackArtist": "Track Artist", "TrackCount": "Track Count", "TrackDownloaded": "Track Downloaded", - "TrackFileCountTrackCountTotalTotalTrackCountInterp": "{0} / {1} (Total: {2})", "TrackFileCounttotalTrackCountTracksDownloadedInterp": "{0}/{1} tracks downloaded", "TrackFiles": "Track Files", "TrackFilesCountMessage": "No track files", diff --git a/yarn.lock b/yarn.lock index 29128e81e..a59d73714 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1202,6 +1202,11 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@juggle/resize-observer@3.4.0": + version "3.4.0" + resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.4.0.tgz#08d6c5e20cf7e4cc02fd181c4b0c225cd31dbb60" + integrity sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA== + "@microsoft/signalr@6.0.21": version "6.0.21" resolved "https://registry.yarnpkg.com/@microsoft/signalr/-/signalr-6.0.21.tgz#b45f335df7011abba831cb3d7974b58da7e725c7" @@ -2634,6 +2639,11 @@ cuint@^0.2.2: resolved "https://registry.yarnpkg.com/cuint/-/cuint-0.2.2.tgz#408086d409550c2631155619e9fa7bcadc3b991b" integrity sha512-d4ZVpCW31eWwCMe1YT3ur7mUDnTXbgwyzaL320DrcRT45rfjYxkt5QWLrmOJ+/UEAI2+fQgKe/fCjR8l4TpRgw== +debounce@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5" + integrity sha512-XRRe6Glud4rd/ZGQfiV1ruXSfbvfJedlV9Y6zOlP+2K04vBYiJEte6stfFkCP03aMnY5tsipamumUjL14fofug== + debug@^3.1.0, debug@^3.2.6, debug@^3.2.7: version "3.2.7" resolved "https://registry.yarnpkg.com/debug/-/debug-3.2.7.tgz#72580b7e9145fb39b6676f9c5e5fb100b934179a" @@ -4474,6 +4484,11 @@ memfs@^3.4.1: dependencies: fs-monkey "^1.0.4" +"memoize-one@>=3.1.1 <6": + version "5.2.1" + resolved "https://registry.yarnpkg.com/memoize-one/-/memoize-one-5.2.1.tgz#8337aa3c4335581839ec01c3d594090cebe8f00e" + integrity sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q== + meow@^9.0.0: version "9.0.0" resolved "https://registry.yarnpkg.com/meow/-/meow-9.0.0.tgz#cd9510bc5cac9dee7d03c73ee1f9ad959f4ea364" @@ -5538,6 +5553,13 @@ react-themeable@^1.1.0: dependencies: object-assign "^3.0.0" +react-use-measure@2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/react-use-measure/-/react-use-measure-2.1.1.tgz#5824537f4ee01c9469c45d5f7a8446177c6cc4ba" + integrity sha512-nocZhN26cproIiIduswYpV5y5lQpSQS1y/4KuvUCjSKmw7ZWIS/+g3aFnX3WdBkyuGUtTLif3UTqnLLhbDoQig== + dependencies: + debounce "^1.2.1" + react-virtualized@9.21.1: version "9.21.1" resolved "https://registry.yarnpkg.com/react-virtualized/-/react-virtualized-9.21.1.tgz#4dbbf8f0a1420e2de3abf28fbb77120815277b3a" @@ -5551,6 +5573,14 @@ react-virtualized@9.21.1: prop-types "^15.6.0" react-lifecycles-compat "^3.0.4" +react-window@1.8.9: + version "1.8.9" + resolved "https://registry.yarnpkg.com/react-window/-/react-window-1.8.9.tgz#24bc346be73d0468cdf91998aac94e32bc7fa6a8" + integrity sha512-+Eqx/fj1Aa5WnhRfj9dJg4VYATGwIUP2ItwItiJ6zboKWA6EX3lYDAXfGF2hyNqplEprhbtjbipiADEcwQ823Q== + dependencies: + "@babel/runtime" "^7.0.0" + memoize-one ">=3.1.1 <6" + react@17.0.2: version "17.0.2" resolved "https://registry.yarnpkg.com/react/-/react-17.0.2.tgz#d0b5cc516d29eb3eee383f75b62864cfb6800037"