New: Book editor on author details page

pull/1335/head^2
ta264 3 years ago
parent bf852cadbe
commit 4887ed0d2f

@ -5,6 +5,7 @@ import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal';
import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector';
import AuthorHistoryTable from 'Author/History/AuthorHistoryTable'; import AuthorHistoryTable from 'Author/History/AuthorHistoryTable';
import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal'; import MonitoringOptionsModal from 'Author/MonitoringOptions/MonitoringOptionsModal';
import BookEditorFooter from 'Book/Editor/BookEditorFooter';
import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable'; import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable';
import IconButton from 'Components/Link/IconButton'; import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link'; import Link from 'Components/Link/Link';
@ -22,6 +23,7 @@ import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable';
import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector';
import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector';
import translate from 'Utilities/String/translate'; import translate from 'Utilities/String/translate';
import getSelectedIds from 'Utilities/Table/getSelectedIds';
import selectAll from 'Utilities/Table/selectAll'; import selectAll from 'Utilities/Table/selectAll';
import toggleSelected from 'Utilities/Table/toggleSelected'; import toggleSelected from 'Utilities/Table/toggleSelected';
import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal';
@ -53,13 +55,56 @@ class AuthorDetails extends Component {
isDeleteAuthorModalOpen: false, isDeleteAuthorModalOpen: false,
isInteractiveImportModalOpen: false, isInteractiveImportModalOpen: false,
isMonitorOptionsModalOpen: false, isMonitorOptionsModalOpen: false,
isBookEditorActive: false,
allExpanded: false, allExpanded: false,
allCollapsed: false, allCollapsed: false,
expandedState: {}, expandedState: {},
allSelected: false,
allUnselected: false,
lastToggled: null,
selectedState: {},
selectedTabIndex: 0 selectedTabIndex: 0
}; };
} }
//
// Control
setSelectedState = (items) => {
const {
selectedState
} = this.state;
const newSelectedState = {};
items.forEach((item) => {
const isItemSelected = selectedState[item.id];
if (isItemSelected) {
newSelectedState[item.id] = isItemSelected;
} else {
newSelectedState[item.id] = false;
}
});
const selectedCount = getSelectedIds(newSelectedState).length;
const newStateCount = Object.keys(newSelectedState).length;
let isAllSelected = false;
let isAllUnselected = false;
if (selectedCount === 0) {
isAllUnselected = true;
} else if (selectedCount === newStateCount) {
isAllSelected = true;
}
this.setState({ selectedState: newSelectedState, allSelected: isAllSelected, allUnselected: isAllUnselected });
}
getSelectedIds = () => {
return getSelectedIds(this.state.selectedState);
}
// //
// Listeners // Listeners
@ -114,6 +159,10 @@ class AuthorDetails extends Component {
this.setState({ isMonitorOptionsModalOpen: false }); this.setState({ isMonitorOptionsModalOpen: false });
} }
onBookEditorTogglePress = () => {
this.setState({ isBookEditorActive: !this.state.isBookEditorActive });
}
onExpandAllPress = () => { onExpandAllPress = () => {
const { const {
allExpanded, allExpanded,
@ -137,6 +186,27 @@ class AuthorDetails extends Component {
}); });
} }
onSelectAllChange = ({ value }) => {
this.setState(selectAll(this.state.selectedState, value));
}
onSelectAllPress = () => {
this.onSelectAllChange({ value: !this.state.allSelected });
}
onSelectedChange = (items, id, value, shiftKey = false) => {
this.setState((state) => {
return toggleSelected(state, items, id, value, shiftKey);
});
}
onSaveSelected = (changes) => {
this.props.onSaveSelected({
bookIds: this.getSelectedIds(),
...changes
});
}
onTabSelect = (index, lastIndex) => { onTabSelect = (index, lastIndex) => {
this.setState({ selectedTabIndex: index }); this.setState({ selectedTabIndex: index });
} }
@ -165,6 +235,10 @@ class AuthorDetails extends Component {
nextAuthor, nextAuthor,
onRefreshPress, onRefreshPress,
onSearchPress, onSearchPress,
isSaving,
saveError,
isDeleting,
deleteError,
statistics statistics
} = this.props; } = this.props;
@ -175,6 +249,9 @@ class AuthorDetails extends Component {
isDeleteAuthorModalOpen, isDeleteAuthorModalOpen,
isInteractiveImportModalOpen, isInteractiveImportModalOpen,
isMonitorOptionsModalOpen, isMonitorOptionsModalOpen,
isBookEditorActive,
allSelected,
selectedState,
allExpanded, allExpanded,
allCollapsed, allCollapsed,
expandedState, expandedState,
@ -189,6 +266,8 @@ class AuthorDetails extends Component {
expandIcon = icons.EXPAND; expandIcon = icons.EXPAND;
} }
const selectedBookIds = this.getSelectedIds();
return ( return (
<PageContent title={authorName}> <PageContent title={authorName}>
<PageToolbar> <PageToolbar>
@ -252,6 +331,33 @@ class AuthorDetails extends Component {
iconName={icons.DELETE} iconName={icons.DELETE}
onPress={this.onDeleteAuthorPress} onPress={this.onDeleteAuthorPress}
/> />
<PageToolbarSeparator />
{
isBookEditorActive ?
<PageToolbarButton
label={translate('BookList')}
iconName={icons.AUTHOR_CONTINUING}
onPress={this.onBookEditorTogglePress}
/> :
<PageToolbarButton
label={translate('BookEditor')}
iconName={icons.EDIT}
onPress={this.onBookEditorTogglePress}
/>
}
{
isBookEditorActive ?
<PageToolbarButton
label={allSelected ? translate('UnselectAll') : translate('SelectAll')}
iconName={icons.CHECK_SQUARE}
onPress={this.onSelectAllPress}
/> :
null
}
</PageToolbarSection> </PageToolbarSection>
<PageToolbarSection alignContent={align.RIGHT}> <PageToolbarSection alignContent={align.RIGHT}>
@ -377,7 +483,11 @@ class AuthorDetails extends Component {
<AuthorDetailsSeasonConnector <AuthorDetailsSeasonConnector
authorId={id} authorId={id}
isExpanded={true} isExpanded={true}
selectedState={selectedState}
onExpandPress={this.onExpandPress} onExpandPress={this.onExpandPress}
setSelectedState={this.setSelectedState}
onSelectedChange={this.onSelectedChange}
isBookEditorActive={isBookEditorActive}
/> />
</TabPanel> </TabPanel>
@ -422,7 +532,6 @@ class AuthorDetails extends Component {
</TabPanel> </TabPanel>
</Tabs> </Tabs>
} }
</div> </div>
<div className={styles.metadataMessage}> <div className={styles.metadataMessage}>
@ -474,6 +583,19 @@ class AuthorDetails extends Component {
onModalClose={this.onMonitorOptionsClose} onModalClose={this.onMonitorOptionsClose}
/> />
</PageContentBody> </PageContentBody>
{
isBookEditorActive &&
<BookEditorFooter
bookIds={selectedBookIds}
selectedCount={selectedBookIds.length}
isSaving={isSaving}
saveError={saveError}
isDeleting={isDeleting}
deleteError={deleteError}
onSaveSelected={this.onSaveSelected}
/>
}
</PageContent> </PageContent>
); );
} }
@ -493,7 +615,6 @@ AuthorDetails.propTypes = {
images: PropTypes.arrayOf(PropTypes.object).isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired, alternateTitles: PropTypes.arrayOf(PropTypes.string).isRequired,
tags: PropTypes.arrayOf(PropTypes.number).isRequired, tags: PropTypes.arrayOf(PropTypes.number).isRequired,
isSaving: PropTypes.bool.isRequired,
isRefreshing: PropTypes.bool.isRequired, isRefreshing: PropTypes.bool.isRequired,
isSearching: PropTypes.bool.isRequired, isSearching: PropTypes.bool.isRequired,
isFetching: PropTypes.bool.isRequired, isFetching: PropTypes.bool.isRequired,
@ -510,13 +631,17 @@ AuthorDetails.propTypes = {
isSmallScreen: PropTypes.bool.isRequired, isSmallScreen: PropTypes.bool.isRequired,
onMonitorTogglePress: PropTypes.func.isRequired, onMonitorTogglePress: PropTypes.func.isRequired,
onRefreshPress: PropTypes.func.isRequired, onRefreshPress: PropTypes.func.isRequired,
onSearchPress: PropTypes.func.isRequired onSearchPress: PropTypes.func.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
onSaveSelected: PropTypes.func.isRequired
}; };
AuthorDetails.defaultProps = { AuthorDetails.defaultProps = {
statistics: {}, statistics: {},
tags: [], tags: []
isSaving: false
}; };
export default AuthorDetails; export default AuthorDetails;

@ -6,6 +6,7 @@ import { connect } from 'react-redux';
import { createSelector } from 'reselect'; import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames'; import * as commandNames from 'Commands/commandNames';
import { toggleAuthorMonitored } from 'Store/Actions/authorActions'; import { toggleAuthorMonitored } from 'Store/Actions/authorActions';
import { saveBookEditor } from 'Store/Actions/bookEditorActions';
import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions'; import { clearBookFiles, fetchBookFiles } from 'Store/Actions/bookFileActions';
import { executeCommand } from 'Store/Actions/commandActions'; import { executeCommand } from 'Store/Actions/commandActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
@ -21,7 +22,8 @@ import AuthorDetails from './AuthorDetails';
const selectBooks = createSelector( const selectBooks = createSelector(
(state) => state.books, (state) => state.books,
(books) => { (state) => state.bookEditor,
(books, editor) => {
const { const {
items, items,
isFetching, isFetching,
@ -37,7 +39,8 @@ const selectBooks = createSelector(
isBooksPopulated: isPopulated, isBooksPopulated: isPopulated,
booksError: error, booksError: error,
hasBooks, hasBooks,
hasMonitoredBooks hasMonitoredBooks,
...editor
}; };
} }
); );
@ -187,6 +190,7 @@ function createMapStateToProps() {
const mapDispatchToProps = { const mapDispatchToProps = {
fetchSeries, fetchSeries,
clearSeries, clearSeries,
saveBookEditor,
fetchBookFiles, fetchBookFiles,
clearBookFiles, clearBookFiles,
toggleAuthorMonitored, toggleAuthorMonitored,
@ -282,6 +286,10 @@ class AuthorDetailsConnector extends Component {
}); });
} }
onSaveSelected = (payload) => {
this.props.saveBookEditor(payload);
}
// //
// Render // Render
@ -292,6 +300,7 @@ class AuthorDetailsConnector extends Component {
onMonitorTogglePress={this.onMonitorTogglePress} onMonitorTogglePress={this.onMonitorTogglePress}
onRefreshPress={this.onRefreshPress} onRefreshPress={this.onRefreshPress}
onSearchPress={this.onSearchPress} onSearchPress={this.onSearchPress}
onSaveSelected={this.onSaveSelected}
/> />
); );
} }
@ -307,6 +316,7 @@ AuthorDetailsConnector.propTypes = {
isRenamingAuthor: PropTypes.bool.isRequired, isRenamingAuthor: PropTypes.bool.isRequired,
fetchSeries: PropTypes.func.isRequired, fetchSeries: PropTypes.func.isRequired,
clearSeries: PropTypes.func.isRequired, clearSeries: PropTypes.func.isRequired,
saveBookEditor: PropTypes.func.isRequired,
fetchBookFiles: PropTypes.func.isRequired, fetchBookFiles: PropTypes.func.isRequired,
clearBookFiles: PropTypes.func.isRequired, clearBookFiles: PropTypes.func.isRequired,
toggleAuthorMonitored: PropTypes.func.isRequired, toggleAuthorMonitored: PropTypes.func.isRequired,

@ -4,6 +4,7 @@ import React, { Component } from 'react';
import Table from 'Components/Table/Table'; import Table from 'Components/Table/Table';
import TableBody from 'Components/Table/TableBody'; import TableBody from 'Components/Table/TableBody';
import { sortDirections } from 'Helpers/Props'; import { sortDirections } from 'Helpers/Props';
import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder';
import getToggledRange from 'Utilities/Table/getToggledRange'; import getToggledRange from 'Utilities/Table/getToggledRange';
import BookRowConnector from './BookRowConnector'; import BookRowConnector from './BookRowConnector';
import styles from './AuthorDetailsSeason.css'; import styles from './AuthorDetailsSeason.css';
@ -21,6 +22,26 @@ class AuthorDetailsSeason extends Component {
}; };
} }
componentDidMount() {
this.props.setSelectedState(this.props.items);
}
componentDidUpdate(prevProps) {
const {
items,
sortKey,
sortDirection,
setSelectedState
} = this.props;
if (sortKey !== prevProps.sortKey ||
sortDirection !== prevProps.sortDirection ||
hasDifferentItemsOrOrder(prevProps.items, items)
) {
setSelectedState(items);
}
}
// //
// Listeners // Listeners
@ -42,26 +63,42 @@ class AuthorDetailsSeason extends Component {
this.props.onMonitorBookPress(_.uniq(bookIds), monitored); this.props.onMonitorBookPress(_.uniq(bookIds), monitored);
} }
onSelectedChange = ({ id, value, shiftKey = false }) => {
const {
onSelectedChange,
items
} = this.props;
return onSelectedChange(items, id, value, shiftKey);
}
// //
// Render // Render
render() { render() {
const { const {
items, items,
isBookEditorActive,
columns, columns,
sortKey, sortKey,
sortDirection, sortDirection,
onSortPress, onSortPress,
onTableOptionChange onTableOptionChange,
selectedState
} = this.props; } = this.props;
let titleColumns = columns;
if (!isBookEditorActive) {
titleColumns = columns.filter((x) => x.name !== 'select');
}
return ( return (
<div <div
className={styles.bookType} className={styles.bookType}
> >
<div className={styles.books}> <div className={styles.books}>
<Table <Table
columns={columns} columns={titleColumns}
sortKey={sortKey} sortKey={sortKey}
sortDirection={sortDirection} sortDirection={sortDirection}
onSortPress={onSortPress} onSortPress={onSortPress}
@ -76,6 +113,9 @@ class AuthorDetailsSeason extends Component {
columns={columns} columns={columns}
{...item} {...item}
onMonitorBookPress={this.onMonitorBookPress} onMonitorBookPress={this.onMonitorBookPress}
isBookEditorActive={isBookEditorActive}
isSelected={selectedState[item.id]}
onSelectedChange={this.onSelectedChange}
/> />
); );
}) })
@ -92,9 +132,13 @@ AuthorDetailsSeason.propTypes = {
sortKey: PropTypes.string, sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all), sortDirection: PropTypes.oneOf(sortDirections.all),
items: PropTypes.arrayOf(PropTypes.object).isRequired, items: PropTypes.arrayOf(PropTypes.object).isRequired,
isBookEditorActive: PropTypes.bool.isRequired,
selectedState: PropTypes.object.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onTableOptionChange: PropTypes.func.isRequired, onTableOptionChange: PropTypes.func.isRequired,
onExpandPress: PropTypes.func.isRequired, onExpandPress: PropTypes.func.isRequired,
setSelectedState: PropTypes.func.isRequired,
onSelectedChange: PropTypes.func.isRequired,
onSortPress: PropTypes.func.isRequired, onSortPress: PropTypes.func.isRequired,
onMonitorBookPress: PropTypes.func.isRequired, onMonitorBookPress: PropTypes.func.isRequired,
uiSettings: PropTypes.object.isRequired uiSettings: PropTypes.object.isRequired

@ -6,6 +6,7 @@ import MonitorToggleButton from 'Components/MonitorToggleButton';
import StarRating from 'Components/StarRating'; import StarRating from 'Components/StarRating';
import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector'; import RelativeDateCellConnector from 'Components/Table/Cells/RelativeDateCellConnector';
import TableRowCell from 'Components/Table/Cells/TableRowCell'; import TableRowCell from 'Components/Table/Cells/TableRowCell';
import TableSelectCell from 'Components/Table/Cells/TableSelectCell';
import TableRow from 'Components/Table/TableRow'; import TableRow from 'Components/Table/TableRow';
import BookStatus from './BookStatus'; import BookStatus from './BookStatus';
import styles from './BookRow.css'; import styles from './BookRow.css';
@ -65,6 +66,9 @@ class BookRow extends Component {
authorMonitored, authorMonitored,
titleSlug, titleSlug,
bookFiles, bookFiles,
isBookEditorActive,
isSelected,
onSelectedChange,
columns columns
} = this.props; } = this.props;
@ -84,6 +88,18 @@ class BookRow extends Component {
return null; return null;
} }
if (isBookEditorActive && name === 'select') {
return (
<TableSelectCell
key={name}
id={id}
isSelected={isSelected}
isDisabled={false}
onSelectedChange={onSelectedChange}
/>
);
}
if (name === 'monitored') { if (name === 'monitored') {
return ( return (
<TableRowCell <TableRowCell
@ -220,6 +236,9 @@ BookRow.propTypes = {
isSaving: PropTypes.bool, isSaving: PropTypes.bool,
authorMonitored: PropTypes.bool.isRequired, authorMonitored: PropTypes.bool.isRequired,
bookFiles: PropTypes.arrayOf(PropTypes.object).isRequired, bookFiles: PropTypes.arrayOf(PropTypes.object).isRequired,
isBookEditorActive: PropTypes.bool.isRequired,
isSelected: PropTypes.bool,
onSelectedChange: PropTypes.func.isRequired,
columns: PropTypes.arrayOf(PropTypes.object).isRequired, columns: PropTypes.arrayOf(PropTypes.object).isRequired,
onMonitorBookPress: PropTypes.func.isRequired onMonitorBookPress: PropTypes.func.isRequired
}; };

@ -0,0 +1,70 @@
.inputContainer {
margin-right: 20px;
min-width: 150px;
}
.buttonContainer {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.buttonContainerContent {
flex-grow: 0;
}
.buttons {
display: flex;
justify-content: flex-end;
flex-grow: 1;
}
.organizeSelectedButton,
.tagsButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-right: 10px;
height: 35px;
}
.deleteSelectedButton {
composes: button from '~Components/Link/SpinnerButton.css';
margin-left: 50px;
height: 35px;
}
@media only screen and (max-width: $breakpointExtraLarge) {
.deleteSelectedButton {
margin-left: 0;
}
}
@media only screen and (max-width: $breakpointLarge) {
.buttonContainer {
justify-content: flex-start;
margin-top: 10px;
}
}
@media only screen and (max-width: $breakpointSmall) {
.inputContainer {
margin-right: 0;
}
.buttonContainer {
justify-content: flex-start;
}
.buttonContainerContent {
flex-grow: 1;
}
.buttons {
justify-content: space-between;
}
.selectedAuthorLabel {
text-align: left;
}
}

@ -0,0 +1,156 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectInput from 'Components/Form/SelectInput';
import SpinnerButton from 'Components/Link/SpinnerButton';
import PageContentFooter from 'Components/Page/PageContentFooter';
import { kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import BookEditorFooterLabel from './BookEditorFooterLabel';
import DeleteBookModal from './Delete/DeleteBookModal';
import styles from './BookEditorFooter.css';
const NO_CHANGE = 'noChange';
class BookEditorFooter extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
monitored: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false,
isDeleteBookModalOpen: false,
isTagsModalOpen: false,
isConfirmMoveModalOpen: false,
destinationRootFolder: null
};
}
componentDidUpdate(prevProps) {
const {
isSaving,
saveError
} = this.props;
if (prevProps.isSaving && !isSaving && !saveError) {
this.setState({
monitored: NO_CHANGE,
rootFolderPath: NO_CHANGE,
savingTags: false
});
}
}
//
// Listeners
onInputChange = ({ name, value }) => {
this.setState({ [name]: value });
if (value === NO_CHANGE) {
return;
}
switch (name) {
case 'monitored':
this.props.onSaveSelected({ [name]: value === 'monitored' });
break;
default:
this.props.onSaveSelected({ [name]: value });
}
}
onDeleteSelectedPress = () => {
this.setState({ isDeleteBookModalOpen: true });
}
onDeleteBookModalClose = () => {
this.setState({ isDeleteBookModalOpen: false });
}
//
// Render
render() {
const {
bookIds,
selectedCount,
isSaving,
isDeleting
} = this.props;
const {
monitored,
isDeleteBookModalOpen
} = this.state;
const monitoredOptions = [
{ key: NO_CHANGE, value: 'No Change', disabled: true },
{ key: 'monitored', value: 'Monitored' },
{ key: 'unmonitored', value: 'Unmonitored' }
];
return (
<PageContentFooter>
<div className={styles.inputContainer}>
<BookEditorFooterLabel
label={translate('MonitorBook')}
isSaving={isSaving && monitored !== NO_CHANGE}
/>
<SelectInput
name="monitored"
value={monitored}
values={monitoredOptions}
isDisabled={!selectedCount}
onChange={this.onInputChange}
/>
</div>
<div className={styles.buttonContainer}>
<div className={styles.buttonContainerContent}>
<BookEditorFooterLabel
label={translate('SelectedCountBooksSelectedInterp', [selectedCount])}
isSaving={false}
/>
<div className={styles.buttons}>
<SpinnerButton
className={styles.deleteSelectedButton}
kind={kinds.DANGER}
isSpinning={isDeleting}
isDisabled={!selectedCount || isDeleting}
onPress={this.onDeleteSelectedPress}
>
Delete
</SpinnerButton>
</div>
</div>
</div>
<DeleteBookModal
isOpen={isDeleteBookModalOpen}
bookIds={bookIds}
onModalClose={this.onDeleteBookModalClose}
/>
</PageContentFooter>
);
}
}
BookEditorFooter.propTypes = {
bookIds: PropTypes.arrayOf(PropTypes.number).isRequired,
selectedCount: PropTypes.number.isRequired,
isSaving: PropTypes.bool.isRequired,
saveError: PropTypes.object,
isDeleting: PropTypes.bool.isRequired,
deleteError: PropTypes.object,
onSaveSelected: PropTypes.func.isRequired
};
export default BookEditorFooter;

@ -0,0 +1,8 @@
.label {
margin-bottom: 3px;
font-weight: bold;
}
.savingIcon {
margin-left: 8px;
}

@ -0,0 +1,40 @@
import PropTypes from 'prop-types';
import React from 'react';
import SpinnerIcon from 'Components/SpinnerIcon';
import { icons } from 'Helpers/Props';
import styles from './BookEditorFooterLabel.css';
function BookEditorFooterLabel(props) {
const {
className,
label,
isSaving
} = props;
return (
<div className={className}>
{label}
{
isSaving &&
<SpinnerIcon
className={styles.savingIcon}
name={icons.SPINNER}
isSpinning={true}
/>
}
</div>
);
}
BookEditorFooterLabel.propTypes = {
className: PropTypes.string.isRequired,
label: PropTypes.string.isRequired,
isSaving: PropTypes.bool.isRequired
};
BookEditorFooterLabel.defaultProps = {
className: styles.label
};
export default BookEditorFooterLabel;

@ -0,0 +1,31 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import DeleteBookModalContentConnector from './DeleteBookModalContentConnector';
function DeleteBookModal(props) {
const {
isOpen,
onModalClose,
...otherProps
} = props;
return (
<Modal
isOpen={isOpen}
onModalClose={onModalClose}
>
<DeleteBookModalContentConnector
{...otherProps}
onModalClose={onModalClose}
/>
</Modal>
);
}
DeleteBookModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default DeleteBookModal;

@ -0,0 +1,9 @@
.message {
margin-top: 20px;
margin-bottom: 10px;
}
.deleteFilesMessage {
margin-top: 20px;
color: $dangerColor;
}

@ -0,0 +1,172 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
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, kinds } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import styles from './DeleteBookModalContent.css';
class DeleteBookModalContent extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
deleteFiles: false,
addImportListExclusion: true
};
}
//
// Listeners
onDeleteFilesChange = ({ value }) => {
this.setState({ deleteFiles: value });
}
onAddImportListExclusionChange = ({ value }) => {
this.setState({ addImportListExclusion: value });
}
onDeleteBookConfirmed = () => {
const {
deleteFiles,
addImportListExclusion
} = this.state;
this.setState({ deleteFiles: false });
this.props.onDeleteSelectedPress(deleteFiles, addImportListExclusion);
}
//
// Render
render() {
const {
book,
files,
onModalClose
} = this.props;
const {
deleteFiles,
addImportListExclusion
} = this.state;
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
Delete Selected Book
</ModalHeader>
<ModalBody>
<div>
<FormGroup>
<FormLabel>{`Delete File${book.length > 1 ? 's' : ''}`}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="deleteFiles"
value={deleteFiles}
helpText={'Delete book files'}
kind={kinds.DANGER}
isDisabled={files.length === 0}
onChange={this.onDeleteFilesChange}
/>
</FormGroup>
<FormGroup>
<FormLabel>{translate('AddListExclusion')}</FormLabel>
<FormInputGroup
type={inputTypes.CHECK}
name="addImportListExclusion"
value={addImportListExclusion}
helpText={translate('AddImportListExclusionHelpText')}
kind={kinds.DANGER}
onChange={this.onAddImportListExclusionChange}
/>
</FormGroup>
{
!addImportListExclusion &&
<div className={styles.deleteFilesMessage}>
<div>
{translate('IfYouDontAddAnImportListExclusionAndTheAuthorHasAMetadataProfileOtherThanNoneThenThisBookMayBeReaddedDuringTheNextAuthorRefresh')}
</div>
</div>
}
</div>
<div className={styles.message}>
{`Are you sure you want to delete ${book.length} selected book${book.length > 1 ? 's' : ''}${deleteFiles ? ' and their files' : ''}?`}
</div>
<ul>
{
book.map((s) => {
return (
<li key={s.title}>
<span>{s.title}</span>
</li>
);
})
}
</ul>
{
deleteFiles &&
<div>
<div className={styles.deleteFilesMessage}>
{translate('TheFollowingFilesWillBeDeleted')}
</div>
<ul>
{
files.map((s) => {
return (
<li key={s.path}>
<span>{s.path}</span>
</li>
);
})
}
</ul>
</div>
}
</ModalBody>
<ModalFooter>
<Button onPress={onModalClose}>
Cancel
</Button>
<Button
kind={kinds.DANGER}
onPress={this.onDeleteBookConfirmed}
>
Delete
</Button>
</ModalFooter>
</ModalContent>
);
}
}
DeleteBookModalContent.propTypes = {
book: PropTypes.arrayOf(PropTypes.object).isRequired,
files: PropTypes.arrayOf(PropTypes.object).isRequired,
onModalClose: PropTypes.func.isRequired,
onDeleteSelectedPress: PropTypes.func.isRequired
};
export default DeleteBookModalContent;

@ -0,0 +1,54 @@
import _ from 'lodash';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import { bulkDeleteBook } from 'Store/Actions/bookEditorActions';
import DeleteBookModalContent from './DeleteBookModalContent';
function createMapStateToProps() {
return createSelector(
(state, { bookIds }) => bookIds,
(state) => state.books.items,
(state) => state.bookFiles.items,
(bookIds, allBooks, allBookFiles) => {
const selectedBook = _.intersectionWith(allBooks, bookIds, (s, id) => {
return s.id === id;
});
const sortedBook = _.orderBy(selectedBook, 'title');
const selectedFiles = _.intersectionWith(allBookFiles, bookIds, (s, id) => {
return s.bookId === id;
});
const files = _.orderBy(selectedFiles, ['bookId', 'path']);
const book = _.map(sortedBook, (s) => {
return {
title: s.title,
path: s.path
};
});
return {
book,
files
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onDeleteSelectedPress(deleteFiles, addImportListExclusion) {
dispatch(bulkDeleteBook({
bookIds: props.bookIds,
deleteFiles,
addImportListExclusion
}));
props.onModalClose();
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(DeleteBookModalContent);

@ -36,6 +36,7 @@ import {
faCaretDown as fasCaretDown, faCaretDown as fasCaretDown,
faCheck as fasCheck, faCheck as fasCheck,
faCheckCircle as fasCheckCircle, faCheckCircle as fasCheckCircle,
faCheckSquare as fasCheckSquare,
faChevronCircleDown as fasChevronCircleDown, faChevronCircleDown as fasChevronCircleDown,
faChevronCircleRight as fasChevronCircleRight, faChevronCircleRight as fasChevronCircleRight,
faChevronCircleUp as fasChevronCircleUp, faChevronCircleUp as fasChevronCircleUp,
@ -127,6 +128,7 @@ export const CARET_DOWN = fasCaretDown;
export const CHECK = fasCheck; export const CHECK = fasCheck;
export const CHECK_INDETERMINATE = fasMinus; export const CHECK_INDETERMINATE = fasMinus;
export const CHECK_CIRCLE = fasCheckCircle; export const CHECK_CIRCLE = fasCheckCircle;
export const CHECK_SQUARE = fasCheckSquare;
export const CIRCLE = fasCircle; export const CIRCLE = fasCircle;
export const CIRCLE_OUTLINE = farCircle; export const CIRCLE_OUTLINE = farCircle;
export const CLEAR = fasTrashAlt; export const CLEAR = fasTrashAlt;

@ -132,6 +132,14 @@ export const defaultState = {
}, },
columns: [ columns: [
{
name: 'select',
columnLabel: 'Select',
isSortable: false,
isVisible: true,
isModifiable: false,
isHidden: true
},
{ {
name: 'monitored', name: 'monitored',
columnLabel: 'Monitored', columnLabel: 'Monitored',

@ -0,0 +1,114 @@
import { batchActions } from 'redux-batched-actions';
import { createThunk, handleThunks } from 'Store/thunks';
import createAjaxRequest from 'Utilities/createAjaxRequest';
import { set, updateItem } from './baseActions';
import createHandleActions from './Creators/createHandleActions';
//
// Variables
export const section = 'bookEditor';
//
// State
export const defaultState = {
isSaving: false,
saveError: null,
isDeleting: false,
deleteError: null
};
//
// Actions Types
export const SAVE_BOOK_EDITOR = 'bookEditor/saveBookEditor';
export const BULK_DELETE_BOOK = 'bookEditor/bulkDeleteBook';
//
// Action Creators
export const saveBookEditor = createThunk(SAVE_BOOK_EDITOR);
export const bulkDeleteBook = createThunk(BULK_DELETE_BOOK);
//
// Action Handlers
export const actionHandlers = handleThunks({
[SAVE_BOOK_EDITOR]: function(getState, payload, dispatch) {
dispatch(set({
section,
isSaving: true
}));
const promise = createAjaxRequest({
url: '/book/editor',
method: 'PUT',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done((data) => {
dispatch(batchActions([
...data.map((book) => {
return updateItem({
id: book.id,
section: 'books',
...book
});
}),
set({
section,
isSaving: false,
saveError: null
})
]));
});
promise.fail((xhr) => {
dispatch(set({
section,
isSaving: false,
saveError: xhr
}));
});
},
[BULK_DELETE_BOOK]: function(getState, payload, dispatch) {
dispatch(set({
section,
isDeleting: true
}));
const promise = createAjaxRequest({
url: '/book/editor',
method: 'DELETE',
data: JSON.stringify(payload),
dataType: 'json'
}).request;
promise.done(() => {
// SignalR will take care of removing the book from the collection
dispatch(set({
section,
isDeleting: false,
deleteError: null
}));
});
promise.fail((xhr) => {
dispatch(set({
section,
isDeleting: false,
deleteError: xhr
}));
});
}
});
//
// Reducers
export const reducers = createHandleActions({}, defaultState, section);

@ -6,6 +6,7 @@ import * as authorHistory from './authorHistoryActions';
import * as authorIndex from './authorIndexActions'; import * as authorIndex from './authorIndexActions';
import * as blocklist from './blocklistActions'; import * as blocklist from './blocklistActions';
import * as books from './bookActions'; import * as books from './bookActions';
import * as bookEditor from './bookEditorActions';
import * as bookFiles from './bookFileActions'; import * as bookFiles from './bookFileActions';
import * as bookHistory from './bookHistoryActions'; import * as bookHistory from './bookHistoryActions';
import * as bookIndex from './bookIndexActions'; import * as bookIndex from './bookIndexActions';
@ -43,6 +44,7 @@ export default [
bookHistory, bookHistory,
bookIndex, bookIndex,
books, books,
bookEditor,
bookStudio, bookStudio,
calendar, calendar,
captcha, captcha,

@ -66,6 +66,7 @@
"Book": "Book", "Book": "Book",
"BookAvailableButMissing": "Book Available, but Missing", "BookAvailableButMissing": "Book Available, but Missing",
"BookDownloaded": "Book Downloaded", "BookDownloaded": "Book Downloaded",
"BookEditor": "Book Editor",
"BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})", "BookFileCountBookCountTotalTotalBookCountInterp": "{0} / {1} (Total: {2})",
"BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded", "BookFileCounttotalBookCountBooksDownloadedInterp": "{0}/{1} books downloaded",
"BookFilesCountMessage": "No book files", "BookFilesCountMessage": "No book files",
@ -73,6 +74,7 @@
"BookIsDownloading": "Book is downloading", "BookIsDownloading": "Book is downloading",
"BookIsDownloadingInterp": "Book is downloading - {0}% {1}", "BookIsDownloadingInterp": "Book is downloading - {0}% {1}",
"BookIsNotMonitored": "Book is not monitored", "BookIsNotMonitored": "Book is not monitored",
"BookList": "Book List",
"BookMissingFromDisk": "Book missing from disk", "BookMissingFromDisk": "Book missing from disk",
"BookMonitoring": "Book Monitoring", "BookMonitoring": "Book Monitoring",
"BookNaming": "Book Naming", "BookNaming": "Book Naming",
@ -558,7 +560,9 @@
"SearchSelected": "Search Selected", "SearchSelected": "Search Selected",
"Season": "Season", "Season": "Season",
"Security": "Security", "Security": "Security",
"SelectAll": "Select All",
"SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected", "SelectedCountAuthorsSelectedInterp": "{0} Author(s) Selected",
"SelectedCountBooksSelectedInterp": "{0} Book(s) Selected",
"SendAnonymousUsageData": "Send Anonymous Usage Data", "SendAnonymousUsageData": "Send Anonymous Usage Data",
"SendMetadataToCalibre": "Send Metadata to Calibre", "SendMetadataToCalibre": "Send Metadata to Calibre",
"Series": "Series", "Series": "Series",
@ -644,6 +648,7 @@
"TestAllLists": "Test All Lists", "TestAllLists": "Test All Lists",
"TheAuthorFolderAndAllOfItsContentWillBeDeleted": "The author folder {0} 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.", "TheBooksFilesWillBeDeleted": "The book's files will be deleted.",
"TheFollowingFilesWillBeDeleted": "The following files will be deleted:",
"ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them", "ThisWillApplyToAllIndexersPleaseFollowTheRulesSetForthByThem": "This will apply to all indexers, please follow the rules set forth by them",
"Time": "Time", "Time": "Time",
"TimeFormat": "Time Format", "TimeFormat": "Time Format",
@ -706,6 +711,7 @@
"UnmappedFiles": "UnmappedFiles", "UnmappedFiles": "UnmappedFiles",
"Unmonitored": "Unmonitored", "Unmonitored": "Unmonitored",
"UnmonitoredHelpText": "Include unmonitored books in the iCal feed", "UnmonitoredHelpText": "Include unmonitored books in the iCal feed",
"UnselectAll": "Unselect All",
"UpdateAll": "Update all", "UpdateAll": "Update all",
"UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates", "UpdateAutomaticallyHelpText": "Automatically download and install updates. You will still be able to install from System: Updates",
"UpdateCovers": "Update Covers", "UpdateCovers": "Update Covers",

@ -0,0 +1,48 @@
using Microsoft.AspNetCore.Mvc;
using NzbDrone.Core.Books;
using NzbDrone.Core.Messaging.Commands;
using Readarr.Http;
namespace Readarr.Api.V1.Books
{
[V1ApiController("book/editor")]
public class BookEditorController : Controller
{
private readonly IBookService _bookService;
private readonly IManageCommandQueue _commandQueueManager;
public BookEditorController(IBookService bookService, IManageCommandQueue commandQueueManager)
{
_bookService = bookService;
_commandQueueManager = commandQueueManager;
}
[HttpPut]
public IActionResult SaveAll([FromBody] BookEditorResource resource)
{
var booksToUpdate = _bookService.GetBooks(resource.BookIds);
foreach (var book in booksToUpdate)
{
if (resource.Monitored.HasValue)
{
book.Monitored = resource.Monitored.Value;
}
}
_bookService.UpdateMany(booksToUpdate);
return Accepted(booksToUpdate.ToResource());
}
[HttpDelete]
public object DeleteBook([FromBody] BookEditorResource resource)
{
foreach (var bookId in resource.BookIds)
{
_bookService.DeleteBook(bookId, resource.DeleteFiles ?? false, resource.AddImportListExclusion ?? false);
}
return new object();
}
}
}

@ -0,0 +1,12 @@
using System.Collections.Generic;
namespace Readarr.Api.V1.Books
{
public class BookEditorResource
{
public List<int> BookIds { get; set; }
public bool? Monitored { get; set; }
public bool? DeleteFiles { get; set; }
public bool? AddImportListExclusion { get; set; }
}
}
Loading…
Cancel
Save