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
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+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 (
+
+
+
+
+
+
+ {
+ showSearchAction &&
+
+ }
+
+
+
+
+
+
+
+
+
+ {
+ 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
+
+
+
+
+
+
+
+
+ Close
+
+
+
+ );
+ }
+}
+
+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()
};