From 2e851b058886764033a4caae913d8764d5392bcb Mon Sep 17 00:00:00 2001 From: Qstick Date: Thu, 22 Dec 2022 13:37:09 -0600 Subject: [PATCH] New: Mobile friendly manual search Fixes #490 --- .../src/Search/Mobile/SearchIndexOverview.css | 47 ++++ .../src/Search/Mobile/SearchIndexOverview.js | 189 ++++++++++++++++ .../Search/Mobile/SearchIndexOverviews.css | 11 + .../src/Search/Mobile/SearchIndexOverviews.js | 211 ++++++++++++++++++ .../Mobile/SearchIndexOverviewsConnector.js | 32 +++ frontend/src/Search/SearchIndex.js | 41 +--- .../Search/Table/SearchIndexItemConnector.js | 12 +- package.json | 1 + yarn.lock | 9 +- 9 files changed, 513 insertions(+), 40 deletions(-) create mode 100644 frontend/src/Search/Mobile/SearchIndexOverview.css create mode 100644 frontend/src/Search/Mobile/SearchIndexOverview.js create mode 100644 frontend/src/Search/Mobile/SearchIndexOverviews.css create mode 100644 frontend/src/Search/Mobile/SearchIndexOverviews.js create mode 100644 frontend/src/Search/Mobile/SearchIndexOverviewsConnector.js diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.css b/frontend/src/Search/Mobile/SearchIndexOverview.css new file mode 100644 index 000000000..9d28a55c2 --- /dev/null +++ b/frontend/src/Search/Mobile/SearchIndexOverview.css @@ -0,0 +1,47 @@ +$hoverScale: 1.05; + +.content { + display: flex; + flex-grow: 0; + margin-left: 5px; +} + +.container { + border-radius: 4px; + background-color: var(--cardBackgroundColor); +} + +.info { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.titleRow { + display: flex; + justify-content: space-between; + flex: 0 0 auto; + margin-bottom: 10px; + height: 38px; +} + +.indexerRow { + color: var(--disabledColor); +} + +.infoRow { + margin-bottom: 5px; +} + +.title { + width: 85%; + font-weight: 500; + font-size: 12px; +} + +.actions { + position: absolute; + right: 0; + white-space: nowrap; +} diff --git a/frontend/src/Search/Mobile/SearchIndexOverview.js b/frontend/src/Search/Mobile/SearchIndexOverview.js new file mode 100644 index 000000000..9de331e78 --- /dev/null +++ b/frontend/src/Search/Mobile/SearchIndexOverview.js @@ -0,0 +1,189 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import TextTruncate from 'react-text-truncate'; +import Label from 'Components/Label'; +import IconButton from 'Components/Link/IconButton'; +import SpinnerIconButton from 'Components/Link/SpinnerIconButton'; +import { icons, kinds } from 'Helpers/Props'; +import CategoryLabel from 'Search/Table/CategoryLabel'; +import Peers from 'Search/Table/Peers'; +import ProtocolLabel from 'Search/Table/ProtocolLabel'; +import dimensions from 'Styles/Variables/dimensions'; +import formatAge from 'Utilities/Number/formatAge'; +import formatBytes from 'Utilities/Number/formatBytes'; +import translate from 'Utilities/String/translate'; +import styles from './SearchIndexOverview.css'; + +const columnPadding = parseInt(dimensions.movieIndexColumnPadding); +const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen); + +function getContentHeight(rowHeight, isSmallScreen) { + const padding = isSmallScreen ? columnPaddingSmallScreen : columnPadding; + + return rowHeight - padding; +} + +function getDownloadIcon(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return icons.SPINNER; + } else if (isGrabbed) { + return icons.DOWNLOADING; + } else if (grabError) { + return icons.DOWNLOADING; + } + + return icons.DOWNLOAD; +} + +function getDownloadTooltip(isGrabbing, isGrabbed, grabError) { + if (isGrabbing) { + return ''; + } else if (isGrabbed) { + return translate('AddedToDownloadClient'); + } else if (grabError) { + return grabError; + } + + return translate('AddToDownloadClient'); +} + +class SearchIndexOverview extends Component { + + // + // Listeners + + onEditSeriesPress = () => { + this.setState({ isEditSeriesModalOpen: true }); + }; + + onEditSeriesModalClose = () => { + this.setState({ isEditSeriesModalOpen: false }); + }; + + // + // Render + + render() { + const { + title, + protocol, + downloadUrl, + categories, + seeders, + leechers, + size, + age, + ageHours, + ageMinutes, + indexer, + rowHeight, + isSmallScreen, + isGrabbed, + isGrabbing, + grabError + } = this.props; + + const contentHeight = getContentHeight(rowHeight, isSmallScreen); + + return ( +
+
+
+
+
+ +
+ +
+ + + +
+
+
+ {indexer} +
+
+ + + { + protocol === 'torrent' && + + } + + + + + + +
+
+
+
+ ); + } +} + +SearchIndexOverview.propTypes = { + guid: PropTypes.string.isRequired, + categories: PropTypes.arrayOf(PropTypes.object).isRequired, + protocol: PropTypes.string.isRequired, + age: PropTypes.number.isRequired, + ageHours: PropTypes.number.isRequired, + ageMinutes: PropTypes.number.isRequired, + publishDate: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + infoUrl: PropTypes.string.isRequired, + downloadUrl: PropTypes.string.isRequired, + indexerId: PropTypes.number.isRequired, + indexer: PropTypes.string.isRequired, + size: PropTypes.number.isRequired, + files: PropTypes.number, + grabs: PropTypes.number, + seeders: PropTypes.number, + leechers: PropTypes.number, + indexerFlags: PropTypes.arrayOf(PropTypes.string).isRequired, + rowHeight: PropTypes.number.isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + timeFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + onGrabPress: PropTypes.func.isRequired, + isGrabbing: PropTypes.bool.isRequired, + isGrabbed: PropTypes.bool.isRequired, + grabError: PropTypes.string +}; + +SearchIndexOverview.defaultProps = { + isGrabbing: false, + isGrabbed: false +}; + +export default SearchIndexOverview; diff --git a/frontend/src/Search/Mobile/SearchIndexOverviews.css b/frontend/src/Search/Mobile/SearchIndexOverviews.css new file mode 100644 index 000000000..f393dcbb0 --- /dev/null +++ b/frontend/src/Search/Mobile/SearchIndexOverviews.css @@ -0,0 +1,11 @@ +.grid { + flex: 1 0 auto; +} + +.container { + &:hover { + .content { + background-color: var(--tableRowHoverBackgroundColor); + } + } +} diff --git a/frontend/src/Search/Mobile/SearchIndexOverviews.js b/frontend/src/Search/Mobile/SearchIndexOverviews.js new file mode 100644 index 000000000..c8e5d1007 --- /dev/null +++ b/frontend/src/Search/Mobile/SearchIndexOverviews.js @@ -0,0 +1,211 @@ +import PropTypes from 'prop-types'; +import React, { Component } from 'react'; +import { Grid, WindowScroller } from 'react-virtualized'; +import Measure from 'Components/Measure'; +import SearchIndexItemConnector from 'Search/Table/SearchIndexItemConnector'; +import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter'; +import hasDifferentItemsOrOrder from 'Utilities/Object/hasDifferentItemsOrOrder'; +import SearchIndexOverview from './SearchIndexOverview'; +import styles from './SearchIndexOverviews.css'; + +class SearchIndexOverviews extends Component { + + // + // Lifecycle + + constructor(props, context) { + super(props, context); + + this.state = { + width: 0, + columnCount: 1, + rowHeight: 100, + scrollRestored: false + }; + + this._grid = null; + } + + componentDidUpdate(prevProps, prevState) { + const { + items, + sortKey, + jumpToCharacter, + scrollTop, + isSmallScreen + } = this.props; + + const { + width, + rowHeight, + scrollRestored + } = this.state; + + if (prevProps.sortKey !== sortKey) { + this.calculateGrid(this.state.width, isSmallScreen); + } + + if ( + this._grid && + (prevState.width !== width || + prevState.rowHeight !== rowHeight || + hasDifferentItemsOrOrder(prevProps.items, items) + ) + ) { + // recomputeGridSize also forces Grid to discard its cache of rendered cells + this._grid.recomputeGridSize(); + } + + if (this._grid && scrollTop !== 0 && !scrollRestored) { + this.setState({ scrollRestored: true }); + this._grid.scrollToPosition({ scrollTop }); + } + + if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) { + const index = getIndexOfFirstCharacter(items, jumpToCharacter); + + if (this._grid && index != null) { + + this._grid.scrollToCell({ + rowIndex: index, + columnIndex: 0 + }); + } + } + } + + // + // Control + + setGridRef = (ref) => { + this._grid = ref; + }; + + calculateGrid = (width = this.state.width, isSmallScreen) => { + const rowHeight = 100; + + this.setState({ + width, + rowHeight + }); + }; + + cellRenderer = ({ key, rowIndex, style }) => { + const { + items, + sortKey, + showRelativeDates, + shortDateFormat, + longDateFormat, + timeFormat, + isSmallScreen, + onGrabPress + } = this.props; + + const { + rowHeight + } = this.state; + + const release = items[rowIndex]; + + return ( +
+ +
+ ); + }; + + // + // Listeners + + onMeasure = ({ width }) => { + this.calculateGrid(width, this.props.isSmallScreen); + }; + + // + // Render + + render() { + const { + items + } = this.props; + + const { + width, + rowHeight + } = this.state; + + return ( + + + {({ height, registerChild, onChildScroll, scrollTop }) => { + if (!height) { + return
; + } + + return ( +
+ +
+ ); + } + } + + + ); + } +} + +SearchIndexOverviews.propTypes = { + items: PropTypes.arrayOf(PropTypes.object).isRequired, + sortKey: PropTypes.string, + scrollTop: PropTypes.number.isRequired, + jumpToCharacter: PropTypes.string, + scroller: PropTypes.instanceOf(Element).isRequired, + showRelativeDates: PropTypes.bool.isRequired, + shortDateFormat: PropTypes.string.isRequired, + longDateFormat: PropTypes.string.isRequired, + isSmallScreen: PropTypes.bool.isRequired, + timeFormat: PropTypes.string.isRequired, + onGrabPress: PropTypes.func.isRequired +}; + +export default SearchIndexOverviews; diff --git a/frontend/src/Search/Mobile/SearchIndexOverviewsConnector.js b/frontend/src/Search/Mobile/SearchIndexOverviewsConnector.js new file mode 100644 index 000000000..39f376e55 --- /dev/null +++ b/frontend/src/Search/Mobile/SearchIndexOverviewsConnector.js @@ -0,0 +1,32 @@ +import { connect } from 'react-redux'; +import { createSelector } from 'reselect'; +import { grabRelease } from 'Store/Actions/releaseActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import SearchIndexOverviews from './SearchIndexOverviews'; + +function createMapStateToProps() { + return createSelector( + createUISettingsSelector(), + createDimensionsSelector(), + (uiSettings, dimensions) => { + return { + showRelativeDates: uiSettings.showRelativeDates, + shortDateFormat: uiSettings.shortDateFormat, + longDateFormat: uiSettings.longDateFormat, + timeFormat: uiSettings.timeFormat, + isSmallScreen: dimensions.isSmallScreen + }; + } + ); +} + +function createMapDispatchToProps(dispatch, props) { + return { + onGrabPress(payload) { + dispatch(grabRelease(payload)); + } + }; +} + +export default connect(createMapStateToProps, createMapDispatchToProps)(SearchIndexOverviews); diff --git a/frontend/src/Search/SearchIndex.js b/frontend/src/Search/SearchIndex.js index 5a7d9d484..884e279d2 100644 --- a/frontend/src/Search/SearchIndex.js +++ b/frontend/src/Search/SearchIndex.js @@ -11,8 +11,6 @@ import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection'; import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator'; import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper'; import { align, icons, sortDirections } from 'Helpers/Props'; -import AddIndexerModal from 'Indexer/Add/AddIndexerModal'; -import EditIndexerModalConnector from 'Indexer/Edit/EditIndexerModalConnector'; import NoIndexer from 'Indexer/NoIndexer'; import * as keyCodes from 'Utilities/Constants/keyCodes'; import getErrorMessage from 'Utilities/Object/getErrorMessage'; @@ -23,12 +21,17 @@ import selectAll from 'Utilities/Table/selectAll'; import toggleSelected from 'Utilities/Table/toggleSelected'; import SearchIndexFilterMenu from './Menus/SearchIndexFilterMenu'; import SearchIndexSortMenu from './Menus/SearchIndexSortMenu'; +import SearchIndexOverviewsConnector from './Mobile/SearchIndexOverviewsConnector'; import NoSearchResults from './NoSearchResults'; import SearchFooterConnector from './SearchFooterConnector'; import SearchIndexTableConnector from './Table/SearchIndexTableConnector'; import styles from './SearchIndex.css'; -function getViewComponent() { +function getViewComponent(isSmallScreen) { + if (isSmallScreen) { + return SearchIndexOverviewsConnector; + } + return SearchIndexTableConnector; } @@ -44,8 +47,6 @@ class SearchIndex extends Component { scroller: null, jumpBarItems: { order: [] }, jumpToCharacter: null, - isAddIndexerModalOpen: false, - isEditIndexerModalOpen: false, searchType: null, lastToggled: null, allSelected: false, @@ -177,21 +178,6 @@ class SearchIndex extends Component { // // Listeners - onAddIndexerPress = () => { - this.setState({ isAddIndexerModalOpen: true }); - }; - - onAddIndexerModalClose = ({ indexerSelected = false } = {}) => { - this.setState({ - isAddIndexerModalOpen: false, - isEditIndexerModalOpen: indexerSelected - }); - }; - - onEditIndexerModalClose = () => { - this.setState({ isEditIndexerModalOpen: false }); - }; - onJumpBarItemPress = (jumpToCharacter) => { this.setState({ jumpToCharacter }); }; @@ -253,6 +239,7 @@ class SearchIndex extends Component { onScroll, onSortSelect, onFilterSelect, + isSmallScreen, hasIndexers, ...otherProps } = this.props; @@ -260,8 +247,6 @@ class SearchIndex extends Component { const { scroller, jumpBarItems, - isAddIndexerModalOpen, - isEditIndexerModalOpen, jumpToCharacter, selectedState, allSelected, @@ -270,7 +255,7 @@ class SearchIndex extends Component { const selectedIndexerIds = this.getSelectedIds(); - const ViewComponent = getViewComponent(); + const ViewComponent = getViewComponent(isSmallScreen); const isLoaded = !!(!error && isPopulated && items.length && scroller); const hasNoIndexer = !totalItems; @@ -384,16 +369,6 @@ class SearchIndex extends Component { onSearchPress={this.onSearchPress} onBulkGrabPress={this.onBulkGrabPress} /> - - - - ); } diff --git a/frontend/src/Search/Table/SearchIndexItemConnector.js b/frontend/src/Search/Table/SearchIndexItemConnector.js index 04db22740..da1133b6d 100644 --- a/frontend/src/Search/Table/SearchIndexItemConnector.js +++ b/frontend/src/Search/Table/SearchIndexItemConnector.js @@ -18,20 +18,20 @@ function createMapStateToProps() { return createSelector( createReleaseSelector(), ( - movie + release ) => { - // If a movie is deleted this selector may fire before the parent - // selecors, which will result in an undefined movie, if that happens + // If a release is deleted this selector may fire before the parent + // selecors, which will result in an undefined release, if that happens // we want to return early here and again in the render function to avoid - // trying to show a movie that has no information available. + // trying to show a release that has no information available. - if (!movie) { + if (!release) { return {}; } return { - ...movie + ...release }; } ); diff --git a/package.json b/package.json index 3ed9997aa..57c4e0e24 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "react-redux": "8.0.5", "react-router": "5.2.0", "react-router-dom": "5.2.0", + "react-text-truncate": "0.19.0", "react-virtualized": "9.21.1", "redux": "4.2.0", "redux-actions": "2.6.5", diff --git a/yarn.lock b/yarn.lock index 5162372bf..573ee0d51 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4714,7 +4714,7 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== -prop-types@15.8.1, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: +prop-types@15.8.1, prop-types@^15.5.0, prop-types@^15.5.10, prop-types@^15.5.4, prop-types@^15.5.6, prop-types@^15.5.7, prop-types@^15.6.0, prop-types@^15.6.1, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -4996,6 +4996,13 @@ react-side-effect@^1.0.2: dependencies: shallowequal "^1.0.1" +react-text-truncate@0.19.0: + version "0.19.0" + resolved "https://registry.yarnpkg.com/react-text-truncate/-/react-text-truncate-0.19.0.tgz#60bc5ecf29a03ebc256f31f90a2d8402176aac91" + integrity sha512-QxHpZABfGG0Z3WEYbRTZ+rXdZn50Zvp+sWZXgVAd7FCKAMzv/kcwctTpNmWgXDTpAoHhMjOVwmgRtX3x5yeF4w== + dependencies: + prop-types "^15.5.7" + react-themeable@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/react-themeable/-/react-themeable-1.1.0.tgz#7d4466dd9b2b5fa75058727825e9f152ba379a0e"