From 5192c7671705ed84cc1cef419dd662c3520f74d7 Mon Sep 17 00:00:00 2001 From: ta264 Date: Sun, 11 Apr 2021 19:28:24 +0100 Subject: [PATCH] New: Swipe left/right to navigate authors/books on mobile --- frontend/src/Author/Details/AuthorDetails.css | 17 + frontend/src/Author/Details/AuthorDetails.js | 338 ++---------------- .../Author/Details/AuthorDetailsConnector.js | 7 +- .../src/Author/Details/AuthorDetailsHeader.js | 330 +++++++++++++++++ .../Details/AuthorDetailsHeaderConnector.js | 71 ++++ frontend/src/Book/Details/BookDetails.css | 20 +- frontend/src/Book/Details/BookDetails.js | 282 +++------------ .../src/Book/Details/BookDetailsConnector.js | 7 +- .../src/Book/Details/BookDetailsHeader.js | 256 +++++++++++++ .../Details/BookDetailsHeaderConnector.js | 62 ++++ frontend/src/Components/Swipe/SwipeHeader.css | 14 + frontend/src/Components/Swipe/SwipeHeader.js | 242 +++++++++++++ .../Components/Swipe/SwipeHeaderConnector.js | 46 +++ 13 files changed, 1157 insertions(+), 535 deletions(-) create mode 100644 frontend/src/Author/Details/AuthorDetailsHeader.js create mode 100644 frontend/src/Author/Details/AuthorDetailsHeaderConnector.js create mode 100644 frontend/src/Book/Details/BookDetailsHeader.js create mode 100644 frontend/src/Book/Details/BookDetailsHeaderConnector.js create mode 100644 frontend/src/Components/Swipe/SwipeHeader.css create mode 100644 frontend/src/Components/Swipe/SwipeHeader.js create mode 100644 frontend/src/Components/Swipe/SwipeHeaderConnector.js diff --git a/frontend/src/Author/Details/AuthorDetails.css b/frontend/src/Author/Details/AuthorDetails.css index 7c5942678..242c52535 100644 --- a/frontend/src/Author/Details/AuthorDetails.css +++ b/frontend/src/Author/Details/AuthorDetails.css @@ -98,10 +98,13 @@ .authorNavigationButtons { position: absolute; right: 0; + z-index: 1; margin-top: 10px; + padding: 30px; white-space: nowrap; } +.authorUpButton, .authorNavigationButton { composes: button from '~Components/Link/IconButton.css'; @@ -182,9 +185,23 @@ padding: 20px 0; } + .authorNavigationButtons, .headerContent { padding: 15px; } + + .authorNavigationButtons { + margin-top: 5px; + } + + .authorNavigationButton { + display: none; + } + + .title { + font-size: 30px; + line-height: 50px; + } } @media only screen and (max-width: $breakpointLarge) { diff --git a/frontend/src/Author/Details/AuthorDetails.js b/frontend/src/Author/Details/AuthorDetails.js index 17f4d746b..df665b41a 100644 --- a/frontend/src/Author/Details/AuthorDetails.js +++ b/frontend/src/Author/Details/AuthorDetails.js @@ -1,60 +1,33 @@ -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 AuthorPoster from 'Author/AuthorPoster'; import DeleteAuthorModal from 'Author/Delete/DeleteAuthorModal'; import EditAuthorModalConnector from 'Author/Edit/EditAuthorModalConnector'; import AuthorHistoryTable from 'Author/History/AuthorHistoryTable'; import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable'; -import HeartRating from 'Components/HeartRating'; -import Icon from 'Components/Icon'; -import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; import Link from 'Components/Link/Link'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Marquee from 'Components/Marquee'; -import Measure from 'Components/Measure'; -import MonitorToggleButton from 'Components/MonitorToggleButton'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; 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 Popover from 'Components/Tooltip/Popover'; -import Tooltip from 'Components/Tooltip/Tooltip'; -import { align, icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import SwipeHeaderConnector from 'Components/Swipe/SwipeHeaderConnector'; +import { align, icons } from 'Helpers/Props'; import InteractiveSearchFilterMenuConnector from 'InteractiveSearch/InteractiveSearchFilterMenuConnector'; import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; -import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; -import fonts from 'Styles/Variables/fonts'; -import formatBytes from 'Utilities/Number/formatBytes'; -import stripHtml from 'Utilities/String/stripHtml'; import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; import InteractiveImportModal from '../../InteractiveImport/InteractiveImportModal'; -import AuthorAlternateTitles from './AuthorAlternateTitles'; -import AuthorDetailsLinks from './AuthorDetailsLinks'; +import AuthorDetailsHeaderConnector from './AuthorDetailsHeaderConnector'; import AuthorDetailsSeasonConnector from './AuthorDetailsSeasonConnector'; import AuthorDetailsSeriesConnector from './AuthorDetailsSeriesConnector'; -import AuthorTagsConnector from './AuthorTagsConnector'; import styles from './AuthorDetails.css'; -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, @@ -80,9 +53,7 @@ class AuthorDetails extends Component { allExpanded: false, allCollapsed: false, expandedState: {}, - selectedTabIndex: 0, - titleWidth: 0, - overviewHeight: 0 + selectedTabIndex: 0 }; } @@ -159,14 +130,6 @@ class AuthorDetails extends Component { this.setState({ selectedTabIndex: index }); } - onTitleMeasure = ({ width }) => { - this.setState({ titleWidth: width }); - } - - onOverviewMeasure = ({ height }) => { - this.setState({ overviewHeight: height }); - } - // // Render @@ -174,18 +137,8 @@ class AuthorDetails extends Component { const { id, authorName, - ratings, path, - statistics, - qualityProfileId, monitored, - status, - overview, - links, - images, - alternateTitles, - tags, - isSaving, isRefreshing, isSearching, isFetching, @@ -199,16 +152,10 @@ class AuthorDetails extends Component { hasBookFiles, previousAuthor, nextAuthor, - onMonitorTogglePress, onRefreshPress, onSearchPress } = this.props; - const { - bookFileCount, - sizeOnDisk - } = statistics; - const { isOrganizeModalOpen, isRetagModalOpen, @@ -218,23 +165,9 @@ class AuthorDetails extends Component { allExpanded, allCollapsed, expandedState, - selectedTabIndex, - titleWidth, - overviewHeight + selectedTabIndex } = this.state; - const marqueeWidth = (titleWidth - 165); - - const continuing = status === 'continuing'; - - 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) { @@ -312,240 +245,40 @@ class AuthorDetails extends Component { -
-
-
-
- -
- } + prevLink={`/author/${previousAuthor.titleSlug}`} + prevComponent={(width) => } + currentComponent={(width) => } + > +
+ -
- -
-
- -
- -
- - - - { - !!alternateTitles.length && -
- - } - title="Alternate Titles" - body={} - position={tooltipPositions.BOTTOM} - /> -
- } - - -
- - - - - -
- - -
-
- -
-
- -
- - - - - - - - - - - - - - - Links - - - } - tooltip={ - - } - kind={kinds.INVERSE} - position={tooltipPositions.BOTTOM} - /> - - { - !!tags.length && - - - - - Tags - - - } - tooltip={} - kind={kinds.INVERSE} - position={tooltipPositions.BOTTOM} - /> - - } -
- - - - + - +
{ @@ -742,6 +475,7 @@ AuthorDetails.propTypes = { hasBookFiles: PropTypes.bool.isRequired, previousAuthor: PropTypes.object.isRequired, nextAuthor: PropTypes.object.isRequired, + isSmallScreen: PropTypes.bool.isRequired, onMonitorTogglePress: PropTypes.func.isRequired, onRefreshPress: PropTypes.func.isRequired, onSearchPress: PropTypes.func.isRequired diff --git a/frontend/src/Author/Details/AuthorDetailsConnector.js b/frontend/src/Author/Details/AuthorDetailsConnector.js index 55a973290..06164cec4 100644 --- a/frontend/src/Author/Details/AuthorDetailsConnector.js +++ b/frontend/src/Author/Details/AuthorDetailsConnector.js @@ -14,6 +14,7 @@ import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions import { clearSeries, fetchSeries } from 'Store/Actions/seriesActions'; import createAllAuthorSelector from 'Store/Selectors/createAllAuthorsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createSortedSectionSelector from 'Store/Selectors/createSortedSectionSelector'; import { findCommand, isCommandExecuting } from 'Utilities/Command'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; @@ -94,7 +95,8 @@ function createMapStateToProps() { selectBookFiles, createAllAuthorSelector(), createCommandsSelector(), - (titleSlug, books, series, bookFiles, allAuthors, commands) => { + createDimensionsSelector(), + (titleSlug, books, series, bookFiles, allAuthors, commands, dimensions) => { const sortedAuthor = _.orderBy(allAuthors, 'sortName'); const authorIndex = _.findIndex(sortedAuthor, { titleSlug }); const author = sortedAuthor[authorIndex]; @@ -176,7 +178,8 @@ function createMapStateToProps() { series: seriesItems, hasBookFiles, previousAuthor, - nextAuthor + nextAuthor, + isSmallScreen: dimensions.isSmallScreen }; } ); diff --git a/frontend/src/Author/Details/AuthorDetailsHeader.js b/frontend/src/Author/Details/AuthorDetailsHeader.js new file mode 100644 index 000000000..dc9ab2d0d --- /dev/null +++ b/frontend/src/Author/Details/AuthorDetailsHeader.js @@ -0,0 +1,330 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import AuthorPoster from 'Author/AuthorPoster'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import Marquee from 'Components/Marquee'; +import Measure from 'Components/Measure'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import Popover from 'Components/Tooltip/Popover'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector'; +import fonts from 'Styles/Variables/fonts'; +import formatBytes from 'Utilities/Number/formatBytes'; +import stripHtml from 'Utilities/String/stripHtml'; +import AuthorAlternateTitles from './AuthorAlternateTitles'; +import AuthorDetailsLinks from './AuthorDetailsLinks'; +import AuthorTagsConnector from './AuthorTagsConnector'; +import styles from './AuthorDetails.css'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images) { + const fanartImage = images.find((x) => x.coverType === 'fanart'); + + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +class AuthorDetailsHeader extends Component { + + // + // Lifecyle + + constructor(props) { + super(props); + + this.state = { + overviewHeight: 0 + }; + } + + // + // Listeners + + onOverviewMeasure = ({ height }) => { + console.log(`overview measure ${height}`); + this.setState({ overviewHeight: height }); + } + // + // Render + + render() { + const { + id, + width, + authorName, + ratings, + path, + statistics, + qualityProfileId, + monitored, + status, + overview, + links, + images, + alternateTitles, + tags, + isSaving, + isSmallScreen, + onMonitorTogglePress + } = this.props; + + const { + bookFileCount, + sizeOnDisk + } = statistics; + + const { + overviewHeight + } = this.state; + + const marqueeWidth = width - (isSmallScreen ? 115 : 225); + + const continuing = status === 'continuing'; + + let bookFilesCountMessage = 'No book files'; + + if (bookFileCount === 1) { + bookFilesCountMessage = '1 book file'; + } else if (bookFileCount > 1) { + bookFilesCountMessage = `${bookFileCount} book files`; + } + + return ( +
+
+
+
+ +
+ + +
+
+
+
+ +
+ +
+ + + + { + !!alternateTitles.length && +
+ + } + title="Alternate Titles" + body={} + position={tooltipPositions.BOTTOM} + /> +
+ } + + + +
+
+ +
+
+ +
+ + + + + + + + + + + + + + + Links + + + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + { + !!tags.length && + + + + + Tags + + + } + tooltip={} + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + + } +
+ + + + + + + ); + } +} + +AuthorDetailsHeader.propTypes = { + id: PropTypes.number.isRequired, + width: 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, + status: PropTypes.string.isRequired, + overview: PropTypes.string, + 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, + isSmallScreen: PropTypes.bool.isRequired, + onMonitorTogglePress: PropTypes.func.isRequired +}; + +export default AuthorDetailsHeader; diff --git a/frontend/src/Author/Details/AuthorDetailsHeaderConnector.js b/frontend/src/Author/Details/AuthorDetailsHeaderConnector.js new file mode 100644 index 000000000..322f8bf7b --- /dev/null +++ b/frontend/src/Author/Details/AuthorDetailsHeaderConnector.js @@ -0,0 +1,71 @@ +/* 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 { toggleAuthorMonitored } from 'Store/Actions/authorActions'; +import createAuthorSelector from 'Store/Selectors/createAuthorSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import AuthorDetailsHeader from './AuthorDetailsHeader'; + +function createMapStateToProps() { + return createSelector( + (state) => state.authors, + createAuthorSelector(), + createDimensionsSelector(), + (authors, author, dimensions) => { + 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, + isSaving: authors.isSaving, + alternateTitles, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + toggleAuthorMonitored +}; + +class AuthorDetailsHeaderConnector extends Component { + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleAuthorMonitored({ + authorId: this.props.authorId, + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +AuthorDetailsHeaderConnector.propTypes = { + authorId: PropTypes.number.isRequired, + toggleAuthorMonitored: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(AuthorDetailsHeaderConnector); diff --git a/frontend/src/Book/Details/BookDetails.css b/frontend/src/Book/Details/BookDetails.css index 23740ac30..fb16a8426 100644 --- a/frontend/src/Book/Details/BookDetails.css +++ b/frontend/src/Book/Details/BookDetails.css @@ -54,7 +54,7 @@ .titleContainer { display: flex; - margin-top: -5px + margin-top: -5px; } .title { @@ -85,9 +85,13 @@ .bookNavigationButtons { position: absolute; right: 0; + z-index: 1; + margin-top: 10px; + padding: 30px; white-space: nowrap; } +.bookUpButton, .bookNavigationButton { composes: button from '~Components/Link/IconButton.css'; @@ -171,9 +175,23 @@ padding: 20px 0; } + .bookNavigationButtons, .headerContent { padding: 15px; } + + .bookNavigationButtons { + margin-top: 5px; + } + + .bookNavigationButton { + display: none; + } + + .title { + font-size: 30px; + line-height: 50px; + } } @media only screen and (max-width: $breakpointLarge) { diff --git a/frontend/src/Book/Details/BookDetails.js b/frontend/src/Book/Details/BookDetails.js index 81e4181d5..ea3b239aa 100644 --- a/frontend/src/Book/Details/BookDetails.js +++ b/frontend/src/Book/Details/BookDetails.js @@ -1,51 +1,27 @@ -import _ from 'lodash'; -import moment from 'moment'; 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 AuthorHistoryTable from 'Author/History/AuthorHistoryTable'; -import BookCover from 'Book/BookCover'; import DeleteBookModal from 'Book/Delete/DeleteBookModal'; import EditBookModalConnector from 'Book/Edit/EditBookModalConnector'; import BookFileEditorTable from 'BookFile/Editor/BookFileEditorTable'; -import HeartRating from 'Components/HeartRating'; -import Icon from 'Components/Icon'; -import Label from 'Components/Label'; import IconButton from 'Components/Link/IconButton'; import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Marquee from 'Components/Marquee'; -import Measure from 'Components/Measure'; -import MonitorToggleButton from 'Components/MonitorToggleButton'; import PageContent from 'Components/Page/PageContent'; import PageContentBody from 'Components/Page/PageContentBody'; 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 Tooltip from 'Components/Tooltip/Tooltip'; -import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import SwipeHeaderConnector from 'Components/Swipe/SwipeHeaderConnector'; +import { icons } from 'Helpers/Props'; import InteractiveSearchFilterMenuConnector from 'InteractiveSearch/InteractiveSearchFilterMenuConnector'; import InteractiveSearchTable from 'InteractiveSearch/InteractiveSearchTable'; import OrganizePreviewModalConnector from 'Organize/OrganizePreviewModalConnector'; import RetagPreviewModalConnector from 'Retag/RetagPreviewModalConnector'; -import fonts from 'Styles/Variables/fonts'; -import formatBytes from 'Utilities/Number/formatBytes'; -import stripHtml from 'Utilities/String/stripHtml'; -import BookDetailsLinks from './BookDetailsLinks'; +import BookDetailsHeaderConnector from './BookDetailsHeaderConnector'; import styles from './BookDetails.css'; -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?:/, ''); - } -} - class BookDetails extends Component { // @@ -59,9 +35,7 @@ class BookDetails extends Component { isRetagModalOpen: false, isEditBookModalOpen: false, isDeleteBookModalOpen: false, - selectedTabIndex: 0, - titleWidth: 0, - overviewHeight: 0 + selectedTabIndex: 0 }; } @@ -107,43 +81,22 @@ class BookDetails extends Component { this.setState({ selectedTabIndex: index }); } - onTitleMeasure = ({ width }) => { - this.setState({ titleWidth: width }); - } - - onOverviewMeasure = ({ height }) => { - this.setState({ overviewHeight: height }); - } - // // Render render() { const { id, - titleSlug, title, - seriesTitle, - pageCount, - overview, - statistics = {}, - monitored, - releaseDate, - ratings, - images, - links, - isSaving, isRefreshing, isFetching, isPopulated, bookFilesError, hasBookFiles, - shortDateFormat, author, previousBook, nextBook, isSearching, - onMonitorTogglePress, onRefreshPress, onSearchPress } = this.props; @@ -153,13 +106,9 @@ class BookDetails extends Component { isRetagModalOpen, isEditBookModalOpen, isDeleteBookModalOpen, - selectedTabIndex, - titleWidth, - overviewHeight + selectedTabIndex } = this.state; - const marqueeWidth = (titleWidth - 165); - return ( @@ -214,181 +163,58 @@ class BookDetails extends Component { -
-
-
-
- -
- ( + + )} + prevLink={`/book/${previousBook.titleSlug}`} + prevComponent={(width) => ( + + )} + currentComponent={(width) => ( + + )} + > +
+ -
- -
- -
- -
- -
- - + - - -
- - - - - -
- - -
-
- {seriesTitle} -
- -
- { - !!pageCount && - - {`${pageCount} pages`} - - } - - -
-
- -
- - - - - - - - - - - - Links - - - } - tooltip={ - - } - kind={kinds.INVERSE} - position={tooltipPositions.BOTTOM} - /> - -
- - - - + - +
{ @@ -502,7 +328,6 @@ BookDetails.propTypes = { seriesTitle: PropTypes.string.isRequired, pageCount: PropTypes.number, overview: PropTypes.string, - statistics: PropTypes.object.isRequired, releaseDate: PropTypes.string.isRequired, ratings: PropTypes.object.isRequired, images: PropTypes.arrayOf(PropTypes.object).isRequired, @@ -519,6 +344,7 @@ BookDetails.propTypes = { author: PropTypes.object, previousBook: PropTypes.object, nextBook: PropTypes.object, + isSmallScreen: PropTypes.bool.isRequired, onMonitorTogglePress: PropTypes.func.isRequired, onRefreshPress: PropTypes.func, onSearchPress: PropTypes.func.isRequired diff --git a/frontend/src/Book/Details/BookDetailsConnector.js b/frontend/src/Book/Details/BookDetailsConnector.js index 95619ec52..87f26fea0 100644 --- a/frontend/src/Book/Details/BookDetailsConnector.js +++ b/frontend/src/Book/Details/BookDetailsConnector.js @@ -11,6 +11,7 @@ import { executeCommand } from 'Store/Actions/commandActions'; import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions'; import createAllAuthorSelector from 'Store/Selectors/createAllAuthorsSelector'; import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import { findCommand, isCommandExecuting } from 'Utilities/Command'; import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; @@ -45,7 +46,8 @@ function createMapStateToProps() { createAllAuthorSelector(), createCommandsSelector(), createUISettingsSelector(), - (titleSlug, bookFiles, books, authors, commands, uiSettings) => { + createDimensionsSelector(), + (titleSlug, bookFiles, books, authors, commands, uiSettings, dimensions) => { const sortedBooks = _.orderBy(books.items, 'releaseDate'); const bookIndex = _.findIndex(sortedBooks, { titleSlug }); const book = sortedBooks[bookIndex]; @@ -90,7 +92,8 @@ function createMapStateToProps() { bookFilesError, hasBookFiles, previousBook, - nextBook + nextBook, + isSmallScreen: dimensions.isSmallScreen }; } ); diff --git a/frontend/src/Book/Details/BookDetailsHeader.js b/frontend/src/Book/Details/BookDetailsHeader.js new file mode 100644 index 000000000..e12bcb329 --- /dev/null +++ b/frontend/src/Book/Details/BookDetailsHeader.js @@ -0,0 +1,256 @@ +import moment from 'moment'; +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import BookCover from 'Book/BookCover'; +import HeartRating from 'Components/HeartRating'; +import Icon from 'Components/Icon'; +import Label from 'Components/Label'; +import Marquee from 'Components/Marquee'; +import Measure from 'Components/Measure'; +import MonitorToggleButton from 'Components/MonitorToggleButton'; +import Tooltip from 'Components/Tooltip/Tooltip'; +import { icons, kinds, sizes, tooltipPositions } from 'Helpers/Props'; +import fonts from 'Styles/Variables/fonts'; +import formatBytes from 'Utilities/Number/formatBytes'; +import stripHtml from 'Utilities/String/stripHtml'; +import BookDetailsLinks from './BookDetailsLinks'; +import styles from './BookDetails.css'; + +const defaultFontSize = parseInt(fonts.defaultFontSize); +const lineHeight = parseFloat(fonts.lineHeight); + +function getFanartUrl(images) { + const fanartImage = images.find((x) => x.coverType === 'fanart'); + + if (fanartImage) { + // Remove protocol + return fanartImage.url.replace(/^https?:/, ''); + } +} + +class BookDetailsHeader extends Component { + + // + // Lifecycle + + constructor(props) { + super(props); + + this.state = { + overviewHeight: 0 + }; + } + + // + // Listeners + + onOverviewMeasure = ({ height }) => { + this.setState({ overviewHeight: height }); + } + + // + // Render + + render() { + const { + width, + titleSlug, + title, + seriesTitle, + pageCount, + overview, + statistics = {}, + monitored, + releaseDate, + ratings, + images, + links, + isSaving, + shortDateFormat, + author, + isSmallScreen, + onMonitorTogglePress + } = this.props; + + const { + overviewHeight + } = this.state; + + const marqueeWidth = width - (isSmallScreen ? 115 : 225); + + return ( +
+
+
+
+ +
+ + +
+
+
+
+ +
+ +
+ + + + + + +
+
+ {seriesTitle} +
+ +
+ { + !!pageCount && + + {`${pageCount} pages`} + + } + + +
+
+ +
+ + + + + + + + + + + Links + + + } + tooltip={ + + } + kind={kinds.INVERSE} + position={tooltipPositions.BOTTOM} + /> + +
+ + + + + + + ); + } +} + +BookDetailsHeader.propTypes = { + id: PropTypes.number.isRequired, + width: PropTypes.number.isRequired, + titleSlug: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + seriesTitle: PropTypes.string.isRequired, + pageCount: PropTypes.number, + overview: PropTypes.string, + statistics: PropTypes.object.isRequired, + releaseDate: PropTypes.string.isRequired, + ratings: PropTypes.object.isRequired, + images: PropTypes.arrayOf(PropTypes.object).isRequired, + links: PropTypes.arrayOf(PropTypes.object).isRequired, + monitored: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + isSaving: PropTypes.bool.isRequired, + author: PropTypes.object, + isSmallScreen: PropTypes.bool.isRequired, + onMonitorTogglePress: PropTypes.func.isRequired +}; + +BookDetailsHeader.defaultProps = { + isSaving: false +}; + +export default BookDetailsHeader; diff --git a/frontend/src/Book/Details/BookDetailsHeaderConnector.js b/frontend/src/Book/Details/BookDetailsHeaderConnector.js new file mode 100644 index 000000000..8a8bebd6f --- /dev/null +++ b/frontend/src/Book/Details/BookDetailsHeaderConnector.js @@ -0,0 +1,62 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { toggleBooksMonitored } from 'Store/Actions/bookActions'; +import createBookSelector from 'Store/Selectors/createBookSelector'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import BookDetailsHeader from './BookDetailsHeader'; + +function createMapStateToProps() { + return createSelector( + createBookSelector(), + createUISettingsSelector(), + createDimensionsSelector(), + (book, uiSettings, dimensions) => { + + return { + ...book, + shortDateFormat: uiSettings.shortDateFormat, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +const mapDispatchToProps = { + toggleBooksMonitored +}; + +class BookDetailsHeaderConnector extends Component { + + // + // Listeners + + onMonitorTogglePress = (monitored) => { + this.props.toggleBooksMonitored({ + bookIds: [this.props.bookId], + monitored + }); + } + + // + // Render + + render() { + return ( + + ); + } +} + +BookDetailsHeaderConnector.propTypes = { + bookId: PropTypes.number, + toggleBooksMonitored: PropTypes.func.isRequired, + author: PropTypes.object +}; + +export default connect(createMapStateToProps, mapDispatchToProps)(BookDetailsHeaderConnector); diff --git a/frontend/src/Components/Swipe/SwipeHeader.css b/frontend/src/Components/Swipe/SwipeHeader.css new file mode 100644 index 000000000..58521b999 --- /dev/null +++ b/frontend/src/Components/Swipe/SwipeHeader.css @@ -0,0 +1,14 @@ +.container { + position: relative; + overflow: hidden; + box-sizing: border-box; +} + +.content { + position: relative; + display: flex; + width: 300%; + height: 100%; + transition: var(--transition); + transform: translateX(var(--translate)); +} diff --git a/frontend/src/Components/Swipe/SwipeHeader.js b/frontend/src/Components/Swipe/SwipeHeader.js new file mode 100644 index 000000000..b168ec286 --- /dev/null +++ b/frontend/src/Components/Swipe/SwipeHeader.js @@ -0,0 +1,242 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import Measure from 'Components/Measure'; +import styles from './SwipeHeader.css'; + +function cursorPosition(event) { + return event.touches ? event.touches[0].clientX : event.clientX; +} + +class SwipeHeader extends Component { + + // + // Lifecycle + + constructor(props) { + super(props); + + this.state = { + containerWidth: 0, + touching: null, + translate: 0, + stage: 'init', + url: null + }; + } + + componentWillUnmount() { + this.removeEventListeners(); + } + + // + // Listeners + + onMouseDown = (e) => { + if (!this.props.isSmallScreen || this.state.touching) { + return; + } + + this.startTouchPosition = cursorPosition(e); + this.initTranslate = this.state.translate; + + this.setState({ + stage: null, + touching: true }, () => { + this.addEventListeners(); + }); + }; + + addEventListeners = () => { + window.addEventListener('mousemove', this.onMouseMove); + window.addEventListener('touchmove', this.onMouseMove); + window.addEventListener('mouseup', this.onMouseUp); + window.addEventListener('touchend', this.onMouseUp); + } + + removeEventListeners = () => { + window.removeEventListener('mousemove', this.onMouseMove); + window.removeEventListener('touchmove', this.onMouseMove); + window.removeEventListener('mouseup', this.onMouseUp); + window.removeEventListener('touchend', this.onMouseUp); + } + + onMouseMove = (e) => { + const { + touching, + containerWidth + } = this.state; + + if (!touching) { + return; + } + + const translate = Math.max(Math.min(cursorPosition(e) - this.startTouchPosition + this.initTranslate, containerWidth), -1 * containerWidth); + + this.setState({ translate }); + }; + + onMouseUp = () => { + this.startTouchPosition = null; + + const { + nextLink, + prevLink, + navWidth + } = this.props; + + const { + containerWidth, + translate + } = this.state; + + const newState = { + touching: false + }; + + const acceptableMove = navWidth * 0.7; + const showNav = Math.abs(translate) >= acceptableMove; + const navWithoutConfirm = Math.abs(translate) >= containerWidth * 0.5; + + if (navWithoutConfirm) { + newState.translate = Math.sign(translate) * containerWidth; + } + + if (!showNav) { + newState.translate = 0; + newState.stage = null; + } + + if (showNav && !navWithoutConfirm) { + newState.translate = Math.sign(translate) * navWidth; + newState.stage = 'showNav'; + } + + this.setState(newState, () => { + if (navWithoutConfirm) { + this.onNavClick(translate < 0 ? nextLink : prevLink, Math.abs(translate) === containerWidth); + } + }); + + this.removeEventListeners(); + } + + onNavClick = (url, callTransition) => { + const { + containerWidth, + translate + } = this.state; + + this.setState({ + stage: 'navigating', + translate: Math.sign(translate) * containerWidth, + url + }, () => { + if (callTransition) { + this.onTransitionEnd(); + } + }); + } + + onTransitionEnd = (e) => { + const { + stage, + url + } = this.state; + + if (stage === 'navigating') { + this.setState({ + stage: 'navigated', + translate: 0, + url: null + }, () => { + this.props.onGoTo(url); + this.setState({ stage: null }); + }); + } + } + + onNext = () => { + this.onNavClick(this.props.nextLink); + } + + onPrev = () => { + this.onNavClick(this.props.prevLink); + } + + onContainerMeasure = ({ width }) => { + this.setState({ containerWidth: width }); + } + + // + // Render + + render() { + const { + transitionDuration, + className, + children, + prevComponent, + currentComponent, + nextComponent + } = this.props; + + const { + containerWidth, + translate, + touching, + stage + } = this.state; + + const useTransition = !touching && stage !== 'navigated' && stage !== 'init'; + + const style = { + '--translate': `${translate - containerWidth}px`, + '--transition': useTransition ? `transform ${transitionDuration}ms ease-out` : undefined + }; + + console.log(`stage: ${stage} translate: ${translate} width: ${containerWidth}`); + console.log(style); + + return ( + + {children} + +
+ {prevComponent(containerWidth)} + {currentComponent(containerWidth)} + {nextComponent(containerWidth)} +
+
+ ); + } +} + +SwipeHeader.propTypes = { + transitionDuration: PropTypes.number.isRequired, + navWidth: PropTypes.number.isRequired, + nextLink: PropTypes.string, + prevLink: PropTypes.string, + nextComponent: PropTypes.func.isRequired, + currentComponent: PropTypes.func.isRequired, + prevComponent: PropTypes.func.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + className: PropTypes.string, + onGoTo: PropTypes.func.isRequired, + children: PropTypes.node.isRequired +}; + +SwipeHeader.defaultProps = { + transitionDuration: 250, + navWidth: 75 +}; + +export default SwipeHeader; diff --git a/frontend/src/Components/Swipe/SwipeHeaderConnector.js b/frontend/src/Components/Swipe/SwipeHeaderConnector.js new file mode 100644 index 000000000..2bbf10029 --- /dev/null +++ b/frontend/src/Components/Swipe/SwipeHeaderConnector.js @@ -0,0 +1,46 @@ +import { push } from 'connected-react-router'; +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 SwipeHeader from './SwipeHeader'; + +function createMapStateToProps() { + return createSelector( + createDimensionsSelector(), + (dimensions) => { + return { + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onGoTo(url) { + dispatch(push(`${window.Readarr.urlBase}${url}`)); + } + }; +} + +class SwipeHeaderConnector extends Component { + + // + // Render + + render() { + return ( + + ); + } +} + +SwipeHeaderConnector.propTypes = { + onGoTo: PropTypes.func.isRequired +}; + +export default connect(createMapStateToProps, createMapDispatchToProps)(SwipeHeaderConnector);