diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js index e5ef3d311..1159264c2 100644 --- a/frontend/src/App/AppRoutes.js +++ b/frontend/src/App/AppRoutes.js @@ -8,6 +8,7 @@ import AuthorDetailsPageConnector from 'Author/Details/AuthorDetailsPageConnecto import AuthorEditorConnector from 'Author/Editor/AuthorEditorConnector'; import AuthorIndexConnector from 'Author/Index/AuthorIndexConnector'; import BookDetailsPageConnector from 'Book/Details/BookDetailsPageConnector'; +import BookIndexConnector from 'Book/Index/BookIndexConnector'; import BookshelfConnector from 'Bookshelf/BookshelfConnector'; import CalendarPageConnector from 'Calendar/CalendarPageConnector'; import NotFound from 'Components/NotFound'; @@ -71,6 +72,11 @@ function AppRoutes(props) { /> } + + + + ); @@ -19,7 +18,8 @@ AuthorPoster.propTypes = { }; AuthorPoster.defaultProps = { - size: 250 + size: 250, + coverType: 'poster' }; export default AuthorPoster; diff --git a/frontend/src/Author/Delete/DeleteAuthorModalContent.js b/frontend/src/Author/Delete/DeleteAuthorModalContent.js index 43018e67c..b90ecaebc 100644 --- a/frontend/src/Author/Delete/DeleteAuthorModalContent.js +++ b/frontend/src/Author/Delete/DeleteAuthorModalContent.js @@ -125,7 +125,7 @@ class DeleteAuthorModalContent extends Component { deleteFiles &&
- {translate('TheAuthorFolderStrongpathstrongAndAllOfItsContentWillBeDeleted')} + {translate('TheAuthorFolderAndAllOfItsContentWillBeDeleted', [path])}
{ diff --git a/frontend/src/Author/NoAuthor.js b/frontend/src/Author/NoAuthor.js index 1589082b4..a411b9545 100644 --- a/frontend/src/Author/NoAuthor.js +++ b/frontend/src/Author/NoAuthor.js @@ -5,13 +5,16 @@ import { kinds } from 'Helpers/Props'; import styles from './NoAuthor.css'; function NoAuthor(props) { - const { totalItems } = props; + const { + totalItems, + itemType + } = props; if (totalItems > 0) { return (
- All authors are hidden due to the applied filter. + {`All ${itemType} are hidden due to the applied filter.`}
); @@ -20,7 +23,7 @@ function NoAuthor(props) { return (
- No authors found, to get started you'll want to add a new author or book or add an existing library location (Root Folder) and update. + {`No ${itemType} found, to get started you'll want to add a new author or book or add an existing library location (Root Folder) and update.`}
@@ -45,7 +48,12 @@ function NoAuthor(props) { } NoAuthor.propTypes = { - totalItems: PropTypes.number.isRequired + totalItems: PropTypes.number.isRequired, + itemType: PropTypes.string.isRequired +}; + +NoAuthor.defaultProps = { + itemType: 'authors' }; export default NoAuthor; diff --git a/frontend/src/Book/BookNameLink.js b/frontend/src/Book/BookNameLink.js new file mode 100644 index 000000000..6ab61623c --- /dev/null +++ b/frontend/src/Book/BookNameLink.js @@ -0,0 +1,20 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Link from 'Components/Link/Link'; + +function BookNameLink({ titleSlug, title }) { + const link = `/book/${titleSlug}`; + + return ( + + {title} + + ); +} + +BookNameLink.propTypes = { + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired +}; + +export default BookNameLink; diff --git a/frontend/src/Book/Details/BookDetailsHeader.css b/frontend/src/Book/Details/BookDetailsHeader.css index f2db3cbcc..8cca0e49d 100644 --- a/frontend/src/Book/Details/BookDetailsHeader.css +++ b/frontend/src/Book/Details/BookDetailsHeader.css @@ -86,6 +86,7 @@ .duration { margin-right: 15px; + margin-left: 10px; } .detailsLabel { diff --git a/frontend/src/Book/Details/BookDetailsHeader.js b/frontend/src/Book/Details/BookDetailsHeader.js index c41d67fb1..dcf2168c0 100644 --- a/frontend/src/Book/Details/BookDetailsHeader.js +++ b/frontend/src/Book/Details/BookDetailsHeader.js @@ -133,6 +133,7 @@ class BookDetailsHeader extends Component {
+ {author.authorName} { !!pageCount && diff --git a/frontend/src/Book/Index/BookIndex.css b/frontend/src/Book/Index/BookIndex.css new file mode 100644 index 000000000..43b445c3c --- /dev/null +++ b/frontend/src/Book/Index/BookIndex.css @@ -0,0 +1,72 @@ +.pageContentBodyWrapper { + display: flex; + flex: 1 0 1px; + overflow: hidden; +} + +.errorMessage { + margin-top: 20px; + text-align: center; + font-size: 20px; +} + +.contentBody { + composes: contentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; +} + +.postersInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.bannersInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; + + /* 5px less padding than normal to handle poster's 5px margin */ + padding: calc($pageContentBodyPadding - 5px); +} + +.tableInnerContentBody { + composes: innerContentBody from '~Components/Page/PageContentBody.css'; + + display: flex; + flex-direction: column; + flex-grow: 1; +} + +.contentBodyContainer { + display: flex; + flex-direction: column; + flex-grow: 1; +} + +@media only screen and (max-width: $breakpointSmall) { + .pageContentBodyWrapper { + flex-basis: auto; + } + + .contentBody { + flex-basis: 1px; + } + + .postersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } + + .bannersInnerContentBody { + padding: calc($pageContentBodyPaddingSmallScreen - 5px); + } +} diff --git a/frontend/src/Book/Index/BookIndex.js b/frontend/src/Book/Index/BookIndex.js new file mode 100644 index 000000000..2e5aec413 --- /dev/null +++ b/frontend/src/Book/Index/BookIndex.js @@ -0,0 +1,378 @@ +import _ from 'lodash'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import NoAuthor from 'Author/NoAuthor'; +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 BookIndexFooterConnector from './BookIndexFooterConnector'; +import BookIndexFilterMenu from './Menus/BookIndexFilterMenu'; +import BookIndexSortMenu from './Menus/BookIndexSortMenu'; +import BookIndexViewMenu from './Menus/BookIndexViewMenu'; +import BookIndexOverviewsConnector from './Overview/BookIndexOverviewsConnector'; +import BookIndexOverviewOptionsModal from './Overview/Options/BookIndexOverviewOptionsModal'; +import BookIndexPostersConnector from './Posters/BookIndexPostersConnector'; +import BookIndexPosterOptionsModal from './Posters/Options/BookIndexPosterOptionsModal'; +import BookIndexTableConnector from './Table/BookIndexTableConnector'; +import BookIndexTableOptionsConnector from './Table/BookIndexTableOptionsConnector'; +import styles from './BookIndex.css'; + +function getViewComponent(view) { + if (view === 'posters') { + return BookIndexPostersConnector; + } + + if (view === 'overview') { + return BookIndexOverviewsConnector; + } + + return BookIndexTableConnector; +} + +class BookIndex extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scroller: null, + jumpBarItems: { order: [] }, + jumpToCharacter: null, + isPosterOptionsModalOpen: 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, + isPopulated + } = this.props; + + // Reset if not sorting by sortName + if (!isPopulated || (sortKey !== 'title' && sortKey !== 'authorTitle')) { + this.setState({ jumpBarItems: { order: [] } }); + return; + } + + const characters = _.reduce(items, (acc, item) => { + let char = item[sortKey].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 }); + } + + 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, + isRefreshingBook, + isRssSyncExecuting, + onScroll, + onSortSelect, + onFilterSelect, + onViewSelect, + onRefreshAuthorPress, + onRssSyncPress, + ...otherProps + } = this.props; + + const { + scroller, + jumpBarItems, + jumpToCharacter, + isPosterOptionsModalOpen, + isOverviewOptionsModalOpen + } = this.state; + + const ViewComponent = getViewComponent(view); + const isLoaded = !!(!error && isPopulated && items.length && scroller); + const hasNoAuthor = !totalItems; + + return ( + + + + + + + + + + + { + view === 'table' ? + + + : + null + } + + { + view === 'posters' ? + : + null + } + + { + view === 'overview' ? + : + null + } + + + + + + + + + + + +
+ + { + isFetching && !isPopulated && + + } + + { + !isFetching && !!error && +
+ {getErrorMessage(error, 'Failed to load books from API')} +
+ } + + { + isLoaded && +
+ + + +
+ } + + { + !error && isPopulated && !items.length && + + } +
+ + { + isLoaded && !!jumpBarItems.order.length && + + } +
+ + + + +
+ ); + } +} + +BookIndex.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, + isRefreshingBook: PropTypes.bool.isRequired, + isRssSyncExecuting: PropTypes.bool.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired, + onFilterSelect: PropTypes.func.isRequired, + onViewSelect: PropTypes.func.isRequired, + onRefreshAuthorPress: PropTypes.func.isRequired, + onRssSyncPress: PropTypes.func.isRequired, + onScroll: PropTypes.func.isRequired +}; + +export default BookIndex; diff --git a/frontend/src/Book/Index/BookIndexConnector.js b/frontend/src/Book/Index/BookIndexConnector.js new file mode 100644 index 000000000..c25ae2eeb --- /dev/null +++ b/frontend/src/Book/Index/BookIndexConnector.js @@ -0,0 +1,143 @@ +/* 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 { clearBooks, fetchBooks } from 'Store/Actions/bookActions'; +import { setBookFilter, setBookSort, setBookTableOption, setBookView } from 'Store/Actions/bookIndexActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import scrollPositions from 'Store/scrollPositions'; +import createBookClientSideCollectionItemsSelector from 'Store/Selectors/createBookClientSideCollectionItemsSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import BookIndex from './BookIndex'; + +function createMapStateToProps() { + return createSelector( + createBookClientSideCollectionItemsSelector('bookIndex'), + createCommandExecutingSelector(commandNames.REFRESH_AUTHOR), + createCommandExecutingSelector(commandNames.REFRESH_BOOK), + createCommandExecutingSelector(commandNames.RSS_SYNC), + createDimensionsSelector(), + ( + book, + isRefreshingAuthorCommand, + isRefreshingBookCommand, + isRssSyncExecuting, + dimensionsState + ) => { + const isRefreshingBook = isRefreshingBookCommand || isRefreshingAuthorCommand; + return { + ...book, + isRefreshingBook, + isRssSyncExecuting, + isSmallScreen: dimensionsState.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setBookTableOption(payload)); + }, + + onSortSelect(sortKey) { + dispatch(setBookSort({ sortKey })); + }, + + onFilterSelect(selectedFilterKey) { + dispatch(setBookFilter({ selectedFilterKey })); + }, + + dispatchSetBookView(view) { + dispatch(setBookView({ view })); + }, + + onRefreshAuthorPress() { + dispatch(executeCommand({ + name: commandNames.REFRESH_AUTHOR + })); + }, + + onRssSyncPress() { + dispatch(executeCommand({ + name: commandNames.RSS_SYNC + })); + }, + + dispatchFetchBooks() { + dispatch(fetchBooks()); + }, + + dispatchClearBooks() { + dispatch(clearBooks()); + } + }; +} + +class BookIndexConnector extends Component { + + // + // Lifecycle + + componentDidMount() { + this.populate(); + } + + componentWillUnmount() { + this.unpopulate(); + } + + // + // Control + + populate = () => { + this.props.dispatchFetchBooks(); + } + + unpopulate = () => { + this.props.dispatchClearBooks(); + } + + // + // Listeners + + onViewSelect = (view) => { + this.props.dispatchSetBookView(view); + } + + onScroll = ({ scrollTop }) => { + scrollPositions.bookIndex = scrollTop; + } + + // + // Render + + render() { + return ( + + ); + } +} + +BookIndexConnector.propTypes = { + isSmallScreen: PropTypes.bool.isRequired, + view: PropTypes.string.isRequired, + dispatchFetchBooks: PropTypes.func.isRequired, + dispatchClearBooks: PropTypes.func.isRequired, + dispatchSetBookView: PropTypes.func.isRequired +}; + +export default withScrollPosition( + connect(createMapStateToProps, createMapDispatchToProps)(BookIndexConnector), + 'bookIndex' +); + diff --git a/frontend/src/Book/Index/BookIndexFilterModalConnector.js b/frontend/src/Book/Index/BookIndexFilterModalConnector.js new file mode 100644 index 000000000..8c49158f7 --- /dev/null +++ b/frontend/src/Book/Index/BookIndexFilterModalConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import FilterModal from 'Components/Filter/FilterModal'; +import { setBookFilter } from 'Store/Actions/bookIndexActions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.books.items, + (state) => state.bookIndex.filterBuilderProps, + (sectionItems, filterBuilderProps) => { + return { + sectionItems, + filterBuilderProps, + customFilterType: 'bookIndex' + }; + } + ); +} + +const mapDispatchToProps = { + dispatchSetFilter: setBookFilter +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(FilterModal); diff --git a/frontend/src/Book/Index/BookIndexFooter.css b/frontend/src/Book/Index/BookIndexFooter.css new file mode 100644 index 000000000..71d0439b6 --- /dev/null +++ b/frontend/src/Book/Index/BookIndexFooter.css @@ -0,0 +1,74 @@ +.footer { + display: flex; + flex-wrap: wrap; + margin-top: 20px; + font-size: $smallFontSize; +} + +.legendItem { + display: flex; + margin-bottom: 4px; + line-height: 16px; +} + +.legendItemColor { + margin-right: 8px; + width: 30px; + height: 16px; + border-radius: 4px; +} + +.continuing { + composes: legendItemColor; + + background-color: $primaryColor; +} + +.ended { + composes: legendItemColor; + + background-color: $successColor; +} + +.missingMonitored { + composes: legendItemColor; + + background-color: $dangerColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(90deg, color($dangerColor shade(5%)), color($dangerColor shade(5%)) 5px, color($dangerColor shade(15%)) 5px, color($dangerColor shade(15%)) 10px); + } +} + +.missingUnmonitored { + composes: legendItemColor; + + background-color: $warningColor; + + &:global(.colorImpaired) { + background: repeating-linear-gradient(45deg, $warningColor, $warningColor 5px, color($warningColor tint(15%)) 5px, color($warningColor tint(15%)) 10px); + } +} + +.statistics { + display: flex; + justify-content: space-between; + flex-wrap: wrap; +} + +@media (max-width: $breakpointLarge) { + .statistics { + display: block; + } +} + +@media (max-width: $breakpointSmall) { + .footer { + display: block; + } + + .statistics { + display: flex; + margin-top: 20px; + } +} diff --git a/frontend/src/Book/Index/BookIndexFooter.js b/frontend/src/Book/Index/BookIndexFooter.js new file mode 100644 index 000000000..d5b64ea04 --- /dev/null +++ b/frontend/src/Book/Index/BookIndexFooter.js @@ -0,0 +1,167 @@ +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 './BookIndexFooter.css'; + +class BookIndexFooter extends PureComponent { + + // + // Render + + render() { + const { author } = this.props; + const count = author.length; + let books = 0; + let bookFiles = 0; + let ended = 0; + let continuing = 0; + let monitored = 0; + let totalFileSize = 0; + + author.forEach((s) => { + const { statistics = {} } = s; + + const { + bookCount = 0, + bookFileCount = 0, + sizeOnDisk = 0 + } = statistics; + + books += bookCount; + bookFiles += bookFileCount; + + if (s.status === 'ended') { + ended++; + } else { + continuing++; + } + + if (s.monitored) { + monitored++; + } + + totalFileSize += sizeOnDisk; + }); + + return ( + + {(enableColorImpairedMode) => { + return ( +
+
+
+
+
+ {translate('ContinuingAllBooksDownloaded')} +
+
+ +
+
+
+ {translate('EndedAllBooksDownloaded')} +
+
+ +
+
+
+ {translate('MissingBooksAuthorMonitored')} +
+
+ +
+
+
+ {translate('MissingBooksAuthorNotMonitored')} +
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+ ); + }} + + ); + } +} + +BookIndexFooter.propTypes = { + author: PropTypes.arrayOf(PropTypes.object).isRequired +}; + +export default BookIndexFooter; diff --git a/frontend/src/Book/Index/BookIndexFooterConnector.js b/frontend/src/Book/Index/BookIndexFooterConnector.js new file mode 100644 index 000000000..74fb5ffe4 --- /dev/null +++ b/frontend/src/Book/Index/BookIndexFooterConnector.js @@ -0,0 +1,46 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector'; +import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector'; +import BookIndexFooter from './BookIndexFooter'; + +function createUnoptimizedSelector() { + return createSelector( + createClientSideCollectionSelector('authors', 'authorIndex'), + (authors) => { + return authors.items.map((s) => { + const { + monitored, + status, + statistics + } = s; + + return { + monitored, + status, + statistics + }; + }); + } + ); +} + +function createAuthorSelector() { + return createDeepEqualSelector( + createUnoptimizedSelector(), + (author) => author + ); +} + +function createMapStateToProps() { + return createSelector( + createAuthorSelector(), + (author) => { + return { + author + }; + } + ); +} + +export default connect(createMapStateToProps)(BookIndexFooter); diff --git a/frontend/src/Book/Index/BookIndexItemConnector.js b/frontend/src/Book/Index/BookIndexItemConnector.js new file mode 100644 index 000000000..93a1c0ff1 --- /dev/null +++ b/frontend/src/Book/Index/BookIndexItemConnector.js @@ -0,0 +1,137 @@ +/* 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 { executeCommand } from 'Store/Actions/commandActions'; +import createBookQualityProfileSelector from 'Store/Selectors/createBookQualityProfileSelector'; +import createBookSelector from 'Store/Selectors/createBookSelector'; +import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector'; + +function selectShowSearchAction() { + return createSelector( + (state) => state.bookIndex, + (bookIndex) => { + const view = bookIndex.view; + + switch (view) { + case 'posters': + return bookIndex.posterOptions.showSearchAction; + case 'banners': + return bookIndex.bannerOptions.showSearchAction; + case 'overview': + return bookIndex.overviewOptions.showSearchAction; + default: + return bookIndex.tableOptions.showSearchAction; + } + } + ); +} + +function createMapStateToProps() { + return createSelector( + createBookSelector(), + createBookQualityProfileSelector(), + selectShowSearchAction(), + createExecutingCommandsSelector(), + ( + book, + qualityProfile, + showSearchAction, + executingCommands + ) => { + + // If an book is deleted this selector may fire before the parent + // selectors, which will result in an undefined book, if that happens + // we want to return early here and again in the render function to avoid + // trying to show an book that has no information available. + + if (!book) { + return {}; + } + + const isRefreshingBook = executingCommands.some((command) => { + return ( + (command.name === commandNames.REFRESH_AUTHOR && + command.body.authorId === book.author.id) || + (command.name === commandNames.REFRESH_BOOK && + command.body.bookId === book.id) + ); + }); + + const isSearchingBook = executingCommands.some((command) => { + return ( + (command.name === commandNames.AUTHOR_SEARCH && + command.body.authorId === book.author.id) || + (command.name === commandNames.BOOK_SEARCH && + command.body.bookIds.includes(book.id)) + ); + }); + + return { + ...book, + qualityProfile, + showSearchAction, + isRefreshingBook, + isSearchingBook + }; + } + ); +} + +const mapDispatchToProps = { + dispatchExecuteCommand: executeCommand +}; + +class BookIndexItemConnector extends Component { + + // + // Listeners + + onRefreshBookPress = () => { + this.props.dispatchExecuteCommand({ + name: commandNames.REFRESH_BOOK, + bookId: this.props.id + }); + } + + onSearchPress = () => { + this.props.dispatchExecuteCommand({ + name: commandNames.BOOK_SEARCH, + bookIds: [this.props.id] + }); + } + + // + // Render + + render() { + const { + id, + component: ItemComponent, + ...otherProps + } = this.props; + + if (!id) { + return null; + } + + return ( + + ); + } +} + +BookIndexItemConnector.propTypes = { + id: PropTypes.number, + component: PropTypes.elementType.isRequired, + dispatchExecuteCommand: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(BookIndexItemConnector); diff --git a/frontend/src/Book/Index/Menus/BookIndexFilterMenu.js b/frontend/src/Book/Index/Menus/BookIndexFilterMenu.js new file mode 100644 index 000000000..0700a1187 --- /dev/null +++ b/frontend/src/Book/Index/Menus/BookIndexFilterMenu.js @@ -0,0 +1,41 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import BookIndexFilterModalConnector from 'Book/Index/BookIndexFilterModalConnector'; +import FilterMenu from 'Components/Menu/FilterMenu'; +import { align } from 'Helpers/Props'; + +function BookIndexFilterMenu(props) { + const { + selectedFilterKey, + filters, + customFilters, + isDisabled, + onFilterSelect + } = props; + + return ( + + ); +} + +BookIndexFilterMenu.propTypes = { + 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 +}; + +BookIndexFilterMenu.defaultProps = { + showCustomFilters: false +}; + +export default BookIndexFilterMenu; diff --git a/frontend/src/Book/Index/Menus/BookIndexSortMenu.js b/frontend/src/Book/Index/Menus/BookIndexSortMenu.js new file mode 100644 index 000000000..6a2c4c598 --- /dev/null +++ b/frontend/src/Book/Index/Menus/BookIndexSortMenu.js @@ -0,0 +1,105 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MenuContent from 'Components/Menu/MenuContent'; +import SortMenu from 'Components/Menu/SortMenu'; +import SortMenuItem from 'Components/Menu/SortMenuItem'; +import { align, sortDirections } from 'Helpers/Props'; + +function BookIndexSortMenu(props) { + const { + sortKey, + sortDirection, + isDisabled, + onSortSelect + } = props; + + return ( + + + + Monitored/Status + + + + Title + + + + Author, Title + + + + Quality Profile + + + + Added + + + + Files + + + + Path + + + + Size on Disk + + + + ); +} + +BookIndexSortMenu.propTypes = { + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + isDisabled: PropTypes.bool.isRequired, + onSortSelect: PropTypes.func.isRequired +}; + +export default BookIndexSortMenu; diff --git a/frontend/src/Book/Index/Menus/BookIndexViewMenu.js b/frontend/src/Book/Index/Menus/BookIndexViewMenu.js new file mode 100644 index 000000000..2834cd789 --- /dev/null +++ b/frontend/src/Book/Index/Menus/BookIndexViewMenu.js @@ -0,0 +1,55 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenu from 'Components/Menu/ViewMenu'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import { align } from 'Helpers/Props'; + +function BookIndexViewMenu(props) { + const { + view, + isDisabled, + onViewSelect + } = props; + + return ( + + + + Table + + + + Posters + + + + Overview + + + + ); +} + +BookIndexViewMenu.propTypes = { + view: PropTypes.string.isRequired, + isDisabled: PropTypes.bool.isRequired, + onViewSelect: PropTypes.func.isRequired +}; + +export default BookIndexViewMenu; diff --git a/frontend/src/Book/Index/Overview/BookIndexOverview.css b/frontend/src/Book/Index/Overview/BookIndexOverview.css new file mode 100644 index 000000000..f2eb3d995 --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverview.css @@ -0,0 +1,99 @@ +$hoverScale: 1.05; + +.container { + &:hover { + .content { + background-color: $tableRowHoverBackgroundColor; + } + } +} + +.content { + display: flex; + flex-grow: 1; +} + +.poster { + position: absolute; + top: 0; + left: 0; +} + +.posterContainer { + position: relative; + overflow: hidden; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + display: block; + color: $defaultColor; + + &:hover { + color: $defaultColor; + text-decoration: none; + } +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.info { + display: flex; + flex: 1 0 1px; + flex-direction: column; + overflow: hidden; + padding-left: 10px; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + margin-bottom: 10px; + line-height: 32px; +} + +.title { + @add-mixin truncate; + composes: link; + + flex: 1 0 1px; + font-weight: 300; + font-size: 30px; +} + +.actions { + white-space: nowrap; +} + +.details { + display: flex; + justify-content: space-between; + flex: 1 0 auto; +} + +.overview { + composes: link; + + flex: 0 1 1000px; + overflow: hidden; + min-height: 0; +} + +@media only screen and (max-width: $breakpointSmall) { + .overview { + display: none; + } +} diff --git a/frontend/src/Book/Index/Overview/BookIndexOverview.js b/frontend/src/Book/Index/Overview/BookIndexOverview.js new file mode 100644 index 000000000..94ed20ace --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverview.js @@ -0,0 +1,278 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import AuthorPoster from 'Author/AuthorPoster'; +import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal'; +import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; +import BookIndexProgressBar from 'Book/Index/ProgressBar/BookIndexProgressBar'; +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 stripHtml from 'Utilities/String/stripHtml'; +import translate from 'Utilities/String/translate'; +import BookIndexOverviewInfo from './BookIndexOverviewInfo'; +import styles from './BookIndexOverview.css'; + +const columnPadding = parseInt(dimensions.authorIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.authorIndexColumnPaddingSmallScreen); +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 BookIndexOverview extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: false + }; + } + + // + // Listeners + + onEditAuthorPress = () => { + this.setState({ isEditAuthorModalOpen: true }); + } + + onEditAuthorModalClose = () => { + this.setState({ isEditAuthorModalOpen: false }); + } + + onDeleteAuthorPress = () => { + this.setState({ + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: true + }); + } + + onDeleteAuthorModalClose = () => { + this.setState({ isDeleteAuthorModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + title, + overview, + monitored, + titleSlug, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + qualityProfile, + overviewOptions, + showSearchAction, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + rowHeight, + isSmallScreen, + isRefreshingBook, + isSearchingBook, + onRefreshBookPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + bookCount, + sizeOnDisk, + bookFileCount, + totalBookCount + } = statistics; + + const { + isEditAuthorModalOpen, + isDeleteAuthorModalOpen + } = this.state; + + const link = `/book/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + objectFit: 'contain' + }; + + const contentHeight = getContentHeight(rowHeight, isSmallScreen); + const overviewHeight = contentHeight - titleRowHeight; + + return ( +
+
+
+ { + status === 'ended' && +
+ } + + + + + + +
+ +
+
+ + {title} + + +
+ + + { + showSearchAction && + + } + + +
+
+ +
+ + + + + + +
+
+
+ + + + +
+ ); + } +} + +BookIndexOverview.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + overview: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + titleSlug: 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, + isRefreshingBook: PropTypes.bool.isRequired, + isSearchingBook: PropTypes.bool.isRequired, + onRefreshBookPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +BookIndexOverview.defaultProps = { + statistics: { + bookCount: 0, + bookFileCount: 0, + totalBookCount: 0 + } +}; + +export default BookIndexOverview; diff --git a/frontend/src/Book/Index/Overview/BookIndexOverviewInfo.css b/frontend/src/Book/Index/Overview/BookIndexOverviewInfo.css new file mode 100644 index 000000000..5dc53762f --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverviewInfo.css @@ -0,0 +1,12 @@ +.infos { + display: flex; + flex: 0 0 250px; + flex-direction: column; + margin-left: 10px; +} + +@media only screen and (max-width: $breakpointSmall) { + .infos { + margin-left: 0; + } +} diff --git a/frontend/src/Book/Index/Overview/BookIndexOverviewInfo.js b/frontend/src/Book/Index/Overview/BookIndexOverviewInfo.js new file mode 100644 index 000000000..196cebf40 --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverviewInfo.js @@ -0,0 +1,205 @@ +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 BookIndexOverviewInfoRow from './BookIndexOverviewInfoRow'; +import styles from './BookIndexOverviewInfo.css'; + +const infoRowHeight = parseInt(dimensions.authorIndexOverviewInfoRowHeight); + +const rows = [ + { + name: 'monitored', + showProp: 'showMonitored', + valueProp: 'monitored' + + }, + { + name: 'qualityProfileId', + showProp: 'showQualityProfile', + valueProp: 'qualityProfile' + }, + { + name: 'releaseDate', + showProp: 'showReleaseDate', + valueProp: 'releaseDate' + }, + { + name: 'added', + showProp: 'showAdded', + valueProp: 'added' + }, + { + name: 'path', + showProp: 'showPath', + valueProp: 'author' + }, + { + 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 === 'releaseDate') { + const { + releaseDate, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat + } = props; + + return { + title: `ReleaseDate: ${formatDateTime(releaseDate, longDateFormat, timeFormat)}`, + iconName: icons.CALENDAR, + label: getRelativeDate( + 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 === 'path') { + return { + title: 'Path', + iconName: icons.FOLDER, + label: props.author.path + }; + } + + if (name === 'sizeOnDisk') { + return { + title: 'Size on Disk', + iconName: icons.DRIVE, + label: formatBytes(props.sizeOnDisk) + }; + } +} + +function BookIndexOverviewInfo(props) { + const { + height + } = props; + + let shownRows = 1; + + const maxRows = Math.floor(height / (infoRowHeight + 4)); + + return ( +
+ { + rows.map((row) => { + if (!isVisible(row, props)) { + return null; + } + + if (shownRows >= maxRows) { + return null; + } + + shownRows++; + + const infoRowProps = getInfoRowProps(row, props); + + return ( + + ); + }) + } +
+ ); +} + +BookIndexOverviewInfo.propTypes = { + height: PropTypes.number.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showAdded: PropTypes.bool.isRequired, + showReleaseDate: PropTypes.bool.isRequired, + showPath: PropTypes.bool.isRequired, + showSizeOnDisk: PropTypes.bool.isRequired, + monitored: PropTypes.bool.isRequired, + qualityProfile: PropTypes.object.isRequired, + author: PropTypes.object.isRequired, + releaseDate: PropTypes.string, + added: PropTypes.string, + 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 BookIndexOverviewInfo; diff --git a/frontend/src/Book/Index/Overview/BookIndexOverviewInfoRow.css b/frontend/src/Book/Index/Overview/BookIndexOverviewInfoRow.css new file mode 100644 index 000000000..a2d94a632 --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverviewInfoRow.css @@ -0,0 +1,10 @@ +.infoRow { + flex: 0 0 $authorIndexOverviewInfoRowHeight; + margin: 2px 0; +} + +.icon { + margin-right: 5px; + width: 25px !important; + text-align: center; +} diff --git a/frontend/src/Book/Index/Overview/BookIndexOverviewInfoRow.js b/frontend/src/Book/Index/Overview/BookIndexOverviewInfoRow.js new file mode 100644 index 000000000..aef905315 --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverviewInfoRow.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import styles from './BookIndexOverviewInfoRow.css'; + +function BookIndexOverviewInfoRow(props) { + const { + title, + iconName, + label + } = props; + + return ( +
+ + + {label} +
+ ); +} + +BookIndexOverviewInfoRow.propTypes = { + title: PropTypes.string, + iconName: PropTypes.object.isRequired, + label: PropTypes.string.isRequired +}; + +export default BookIndexOverviewInfoRow; diff --git a/frontend/src/Book/Index/Overview/BookIndexOverviews.css b/frontend/src/Book/Index/Overview/BookIndexOverviews.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverviews.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Book/Index/Overview/BookIndexOverviews.js b/frontend/src/Book/Index/Overview/BookIndexOverviews.js new file mode 100644 index 000000000..63ce7ab91 --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverviews.js @@ -0,0 +1,269 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid, WindowScroller } from 'react-virtualized'; +import BookIndexItemConnector from 'Book/Index/BookIndexItemConnector'; +import Measure from 'Components/Measure'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import BookIndexOverview from './BookIndexOverview'; +import styles from './BookIndexOverviews.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.authorIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.authorIndexColumnPaddingSmallScreen); +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 Math.ceil((400 / 256) * posterWidth); +} + +class BookIndexOverviews extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnCount: 1, + posterWidth: 162, + posterHeight: 253, + rowHeight: calculateRowHeight(253, null, props.isSmallScreen, {}), + scrollRestored: false + }; + + this._grid = null; + } + + componentDidUpdate(prevProps, prevState) { + const { + items, + sortKey, + overviewOptions, + jumpToCharacter, + scrollTop + } = this.props; + + const { + width, + rowHeight, + scrollRestored + } = this.state; + + if (prevProps.sortKey !== sortKey || + prevProps.overviewOptions !== overviewOptions) { + this.calculateGrid(); + } + + 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, sortKey, 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 book = items[rowIndex]; + + if (!book) { + 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 ( +
+ +
+ ); + } + } + + + ); + } +} + +BookIndexOverviews.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 BookIndexOverviews; diff --git a/frontend/src/Book/Index/Overview/BookIndexOverviewsConnector.js b/frontend/src/Book/Index/Overview/BookIndexOverviewsConnector.js new file mode 100644 index 000000000..49657bf08 --- /dev/null +++ b/frontend/src/Book/Index/Overview/BookIndexOverviewsConnector.js @@ -0,0 +1,25 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import BookIndexOverviews from './BookIndexOverviews'; + +function createMapStateToProps() { + return createSelector( + (state) => state.authorIndex.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)(BookIndexOverviews); diff --git a/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModal.js b/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModal.js new file mode 100644 index 000000000..ba5af86b7 --- /dev/null +++ b/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import BookIndexOverviewOptionsModalContentConnector from './BookIndexOverviewOptionsModalContentConnector'; + +function BookIndexOverviewOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +BookIndexOverviewOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default BookIndexOverviewOptionsModal; diff --git a/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModalContent.js b/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModalContent.js new file mode 100644 index 000000000..21cfcb8ab --- /dev/null +++ b/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModalContent.js @@ -0,0 +1,287 @@ +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 BookIndexOverviewOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showReleaseDate: props.showReleaseDate, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showAdded: props.showAdded, + showPath: props.showPath, + showSizeOnDisk: props.showSizeOnDisk, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showReleaseDate, + showMonitored, + showQualityProfile, + showAdded, + showPath, + showSizeOnDisk, + showSearchAction + } = this.props; + + const state = {}; + + if (detailedProgressBar !== prevProps.detailedProgressBar) { + state.detailedProgressBar = detailedProgressBar; + } + + if (size !== prevProps.size) { + state.size = size; + } + + if (showReleaseDate !== prevProps.showReleaseDate) { + state.showReleaseDate = showReleaseDate; + } + + if (showMonitored !== prevProps.showMonitored) { + state.showMonitored = showMonitored; + } + + if (showQualityProfile !== prevProps.showQualityProfile) { + state.showQualityProfile = showQualityProfile; + } + + if (showAdded !== prevProps.showAdded) { + state.showAdded = showAdded; + } + + 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, + showReleaseDate, + showMonitored, + showQualityProfile, + showAdded, + showPath, + showSizeOnDisk, + showSearchAction + } = this.state; + + return ( + + + Overview Options + + + +
+ + + {translate('PosterSize')} + + + + + + + + {translate('DetailedProgressBar')} + + + + + + + + {translate('ShowReleaseDate')} + + + + + + + + {translate('ShowMonitored')} + + + + + + + + + {translate('ShowQualityProfile')} + + + + + + + + {translate('ShowDateAdded')} + + + + + + + + {translate('ShowPath')} + + + + + + + + {translate('ShowSizeOnDisk')} + + + + + + + + {translate('ShowSearch')} + + + + +
+
+ + + + +
+ ); + } +} + +BookIndexOverviewOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + showReleaseDate: PropTypes.bool.isRequired, + showAdded: 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 BookIndexOverviewOptionsModalContent; diff --git a/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModalContentConnector.js b/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModalContentConnector.js new file mode 100644 index 000000000..674255cc0 --- /dev/null +++ b/frontend/src/Book/Index/Overview/Options/BookIndexOverviewOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setAuthorOverviewOption } from 'Store/Actions/authorIndexActions'; +import BookIndexOverviewOptionsModalContent from './BookIndexOverviewOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.authorIndex, + (authorIndex) => { + return authorIndex.overviewOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangeOverviewOption(payload) { + dispatch(setAuthorOverviewOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BookIndexOverviewOptionsModalContent); diff --git a/frontend/src/Book/Index/Posters/BookIndexPoster.css b/frontend/src/Book/Index/Posters/BookIndexPoster.css new file mode 100644 index 000000000..50dfce8d0 --- /dev/null +++ b/frontend/src/Book/Index/Posters/BookIndexPoster.css @@ -0,0 +1,106 @@ +$hoverScale: 1.05; + +.content { + transition: all 200ms ease-in; + + &:hover { + z-index: 2; + box-shadow: 0 0 12px $black; + transition: all 200ms ease-in; + + .controls { + opacity: 0.9; + transition: opacity 200ms linear 150ms; + } + } +} + +.posterContainer { + position: relative; + overflow: hidden; +} + +.poster { + position: absolute; + top: 0; + left: 0; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.nextAiring { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} + +.title { + @add-mixin truncate; + + background-color: $defaultColor; + color: $white; + text-align: center; + font-size: $smallFontSize; +} + +.ended { + position: absolute; + top: 0; + right: 0; + z-index: 1; + width: 0; + height: 0; + border-width: 0 25px 25px 0; + border-style: solid; + border-color: transparent $dangerColor transparent transparent; + color: $white; +} + +.controls { + position: absolute; + bottom: 10px; + left: 10px; + z-index: 3; + border-radius: 4px; + background-color: $themeLightColor; + color: $white; + font-size: $smallFontSize; + opacity: 0; + transition: opacity 0; +} + +.action { + composes: button from '~Components/Link/IconButton.css'; + + &:hover { + color: #ccc; + } +} + +@media only screen and (max-width: $breakpointSmall) { + .container { + padding: 5px; + } +} diff --git a/frontend/src/Book/Index/Posters/BookIndexPoster.js b/frontend/src/Book/Index/Posters/BookIndexPoster.js new file mode 100644 index 000000000..7f0be2943 --- /dev/null +++ b/frontend/src/Book/Index/Posters/BookIndexPoster.js @@ -0,0 +1,323 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import AuthorPoster from 'Author/AuthorPoster'; +import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal'; +import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; +import EditBookModalConnector from 'Book/Edit/EditBookModalConnector'; +import BookIndexProgressBar from 'Book/Index/ProgressBar/BookIndexProgressBar'; +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 BookIndexPosterInfo from './BookIndexPosterInfo'; +import styles from './BookIndexPoster.css'; + +class BookIndexPoster extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasPosterError: false, + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: false, + isEditBookModalOpen: false + }; + } + + // + // Listeners + + onEditAuthorPress = () => { + this.setState({ isEditAuthorModalOpen: true }); + } + + onEditAuthorModalClose = () => { + this.setState({ isEditAuthorModalOpen: false }); + } + + onDeleteAuthorPress = () => { + this.setState({ + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: true + }); + } + + onDeleteAuthorModalClose = () => { + this.setState({ isDeleteAuthorModalOpen: false }); + } + + onEditBookPress = () => { + this.setState({ isEditBookModalOpen: true }); + } + + onEditBookModalClose = () => { + this.setState({ isEditBookModalOpen: false }); + } + + onPosterLoad = () => { + if (this.state.hasPosterError) { + this.setState({ hasPosterError: false }); + } + } + + onPosterLoadError = () => { + if (!this.state.hasPosterError) { + this.setState({ hasPosterError: true }); + } + } + + // + // Render + + render() { + const { + id, + title, + authorId, + author, + monitored, + titleSlug, + nextAiring, + statistics, + images, + posterWidth, + posterHeight, + detailedProgressBar, + showTitle, + showAuthor, + showMonitored, + showQualityProfile, + qualityProfile, + showSearchAction, + showRelativeDates, + shortDateFormat, + timeFormat, + isRefreshingBook, + isSearchingBook, + onRefreshBookPress, + onSearchPress, + ...otherProps + } = this.props; + + const { + bookCount, + sizeOnDisk, + bookFileCount, + totalBookCount + } = statistics; + + const { + hasPosterError, + isEditAuthorModalOpen, + isDeleteAuthorModalOpen, + isEditBookModalOpen + } = this.state; + + const link = `/book/${titleSlug}`; + + const elementStyle = { + width: `${posterWidth}px`, + height: `${posterHeight}px`, + objectFit: 'contain' + }; + + return ( +
+
+
+ + + + + + { + hasPosterError && +
+ {title} +
+ } + + +
+ + + + { + showTitle && +
+ {title} +
+ } + + { + showAuthor && +
+ {author.authorName} +
+ } + + { + showMonitored && +
+ {monitored ? 'Monitored' : 'Unmonitored'} +
+ } + + { + showQualityProfile && +
+ {qualityProfile.name} +
+ } + { + nextAiring && +
+ { + getRelativeDate( + nextAiring, + shortDateFormat, + showRelativeDates, + { + timeFormat, + timeForToday: true + } + ) + } +
+ } + + + + + + + +
+
+ ); + } +} + +BookIndexPoster.propTypes = { + id: PropTypes.number.isRequired, + title: PropTypes.string.isRequired, + authorId: PropTypes.number.isRequired, + author: PropTypes.object.isRequired, + monitored: PropTypes.bool.isRequired, + titleSlug: PropTypes.string.isRequired, + nextAiring: PropTypes.string, + 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, + showAuthor: 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, + isRefreshingBook: PropTypes.bool.isRequired, + isSearchingBook: PropTypes.bool.isRequired, + onRefreshBookPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +BookIndexPoster.defaultProps = { + statistics: { + bookCount: 0, + bookFileCount: 0, + totalBookCount: 0 + } +}; + +export default BookIndexPoster; diff --git a/frontend/src/Book/Index/Posters/BookIndexPosterInfo.css b/frontend/src/Book/Index/Posters/BookIndexPosterInfo.css new file mode 100644 index 000000000..aab27d827 --- /dev/null +++ b/frontend/src/Book/Index/Posters/BookIndexPosterInfo.css @@ -0,0 +1,5 @@ +.info { + background-color: #fafbfc; + text-align: center; + font-size: $smallFontSize; +} diff --git a/frontend/src/Book/Index/Posters/BookIndexPosterInfo.js b/frontend/src/Book/Index/Posters/BookIndexPosterInfo.js new file mode 100644 index 000000000..59a5deecf --- /dev/null +++ b/frontend/src/Book/Index/Posters/BookIndexPosterInfo.js @@ -0,0 +1,115 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import formatBytes from 'Utilities/Number/formatBytes'; +import styles from './BookIndexPosterInfo.css'; + +function BookIndexPosterInfo(props) { + const { + qualityProfile, + showQualityProfile, + previousAiring, + added, + author, + bookFileCount, + 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 === 'bookFileCount') { + let books = '1 file'; + + if (bookFileCount === 0) { + books = 'No files'; + } else if (bookFileCount > 1) { + books = `${bookFileCount} files`; + } + + return ( +
+ {books} +
+ ); + } + + if (sortKey === 'path') { + return ( +
+ {author.path} +
+ ); + } + + if (sortKey === 'sizeOnDisk') { + return ( +
+ {formatBytes(sizeOnDisk)} +
+ ); + } + + return null; +} + +BookIndexPosterInfo.propTypes = { + qualityProfile: PropTypes.object.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + previousAiring: PropTypes.string, + author: PropTypes.object.isRequired, + added: PropTypes.string, + bookFileCount: PropTypes.number.isRequired, + sizeOnDisk: PropTypes.number, + sortKey: PropTypes.string.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default BookIndexPosterInfo; diff --git a/frontend/src/Book/Index/Posters/BookIndexPosters.css b/frontend/src/Book/Index/Posters/BookIndexPosters.css new file mode 100644 index 000000000..9c6520fb5 --- /dev/null +++ b/frontend/src/Book/Index/Posters/BookIndexPosters.css @@ -0,0 +1,3 @@ +.grid { + flex: 1 0 auto; +} diff --git a/frontend/src/Book/Index/Posters/BookIndexPosters.js b/frontend/src/Book/Index/Posters/BookIndexPosters.js new file mode 100644 index 000000000..08bbdfdc2 --- /dev/null +++ b/frontend/src/Book/Index/Posters/BookIndexPosters.js @@ -0,0 +1,339 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid, WindowScroller } from 'react-virtualized'; +import BookIndexItemConnector from 'Book/Index/BookIndexItemConnector'; +import Measure from 'Components/Measure'; +import dimensions from 'Styles/Variables/dimensions'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import BookIndexPoster from './BookIndexPoster'; +import styles from './BookIndexPosters.css'; + +// Poster container dimensions +const columnPadding = parseInt(dimensions.authorIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.authorIndexColumnPaddingSmallScreen); +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, + showAuthor, + showMonitored, + showQualityProfile + } = posterOptions; + + const nextAiringHeight = 19; + + const heights = [ + posterHeight, + detailedProgressBar ? detailedProgressBarHeight : progressBarHeight, + nextAiringHeight, + isSmallScreen ? columnPaddingSmallScreen : columnPadding + ]; + + if (showTitle) { + heights.push(19); + } + + if (showAuthor) { + 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 calculatePosterHeight(posterWidth) { + return Math.ceil((400 / 256) * posterWidth); +} + +class BookIndexPosters extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnWidth: 182, + columnCount: 1, + posterWidth: 162, + posterHeight: 253, + rowHeight: calculateRowHeight(253, 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, + isSmallScreen, + scrollTop + } = 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, sortKey, 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, + timeFormat + } = this.props; + + const { + posterWidth, + posterHeight, + columnCount + } = this.state; + + const { + detailedProgressBar, + showTitle, + showAuthor, + showMonitored, + showQualityProfile + } = posterOptions; + + const bookIdx = rowIndex * columnCount + columnIndex; + const book = items[bookIdx]; + + if (!book) { + 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 ( +
+ +
+ ); + } + } + + + ); + } +} + +BookIndexPosters.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, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired +}; + +export default BookIndexPosters; diff --git a/frontend/src/Book/Index/Posters/BookIndexPostersConnector.js b/frontend/src/Book/Index/Posters/BookIndexPostersConnector.js new file mode 100644 index 000000000..b266dec74 --- /dev/null +++ b/frontend/src/Book/Index/Posters/BookIndexPostersConnector.js @@ -0,0 +1,24 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import BookIndexPosters from './BookIndexPosters'; + +function createMapStateToProps() { + return createSelector( + (state) => state.bookIndex.posterOptions, + createUISettingsSelector(), + createDimensionsSelector(), + (posterOptions, uiSettings, dimensions) => { + return { + posterOptions, + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +export default connect(createMapStateToProps)(BookIndexPosters); diff --git a/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModal.js b/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModal.js new file mode 100644 index 000000000..12bc07375 --- /dev/null +++ b/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModal.js @@ -0,0 +1,25 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import BookIndexPosterOptionsModalContentConnector from './BookIndexPosterOptionsModalContentConnector'; + +function BookIndexPosterOptionsModal({ isOpen, onModalClose, ...otherProps }) { + return ( + + + + ); +} + +BookIndexPosterOptionsModal.propTypes = { + isOpen: PropTypes.bool.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default BookIndexPosterOptionsModal; diff --git a/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModalContent.js b/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModalContent.js new file mode 100644 index 000000000..7f0ad8ad3 --- /dev/null +++ b/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModalContent.js @@ -0,0 +1,248 @@ +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 BookIndexPosterOptionsModalContent extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + detailedProgressBar: props.detailedProgressBar, + size: props.size, + showTitle: props.showTitle, + showAuthor: props.showAuthor, + showMonitored: props.showMonitored, + showQualityProfile: props.showQualityProfile, + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + detailedProgressBar, + size, + showTitle, + showAuthor, + 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 (showAuthor !== prevProps.showAuthor) { + state.showAuthor = showAuthor; + } + + 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 + + onChangePosterOption = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onChangePosterOption({ [name]: value }); + }); + } + + // + // Render + + render() { + const { + onModalClose + } = this.props; + + const { + detailedProgressBar, + size, + showTitle, + showAuthor, + showMonitored, + showQualityProfile, + showSearchAction + } = this.state; + + return ( + + + Poster Options + + + +
+ + + {translate('PosterSize')} + + + + + + + + {translate('DetailedProgressBar')} + + + + + + + + {translate('ShowTitle')} + + + + + + + + {translate('ShowName')} + + + + + + + + {translate('ShowMonitored')} + + + + + + + + {translate('ShowQualityProfile')} + + + + + + + + {translate('ShowSearch')} + + + + +
+
+ + + + +
+ ); + } +} + +BookIndexPosterOptionsModalContent.propTypes = { + size: PropTypes.string.isRequired, + showTitle: PropTypes.bool.isRequired, + showAuthor: PropTypes.bool.isRequired, + showMonitored: PropTypes.bool.isRequired, + showQualityProfile: PropTypes.bool.isRequired, + detailedProgressBar: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onChangePosterOption: PropTypes.func.isRequired, + onModalClose: PropTypes.func.isRequired +}; + +export default BookIndexPosterOptionsModalContent; diff --git a/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModalContentConnector.js b/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModalContentConnector.js new file mode 100644 index 000000000..28572f1ee --- /dev/null +++ b/frontend/src/Book/Index/Posters/Options/BookIndexPosterOptionsModalContentConnector.js @@ -0,0 +1,23 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setBookPosterOption } from 'Store/Actions/bookIndexActions'; +import BookIndexPosterOptionsModalContent from './BookIndexPosterOptionsModalContent'; + +function createMapStateToProps() { + return createSelector( + (state) => state.bookIndex, + (bookIndex) => { + return bookIndex.posterOptions; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onChangePosterOption(payload) { + dispatch(setBookPosterOption(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BookIndexPosterOptionsModalContent); diff --git a/frontend/src/Book/Index/ProgressBar/BookIndexProgressBar.css b/frontend/src/Book/Index/ProgressBar/BookIndexProgressBar.css new file mode 100644 index 000000000..b98bb33d5 --- /dev/null +++ b/frontend/src/Book/Index/ProgressBar/BookIndexProgressBar.css @@ -0,0 +1,14 @@ +.progress { + composes: container from '~Components/ProgressBar.css'; + + border-radius: 0; + background-color: #5b5b5b; + color: $white; + transition: width 200ms ease; +} + +.progressBar { + composes: progressBar from '~Components/ProgressBar.css'; + + transition: width 200ms ease; +} diff --git a/frontend/src/Book/Index/ProgressBar/BookIndexProgressBar.js b/frontend/src/Book/Index/ProgressBar/BookIndexProgressBar.js new file mode 100644 index 000000000..7f6ba8829 --- /dev/null +++ b/frontend/src/Book/Index/ProgressBar/BookIndexProgressBar.js @@ -0,0 +1,46 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import ProgressBar from 'Components/ProgressBar'; +import { sizes } from 'Helpers/Props'; +import getProgressBarKind from 'Utilities/Author/getProgressBarKind'; +import translate from 'Utilities/String/translate'; +import styles from './BookIndexProgressBar.css'; + +function BookIndexProgressBar(props) { + const { + monitored, + bookCount, + bookFileCount, + totalBookCount, + posterWidth, + detailedProgressBar + } = props; + + const progress = bookCount ? bookFileCount / bookCount * 100 : 100; + const text = `${bookFileCount} / ${bookCount}`; + + return ( + + ); +} + +BookIndexProgressBar.propTypes = { + monitored: PropTypes.bool.isRequired, + bookCount: PropTypes.number.isRequired, + bookFileCount: PropTypes.number.isRequired, + totalBookCount: PropTypes.number.isRequired, + posterWidth: PropTypes.number.isRequired, + detailedProgressBar: PropTypes.bool.isRequired +}; + +export default BookIndexProgressBar; diff --git a/frontend/src/Book/Index/Table/BookIndexActionsCell.js b/frontend/src/Book/Index/Table/BookIndexActionsCell.js new file mode 100644 index 000000000..3bc2d0106 --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexActionsCell.js @@ -0,0 +1,103 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal'; +import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; +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 BookIndexActionsCell extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: false + }; + } + + // + // Listeners + + onEditAuthorPress = () => { + this.setState({ isEditAuthorModalOpen: true }); + } + + onEditAuthorModalClose = () => { + this.setState({ isEditAuthorModalOpen: false }); + } + + onDeleteAuthorPress = () => { + this.setState({ + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: true + }); + } + + onDeleteAuthorModalClose = () => { + this.setState({ isDeleteAuthorModalOpen: false }); + } + + // + // Render + + render() { + const { + id, + isRefreshingAuthor, + onRefreshAuthorPress, + ...otherProps + } = this.props; + + const { + isEditAuthorModalOpen, + isDeleteAuthorModalOpen + } = this.state; + + return ( + + + + + + + + + + ); + } +} + +BookIndexActionsCell.propTypes = { + id: PropTypes.number.isRequired, + isRefreshingAuthor: PropTypes.bool.isRequired, + onRefreshAuthorPress: PropTypes.func.isRequired +}; + +export default BookIndexActionsCell; diff --git a/frontend/src/Book/Index/Table/BookIndexHeader.css b/frontend/src/Book/Index/Table/BookIndexHeader.css new file mode 100644 index 000000000..d2229e76e --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexHeader.css @@ -0,0 +1,63 @@ +.status { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 30px; +} + +.title { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 4 0 110px; +} + +.authorName { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 110px; +} + +.bookFileCount, +.qualityProfileId, +.metadataProfileId { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 125px; +} + +.releaseDate, +.added, +.genres { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 180px; +} + +.path { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 1 0 60px; +} + +.actions { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 1 90px; +} diff --git a/frontend/src/Book/Index/Table/BookIndexHeader.js b/frontend/src/Book/Index/Table/BookIndexHeader.js new file mode 100644 index 000000000..d15f99458 --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexHeader.js @@ -0,0 +1,81 @@ +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 BookIndexTableOptionsConnector from './BookIndexTableOptionsConnector'; +import styles from './BookIndexHeader.css'; + +function BookIndexHeader(props) { + const { + columns, + onTableOptionChange, + ...otherProps + } = props; + + return ( + + { + columns.map((column) => { + const { + name, + label, + isSortable, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'actions') { + return ( + + + + + + + ); + } + + return ( + + {label} + + ); + }) + } + + ); +} + +BookIndexHeader.propTypes = { + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default BookIndexHeader; diff --git a/frontend/src/Book/Index/Table/BookIndexHeaderConnector.js b/frontend/src/Book/Index/Table/BookIndexHeaderConnector.js new file mode 100644 index 000000000..9b722f327 --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexHeaderConnector.js @@ -0,0 +1,13 @@ +import { connect } from 'react-redux'; +import { setBookTableOption } from 'Store/Actions/bookIndexActions'; +import BookIndexHeader from './BookIndexHeader'; + +function createMapDispatchToProps(dispatch, props) { + return { + onTableOptionChange(payload) { + dispatch(setBookTableOption(payload)); + } + }; +} + +export default connect(undefined, createMapDispatchToProps)(BookIndexHeader); diff --git a/frontend/src/Book/Index/Table/BookIndexRow.css b/frontend/src/Book/Index/Table/BookIndexRow.css new file mode 100644 index 000000000..3cfbd9a5b --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexRow.css @@ -0,0 +1,115 @@ +.cell { + composes: cell from '~Components/Table/Cells/VirtualTableRowCell.css'; + + display: flex; + align-items: center; +} + +.status { + composes: cell; + + flex: 0 0 30px; +} + +.title { + composes: cell; + + flex: 4 0 110px; +} + +.authorName { + composes: cell; + + flex: 1 0 110px; +} + +.link { + composes: link from '~Components/Link/Link.css'; + + position: relative; + display: block; + height: 70px; + background-color: $defaultColor; +} + +.bannerImage { + width: 379px; + height: 70px; +} + +.overlayTitle { + position: absolute; + top: 0; + left: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 5px; + width: 100%; + height: 100%; + color: $offWhite; + text-align: center; + font-size: 20px; +} + +.bookFileCount, +.qualityProfileId, +.metadataProfileId { + composes: cell; + + flex: 1 0 125px; +} + +.releaseDate, +.added, +.genres { + composes: cell; + + flex: 0 0 180px; +} + +.bookProgress { + composes: cell; + + display: flex; + justify-content: center; + flex: 0 0 150px; + flex-direction: column; +} + +.path { + composes: cell; + + flex: 1 0 150px; +} + +.sizeOnDisk { + composes: cell; + + flex: 0 0 120px; +} + +.ratings { + composes: headerCell from '~Components/Table/VirtualTableHeaderCell.css'; + + flex: 0 0 80px; +} + +.tags { + composes: cell; + + flex: 1 0 60px; +} + +.actions { + composes: cell; + + flex: 0 1 90px; + min-width: 60px; +} + +.checkInput { + composes: input from '~Components/Form/CheckInput.css'; + + margin-top: 0; +} diff --git a/frontend/src/Book/Index/Table/BookIndexRow.js b/frontend/src/Book/Index/Table/BookIndexRow.js new file mode 100644 index 000000000..21e0e590e --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexRow.js @@ -0,0 +1,384 @@ +import classNames from 'classnames'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import AuthorNameLink from 'Author/AuthorNameLink'; +import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal'; +import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; +import BookNameLink from 'Book/BookNameLink'; +import EditBookModalConnector from 'Book/Edit/EditBookModalConnector'; +import HeartRating from 'Components/HeartRating'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +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 formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import BookStatusCell from './BookStatusCell'; +import styles from './BookIndexRow.css'; + +class BookIndexRow extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + hasBannerError: false, + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: false, + isEditBookModalOpen: false + }; + } + + onEditAuthorPress = () => { + this.setState({ isEditAuthorModalOpen: true }); + } + + onEditAuthorModalClose = () => { + this.setState({ isEditAuthorModalOpen: false }); + } + + onDeleteAuthorPress = () => { + this.setState({ + isEditAuthorModalOpen: false, + isDeleteAuthorModalOpen: true + }); + } + + onDeleteAuthorModalClose = () => { + this.setState({ isDeleteAuthorModalOpen: false }); + } + + onEditBookPress = () => { + this.setState({ isEditBookModalOpen: true }); + } + + onEditBookModalClose = () => { + this.setState({ isEditBookModalOpen: 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, + authorId, + monitored, + title, + author, + titleSlug, + qualityProfile, + releaseDate, + added, + statistics, + genres, + ratings, + tags, + showSearchAction, + columns, + isRefreshingBook, + isSearchingBook, + onRefreshBookPress, + onSearchPress + } = this.props; + + const { + bookFileCount, + sizeOnDisk + } = statistics; + + const { + isEditAuthorModalOpen, + isDeleteAuthorModalOpen, + isEditBookModalOpen + } = this.state; + + return ( + <> + { + columns.map((column) => { + const { + name, + isVisible + } = column; + + if (!isVisible) { + return null; + } + + if (name === 'status') { + return ( + + ); + } + + if (name === 'title') { + return ( + + + + ); + } + + if (name === 'authorName') { + return ( + + + + ); + } + + if (name === 'qualityProfileId') { + return ( + + {qualityProfile.name} + + ); + } + + if (name === 'releaseDate') { + return ( + + ); + } + + if (name === 'added') { + return ( + + ); + } + + if (name === 'bookFileCount') { + return ( + + {bookFileCount} + + + ); + } + + if (name === 'path') { + return ( + + {author.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; + }) + } + + + + + + + + ); + } +} + +BookIndexRow.propTypes = { + id: PropTypes.number.isRequired, + authorId: PropTypes.number.isRequired, + monitored: PropTypes.bool.isRequired, + title: PropTypes.string.isRequired, + titleSlug: PropTypes.string.isRequired, + author: PropTypes.object.isRequired, + qualityProfile: PropTypes.object.isRequired, + releaseDate: PropTypes.string, + added: PropTypes.string, + statistics: PropTypes.object.isRequired, + genres: PropTypes.arrayOf(PropTypes.string).isRequired, + ratings: PropTypes.object.isRequired, + tags: PropTypes.arrayOf(PropTypes.number).isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + showSearchAction: PropTypes.bool.isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + isRefreshingBook: PropTypes.bool.isRequired, + isSearchingBook: PropTypes.bool.isRequired, + onRefreshBookPress: PropTypes.func.isRequired, + onSearchPress: PropTypes.func.isRequired +}; + +BookIndexRow.defaultProps = { + statistics: { + bookCount: 0, + bookFileCount: 0, + totalBookCount: 0 + }, + genres: [], + tags: [] +}; + +export default BookIndexRow; diff --git a/frontend/src/Book/Index/Table/BookIndexTable.css b/frontend/src/Book/Index/Table/BookIndexTable.css new file mode 100644 index 000000000..23ab127b5 --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexTable.css @@ -0,0 +1,5 @@ +.tableContainer { + composes: tableContainer from '~Components/Table/VirtualTable.css'; + + flex: 1 0 auto; +} diff --git a/frontend/src/Book/Index/Table/BookIndexTable.js b/frontend/src/Book/Index/Table/BookIndexTable.js new file mode 100644 index 000000000..54f9f3a3b --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexTable.js @@ -0,0 +1,126 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import BookIndexItemConnector from 'Book/Index/BookIndexItemConnector'; +import VirtualTable from 'Components/Table/VirtualTable'; +import VirtualTableRow from 'Components/Table/VirtualTableRow'; +import { sortDirections } from 'Helpers/Props'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import BookIndexHeaderConnector from './BookIndexHeaderConnector'; +import BookIndexRow from './BookIndexRow'; +import styles from './BookIndexTable.css'; + +class BookIndexTable extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + scrollIndex: null + }; + } + + componentDidUpdate(prevProps) { + const { + items, + sortKey, + jumpToCharacter + } = this.props; + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + + const scrollIndex = getIndexOfFirstCharacter(items, sortKey, 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 + } = this.props; + + const book = items[rowIndex]; + + return ( + + + + ); + } + + // + // Render + + render() { + const { + items, + columns, + sortKey, + sortDirection, + isSmallScreen, + onSortPress, + scroller, + scrollTop + } = this.props; + + return ( + + } + columns={columns} + sortKey={sortKey} + sortDirection={sortDirection} + /> + ); + } +} + +BookIndexTable.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + columns: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + sortDirection: PropTypes.oneOf(sortDirections.all), + jumpToCharacter: PropTypes.string, + scrollTop: PropTypes.number, + scroller: PropTypes.instanceOf(Element).isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onSortPress: PropTypes.func.isRequired +}; + +export default BookIndexTable; diff --git a/frontend/src/Book/Index/Table/BookIndexTableConnector.js b/frontend/src/Book/Index/Table/BookIndexTableConnector.js new file mode 100644 index 000000000..53523d5fe --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexTableConnector.js @@ -0,0 +1,29 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { setBookSort } from 'Store/Actions/bookIndexActions'; +import BookIndexTable from './BookIndexTable'; + +function createMapStateToProps() { + return createSelector( + (state) => state.app.dimensions, + (state) => state.bookIndex.tableOptions, + (state) => state.bookIndex.columns, + (dimensions, tableOptions, columns) => { + return { + isSmallScreen: dimensions.isSmallScreen, + showBanners: tableOptions.showBanners, + columns + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onSortPress(sortKey) { + dispatch(setBookSort({ sortKey })); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(BookIndexTable); diff --git a/frontend/src/Book/Index/Table/BookIndexTableOptions.js b/frontend/src/Book/Index/Table/BookIndexTableOptions.js new file mode 100644 index 000000000..6d5df554b --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexTableOptions.js @@ -0,0 +1,86 @@ +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 BookIndexTableOptions extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + showSearchAction: props.showSearchAction + }; + } + + componentDidUpdate(prevProps) { + const { + showSearchAction + } = this.props; + + if ( + showSearchAction !== prevProps.showSearchAction + ) { + this.setState({ + showSearchAction + }); + } + } + + // + // Listeners + + onTableOptionChange = ({ name, value }) => { + this.setState({ + [name]: value + }, () => { + this.props.onTableOptionChange({ + tableOptions: { + ...this.state, + [name]: value + } + }); + }); + } + + // + // Render + + render() { + const { + showSearchAction + } = this.state; + + return ( + + + + {translate('ShowSearch')} + + + + + + ); + } +} + +BookIndexTableOptions.propTypes = { + showBanners: PropTypes.bool.isRequired, + showSearchAction: PropTypes.bool.isRequired, + onTableOptionChange: PropTypes.func.isRequired +}; + +export default BookIndexTableOptions; diff --git a/frontend/src/Book/Index/Table/BookIndexTableOptionsConnector.js b/frontend/src/Book/Index/Table/BookIndexTableOptionsConnector.js new file mode 100644 index 000000000..e6bafac58 --- /dev/null +++ b/frontend/src/Book/Index/Table/BookIndexTableOptionsConnector.js @@ -0,0 +1,14 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import BookIndexTableOptions from './BookIndexTableOptions'; + +function createMapStateToProps() { + return createSelector( + (state) => state.authorIndex.tableOptions, + (tableOptions) => { + return tableOptions; + } + ); +} + +export default connect(createMapStateToProps)(BookIndexTableOptions); diff --git a/frontend/src/Book/Index/Table/BookStatusCell.css b/frontend/src/Book/Index/Table/BookStatusCell.css new file mode 100644 index 000000000..fbcd5eee9 --- /dev/null +++ b/frontend/src/Book/Index/Table/BookStatusCell.css @@ -0,0 +1,9 @@ +.status { + composes: cell from '~Components/Table/Cells/TableRowCell.css'; + + width: 60px; +} + +.statusIcon { + width: 20px !important; +} diff --git a/frontend/src/Book/Index/Table/BookStatusCell.js b/frontend/src/Book/Index/Table/BookStatusCell.js new file mode 100644 index 000000000..451a9fb78 --- /dev/null +++ b/frontend/src/Book/Index/Table/BookStatusCell.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import Icon from 'Components/Icon'; +import VirtualTableRowCell from 'Components/Table/Cells/TableRowCell'; +import { icons } from 'Helpers/Props'; +import translate from 'Utilities/String/translate'; +import styles from './BookStatusCell.css'; + +function BookStatusCell(props) { + const { + className, + monitored, + component: Component, + ...otherProps + } = props; + + return ( + + + + ); +} + +BookStatusCell.propTypes = { + className: PropTypes.string.isRequired, + monitored: PropTypes.bool.isRequired, + component: PropTypes.elementType +}; + +BookStatusCell.defaultProps = { + className: styles.status, + component: VirtualTableRowCell +}; + +export default BookStatusCell; diff --git a/frontend/src/Book/Index/Table/hasGrowableColumns.js b/frontend/src/Book/Index/Table/hasGrowableColumns.js new file mode 100644 index 000000000..994436d9f --- /dev/null +++ b/frontend/src/Book/Index/Table/hasGrowableColumns.js @@ -0,0 +1,16 @@ +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/Components/Page/Sidebar/PageSidebar.js b/frontend/src/Components/Page/Sidebar/PageSidebar.js index 076681a97..4a08194fc 100644 --- a/frontend/src/Components/Page/Sidebar/PageSidebar.js +++ b/frontend/src/Components/Page/Sidebar/PageSidebar.js @@ -22,8 +22,16 @@ const links = [ iconName: icons.AUTHOR_CONTINUING, title: 'Library', to: '/', - alias: '/author', + alias: '/authors', children: [ + { + title: 'Authors', + to: '/authors' + }, + { + title: 'Books', + to: '/books' + }, { title: 'Add New', to: '/add/search' diff --git a/frontend/src/Store/Actions/bookActions.js b/frontend/src/Store/Actions/bookActions.js index 1b5aff7b4..7238f3f00 100644 --- a/frontend/src/Store/Actions/bookActions.js +++ b/frontend/src/Store/Actions/bookActions.js @@ -2,9 +2,10 @@ import _ from 'lodash'; import { createAction } from 'redux-actions'; import { batchActions } from 'redux-batched-actions'; import bookEntities from 'Book/bookEntities'; -import { sortDirections } from 'Helpers/Props'; +import { filterTypePredicates, filterTypes, sortDirections } from 'Helpers/Props'; import { createThunk, handleThunks } from 'Store/thunks'; import createAjaxRequest from 'Utilities/createAjaxRequest'; +import dateFilterPredicate from 'Utilities/Date/dateFilterPredicate'; import { updateItem } from './baseActions'; import createFetchHandler from './Creators/createFetchHandler'; import createHandleActions from './Creators/createHandleActions'; @@ -19,6 +20,113 @@ import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptio export const section = 'books'; +export const filters = [ + { + key: 'all', + label: 'All', + filters: [] + }, + { + key: 'monitored', + label: 'Monitored Only', + filters: [ + { + key: 'monitored', + value: true, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'unmonitored', + label: 'Unmonitored Only', + filters: [ + { + key: 'monitored', + value: false, + type: filterTypes.EQUAL + } + ] + }, + { + key: 'missing', + label: 'Missing Books', + filters: [ + { + key: 'missing', + value: true, + type: filterTypes.EQUAL + } + ] + } +]; + +export const filterPredicates = { + missing: function(item) { + const { statistics = {} } = item; + + return statistics.bookFileCount === 0; + }, + + releaseDate: function(item, filterValue, type) { + return dateFilterPredicate(item.releaseDate, filterValue, type); + }, + + added: function(item, filterValue, type) { + return dateFilterPredicate(item.added, filterValue, type); + }, + + qualityProfileId: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + return predicate(item.author.qualityProfileId, filterValue); + }, + + ratings: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + + return predicate(item.ratings.value * 10, filterValue); + }, + + bookFileCount: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const bookCount = item.statistics ? item.statistics.bookFileCount : 0; + + return predicate(bookCount, filterValue); + }, + + sizeOnDisk: function(item, filterValue, type) { + const predicate = filterTypePredicates[type]; + const sizeOnDisk = item.statistics && item.statistics.sizeOnDisk ? + item.statistics.sizeOnDisk : + 0; + + return predicate(sizeOnDisk, filterValue); + } +}; + +export const sortPredicates = { + status: function(item) { + let result = 0; + + if (item.monitored) { + result += 2; + } + + if (item.status === 'continuing') { + result++; + } + + return result; + }, + + sizeOnDisk: function(item) { + const { statistics = {} } = item; + + return statistics.sizeOnDisk || 0; + } +}; + // // State diff --git a/frontend/src/Store/Actions/bookIndexActions.js b/frontend/src/Store/Actions/bookIndexActions.js new file mode 100644 index 000000000..8c5a52e30 --- /dev/null +++ b/frontend/src/Store/Actions/bookIndexActions.js @@ -0,0 +1,318 @@ +import { createAction } from 'redux-actions'; +import { filterBuilderTypes, filterBuilderValueTypes, sortDirections } from 'Helpers/Props'; +import sortByName from 'Utilities/Array/sortByName'; +import { filterPredicates, filters, sortPredicates } from './bookActions'; +import createHandleActions from './Creators/createHandleActions'; +import createSetClientSideCollectionFilterReducer from './Creators/Reducers/createSetClientSideCollectionFilterReducer'; +import createSetClientSideCollectionSortReducer from './Creators/Reducers/createSetClientSideCollectionSortReducer'; +import createSetTableOptionReducer from './Creators/Reducers/createSetTableOptionReducer'; + +// +// Variables + +export const section = 'bookIndex'; + +// +// State + +export const defaultState = { + sortKey: 'title', + sortDirection: sortDirections.ASCENDING, + secondarySortKey: 'title', + secondarySortDirection: sortDirections.ASCENDING, + view: 'posters', + + posterOptions: { + detailedProgressBar: false, + size: 'large', + showTitle: true, + showAuthor: true, + showMonitored: true, + showQualityProfile: true, + showSearchAction: false + }, + + overviewOptions: { + detailedProgressBar: false, + size: 'medium', + showReleaseDate: true, + showMonitored: true, + showQualityProfile: true, + showAdded: false, + showPath: false, + showSizeOnDisk: false, + showSearchAction: false + }, + + tableOptions: { + showSearchAction: false + }, + + columns: [ + { + name: 'status', + columnLabel: 'Status', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'title', + label: 'Book', + isSortable: true, + isVisible: true, + isModifiable: false + }, + { + name: 'authorName', + label: 'Author', + isSortable: true, + isVisible: true, + isModifiable: true + }, + { + name: 'releaseDate', + label: 'Release Date', + isSortable: true, + isVisible: false + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + isSortable: true, + isVisible: true + }, + { + name: 'added', + label: 'Added', + isSortable: true, + isVisible: false + }, + { + name: 'bookFileCount', + label: 'File Count', + isSortable: true, + isVisible: true + }, + { + name: 'path', + label: 'Path', + isSortable: true, + isVisible: false + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + isSortable: true, + isVisible: false + }, + { + name: 'genres', + label: 'Genres', + isSortable: false, + isVisible: false + }, + { + name: 'ratings', + label: 'Rating', + isSortable: true, + isVisible: false + }, + { + name: 'tags', + label: 'Tags', + isSortable: false, + isVisible: false + }, + { + name: 'actions', + columnLabel: 'Actions', + isVisible: true, + isModifiable: false + } + ], + + sortPredicates: { + ...sortPredicates, + + bookFileCount: function(item) { + const { statistics = {} } = item; + + return statistics.bookCount || 0; + }, + + ratings: function(item) { + const { ratings = {} } = item; + + return ratings.value; + } + }, + + selectedFilterKey: 'all', + + filters, + + filterPredicates: { + ...filterPredicates + }, + + filterBuilderProps: [ + { + name: 'monitored', + label: 'Monitored', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.BOOL + }, + { + name: 'qualityProfileId', + label: 'Quality Profile', + type: filterBuilderTypes.EXACT, + valueType: filterBuilderValueTypes.QUALITY_PROFILE + }, + { + name: 'releaseDate', + label: 'Release Date', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'added', + label: 'Added', + type: filterBuilderTypes.DATE, + valueType: filterBuilderValueTypes.DATE + }, + { + name: 'bookFileCount', + label: 'File Count', + type: filterBuilderTypes.NUMBER + }, + { + name: 'path', + label: 'Path', + type: filterBuilderTypes.STRING + }, + { + name: 'sizeOnDisk', + label: 'Size on Disk', + type: filterBuilderTypes.NUMBER, + valueType: filterBuilderValueTypes.BYTES + }, + { + name: 'genres', + label: 'Genres', + type: filterBuilderTypes.ARRAY, + optionsSelector: function(items) { + const tagList = items.reduce((acc, Book) => { + Book.genres.forEach((genre) => { + acc.push({ + id: genre, + name: genre + }); + }); + + return acc; + }, []); + + return tagList.sort(sortByName); + } + }, + { + name: 'ratings', + label: 'Rating', + type: filterBuilderTypes.NUMBER + }, + { + name: 'tags', + label: 'Tags', + type: filterBuilderTypes.ARRAY, + valueType: filterBuilderValueTypes.TAG + } + ] +}; + +export const persistState = [ + 'bookIndex.sortKey', + 'bookIndex.sortDirection', + 'bookIndex.selectedFilterKey', + 'bookIndex.customFilters', + 'bookIndex.view', + 'bookIndex.columns', + 'bookIndex.posterOptions', + 'bookIndex.bannerOptions', + 'bookIndex.overviewOptions', + 'bookIndex.tableOptions' +]; + +// +// Actions Types + +export const SET_BOOK_SORT = 'bookIndex/setBookSort'; +export const SET_BOOK_FILTER = 'bookIndex/setBookFilter'; +export const SET_BOOK_VIEW = 'bookIndex/setBookView'; +export const SET_BOOK_TABLE_OPTION = 'bookIndex/setBookTableOption'; +export const SET_BOOK_POSTER_OPTION = 'bookIndex/setBookPosterOption'; +export const SET_BOOK_BANNER_OPTION = 'bookIndex/setBookBannerOption'; +export const SET_BOOK_OVERVIEW_OPTION = 'bookIndex/setBookOverviewOption'; + +// +// Action Creators + +export const setBookSort = createAction(SET_BOOK_SORT); +export const setBookFilter = createAction(SET_BOOK_FILTER); +export const setBookView = createAction(SET_BOOK_VIEW); +export const setBookTableOption = createAction(SET_BOOK_TABLE_OPTION); +export const setBookPosterOption = createAction(SET_BOOK_POSTER_OPTION); +export const setBookBannerOption = createAction(SET_BOOK_BANNER_OPTION); +export const setBookOverviewOption = createAction(SET_BOOK_OVERVIEW_OPTION); + +// +// Reducers + +export const reducers = createHandleActions({ + + [SET_BOOK_SORT]: createSetClientSideCollectionSortReducer(section), + [SET_BOOK_FILTER]: createSetClientSideCollectionFilterReducer(section), + + [SET_BOOK_VIEW]: function(state, { payload }) { + return Object.assign({}, state, { view: payload.view }); + }, + + [SET_BOOK_TABLE_OPTION]: createSetTableOptionReducer(section), + + [SET_BOOK_POSTER_OPTION]: function(state, { payload }) { + const posterOptions = state.posterOptions; + + return { + ...state, + posterOptions: { + ...posterOptions, + ...payload + } + }; + }, + + [SET_BOOK_BANNER_OPTION]: function(state, { payload }) { + const bannerOptions = state.bannerOptions; + + return { + ...state, + bannerOptions: { + ...bannerOptions, + ...payload + } + }; + }, + + [SET_BOOK_OVERVIEW_OPTION]: function(state, { payload }) { + const overviewOptions = state.overviewOptions; + + return { + ...state, + overviewOptions: { + ...overviewOptions, + ...payload + } + }; + } + +}, defaultState, section); diff --git a/frontend/src/Store/Actions/index.js b/frontend/src/Store/Actions/index.js index 1dcba166c..9053ab5b3 100644 --- a/frontend/src/Store/Actions/index.js +++ b/frontend/src/Store/Actions/index.js @@ -7,6 +7,7 @@ import * as blacklist from './blacklistActions'; import * as books from './bookActions'; import * as bookFiles from './bookFileActions'; import * as bookHistory from './bookHistoryActions'; +import * as bookIndex from './bookIndexActions'; import * as bookStudio from './bookshelfActions'; import * as calendar from './calendarActions'; import * as captcha from './captchaActions'; @@ -38,6 +39,7 @@ export default [ books, bookFiles, bookHistory, + bookIndex, history, interactiveImportActions, oAuth, diff --git a/frontend/src/Store/Selectors/createBookClientSideCollectionItemsSelector.js b/frontend/src/Store/Selectors/createBookClientSideCollectionItemsSelector.js new file mode 100644 index 000000000..9e9eab534 --- /dev/null +++ b/frontend/src/Store/Selectors/createBookClientSideCollectionItemsSelector.js @@ -0,0 +1,47 @@ +import { createSelector, createSelectorCreator, defaultMemoize } from 'reselect'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import createClientSideCollectionSelector from './createClientSideCollectionSelector'; + +function createUnoptimizedSelector(uiSection) { + return createSelector( + createClientSideCollectionSelector('books', uiSection), + (books) => { + const items = books.items.map((s) => { + const { + id, + title, + authorTitle + } = s; + + return { + id, + title, + authorTitle + }; + }); + + return { + ...books, + items + }; + } + ); +} + +function bookListEqual(a, b) { + return hasDifferentItemsOrOrder(a, b); +} + +const createBookEqualSelector = createSelectorCreator( + defaultMemoize, + bookListEqual +); + +function createBookClientSideCollectionItemsSelector(uiSection) { + return createBookEqualSelector( + createUnoptimizedSelector(uiSection), + (book) => book + ); +} + +export default createBookClientSideCollectionItemsSelector; diff --git a/frontend/src/Store/Selectors/createBookQualityProfileSelector.js b/frontend/src/Store/Selectors/createBookQualityProfileSelector.js new file mode 100644 index 000000000..14ad1db2d --- /dev/null +++ b/frontend/src/Store/Selectors/createBookQualityProfileSelector.js @@ -0,0 +1,16 @@ +import { createSelector } from 'reselect'; +import createBookSelector from './createBookSelector'; + +function createBookQualityProfileSelector() { + return createSelector( + (state) => state.settings.qualityProfiles.items, + createBookSelector(), + (qualityProfiles, book = {}) => { + return qualityProfiles.find((profile) => { + return profile.id === book.author.qualityProfileId; + }); + } + ); +} + +export default createBookQualityProfileSelector; diff --git a/frontend/src/Store/scrollPositions.js b/frontend/src/Store/scrollPositions.js index c29804bb8..dd0bb9faf 100644 --- a/frontend/src/Store/scrollPositions.js +++ b/frontend/src/Store/scrollPositions.js @@ -1,5 +1,6 @@ const scrollPositions = { - authorIndex: 0 + authorIndex: 0, + bookIndex: 0 }; export default scrollPositions; diff --git a/src/NzbDrone.Core/Localization/Core/en.json b/src/NzbDrone.Core/Localization/Core/en.json index 8f91bc8ed..507a8b0b6 100644 --- a/src/NzbDrone.Core/Localization/Core/en.json +++ b/src/NzbDrone.Core/Localization/Core/en.json @@ -558,6 +558,7 @@ "ShowBanners": "Show Banners", "ShowBannersHelpText": "Show banners instead of names", "ShowBookCount": "Show Book Count", + "ShowBookTitleHelpText": "Show book title under poster", "ShowCutoffUnmetIconHelpText": "Show icon for files when the cutoff hasn't been met", "ShowDateAdded": "Show Date Added", "ShowLastBook": "Show Last Book", @@ -568,11 +569,13 @@ "ShowPath": "Show Path", "ShowQualityProfile": "Show Quality Profile", "ShowQualityProfileHelpText": "Show quality profile under poster", + "ShowReleaseDate": "Show Release Date", "ShowRelativeDates": "Show Relative Dates", "ShowRelativeDatesHelpText": "Show relative (Today/Yesterday/etc) or absolute dates", "ShowSearch": "Show Search", "ShowSearchActionHelpText": "Show search button on hover", "ShowSizeOnDisk": "Show Size on Disk", + "ShowTitle": "Show Title", "ShowTitleHelpText": "Show author name under poster", "ShowUnknownAuthorItems": "Show Unknown Author Items", "Size": " Size", @@ -621,7 +624,7 @@ "TestAllClients": "Test All Clients", "TestAllIndexers": "Test All Indexers", "TestAllLists": "Test All Lists", - "TheAuthorFolderStrongpathstrongAndAllOfItsContentWillBeDeleted": "The author folder {path} and all of its content will be deleted.", + "TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} and all of its content will be deleted.", "TheBooksFilesWillBeDeleted": "The book's files will be deleted.", "ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them", "TimeFormat": "Time Format", diff --git a/src/Readarr.Api.V1/Books/BookResource.cs b/src/Readarr.Api.V1/Books/BookResource.cs index 4443a6ba9..12c3267b9 100644 --- a/src/Readarr.Api.V1/Books/BookResource.cs +++ b/src/Readarr.Api.V1/Books/BookResource.cs @@ -13,6 +13,7 @@ namespace Readarr.Api.V1.Books public class BookResource : RestResource { public string Title { get; set; } + public string AuthorTitle { get; set; } public string SeriesTitle { get; set; } public string Disambiguation { get; set; } public string Overview { get; set; } @@ -29,6 +30,7 @@ namespace Readarr.Api.V1.Books public List Images { get; set; } public List Links { get; set; } public BookStatisticsResource Statistics { get; set; } + public DateTime? Added { get; set; } public AddBookOptions AddOptions { get; set; } public string RemoteCover { get; set; } public List Editions { get; set; } @@ -49,6 +51,9 @@ namespace Readarr.Api.V1.Books var selectedEdition = model.Editions?.Value.Where(x => x.Monitored).SingleOrDefault(); + var title = selectedEdition?.Title ?? model.Title; + var authorTitle = $"{model.Author.Value.Metadata.Value.SortNameLastFirst} {title}"; + return new BookResource { Id = model.Id, @@ -60,13 +65,15 @@ namespace Readarr.Api.V1.Books ReleaseDate = model.ReleaseDate, PageCount = selectedEdition?.PageCount ?? 0, Genres = model.Genres, - Title = selectedEdition?.Title ?? model.Title, + Title = title, + AuthorTitle = authorTitle, SeriesTitle = model.SeriesLinks?.Value?.Select(x => x?.Series?.Value?.Title + (x?.Position.IsNotNullOrWhiteSpace() ?? false ? $" #{x.Position}" : string.Empty)).ConcatToString("; "), Disambiguation = selectedEdition?.Disambiguation, Overview = selectedEdition?.Overview, Images = selectedEdition?.Images ?? new List(), Links = model.Links.Concat(selectedEdition?.Links ?? new List()).ToList(), Ratings = selectedEdition?.Ratings ?? new Ratings(), + Added = model.Added, Author = model.Author?.Value.ToResource(), Editions = model.Editions?.Value.ToResource() ?? new List() };