Renames in Frontend

This commit is contained in:
Qstick
2020-05-15 23:32:52 -04:00
committed by ta264
parent ee4e44b81a
commit ee43ccf620
387 changed files with 4036 additions and 4364 deletions
@@ -0,0 +1,3 @@
.alternateTitle {
white-space: nowrap;
}
@@ -0,0 +1,28 @@
import PropTypes from 'prop-types';
import React from 'react';
import styles from './AuthorAlternateTitles.css';
function AuthorAlternateTitles({ alternateTitles }) {
return (
<ul>
{
alternateTitles.map((alternateTitle) => {
return (
<li
key={alternateTitle}
className={styles.alternateTitle}
>
{alternateTitle}
</li>
);
})
}
</ul>
);
}
AuthorAlternateTitles.propTypes = {
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default AuthorAlternateTitles;
@@ -0,0 +1,195 @@
.innerContentBody {
padding: 0;
}
.header {
position: relative;
width: 100%;
height: 310px;
}
.errorMessage {
margin-top: 20px;
text-align: center;
font-size: 20px;
}
.backdrop {
position: absolute;
z-index: -1;
width: 100%;
height: 100%;
background-size: cover;
}
.backdropOverlay {
position: absolute;
width: 100%;
height: 100%;
background: $black;
opacity: 0.7;
}
.headerContent {
display: flex;
padding: 30px;
width: 100%;
height: 100%;
color: $white;
}
.poster {
flex-shrink: 0;
margin-right: 35px;
height: 250px;
}
.info {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
}
.metadataMessage {
color: $helpTextColor;
text-align: center;
font-weight: 300;
font-size: 20px;
}
.titleRow {
display: flex;
justify-content: space-between;
flex: 0 0 auto;
}
.titleContainer {
display: flex;
margin-bottom: 5px;
}
.title {
font-weight: 300;
font-size: 50px;
line-height: 50px;
}
.toggleMonitoredContainer {
align-self: center;
margin-right: 10px;
}
.monitorToggleButton {
composes: toggleButton from '~Components/MonitorToggleButton.css';
width: 40px;
&:hover {
color: $iconButtonHoverLightColor;
}
}
.alternateTitlesIconContainer {
align-self: flex-end;
margin-left: 20px;
}
.filterIcon {
float: right;
}
.authorNavigationButtons {
white-space: nowrap;
}
.authorNavigationButton {
composes: button from '~Components/Link/IconButton.css';
margin-left: 5px;
width: 30px;
color: #e1e2e3;
white-space: nowrap;
&:hover {
color: $iconButtonHoverLightColor;
}
}
.details {
margin-bottom: 8px;
font-weight: 300;
font-size: 20px;
}
.runtime {
margin-right: 15px;
}
.detailsLabel {
composes: label from '~Components/Label.css';
margin: 5px 10px 5px 0;
}
.path,
.sizeOnDisk,
.qualityProfileName,
.links,
.tags {
margin-left: 8px;
font-weight: 300;
font-size: 17px;
}
.overview {
flex: 1 0 auto;
margin-top: 8px;
min-height: 0;
font-size: $intermediateFontSize;
}
.contentContainer {
padding: 20px;
}
.tabList {
margin: 0;
padding: 0;
border-bottom: 1px solid $lightGray;
}
.tab {
position: relative;
bottom: -1px;
display: inline-block;
padding: 6px 12px;
border: 1px solid transparent;
border-top: none;
list-style: none;
cursor: pointer;
}
.selectedTab {
border-bottom: 4px solid $linkColor;
}
.tabContent {
margin-top: 20px;
}
@media only screen and (max-width: $breakpointSmall) {
.contentContainer {
padding: 20px 0;
}
.headerContent {
padding: 15px;
}
}
@media only screen and (max-width: $breakpointLarge) {
.poster {
display: none;
}
}
@@ -0,0 +1,731 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
import TextTruncate from 'react-text-truncate';
import formatBytes from 'Utilities/Number/formatBytes';
import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected';
import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props';
import fonts from 'Styles/Variables/fonts';
import HeartRating from 'Components/HeartRating';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Label from 'Components/Label';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
import Popover from 'Components/Tooltip/Popover';
import Tooltip from 'Components/Tooltip/Tooltip';
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
import AuthorPoster from 'Author/AuthorPoster';
import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector';
import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal';
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
import AuthorAlternateTitles from './AuthorAlternateTitles';
import AuthorDetailsSeasonConnector from './AuthorDetailsSeasonConnector';
import AuthorDetailsSeriesConnector from './AuthorDetailsSeriesConnector';
import AuthorTagsConnector from './AuthorTagsConnector';
import AuthorDetailsLinks from './AuthorDetailsLinks';
import styles from './AuthorDetails.css';
import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable';
import InteractiveSearchFilterMenuConnector from 'InteractiveSearch/InteractiveSearchFilterMenuConnector';
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
import Link from 'Components/Link/Link';
const defaultFontSize = parseInt(fonts.defaultFontSize);
const lineHeight = parseFloat(fonts.lineHeight);
function getFanartUrl(images) {
const fanartImage = _.find(images, { coverType: 'fanart' });
if (fanartImage) {
// Remove protocol
return fanartImage.url.replace(/^https?:/, '');
}
}
function getExpandedState(newState) {
return {
allExpanded: newState.allSelected,
allCollapsed: newState.allUnselected,
expandedState: newState.selectedState
};
}
class AuthorDetails extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOrganizeModalOpen: false,
isRetagModalOpen: false,
isEditAuthorModalOpen: false,
isDeleteAuthorModalOpen: false,
isInteractiveImportModalOpen: false,
allExpanded: false,
allCollapsed: false,
expandedState: {},
selectedTabIndex: 0
};
}
//
// Listeners
onOrganizePress = () => {
this.setState({ isOrganizeModalOpen: true });
}
onOrganizeModalClose = () => {
this.setState({ isOrganizeModalOpen: false });
}
onRetagPress = () => {
this.setState({ isRetagModalOpen: true });
}
onRetagModalClose = () => {
this.setState({ isRetagModalOpen: false });
}
onInteractiveImportPress = () => {
this.setState({ isInteractiveImportModalOpen: true });
}
onInteractiveImportModalClose = () => {
this.setState({ isInteractiveImportModalOpen: false });
}
onEditAuthorPress = () => {
this.setState({ isEditAuthorModalOpen: true });
}
onEditAuthorModalClose = () => {
this.setState({ isEditAuthorModalOpen: false });
}
onDeleteAuthorPress = () => {
this.setState({
isEditAuthorModalOpen: false,
isDeleteAuthorModalOpen: true
});
}
onDeleteAuthorModalClose = () => {
this.setState({ isDeleteAuthorModalOpen: false });
}
onExpandAllPress = () => {
const {
allExpanded,
expandedState
} = this.state;
this.setState(getExpandedState(selectAll(expandedState, !allExpanded)));
}
onExpandPress = (bookId, isExpanded) => {
this.setState((state) => {
const convertedState = {
allSelected: state.allExpanded,
allUnselected: state.allCollapsed,
selectedState: state.expandedState
};
const newState = toggleSelected(convertedState, [], bookId, isExpanded, false);
return getExpandedState(newState);
});
}
//
// Render
render() {
const {
id,
authorName,
ratings,
path,
statistics,
qualityProfileId,
monitored,
status,
overview,
links,
images,
authorType,
alternateTitles,
tags,
isSaving,
isRefreshing,
isSearching,
isFetching,
isPopulated,
booksError,
bookFilesError,
hasBooks,
hasMonitoredBooks,
hasSeries,
series,
hasBookFiles,
previousAuthor,
nextAuthor,
onMonitorTogglePress,
onRefreshPress,
onSearchPress
} = this.props;
const {
bookFileCount,
sizeOnDisk
} = statistics;
const {
isOrganizeModalOpen,
isRetagModalOpen,
isEditAuthorModalOpen,
isDeleteAuthorModalOpen,
isInteractiveImportModalOpen,
allExpanded,
allCollapsed,
expandedState,
selectedTabIndex
} = this.state;
const continuing = status === 'continuing';
const endedString = authorType === 'Person' ? 'Deceased' : 'Ended';
let bookFilesCountMessage = 'No book files';
if (bookFileCount === 1) {
bookFilesCountMessage = '1 book file';
} else if (bookFileCount > 1) {
bookFilesCountMessage = `${bookFileCount} book files`;
}
let expandIcon = icons.EXPAND_INDETERMINATE;
if (allExpanded) {
expandIcon = icons.COLLAPSE;
} else if (allCollapsed) {
expandIcon = icons.EXPAND;
}
return (
<PageContent title={authorName}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
label="Refresh & Scan"
iconName={icons.REFRESH}
spinningName={icons.REFRESH}
title="Refresh information and scan disk"
isSpinning={isRefreshing}
onPress={onRefreshPress}
/>
<PageToolbarButton
label="Search Monitored"
iconName={icons.SEARCH}
isDisabled={!monitored || !hasMonitoredBooks || !hasBooks}
isSpinning={isSearching}
title={hasMonitoredBooks ? undefined : 'No monitored books for this author'}
onPress={onSearchPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Preview Rename"
iconName={icons.ORGANIZE}
isDisabled={!hasBookFiles}
onPress={this.onOrganizePress}
/>
{/* <PageToolbarButton */}
{/* label="Preview Retag" */}
{/* iconName={icons.RETAG} */}
{/* isDisabled={!hasBookFiles} */}
{/* onPress={this.onRetagPress} */}
{/* /> */}
<PageToolbarButton
label="Manual Import"
iconName={icons.INTERACTIVE}
onPress={this.onInteractiveImportPress}
/>
<PageToolbarSeparator />
<PageToolbarButton
label="Edit"
iconName={icons.EDIT}
onPress={this.onEditAuthorPress}
/>
<PageToolbarButton
label="Delete"
iconName={icons.DELETE}
onPress={this.onDeleteAuthorPress}
/>
</PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}>
<PageToolbarButton
label={allExpanded ? 'Collapse All' : 'Expand All'}
iconName={expandIcon}
onPress={this.onExpandAllPress}
/>
</PageToolbarSection>
</PageToolbar>
<PageContentBodyConnector innerClassName={styles.innerContentBody}>
<div className={styles.header}>
<div
className={styles.backdrop}
style={{
backgroundImage: `url(${getFanartUrl(images)})`
}}
>
<div className={styles.backdropOverlay} />
</div>
<div className={styles.headerContent}>
<AuthorPoster
className={styles.poster}
images={images}
size={250}
lazy={false}
/>
<div className={styles.info}>
<div className={styles.titleRow}>
<div className={styles.titleContainer}>
<div className={styles.toggleMonitoredContainer}>
<MonitorToggleButton
className={styles.monitorToggleButton}
monitored={monitored}
isSaving={isSaving}
size={40}
onPress={onMonitorTogglePress}
/>
</div>
<div className={styles.title}>
{authorName}
</div>
{
!!alternateTitles.length &&
<div className={styles.alternateTitlesIconContainer}>
<Popover
anchor={
<Icon
name={icons.ALTERNATE_TITLES}
size={20}
/>
}
title="Alternate Titles"
body={<AuthorAlternateTitles alternateTitles={alternateTitles} />}
position={tooltipPositions.BOTTOM}
/>
</div>
}
</div>
<div className={styles.authorNavigationButtons}>
<IconButton
className={styles.authorNavigationButton}
name={icons.ARROW_LEFT}
size={30}
title={`Go to ${previousAuthor.authorName}`}
to={`/author/${previousAuthor.titleSlug}`}
/>
<IconButton
className={styles.authorNavigationButton}
name={icons.ARROW_UP}
size={30}
title={'Go to author listing'}
to={'/'}
/>
<IconButton
className={styles.authorNavigationButton}
name={icons.ARROW_RIGHT}
size={30}
title={`Go to ${nextAuthor.authorName}`}
to={`/author/${nextAuthor.titleSlug}`}
/>
</div>
</div>
<div className={styles.details}>
<div>
<HeartRating
rating={ratings.value}
iconSize={20}
/>
</div>
</div>
<div className={styles.detailsLabels}>
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={icons.FOLDER}
size={17}
/>
<span className={styles.path}>
{path}
</span>
</Label>
<Label
className={styles.detailsLabel}
title={bookFilesCountMessage}
size={sizes.LARGE}
>
<Icon
name={icons.DRIVE}
size={17}
/>
<span className={styles.sizeOnDisk}>
{
formatBytes(sizeOnDisk)
}
</span>
</Label>
<Label
className={styles.detailsLabel}
title="Quality Profile"
size={sizes.LARGE}
>
<Icon
name={icons.PROFILE}
size={17}
/>
<span className={styles.qualityProfileName}>
{
<QualityProfileNameConnector
qualityProfileId={qualityProfileId}
/>
}
</span>
</Label>
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={monitored ? icons.MONITORED : icons.UNMONITORED}
size={17}
/>
<span className={styles.qualityProfileName}>
{monitored ? 'Monitored' : 'Unmonitored'}
</span>
</Label>
<Label
className={styles.detailsLabel}
title={continuing ? 'More books are expected' : 'No additional books are expected'}
size={sizes.LARGE}
>
<Icon
name={continuing ? icons.AUTHOR_CONTINUING : icons.AUTHOR_ENDED}
size={17}
/>
<span className={styles.qualityProfileName}>
{continuing ? 'Continuing' : endedString}
</span>
</Label>
<Tooltip
anchor={
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={icons.EXTERNAL_LINK}
size={17}
/>
<span className={styles.links}>
Links
</span>
</Label>
}
tooltip={
<AuthorDetailsLinks
links={links}
/>
}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
{
!!tags.length &&
<Tooltip
anchor={
<Label
className={styles.detailsLabel}
size={sizes.LARGE}
>
<Icon
name={icons.TAGS}
size={17}
/>
<span className={styles.tags}>
Tags
</span>
</Label>
}
tooltip={<AuthorTagsConnector authorId={id} />}
kind={kinds.INVERSE}
position={tooltipPositions.BOTTOM}
/>
}
</div>
<div className={styles.overview}>
<TextTruncate
line={Math.floor(125 / (defaultFontSize * lineHeight))}
text={overview.replace(/<[^>]*>?/gm, '')}
/>
</div>
</div>
</div>
</div>
<div className={styles.contentContainer}>
{
!isPopulated && !booksError && !bookFilesError &&
<LoadingIndicator />
}
{
!isFetching && booksError &&
<div>Loading books failed</div>
}
{
!isFetching && bookFilesError &&
<div>Loading book files failed</div>
}
{
isPopulated &&
<Tabs selectedIndex={this.state.tabIndex} onSelect={(tabIndex) => this.setState({ selectedTabIndex: tabIndex })}>
<TabList
className={styles.tabList}
>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Books
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Series
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
History
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Search
</Tab>
<Tab
className={styles.tab}
selectedClassName={styles.selectedTab}
>
Files
</Tab>
{
selectedTabIndex === 3 &&
<div className={styles.filterIcon}>
<InteractiveSearchFilterMenuConnector
type="author"
/>
</div>
}
</TabList>
<TabPanel>
<AuthorDetailsSeasonConnector
authorId={id}
isExpanded={true}
onExpandPress={this.onExpandPress}
/>
</TabPanel>
<TabPanel>
{
isPopulated && hasSeries &&
<div>
{
series.map((item) => {
return (
<AuthorDetailsSeriesConnector
key={item.id}
seriesId={item.id}
authorId={id}
isExpanded={expandedState[item.id]}
onExpandPress={this.onExpandPress}
/>
);
})
}
</div>
}
</TabPanel>
<TabPanel>
<AuthorHistoryTable
authorId={id}
/>
</TabPanel>
<TabPanel>
<InteractiveSearchTable
type="author"
authorId={id}
/>
</TabPanel>
<TabPanel>
<BookFileEditorTable
authorId={id}
/>
</TabPanel>
</Tabs>
}
</div>
<div className={styles.metadataMessage}>
Missing or too many books? Modify or create a new
<Link to='/settings/profiles'> Metadata Profile </Link>
or manually
<Link to={`/add/search?term=${encodeURIComponent(authorName)}`}> Search </Link>
for new items!
</div>
<OrganizePreviewModalConnector
isOpen={isOrganizeModalOpen}
authorId={id}
onModalClose={this.onOrganizeModalClose}
/>
<RetagPreviewModalConnector
isOpen={isRetagModalOpen}
authorId={id}
onModalClose={this.onRetagModalClose}
/>
<EditAuthorModalConnector
isOpen={isEditAuthorModalOpen}
authorId={id}
onModalClose={this.onEditAuthorModalClose}
onDeleteAuthorPress={this.onDeleteAuthorPress}
/>
<DeleteAuthorModal
isOpen={isDeleteAuthorModalOpen}
authorId={id}
onModalClose={this.onDeleteAuthorModalClose}
/>
<InteractiveImportModal
isOpen={isInteractiveImportModalOpen}
folder={path}
allowAuthorChange={false}
showFilterExistingFiles={true}
showImportMode={false}
onModalClose={this.onInteractiveImportModalClose}
/>
</PageContentBodyConnector>
</PageContent>
);
}
}
AuthorDetails.propTypes = {
id: PropTypes.number.isRequired,
authorName: PropTypes.string.isRequired,
ratings: PropTypes.object.isRequired,
path: PropTypes.string.isRequired,
statistics: PropTypes.object.isRequired,
qualityProfileId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
authorType: PropTypes.string,
status: PropTypes.string.isRequired,
overview: PropTypes.string.isRequired,
links: PropTypes.arrayOf(PropTypes.object).isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isSaving: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
booksError: PropTypes.object,
bookFilesError: PropTypes.object,
hasBooks: PropTypes.bool.isRequired,
hasMonitoredBooks: PropTypes.bool.isRequired,
hasSeries: PropTypes.bool.isRequired,
series: PropTypes.arrayOf(PropTypes.object).isRequired,
hasBookFiles: PropTypes.bool.isRequired,
previousAuthor: PropTypes.object.isRequired,
nextAuthor: PropTypes.object.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired
};
AuthorDetails.defaultProps = {
statistics: {},
tags: [],
isSaving: false
};
export default AuthorDetails;
@@ -0,0 +1,324 @@
/* eslint max-params: 0 */
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { findCommand, isCommandExecuting } from 'Utilities/Command';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector';
import createAllAuthorSelector from 'Store/Selectors/createAllAuthorsSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import { clearBooks, fetchBooks } from 'Store/Actions/bookActions';
import { clearSeries, fetchSeries } from 'Store/Actions/seriesActions';
import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions';
import { toggleAuthorMonitored } from 'Store/Actions/authorActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
import { executeCommand } from 'Store/Actions/commandActions';
import * as commandNames from 'Commands/commandNames';
import AuthorDetails from './AuthorDetails';
const selectBooks = createSelector(
(state) => state.books,
(books) => {
const {
items,
isFetching,
isPopulated,
error
} = books;
const hasBooks = !!items.length;
const hasMonitoredBooks = items.some((e) => e.monitored);
return {
isBooksFetching: isFetching,
isBooksPopulated: isPopulated,
booksError: error,
hasBooks,
hasMonitoredBooks
};
}
);
const selectSeries = createSelector(
createSortedSectionSelector('series', (a, b) => a.title.localeCompare(b.title)),
(state) => state.series,
(series) => {
const {
items,
isFetching,
isPopulated,
error
} = series;
const hasSeries = !!items.length;
return {
isSeriesFetching: isFetching,
isSeriesPopulated: isPopulated,
seriesError: error,
hasSeries,
series: series.items
};
}
);
const selectBookFiles = createSelector(
(state) => state.bookFiles,
(bookFiles) => {
const {
items,
isFetching,
isPopulated,
error
} = bookFiles;
const hasBookFiles = !!items.length;
return {
isBookFilesFetching: isFetching,
isBookFilesPopulated: isPopulated,
bookFilesError: error,
hasBookFiles
};
}
);
function createMapStateToProps() {
return createSelector(
(state, { titleSlug }) => titleSlug,
selectBooks,
selectSeries,
selectBookFiles,
createAllAuthorSelector(),
createCommandsSelector(),
(titleSlug, books, series, bookFiles, allAuthors, commands) => {
const sortedAuthor = _.orderBy(allAuthors, 'sortName');
const authorIndex = _.findIndex(sortedAuthor, { titleSlug });
const author = sortedAuthor[authorIndex];
if (!author) {
return {};
}
const {
isBooksFetching,
isBooksPopulated,
booksError,
hasBooks,
hasMonitoredBooks
} = books;
const {
isSeriesFetching,
isSeriesPopulated,
seriesError,
hasSeries,
series: seriesItems
} = series;
const {
isBookFilesFetching,
isBookFilesPopulated,
bookFilesError,
hasBookFiles
} = bookFiles;
const previousAuthor = sortedAuthor[authorIndex - 1] || _.last(sortedAuthor);
const nextAuthor = sortedAuthor[authorIndex + 1] || _.first(sortedAuthor);
const isAuthorRefreshing = isCommandExecuting(findCommand(commands, { name: commandNames.REFRESH_AUTHOR, authorId: author.id }));
const authorRefreshingCommand = findCommand(commands, { name: commandNames.REFRESH_AUTHOR });
const allAuthorRefreshing = (
isCommandExecuting(authorRefreshingCommand) &&
!authorRefreshingCommand.body.authorId
);
const isRefreshing = isAuthorRefreshing || allAuthorRefreshing;
const isSearching = isCommandExecuting(findCommand(commands, { name: commandNames.AUTHOR_SEARCH, authorId: author.id }));
const isRenamingFiles = isCommandExecuting(findCommand(commands, { name: commandNames.RENAME_FILES, authorId: author.id }));
const isRenamingAuthorCommand = findCommand(commands, { name: commandNames.RENAME_AUTHOR });
const isRenamingAuthor = (
isCommandExecuting(isRenamingAuthorCommand) &&
isRenamingAuthorCommand.body.authorIds.indexOf(author.id) > -1
);
const isFetching = isBooksFetching || isSeriesFetching || isBookFilesFetching;
const isPopulated = isBooksPopulated && isSeriesPopulated && isBookFilesPopulated;
const alternateTitles = _.reduce(author.alternateTitles, (acc, alternateTitle) => {
if ((alternateTitle.seasonNumber === -1 || alternateTitle.seasonNumber === undefined) &&
(alternateTitle.sceneSeasonNumber === -1 || alternateTitle.sceneSeasonNumber === undefined)) {
acc.push(alternateTitle.title);
}
return acc;
}, []);
return {
...author,
alternateTitles,
isAuthorRefreshing,
allAuthorRefreshing,
isRefreshing,
isSearching,
isRenamingFiles,
isRenamingAuthor,
isFetching,
isPopulated,
booksError,
seriesError,
bookFilesError,
hasBooks,
hasMonitoredBooks,
hasSeries,
series: seriesItems,
hasBookFiles,
previousAuthor,
nextAuthor
};
}
);
}
const mapDispatchToProps = {
fetchBooks,
clearBooks,
fetchSeries,
clearSeries,
fetchBookFiles,
clearBookFiles,
toggleAuthorMonitored,
fetchQueueDetails,
clearQueueDetails,
clearReleases,
cancelFetchReleases,
executeCommand
};
class AuthorDetailsConnector extends Component {
//
// Lifecycle
componentDidMount() {
registerPagePopulator(this.populate);
this.populate();
}
componentDidUpdate(prevProps) {
const {
id,
isAuthorRefreshing,
allAuthorRefreshing,
isRenamingFiles,
isRenamingAuthor
} = this.props;
if (
(prevProps.isAuthorRefreshing && !isAuthorRefreshing) ||
(prevProps.allAuthorRefreshing && !allAuthorRefreshing) ||
(prevProps.isRenamingFiles && !isRenamingFiles) ||
(prevProps.isRenamingAuthor && !isRenamingAuthor)
) {
this.populate();
}
// If the id has changed we need to clear the books
// files and fetch from the server.
if (prevProps.id !== id) {
this.unpopulate();
this.populate();
}
}
componentWillUnmount() {
unregisterPagePopulator(this.populate);
this.unpopulate();
}
//
// Control
populate = () => {
const authorId = this.props.id;
this.props.fetchBooks({ authorId });
this.props.fetchSeries({ authorId });
this.props.fetchBookFiles({ authorId });
this.props.fetchQueueDetails({ authorId });
}
unpopulate = () => {
this.props.cancelFetchReleases();
this.props.clearBooks();
this.props.clearSeries();
this.props.clearBookFiles();
this.props.clearQueueDetails();
this.props.clearReleases();
}
//
// Listeners
onMonitorTogglePress = (monitored) => {
this.props.toggleAuthorMonitored({
authorId: this.props.id,
monitored
});
}
onRefreshPress = () => {
this.props.executeCommand({
name: commandNames.REFRESH_AUTHOR,
authorId: this.props.id
});
}
onSearchPress = () => {
this.props.executeCommand({
name: commandNames.AUTHOR_SEARCH,
authorId: this.props.id
});
}
//
// Render
render() {
return (
<AuthorDetails
{...this.props}
onMonitorTogglePress={this.onMonitorTogglePress}
onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress}
/>
);
}
}
AuthorDetailsConnector.propTypes = {
id: PropTypes.number.isRequired,
titleSlug: PropTypes.string.isRequired,
isAuthorRefreshing: PropTypes.bool.isRequired,
allAuthorRefreshing: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired,
isRenamingFiles: PropTypes.bool.isRequired,
isRenamingAuthor: PropTypes.bool.isRequired,
fetchBooks: PropTypes.func.isRequired,
clearBooks: PropTypes.func.isRequired,
fetchSeries: PropTypes.func.isRequired,
clearSeries: PropTypes.func.isRequired,
fetchBookFiles: PropTypes.func.isRequired,
clearBookFiles: PropTypes.func.isRequired,
toggleAuthorMonitored: PropTypes.func.isRequired,
fetchQueueDetails: PropTypes.func.isRequired,
clearQueueDetails: PropTypes.func.isRequired,
clearReleases: PropTypes.func.isRequired,
cancelFetchReleases: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AuthorDetailsConnector);
@@ -0,0 +1,13 @@
.links {
margin: 0;
}
.link {
white-space: nowrap;
}
.linkLabel {
composes: label from '~Components/Label.css';
cursor: pointer;
}
@@ -0,0 +1,48 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import Label from 'Components/Label';
import Link from 'Components/Link/Link';
import styles from './AuthorDetailsLinks.css';
function AuthorDetailsLinks(props) {
const {
links
} = props;
return (
<div className={styles.links}>
{links.map((link, index) => {
return (
<span key={index}>
<Link className={styles.link}
to={link.url}
key={index}
>
<Label
className={styles.linkLabel}
kind={kinds.INFO}
size={sizes.LARGE}
>
{link.name}
</Label>
</Link>
{(index > 0 && index % 5 === 0) &&
<br />
}
</span>
);
})}
</div>
);
}
AuthorDetailsLinks.propTypes = {
links: PropTypes.arrayOf(PropTypes.object).isRequired
};
export default AuthorDetailsLinks;
@@ -0,0 +1,117 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { push } from 'connected-react-router';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import PageContent from 'Components/Page/PageContent';
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import NotFound from 'Components/NotFound';
import AuthorDetailsConnector from './AuthorDetailsConnector';
import styles from './AuthorDetails.css';
function createMapStateToProps() {
return createSelector(
(state, { match }) => match,
(state) => state.authors,
(match, authors) => {
const titleSlug = match.params.titleSlug;
const {
isFetching,
isPopulated,
error,
items
} = authors;
const authorIndex = _.findIndex(items, { titleSlug });
if (authorIndex > -1) {
return {
isFetching,
isPopulated,
titleSlug
};
}
return {
isFetching,
isPopulated,
error
};
}
);
}
const mapDispatchToProps = {
push
};
class AuthorDetailsPageConnector extends Component {
//
// Lifecycle
componentDidUpdate(prevProps) {
if (!this.props.titleSlug) {
this.props.push(`${window.Readarr.urlBase}/`);
return;
}
}
//
// Render
render() {
const {
titleSlug,
isFetching,
isPopulated,
error
} = this.props;
if (isFetching && !isPopulated) {
return (
<PageContent title='loading'>
<PageContentBodyConnector>
<LoadingIndicator />
</PageContentBodyConnector>
</PageContent>
);
}
if (!isFetching && !!error) {
return (
<div className={styles.errorMessage}>
{getErrorMessage(error, 'Failed to load author from API')}
</div>
);
}
if (!titleSlug) {
return (
<NotFound
message="Sorry, that author cannot be found."
/>
);
}
return (
<AuthorDetailsConnector
titleSlug={titleSlug}
/>
);
}
}
AuthorDetailsPageConnector.propTypes = {
titleSlug: PropTypes.string,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
match: PropTypes.shape({ params: PropTypes.shape({ titleSlug: PropTypes.string.isRequired }).isRequired }).isRequired,
push: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AuthorDetailsPageConnector);
@@ -0,0 +1,125 @@
.bookType {
margin-bottom: 20px;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
&:last-of-type {
margin-bottom: 0;
}
}
.header {
position: relative;
display: flex;
align-items: center;
width: 100%;
font-size: 24px;
cursor: pointer;
}
.bookTypeLabel {
margin-right: 5px;
margin-left: 5px;
}
.bookCount {
color: #8895aa;
font-style: italic;
font-size: 18px;
}
.episodeCountTooltip {
display: flex;
}
.expandButton {
composes: link from '~Components/Link/Link.css';
flex-grow: 1;
width: 100%;
text-align: center;
}
.left {
display: flex;
align-items: center;
flex: 0 1 300px;
}
.left,
.actions {
padding: 15px 10px;
}
.actionsMenu {
composes: menu from '~Components/Menu/Menu.css';
flex: 0 0 45px;
}
.actionsMenuContent {
composes: menuContent from '~Components/Menu/MenuContent.css';
white-space: nowrap;
font-size: $defaultFontSize;
}
.actionMenuIcon {
margin-right: 8px;
}
.actionButton {
composes: button from '~Components/Link/IconButton.css';
width: 30px;
}
.books {
padding-top: 15px;
border-top: 1px solid $borderColor;
}
.collapseButtonContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 15px;
width: 100%;
border-top: 1px solid $borderColor;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fafafa;
}
.collapseButtonIcon {
margin-bottom: -4px;
}
.expandButtonIcon {
composes: actionButton;
position: absolute;
top: 50%;
left: 50%;
margin-top: -12px;
margin-left: -15px;
}
.noBooks {
margin-bottom: 15px;
text-align: center;
}
@media only screen and (max-width: $breakpointSmall) {
.bookType {
border-right: 0;
border-left: 0;
border-radius: 0;
}
.expandButtonIcon {
position: static;
margin: 0;
}
}
@@ -0,0 +1,103 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getToggledRange from 'Utilities/Table/getToggledRange';
import { sortDirections } from 'Helpers/Props';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import BookRowConnector from './BookRowConnector';
import styles from './AuthorDetailsSeason.css';
class AuthorDetailsSeason extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
lastToggledBook: null
};
}
//
// Listeners
onMonitorBookPress = (bookId, monitored, { shiftKey }) => {
const lastToggled = this.state.lastToggledBook;
const bookIds = [bookId];
if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(this.props.items, bookId, lastToggled);
const items = this.props.items;
for (let i = lower; i < upper; i++) {
bookIds.push(items[i].id);
}
}
this.setState({ lastToggledBook: bookId });
this.props.onMonitorBookPress(_.uniq(bookIds), monitored);
}
//
// Render
render() {
const {
items,
columns,
sortKey,
sortDirection,
onSortPress,
onTableOptionChange
} = this.props;
return (
<div
className={styles.bookType}
>
<div className={styles.books}>
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onTableOptionChange={onTableOptionChange}
>
<TableBody>
{
items.map((item) => {
return (
<BookRowConnector
key={item.id}
columns={columns}
{...item}
onMonitorBookPress={this.onMonitorBookPress}
/>
);
})
}
</TableBody>
</Table>
</div>
</div>
);
}
}
AuthorDetailsSeason.propTypes = {
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
items: PropTypes.arrayOf(PropTypes.object).isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
onMonitorBookPress: PropTypes.func.isRequired,
uiSettings: PropTypes.object.isRequired
};
export default AuthorDetailsSeason;
@@ -0,0 +1,99 @@
/* eslint max-params: 0 */
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { setBooksSort, setBooksTableOption, toggleBooksMonitored } from 'Store/Actions/bookActions';
import { executeCommand } from 'Store/Actions/commandActions';
import AuthorDetailsSeason from './AuthorDetailsSeason';
function createMapStateToProps() {
return createSelector(
(state, { label }) => label,
createClientSideCollectionSelector('books'),
createAuthorSelector(),
createCommandsSelector(),
createDimensionsSelector(),
createUISettingsSelector(),
(label, books, author, commands, dimensions, uiSettings) => {
const booksInGroup = books.items;
let sortDir = 'asc';
if (books.sortDirection === 'descending') {
sortDir = 'desc';
}
const sortedBooks = _.orderBy(booksInGroup, books.sortKey, sortDir);
return {
items: sortedBooks,
columns: books.columns,
sortKey: books.sortKey,
sortDirection: books.sortDirection,
authorMonitored: author.monitored,
isSmallScreen: dimensions.isSmallScreen,
uiSettings
};
}
);
}
const mapDispatchToProps = {
toggleBooksMonitored,
setBooksTableOption,
dispatchSetBookSort: setBooksSort,
executeCommand
};
class AuthorDetailsSeasonConnector extends Component {
//
// Listeners
onTableOptionChange = (payload) => {
this.props.setBooksTableOption(payload);
}
onSortPress = (sortKey) => {
this.props.dispatchSetBookSort({ sortKey });
}
onMonitorBookPress = (bookIds, monitored) => {
this.props.toggleBooksMonitored({
bookIds,
monitored
});
}
//
// Render
render() {
return (
<AuthorDetailsSeason
{...this.props}
onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange}
onMonitorBookPress={this.onMonitorBookPress}
/>
);
}
}
AuthorDetailsSeasonConnector.propTypes = {
authorId: PropTypes.number.isRequired,
toggleBooksMonitored: PropTypes.func.isRequired,
setBooksTableOption: PropTypes.func.isRequired,
dispatchSetBookSort: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AuthorDetailsSeasonConnector);
@@ -0,0 +1,127 @@
.bookType {
margin-bottom: 20px;
border: 1px solid $borderColor;
border-radius: 4px;
background-color: $white;
&:last-of-type {
margin-bottom: 0;
}
}
.header {
position: relative;
display: flex;
align-items: center;
width: 100%;
font-size: 24px;
cursor: pointer;
}
.bookTypeLabel {
margin-right: 5px;
margin-left: 5px;
}
.bookCount {
color: #8895aa;
font-style: italic;
font-size: 18px;
}
.episodeCountTooltip {
display: flex;
}
.expandButton {
composes: link from '~Components/Link/Link.css';
flex-grow: 1;
width: 100%;
text-align: center;
}
.left {
display: flex;
align-items: center;
flex: 1 1 300px;
}
.left,
.actions {
padding: 15px 10px;
}
.actionsMenu {
composes: menu from '~Components/Menu/Menu.css';
flex: 0 0 45px;
}
.actionsMenuContent {
composes: menuContent from '~Components/Menu/MenuContent.css';
white-space: nowrap;
font-size: $defaultFontSize;
}
.actionMenuIcon {
margin-right: 8px;
}
.actionButton {
composes: button from '~Components/Link/IconButton.css';
width: 30px;
}
.books {
padding-top: 15px;
border-top: 1px solid $borderColor;
}
.collapseButtonContainer {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 15px;
width: 100%;
border-top: 1px solid $borderColor;
border-bottom-right-radius: 4px;
border-bottom-left-radius: 4px;
background-color: #fafafa;
}
.collapseButtonIcon {
margin-bottom: -4px;
}
.expandButtonIcon {
composes: actionButton;
margin-right: 15px;
/* position: absolute; */
/* top: 50%; */
/* left: 90%; */
/* margin-top: -12px; */
/* margin-left: -15px; */
}
.noBooks {
margin-bottom: 15px;
text-align: center;
}
@media only screen and (max-width: $breakpointSmall) {
.bookType {
border-right: 0;
border-left: 0;
border-radius: 0;
}
.expandButtonIcon {
position: static;
margin: 0;
}
}
@@ -0,0 +1,205 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import getToggledRange from 'Utilities/Table/getToggledRange';
import { icons, sortDirections } from 'Helpers/Props';
import Icon from 'Components/Icon';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody';
import BookRowConnector from './BookRowConnector';
import styles from './AuthorDetailsSeries.css';
class AuthorDetailsSeries extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isOrganizeModalOpen: false,
isManageBooksOpen: false,
lastToggledBook: null
};
}
componentDidMount() {
this._expandByDefault();
}
componentDidUpdate(prevProps) {
const {
authorId
} = this.props;
if (prevProps.authorId !== authorId) {
this._expandByDefault();
return;
}
}
//
// Control
_expandByDefault() {
const {
id,
onExpandPress
} = this.props;
onExpandPress(id, true);
}
//
// Listeners
onExpandPress = () => {
const {
id,
isExpanded
} = this.props;
this.props.onExpandPress(id, !isExpanded);
}
onMonitorBookPress = (bookId, monitored, { shiftKey }) => {
const lastToggled = this.state.lastToggledBook;
const bookIds = [bookId];
if (shiftKey && lastToggled) {
const { lower, upper } = getToggledRange(this.props.items, bookId, lastToggled);
const items = this.props.items;
for (let i = lower; i < upper; i++) {
bookIds.push(items[i].id);
}
}
this.setState({ lastToggledBook: bookId });
this.props.onMonitorBookPress(_.uniq(bookIds), monitored);
}
//
// Render
render() {
const {
label,
items,
positionMap,
columns,
isExpanded,
sortKey,
sortDirection,
onSortPress,
isSmallScreen,
onTableOptionChange
} = this.props;
return (
<div
className={styles.bookType}
>
<Link
className={styles.expandButton}
onPress={this.onExpandPress}
>
<div className={styles.header}>
<div className={styles.left}>
{
<div>
<span className={styles.bookTypeLabel}>
{label}
</span>
<span className={styles.bookCount}>
({items.length} Books)
</span>
</div>
}
</div>
<Icon
className={styles.expandButtonIcon}
name={isExpanded ? icons.COLLAPSE : icons.EXPAND}
title={isExpanded ? 'Hide books' : 'Show books'}
size={24}
/>
{
!isSmallScreen &&
<span>&nbsp;</span>
}
</div>
</Link>
<div>
{
isExpanded &&
<div className={styles.books}>
<Table
columns={columns}
sortKey={sortKey}
sortDirection={sortDirection}
onSortPress={onSortPress}
onTableOptionChange={onTableOptionChange}
>
<TableBody>
{
items.map((item) => {
return (
<BookRowConnector
key={item.id}
columns={columns}
{...item}
position={positionMap[item.id]}
onMonitorBookPress={this.onMonitorBookPress}
/>
);
})
}
</TableBody>
</Table>
<div className={styles.collapseButtonContainer}>
<IconButton
iconClassName={styles.collapseButtonIcon}
name={icons.COLLAPSE}
size={20}
title="Hide books"
onPress={this.onExpandPress}
/>
</div>
</div>
}
</div>
</div>
);
}
}
AuthorDetailsSeries.propTypes = {
id: PropTypes.number.isRequired,
authorId: PropTypes.number.isRequired,
label: PropTypes.string.isRequired,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
items: PropTypes.arrayOf(PropTypes.object).isRequired,
positionMap: PropTypes.object.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
isExpanded: PropTypes.bool,
isSmallScreen: PropTypes.bool.isRequired,
onTableOptionChange: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired,
onMonitorBookPress: PropTypes.func.isRequired,
uiSettings: PropTypes.object.isRequired
};
export default AuthorDetailsSeries;
@@ -0,0 +1,121 @@
/* eslint max-params: 0 */
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
// import createClientSideCollectionSelector from 'Store/Selectors/createClientSideCollectionSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import { setBooksTableOption, toggleBooksMonitored } from 'Store/Actions/bookActions';
import { setSeriesSort } from 'Store/Actions/seriesActions';
import { executeCommand } from 'Store/Actions/commandActions';
import AuthorDetailsSeries from './AuthorDetailsSeries';
function createMapStateToProps() {
return createSelector(
(state, { seriesId }) => seriesId,
(state) => state.books,
createAuthorSelector(),
(state) => state.series,
createCommandsSelector(),
createDimensionsSelector(),
createUISettingsSelector(),
(seriesId, books, author, series, commands, dimensions, uiSettings) => {
const currentSeries = _.find(series.items, { id: seriesId });
const bookIds = currentSeries.links.map((x) => x.bookId);
const positionMap = currentSeries.links.reduce((acc, curr) => {
acc[curr.bookId] = curr.position;
return acc;
}, {});
const booksInSeries = _.filter(books.items, (book) => bookIds.includes(book.id));
let sortDir = 'asc';
if (series.sortDirection === 'descending') {
sortDir = 'desc';
}
let sortedBooks = [];
if (series.sortKey === 'position') {
sortedBooks = booksInSeries.sort((a, b) => {
const apos = positionMap[a.id] || '';
const bpos = positionMap[b.id] || '';
return apos.localeCompare(bpos, undefined, { numeric: true, sensivity: 'base' });
});
} else {
sortedBooks = _.orderBy(booksInSeries, series.sortKey, sortDir);
}
return {
id: currentSeries.id,
label: currentSeries.title,
items: sortedBooks,
positionMap,
columns: series.columns,
sortKey: series.sortKey,
sortDirection: series.sortDirection,
authorMonitored: author.monitored,
isSmallScreen: dimensions.isSmallScreen,
uiSettings
};
}
);
}
const mapDispatchToProps = {
toggleBooksMonitored,
setBooksTableOption,
dispatchSetSeriesSort: setSeriesSort,
executeCommand
};
class AuthorDetailsSeasonConnector extends Component {
//
// Listeners
onTableOptionChange = (payload) => {
this.props.setBooksTableOption(payload);
}
onSortPress = (sortKey) => {
this.props.dispatchSetSeriesSort({ sortKey });
}
onMonitorBookPress = (bookIds, monitored) => {
this.props.toggleBooksMonitored({
bookIds,
monitored
});
}
//
// Render
render() {
return (
<AuthorDetailsSeries
{...this.props}
onSortPress={this.onSortPress}
onTableOptionChange={this.onTableOptionChange}
onMonitorBookPress={this.onMonitorBookPress}
/>
);
}
}
AuthorDetailsSeasonConnector.propTypes = {
authorId: PropTypes.number.isRequired,
toggleBooksMonitored: PropTypes.func.isRequired,
setBooksTableOption: PropTypes.func.isRequired,
dispatchSetSeriesSort: PropTypes.func.isRequired,
executeCommand: PropTypes.func.isRequired
};
export default connect(createMapStateToProps, mapDispatchToProps)(AuthorDetailsSeasonConnector);
@@ -0,0 +1,8 @@
.tags {
margin: 0;
padding-left: 20px;
}
.tag {
white-space: nowrap;
}
+30
View File
@@ -0,0 +1,30 @@
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import Label from 'Components/Label';
function AuthorTags({ tags }) {
return (
<div>
{
tags.map((tag) => {
return (
<Label
key={tag}
kind={kinds.INFO}
size={sizes.LARGE}
>
{tag}
</Label>
);
})
}
</div>
);
}
AuthorTags.propTypes = {
tags: PropTypes.arrayOf(PropTypes.string).isRequired
};
export default AuthorTags;
@@ -0,0 +1,30 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import AuthorTags from './AuthorTags';
function createMapStateToProps() {
return createSelector(
createAuthorSelector(),
createTagsSelector(),
(author, tagList) => {
const tags = _.reduce(author.tags, (acc, tag) => {
const matchingTag = _.find(tagList, { id: tag });
if (matchingTag) {
acc.push(matchingTag.label);
}
return acc;
}, []);
return {
tags
};
}
);
}
export default connect(createMapStateToProps)(AuthorTags);
+17
View File
@@ -0,0 +1,17 @@
.title {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
white-space: nowrap;
}
.monitored {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 42px;
}
.status {
composes: cell from '~Components/Table/Cells/TableRowCell.css';
width: 100px;
}
+227
View File
@@ -0,0 +1,227 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MonitorToggleButton from 'Components/MonitorToggleButton';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import { kinds, sizes } from 'Helpers/Props';
import TableRow from 'Components/Table/TableRow';
import Label from 'Components/Label';
import TableRowCell from 'Components/Table/Cells/TableRowCell';
import BookSearchCellConnector from 'Book/BookSearchCellConnector';
import BookTitleLink from 'Book/BookTitleLink';
import StarRating from 'Components/StarRating';
import styles from './BookRow.css';
function getBookCountKind(monitored, bookFileCount, bookCount) {
if (bookFileCount === bookCount && bookCount > 0) {
return kinds.SUCCESS;
}
if (!monitored) {
return kinds.WARNING;
}
return kinds.DANGER;
}
class BookRow extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isDetailsModalOpen: false,
isEditBookModalOpen: false
};
}
//
// Listeners
onManualSearchPress = () => {
this.setState({ isDetailsModalOpen: true });
}
onDetailsModalClose = () => {
this.setState({ isDetailsModalOpen: false });
}
onEditBookPress = () => {
this.setState({ isEditBookModalOpen: true });
}
onEditBookModalClose = () => {
this.setState({ isEditBookModalOpen: false });
}
onMonitorBookPress = (monitored, options) => {
this.props.onMonitorBookPress(this.props.id, monitored, options);
}
//
// Render
render() {
const {
id,
authorId,
monitored,
statistics,
releaseDate,
title,
position,
ratings,
disambiguation,
isSaving,
authorMonitored,
titleSlug,
columns
} = this.props;
const {
bookCount,
bookFileCount,
totalBookCount
} = statistics;
return (
<TableRow>
{
columns.map((column) => {
const {
name,
isVisible
} = column;
if (!isVisible) {
return null;
}
if (name === 'monitored') {
return (
<TableRowCell
key={name}
className={styles.monitored}
>
<MonitorToggleButton
monitored={monitored}
isDisabled={!authorMonitored}
isSaving={isSaving}
onPress={this.onMonitorBookPress}
/>
</TableRowCell>
);
}
if (name === 'title') {
return (
<TableRowCell
key={name}
className={styles.title}
>
<BookTitleLink
titleSlug={titleSlug}
title={title}
disambiguation={disambiguation}
/>
</TableRowCell>
);
}
if (name === 'position') {
return (
<TableRowCell
key={name}
className={styles.title}
>
{position || ''}
</TableRowCell>
);
}
if (name === 'rating') {
return (
<TableRowCell key={name}>
{
<StarRating
rating={ratings.value}
votes={ratings.votes}
/>
}
</TableRowCell>
);
}
if (name === 'releaseDate') {
return (
<RelativeDateCellConnector
key={name}
date={releaseDate}
/>
);
}
if (name === 'status') {
return (
<TableRowCell
key={name}
className={styles.status}
>
<Label
title={`${totalBookCount} books total. ${bookFileCount} books with files.`}
kind={getBookCountKind(monitored, bookFileCount, bookCount)}
size={sizes.MEDIUM}
>
{
<span>{bookFileCount} / {bookCount}</span>
}
</Label>
</TableRowCell>
);
}
if (name === 'actions') {
return (
<BookSearchCellConnector
key={name}
bookId={id}
authorId={authorId}
bookTitle={title}
/>
);
}
return null;
})
}
</TableRow>
);
}
}
BookRow.propTypes = {
id: PropTypes.number.isRequired,
authorId: PropTypes.number.isRequired,
monitored: PropTypes.bool.isRequired,
releaseDate: PropTypes.string,
title: PropTypes.string.isRequired,
position: PropTypes.string,
ratings: PropTypes.object.isRequired,
disambiguation: PropTypes.string,
titleSlug: PropTypes.string.isRequired,
isSaving: PropTypes.bool,
authorMonitored: PropTypes.bool.isRequired,
statistics: PropTypes.object.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onMonitorBookPress: PropTypes.func.isRequired
};
BookRow.defaultProps = {
statistics: {
bookCount: 0,
bookFileCount: 0
}
};
export default BookRow;
@@ -0,0 +1,20 @@
/* eslint max-params: 0 */
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAuthorSelector from 'Store/Selectors/createAuthorSelector';
import createBookFileSelector from 'Store/Selectors/createBookFileSelector';
import BookRow from './BookRow';
function createMapStateToProps() {
return createSelector(
createAuthorSelector(),
createBookFileSelector(),
(author = {}, bookFile) => {
return {
authorMonitored: author.monitored,
bookFilePath: bookFile ? bookFile.path : null
};
}
);
}
export default connect(createMapStateToProps)(BookRow);