New: Add index views for all books

pull/1205/head
ta264 3 years ago
parent a774cf0682
commit 2558660b7b

@ -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) {
/>
}
<Route
path="/authors"
component={AuthorIndexConnector}
/>
<Route
path="/add/search"
component={AddNewItemConnector}
@ -86,6 +92,11 @@ function AppRoutes(props) {
component={BookshelfConnector}
/>
<Route
path="/books"
component={BookIndexConnector}
/>
<Route
path="/unmapped"
component={UnmappedFilesTableConnector}

@ -8,7 +8,6 @@ function AuthorPoster(props) {
return (
<AuthorImage
{...props}
coverType="poster"
placeholder={posterPlaceholder}
/>
);
@ -19,7 +18,8 @@ AuthorPoster.propTypes = {
};
AuthorPoster.defaultProps = {
size: 250
size: 250,
coverType: 'poster'
};
export default AuthorPoster;

@ -125,7 +125,7 @@ class DeleteAuthorModalContent extends Component {
deleteFiles &&
<div className={styles.deleteFilesMessage}>
<div>
{translate('TheAuthorFolderStrongpathstrongAndAllOfItsContentWillBeDeleted')}
{translate('TheAuthorFolderAndAllOfItsContentWillBeDeleted', [path])}
</div>
{

@ -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 (
<div>
<div className={styles.message}>
All authors are hidden due to the applied filter.
{`All ${itemType} are hidden due to the applied filter.`}
</div>
</div>
);
@ -20,7 +23,7 @@ function NoAuthor(props) {
return (
<div>
<div className={styles.message}>
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.`}
</div>
<div className={styles.buttonContainer}>
@ -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;

@ -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 (
<Link to={link}>
{title}
</Link>
);
}
BookNameLink.propTypes = {
titleSlug: PropTypes.string.isRequired,
title: PropTypes.string.isRequired
};
export default BookNameLink;

@ -86,6 +86,7 @@
.duration {
margin-right: 15px;
margin-left: 10px;
}
.detailsLabel {

@ -133,6 +133,7 @@ class BookDetailsHeader extends Component {
</div>
<div>
{author.authorName}
{
!!pageCount &&
<span className={styles.duration}>

@ -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);
}
}

@ -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 (
<PageContent>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label={translate('UpdateAll')}
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
isSpinning={isRefreshingBook}
onPress={onRefreshAuthorPress}
/>
<PageToolbarButton
label={translate('RSSSync')}
iconName={icons.RSS}
isSpinning={isRssSyncExecuting}
isDisabled={hasNoAuthor}
onPress={onRssSyncPress}
/>
</PageToolbarSection>
<PageToolbarSection
alignContent={align.RIGHT}
collapseButtons={false}
>
{
view === 'table' ?
<TableOptionsModalWrapper
{...otherProps}
columns={columns}
optionsComponent={BookIndexTableOptionsConnector}
>
<PageToolbarButton
label={translate('Options')}
iconName={icons.TABLE}
/>
</TableOptionsModalWrapper> :
null
}
{
view === 'posters' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.POSTER}
isDisabled={hasNoAuthor}
onPress={this.onPosterOptionsPress}
/> :
null
}
{
view === 'overview' ?
<PageToolbarButton
label={translate('Options')}
iconName={icons.OVERVIEW}
isDisabled={hasNoAuthor}
onPress={this.onOverviewOptionsPress}
/> :
null
}
<PageToolbarSeparator />
<BookIndexViewMenu
view={view}
isDisabled={hasNoAuthor}
onViewSelect={onViewSelect}
/>
<BookIndexSortMenu
sortKey={sortKey}
sortDirection={sortDirection}
isDisabled={hasNoAuthor}
onSortSelect={onSortSelect}
/>
<BookIndexFilterMenu
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
isDisabled={hasNoAuthor}
onFilterSelect={onFilterSelect}
/>
</PageToolbarSection>
</PageToolbar>
<div className={styles.pageContentBodyWrapper}>
<PageContentBody
registerScroller={this.setScrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{
isFetching && !isPopulated &&
<LoadingIndicator />
}
{
!isFetching && !!error &&
<div className={styles.errorMessage}>
{getErrorMessage(error, 'Failed to load books from API')}
</div>
}
{
isLoaded &&
<div className={styles.contentBodyContainer}>
<ViewComponent
scroller={scroller}
items={items}
filters={filters}
sortKey={sortKey}
sortDirection={sortDirection}
jumpToCharacter={jumpToCharacter}
{...otherProps}
/>
<BookIndexFooterConnector />
</div>
}
{
!error && isPopulated && !items.length &&
<NoAuthor
totalItems={totalItems}
itemType={'books'}
/>
}
</PageContentBody>
{
isLoaded && !!jumpBarItems.order.length &&
<PageJumpBar
items={jumpBarItems}
onItemPress={this.onJumpBarItemPress}
/>
}
</div>
<BookIndexPosterOptionsModal
isOpen={isPosterOptionsModalOpen}
onModalClose={this.onPosterOptionsModalClose}
/>
<BookIndexOverviewOptionsModal
isOpen={isOverviewOptionsModalOpen}
onModalClose={this.onOverviewOptionsModalClose}
/>
</PageContent>
);
}
}
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;

@ -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 (
<BookIndex
{...this.props}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
/>
);
}
}
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'
);

@ -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);

@ -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;
}
}

@ -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 (
<ColorImpairedConsumer>
{(enableColorImpairedMode) => {
return (
<div className={styles.footer}>
<div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.continuing,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('ContinuingAllBooksDownloaded')}
</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.ended,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('EndedAllBooksDownloaded')}
</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingMonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('MissingBooksAuthorMonitored')}
</div>
</div>
<div className={styles.legendItem}>
<div
className={classNames(
styles.missingUnmonitored,
enableColorImpairedMode && 'colorImpaired'
)}
/>
<div>
{translate('MissingBooksAuthorNotMonitored')}
</div>
</div>
</div>
<div className={styles.statistics}>
<DescriptionList>
<DescriptionListItem
title={translate('Authors')}
data={count}
/>
<DescriptionListItem
title={translate('Ended')}
data={ended}
/>
<DescriptionListItem
title={translate('Continuing')}
data={continuing}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Monitored')}
data={monitored}
/>
<DescriptionListItem
title={translate('Unmonitored')}
data={count - monitored}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('Books')}
data={books}
/>
<DescriptionListItem
title={translate('Files')}
data={bookFiles}
/>
</DescriptionList>
<DescriptionList>
<DescriptionListItem
title={translate('TotalFileSize')}
data={formatBytes(totalFileSize)}
/>
</DescriptionList>
</div>
</div>
);
}}
</ColorImpairedConsumer>
);
}
}
BookIndexFooter.propTypes = {
author: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default BookIndexFooter;

@ -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);

@ -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 (
<ItemComponent
{...otherProps}
id={id}
onRefreshBookPress={this.onRefreshBookPress}
onSearchPress={this.onSearchPress}
/>
);
}
}
BookIndexItemConnector.propTypes = {
id: PropTypes.number,
component: PropTypes.elementType.isRequired,
dispatchExecuteCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(BookIndexItemConnector);

@ -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 (
<FilterMenu
alignMenu={align.RIGHT}
isDisabled={isDisabled}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
filterModalConnectorComponent={BookIndexFilterModalConnector}
onFilterSelect={onFilterSelect}
/>
);
}
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;

@ -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 (
<SortMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<MenuContent>
<SortMenuItem
name="status"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Monitored/Status
</SortMenuItem>
<SortMenuItem
name="title"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Title
</SortMenuItem>
<SortMenuItem
name="authorTitle"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Author, Title
</SortMenuItem>
<SortMenuItem
name="qualityProfileId"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Quality Profile
</SortMenuItem>
<SortMenuItem
name="added"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Added
</SortMenuItem>
<SortMenuItem
name="bookFileCount"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Files
</SortMenuItem>
<SortMenuItem
name="path"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Path
</SortMenuItem>
<SortMenuItem
name="sizeOnDisk"
sortKey={sortKey}
sortDirection={sortDirection}
onPress={onSortSelect}
>
Size on Disk
</SortMenuItem>
</MenuContent>
</SortMenu>
);
}
BookIndexSortMenu.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
isDisabled: PropTypes.bool.isRequired,
onSortSelect: PropTypes.func.isRequired
};
export default BookIndexSortMenu;

@ -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 (
<ViewMenu
isDisabled={isDisabled}
alignMenu={align.RIGHT}
>
<MenuContent>
<ViewMenuItem
name="table"
selectedView={view}
onPress={onViewSelect}
>
Table
</ViewMenuItem>
<ViewMenuItem
name="posters"
selectedView={view}
onPress={onViewSelect}
>
Posters
</ViewMenuItem>
<ViewMenuItem
name="overview"
selectedView={view}
onPress={onViewSelect}
>
Overview
</ViewMenuItem>
</MenuContent>
</ViewMenu>
);
}
BookIndexViewMenu.propTypes = {
view: PropTypes.string.isRequired,
isDisabled: PropTypes.bool.isRequired,
onViewSelect: PropTypes.func.isRequired
};
export default BookIndexViewMenu;

@ -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;
}
}

@ -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 (
<div className={styles.container}>
<div className={styles.content}>
<div className={styles.posterContainer}>
{
status === 'ended' &&
<div
className={styles.ended}
title={translate('Ended')}
/>
}
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<AuthorPoster
className={styles.poster}
style={elementStyle}
images={images}
coverType={'cover'}
size={250}
lazy={false}
overflow={true}
blurBackground={true}
/>
</Link>
<BookIndexProgressBar
monitored={monitored}
bookCount={bookCount}
bookFileCount={bookFileCount}
totalBookCount={totalBookCount}
posterWidth={posterWidth}
detailedProgressBar={overviewOptions.detailedProgressBar}
/>
</div>
<div className={styles.info} style={{ maxHeight: contentHeight }}>
<div className={styles.titleRow}>
<Link
className={styles.title}
to={link}
>
{title}
</Link>
<div className={styles.actions}>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshBook')}
isSpinning={isRefreshingBook}
onPress={onRefreshBookPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredBooks')}
isSpinning={isSearchingBook}
onPress={onSearchPress}
/>
}
<IconButton
name={icons.EDIT}
title={translate('EditAuthor')}
onPress={this.onEditAuthorPress}
/>
</div>
</div>
<div className={styles.details}>
<Link
className={styles.overview}
to={link}
>
<TextTruncate
line={Math.floor(overviewHeight / (defaultFontSize * lineHeight))}
text={stripHtml(overview)}
/>
</Link>
<BookIndexOverviewInfo
height={overviewHeight}
monitored={monitored}
sizeOnDisk={sizeOnDisk}
qualityProfile={qualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
{...overviewOptions}
{...otherProps}
/>
</div>
</div>
</div>
<EditAuthorModalConnector
isOpen={isEditAuthorModalOpen}
authorId={id}
onModalClose={this.onEditAuthorModalClose}
onDeleteAuthorPress={this.onDeleteAuthorPress}
/>
<DeleteAuthorModal
isOpen={isDeleteAuthorModalOpen}
authorId={id}
onModalClose={this.onDeleteAuthorModalClose}
/>
</div>
);
}
}
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;

@ -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;
}
}

@ -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 (
<div className={styles.infos}>
{
rows.map((row) => {
if (!isVisible(row, props)) {
return null;
}
if (shownRows >= maxRows) {
return null;
}
shownRows++;
const infoRowProps = getInfoRowProps(row, props);
return (
<BookIndexOverviewInfoRow
key={row.name}
{...infoRowProps}
/>
);
})
}
</div>
);
}
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;

@ -0,0 +1,10 @@
.infoRow {
flex: 0 0 $authorIndexOverviewInfoRowHeight;
margin: 2px 0;
}
.icon {
margin-right: 5px;
width: 25px !important;
text-align: center;
}

@ -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 (
<div
className={styles.infoRow}
title={title}
>
<Icon
className={styles.icon}
name={iconName}
size={14}
/>
{label}
</div>
);
}
BookIndexOverviewInfoRow.propTypes = {
title: PropTypes.string,
iconName: PropTypes.object.isRequired,
label: PropTypes.string.isRequired
};
export default BookIndexOverviewInfoRow;

@ -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 (
<div
key={key}
style={style}
>
<BookIndexItemConnector
key={book.id}
component={BookIndexOverview}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
rowHeight={rowHeight}
overviewOptions={overviewOptions}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
longDateFormat={longDateFormat}
timeFormat={timeFormat}
isSmallScreen={isSmallScreen}
bookId={book.id}
authorId={book.authorId}
/>
</div>
);
}
//
// Listeners
onMeasure = ({ width }) => {
this.calculateGrid(width, this.props.isSmallScreen);
}
//
// Render
render() {
const {
items,
isSmallScreen,
scroller
} = this.props;
const {
width,
rowHeight
} = this.state;
return (
<Measure
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={1}
columnWidth={width}
rowCount={items.length}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
onSectionRendered={this.onSectionRendered}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
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;

@ -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);

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<BookIndexOverviewOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
BookIndexOverviewOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default BookIndexOverviewOptionsModal;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Overview Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>
{translate('PosterSize')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('DetailedProgressBar')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowReleaseDate')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showReleaseDate"
value={showReleaseDate}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowMonitored')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowQualityProfile')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowDateAdded')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAdded"
value={showAdded}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowPath')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showPath"
value={showPath}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSizeOnDisk')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSizeOnDisk"
value={showSizeOnDisk}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={this.onChangeOverviewOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
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;

@ -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);

@ -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;
}
}

@ -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 (
<div>
<div className={styles.content}>
<div className={styles.posterContainer}>
<Label className={styles.controls}>
<SpinnerIconButton
className={styles.action}
name={icons.REFRESH}
title={translate('RefreshBook')}
isSpinning={isRefreshingBook}
onPress={onRefreshBookPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredBooks')}
isSpinning={isSearchingBook}
onPress={onSearchPress}
/>
}
<IconButton
className={styles.action}
name={icons.INTERACTIVE}
title={translate('EditAuthor')}
onPress={this.onEditAuthorPress}
/>
<IconButton
className={styles.action}
name={icons.EDIT}
title={translate('EditBook')}
onPress={this.onEditBookPress}
/>
</Label>
<Link
className={styles.link}
style={elementStyle}
to={link}
>
<AuthorPoster
className={styles.poster}
style={elementStyle}
images={images}
coverType={'cover'}
size={250}
lazy={false}
overflow={true}
blurBackground={true}
onError={this.onPosterLoadError}
onLoad={this.onPosterLoad}
/>
{
hasPosterError &&
<div className={styles.overlayTitle}>
{title}
</div>
}
</Link>
</div>
<BookIndexProgressBar
monitored={monitored}
bookCount={bookCount}
bookFileCount={bookFileCount}
totalBookCount={totalBookCount}
posterWidth={posterWidth}
detailedProgressBar={detailedProgressBar}
/>
{
showTitle &&
<div className={styles.title}>
{title}
</div>
}
{
showAuthor &&
<div className={styles.title}>
{author.authorName}
</div>
}
{
showMonitored &&
<div className={styles.title}>
{monitored ? 'Monitored' : 'Unmonitored'}
</div>
}
{
showQualityProfile &&
<div className={styles.title}>
{qualityProfile.name}
</div>
}
{
nextAiring &&
<div className={styles.nextAiring}>
{
getRelativeDate(
nextAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
}
<BookIndexPosterInfo
author={author}
bookFileCount={bookFileCount}
sizeOnDisk={sizeOnDisk}
qualityProfile={qualityProfile}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
{...otherProps}
/>
<EditAuthorModalConnector
isOpen={isEditAuthorModalOpen}
authorId={authorId}
onModalClose={this.onEditAuthorModalClose}
onDeleteAuthorPress={this.onDeleteAuthorPress}
/>
<DeleteAuthorModal
isOpen={isDeleteAuthorModalOpen}
authorId={authorId}
onModalClose={this.onDeleteAuthorModalClose}
/>
<EditBookModalConnector
isOpen={isEditBookModalOpen}
authorId={authorId}
bookId={id}
onModalClose={this.onEditBookModalClose}
/>
</div>
</div>
);
}
}
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;

@ -0,0 +1,5 @@
.info {
background-color: #fafbfc;
text-align: center;
font-size: $smallFontSize;
}

@ -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 (
<div className={styles.info}>
{qualityProfile.name}
</div>
);
}
if (sortKey === 'previousAiring' && previousAiring) {
return (
<div className={styles.info}>
{
getRelativeDate(
previousAiring,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: true
}
)
}
</div>
);
}
if (sortKey === 'added' && added) {
const addedDate = getRelativeDate(
added,
shortDateFormat,
showRelativeDates,
{
timeFormat,
timeForToday: false
}
);
return (
<div className={styles.info}>
{`Added ${addedDate}`}
</div>
);
}
if (sortKey === 'bookFileCount') {
let books = '1 file';
if (bookFileCount === 0) {
books = 'No files';
} else if (bookFileCount > 1) {
books = `${bookFileCount} files`;
}
return (
<div className={styles.info}>
{books}
</div>
);
}
if (sortKey === 'path') {
return (
<div className={styles.info}>
{author.path}
</div>
);
}
if (sortKey === 'sizeOnDisk') {
return (
<div className={styles.info}>
{formatBytes(sizeOnDisk)}
</div>
);
}
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;

@ -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 (
<div
key={key}
style={{
...style,
padding: this._padding
}}
>
<BookIndexItemConnector
key={book.id}
component={BookIndexPoster}
sortKey={sortKey}
posterWidth={posterWidth}
posterHeight={posterHeight}
detailedProgressBar={detailedProgressBar}
showTitle={showTitle}
showAuthor={showAuthor}
showMonitored={showMonitored}
showQualityProfile={showQualityProfile}
showRelativeDates={showRelativeDates}
shortDateFormat={shortDateFormat}
timeFormat={timeFormat}
style={style}
bookId={book.id}
authorId={book.authorId}
/>
</div>
);
}
//
// 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 (
<Measure
onMeasure={this.onMeasure}
>
<WindowScroller
scrollElement={isSmallScreen ? undefined : scroller}
>
{({ height, registerChild, onChildScroll, scrollTop }) => {
if (!height) {
return <div />;
}
return (
<div ref={registerChild}>
<Grid
ref={this.setGridRef}
className={styles.grid}
autoHeight={true}
height={height}
columnCount={columnCount}
columnWidth={columnWidth}
rowCount={rowCount}
rowHeight={rowHeight}
width={width}
onScroll={onChildScroll}
scrollTop={scrollTop}
overscanRowCount={2}
cellRenderer={this.cellRenderer}
scrollToAlignment={'start'}
isScrollingOptOut={true}
/>
</div>
);
}
}
</WindowScroller>
</Measure>
);
}
}
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;

@ -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);

@ -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 (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<BookIndexPosterOptionsModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
BookIndexPosterOptionsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default BookIndexPosterOptionsModal;

@ -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 (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Poster Options
</ModalHeader>
<ModalBody>
<Form>
<FormGroup>
<FormLabel>
{translate('PosterSize')}
</FormLabel>
<FormInputGroup
type={inputTypes.SELECT}
name="size"
value={size}
values={posterSizeOptions}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('DetailedProgressBar')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="detailedProgressBar"
value={detailedProgressBar}
helpText={translate('DetailedProgressBarHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowTitle')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showTitle"
value={showTitle}
helpText={translate('ShowBookTitleHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowName')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showAuthor"
value={showAuthor}
helpText={translate('ShowTitleHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowMonitored')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showMonitored"
value={showMonitored}
helpText={translate('ShowMonitoredHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowQualityProfile')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showQualityProfile"
value={showQualityProfile}
helpText={translate('ShowQualityProfileHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
<FormGroup>
<FormLabel>
{translate('ShowSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={this.onChangePosterOption}
/>
</FormGroup>
</Form>
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
Close
</Button>
</ModalFooter>
</ModalContent>
);
}
}
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;

@ -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);

@ -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;
}

@ -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 (
<ProgressBar
className={styles.progressBar}
containerClassName={styles.progress}
progress={progress}
kind={getProgressBarKind('ended', monitored, progress)}
size={detailedProgressBar ? sizes.MEDIUM : sizes.SMALL}
showText={detailedProgressBar}
text={text}
title={translate('BookFileCountBookCountTotalTotalBookCountInterp', [bookFileCount, bookCount, totalBookCount])}
width={posterWidth}
/>
);
}
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;

@ -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 (
<VirtualTableRowCell
{...otherProps}
>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshAuthor')}
isSpinning={isRefreshingAuthor}
onPress={onRefreshAuthorPress}
/>
<IconButton
name={icons.EDIT}
title={translate('EditAuthor')}
onPress={this.onEditAuthorPress}
/>
<EditAuthorModalConnector
isOpen={isEditAuthorModalOpen}
authorId={id}
onModalClose={this.onEditAuthorModalClose}
onDeleteAuthorPress={this.onDeleteAuthorPress}
/>
<DeleteAuthorModal
isOpen={isDeleteAuthorModalOpen}
authorId={id}
onModalClose={this.onDeleteAuthorModalClose}
/>
</VirtualTableRowCell>
);
}
}
BookIndexActionsCell.propTypes = {
id: PropTypes.number.isRequired,
isRefreshingAuthor: PropTypes.bool.isRequired,
onRefreshAuthorPress: PropTypes.func.isRequired
};
export default BookIndexActionsCell;

@ -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;
}

@ -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 (
<VirtualTableHeader>
{
columns.map((column) => {
const {
name,
label,
isSortable,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'actions') {
return (
<VirtualTableHeaderCell
key={name}
className={styles[name]}
name={name}
isSortable={false}
{...otherProps}
>
<TableOptionsModalWrapper
columns={columns}
optionsComponent={BookIndexTableOptionsConnector}
onTableOptionChange={onTableOptionChange}
>
<IconButton
name={icons.ADVANCED_SETTINGS}
/>
</TableOptionsModalWrapper>
</VirtualTableHeaderCell>
);
}
return (
<VirtualTableHeaderCell
key={name}
className={classNames(
styles[name]
)}
name={name}
isSortable={isSortable}
{...otherProps}
>
{label}
</VirtualTableHeaderCell>
);
})
}
</VirtualTableHeader>
);
}
BookIndexHeader.propTypes = {
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default BookIndexHeader;

@ -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);

@ -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;
}

@ -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 (
<BookStatusCell
key={name}
className={styles[name]}
monitored={monitored}
status={status}
component={VirtualTableRowCell}
/>
);
}
if (name === 'title') {
return (
<VirtualTableRowCell
key={name}
className={classNames(
styles[name]
)}
>
<BookNameLink
titleSlug={titleSlug}
title={title}
/>
</VirtualTableRowCell>
);
}
if (name === 'authorName') {
return (
<VirtualTableRowCell
key={name}
className={classNames(
styles[name]
)}
>
<AuthorNameLink
titleSlug={author.titleSlug}
authorName={author.authorName}
/>
</VirtualTableRowCell>
);
}
if (name === 'qualityProfileId') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{qualityProfile.name}
</VirtualTableRowCell>
);
}
if (name === 'releaseDate') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={releaseDate}
component={VirtualTableRowCell}
/>
);
}
if (name === 'added') {
return (
<RelativeDateCellConnector
key={name}
className={styles[name]}
date={added}
component={VirtualTableRowCell}
/>
);
}
if (name === 'bookFileCount') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{bookFileCount}
</VirtualTableRowCell>
);
}
if (name === 'path') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{author.path}
</VirtualTableRowCell>
);
}
if (name === 'sizeOnDisk') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
{formatBytes(sizeOnDisk)}
</VirtualTableRowCell>
);
}
if (name === 'genres') {
const joinedGenres = genres.join(', ');
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<span title={joinedGenres}>
{joinedGenres}
</span>
</VirtualTableRowCell>
);
}
if (name === 'ratings') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<HeartRating
rating={ratings.value}
/>
</VirtualTableRowCell>
);
}
if (name === 'tags') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<TagListConnector
tags={tags}
/>
</VirtualTableRowCell>
);
}
if (name === 'actions') {
return (
<VirtualTableRowCell
key={name}
className={styles[name]}
>
<SpinnerIconButton
name={icons.REFRESH}
title={translate('RefreshBook')}
isSpinning={isRefreshingBook}
onPress={onRefreshBookPress}
/>
{
showSearchAction &&
<SpinnerIconButton
className={styles.action}
name={icons.SEARCH}
title={translate('SearchForMonitoredBooks')}
isSpinning={isSearchingBook}
onPress={onSearchPress}
/>
}
<IconButton
name={icons.INTERACTIVE}
title={translate('EditAuthor')}
onPress={this.onEditAuthorPress}
/>
<IconButton
className={styles.action}
name={icons.EDIT}
title={translate('EditBook')}
onPress={this.onEditBookPress}
/>
</VirtualTableRowCell>
);
}
return null;
})
}
<EditAuthorModalConnector
isOpen={isEditAuthorModalOpen}
authorId={authorId}
onModalClose={this.onEditAuthorModalClose}
onDeleteAuthorPress={this.onDeleteAuthorPress}
/>
<DeleteAuthorModal
isOpen={isDeleteAuthorModalOpen}
authorId={authorId}
onModalClose={this.onDeleteAuthorModalClose}
/>
<EditBookModalConnector
isOpen={isEditBookModalOpen}
authorId={authorId}
bookId={id}
onModalClose={this.onEditBookModalClose}
/>
</>
);
}
}
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;

@ -0,0 +1,5 @@
.tableContainer {
composes: tableContainer from '~Components/Table/VirtualTable.css';
flex: 1 0 auto;
}

@ -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 (
<VirtualTableRow
key={key}
style={style}
>
<BookIndexItemConnector
key={book.id}
component={BookIndexRow}
style={style}
columns={columns}
authorId={book.authorId}
bookId={book.id}
/>
</VirtualTableRow>
);
}
//
// Render
render() {
const {
items,
columns,
sortKey,
sortDirection,
isSmallScreen,
onSortPress,
scroller,
scrollTop
} = this.props;
return (
<VirtualTable
className={styles.tableContainer}
items={items}
scrollIndex={this.state.scrollIndex}
scrollTop={scrollTop}
isSmallScreen={isSmallScreen}
scroller={scroller}
rowHeight={38}
overscanRowCount={2}
rowRenderer={this.rowRenderer}
header={
<BookIndexHeaderConnector
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
/>
}
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;

@ -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);

@ -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 (
<Fragment>
<FormGroup>
<FormLabel>
{translate('ShowSearch')}
</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="showSearchAction"
value={showSearchAction}
helpText={translate('ShowSearchActionHelpText')}
onChange={this.onTableOptionChange}
/>
</FormGroup>
</Fragment>
);
}
}
BookIndexTableOptions.propTypes = {
showBanners: PropTypes.bool.isRequired,
showSearchAction: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired
};
export default BookIndexTableOptions;

@ -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);

@ -0,0 +1,9 @@
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 60px;
}
.statusIcon {
width: 20px !important;
}

@ -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 (
<Component
className={className}
{...otherProps}
>
<Icon
className={styles.statusIcon}
name={monitored ? icons.MONITORED : icons.UNMONITORED}
title={monitored ? translate('MonitoredAuthorIsMonitored') : translate('MonitoredAuthorIsUnmonitored')}
/>
</Component>
);
}
BookStatusCell.propTypes = {
className: PropTypes.string.isRequired,
monitored: PropTypes.bool.isRequired,
component: PropTypes.elementType
};
BookStatusCell.defaultProps = {
className: styles.status,
component: VirtualTableRowCell
};
export default BookStatusCell;

@ -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;
});
}

@ -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'

@ -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

@ -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);

@ -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,

@ -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;

@ -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;

@ -1,5 +1,6 @@
const scrollPositions = {
authorIndex: 0
authorIndex: 0,
bookIndex: 0
};
export default scrollPositions;

@ -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",

@ -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<MediaCover> Images { get; set; }
public List<Links> 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<EditionResource> 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<MediaCover>(),
Links = model.Links.Concat(selectedEdition?.Links ?? new List<Links>()).ToList(),
Ratings = selectedEdition?.Ratings ?? new Ratings(),
Added = model.Added,
Author = model.Author?.Value.ToResource(),
Editions = model.Editions?.Value.ToResource() ?? new List<EditionResource>()
};

Loading…
Cancel
Save