diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index bac4d40df4..3ff2523188 100644 --- a/frontend/src/App/AppRoutes.tsx +++ b/frontend/src/App/AppRoutes.tsx @@ -5,7 +5,7 @@ import History from 'Activity/History/History'; import Queue from 'Activity/Queue/Queue'; import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector'; import ImportMovies from 'AddMovie/ImportMovie/ImportMovies'; -import CalendarPageConnector from 'Calendar/CalendarPageConnector'; +import CalendarPage from 'Calendar/CalendarPage'; import CollectionConnector from 'Collection/CollectionConnector'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; @@ -73,7 +73,7 @@ function AppRoutes() { Calendar */} - + {/* Activity diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index faaff34086..d5e16cdb9e 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -68,6 +68,7 @@ export interface AppSectionState { prevVersion?: string; dimensions: { isSmallScreen: boolean; + isLargeScreen: boolean; width: number; height: number; }; diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts index d13e7af6eb..d439c62b59 100644 --- a/frontend/src/App/State/CalendarAppState.ts +++ b/frontend/src/App/State/CalendarAppState.ts @@ -1,10 +1,26 @@ +import moment from 'moment'; import AppSectionState, { AppSectionFilterState, } from 'App/State/AppSectionState'; -import Movie from 'Movie/Movie'; +import { CalendarView } from 'Calendar/calendarViews'; +import { CalendarItem } from 'typings/Calendar'; + +interface CalendarOptions { + showMovieInformation: boolean; + showCutoffUnmetIcon: boolean; + fullColorEvents: boolean; +} interface CalendarAppState - extends AppSectionState, - AppSectionFilterState {} + extends AppSectionState, + AppSectionFilterState { + searchMissingCommandId: number | null; + start: moment.Moment; + end: moment.Moment; + dates: string[]; + time: string; + view: CalendarView; + options: CalendarOptions; +} export default CalendarAppState; diff --git a/frontend/src/Calendar/Agenda/Agenda.js b/frontend/src/Calendar/Agenda/Agenda.js deleted file mode 100644 index 48526407e4..0000000000 --- a/frontend/src/Calendar/Agenda/Agenda.js +++ /dev/null @@ -1,69 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React from 'react'; -import AgendaEventConnector from './AgendaEventConnector'; -import styles from './Agenda.css'; - -function Agenda(props) { - const { - items, - start, - end - } = props; - - const startDateParsed = Date.parse(start); - const endDateParsed = Date.parse(end); - - items.forEach((item) => { - const cinemaDateParsed = Date.parse(item.inCinemas); - const digitalDateParsed = Date.parse(item.digitalRelease); - const physicalDateParsed = Date.parse(item.physicalRelease); - const dates = []; - - if (cinemaDateParsed > 0 && cinemaDateParsed >= startDateParsed && cinemaDateParsed <= endDateParsed) { - dates.push(cinemaDateParsed); - } - if (digitalDateParsed > 0 && digitalDateParsed >= startDateParsed && digitalDateParsed <= endDateParsed) { - dates.push(digitalDateParsed); - } - if (physicalDateParsed > 0 && physicalDateParsed >= startDateParsed && physicalDateParsed <= endDateParsed) { - dates.push(physicalDateParsed); - } - - item.sortDate = Math.min(...dates); - item.cinemaDateParsed = cinemaDateParsed; - item.digitalDateParsed = digitalDateParsed; - item.physicalDateParsed = physicalDateParsed; - }); - - items.sort((a, b) => ((a.sortDate > b.sortDate) ? 1 : -1)); - - return ( -
- { - items.map((item, index) => { - const momentDate = moment(item.sortDate); - const showDate = index === 0 || - !moment(items[index - 1].sortDate).isSame(momentDate, 'day'); - - return ( - - ); - }) - } -
- ); -} - -Agenda.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).isRequired, - start: PropTypes.string.isRequired, - end: PropTypes.string.isRequired -}; - -export default Agenda; diff --git a/frontend/src/Calendar/Agenda/Agenda.tsx b/frontend/src/Calendar/Agenda/Agenda.tsx new file mode 100644 index 0000000000..a4856d2927 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.tsx @@ -0,0 +1,81 @@ +import moment from 'moment'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Movie from 'Movie/Movie'; +import AgendaEvent from './AgendaEvent'; +import styles from './Agenda.css'; + +interface AgendaMovie extends Movie { + sortDate: moment.Moment; +} + +function Agenda() { + const { start, end, items } = useSelector( + (state: AppState) => state.calendar + ); + + const events = useMemo(() => { + const result = items.map((item): AgendaMovie => { + const { inCinemas, digitalRelease, physicalRelease } = item; + + const dates = []; + + if (inCinemas) { + const inCinemasMoment = moment(inCinemas); + + if (inCinemasMoment.isAfter(start) && inCinemasMoment.isBefore(end)) { + dates.push(inCinemasMoment); + } + } + + if (digitalRelease) { + const digitalReleaseMoment = moment(digitalRelease); + + if ( + digitalReleaseMoment.isAfter(start) && + digitalReleaseMoment.isBefore(end) + ) { + dates.push(digitalReleaseMoment); + } + } + + if (physicalRelease) { + const physicalReleaseMoment = moment(physicalRelease); + + if ( + physicalReleaseMoment.isAfter(start) && + physicalReleaseMoment.isBefore(end) + ) { + dates.push(physicalReleaseMoment); + } + } + + const sortDate = moment.min(...dates); + + return { + ...item, + sortDate, + }; + }); + + result.sort((a, b) => (a.sortDate > b.sortDate ? 1 : -1)); + + return result; + }, [items, start, end]); + + return ( +
+ {events.map((item, index) => { + const momentDate = moment(item.sortDate); + const showDate = + index === 0 || + !moment(events[index - 1].sortDate).isSame(momentDate, 'day'); + + return ; + })} +
+ ); +} + +export default Agenda; diff --git a/frontend/src/Calendar/Agenda/AgendaConnector.js b/frontend/src/Calendar/Agenda/AgendaConnector.js deleted file mode 100644 index b6f2388736..0000000000 --- a/frontend/src/Calendar/Agenda/AgendaConnector.js +++ /dev/null @@ -1,14 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import Agenda from './Agenda'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (calendar) => { - return calendar; - } - ); -} - -export default connect(createMapStateToProps)(Agenda); diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css b/frontend/src/Calendar/Agenda/AgendaEvent.css index 28de0b1ca5..8d954fa715 100644 --- a/frontend/src/Calendar/Agenda/AgendaEvent.css +++ b/frontend/src/Calendar/Agenda/AgendaEvent.css @@ -53,6 +53,13 @@ margin-right: 10px; } +.releaseIcon { + margin-right: 20px; + width: 25px; + cursor: default; + pointer-events: all; +} + .statusIcon { margin-left: 3px; cursor: default; @@ -107,8 +114,3 @@ flex: 0 0 100%; } } - -.releaseIcon { - margin-right: 20px; - width: 25px; -} diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js deleted file mode 100644 index 3d31371c9d..0000000000 --- a/frontend/src/Calendar/Agenda/AgendaEvent.js +++ /dev/null @@ -1,190 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import styles from './AgendaEvent.css'; - -class AgendaEvent extends Component { - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onPress = () => { - this.setState({ isDetailsModalOpen: true }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }); - }; - - // - // Render - - render() { - const { - movieFile, - title, - titleSlug, - genres, - isAvailable, - inCinemas, - digitalRelease, - physicalRelease, - monitored, - hasFile, - grabbed, - queueItem, - showDate, - showMovieInformation, - showCutoffUnmetIcon, - longDateFormat, - colorImpairedMode, - cinemaDateParsed, - digitalDateParsed, - physicalDateParsed, - sortDate - } = this.props; - - let startTime = null; - let releaseIcon = null; - - if (physicalDateParsed === sortDate) { - startTime = physicalRelease; - releaseIcon = icons.DISC; - } - - if (digitalDateParsed === sortDate) { - startTime = digitalRelease; - releaseIcon = icons.MOVIE_FILE; - } - - if (cinemaDateParsed === sortDate) { - startTime = inCinemas; - releaseIcon = icons.IN_CINEMAS; - } - - startTime = moment(startTime); - const downloading = !!(queueItem || grabbed); - const isMonitored = monitored; - const statusStyle = getStatusStyle(hasFile, downloading, isMonitored, isAvailable); - const joinedGenres = genres.slice(0, 2).join(', '); - const link = `/movie/${titleSlug}`; - - return ( -
- - -
-
- {showDate ? startTime.format(longDateFormat) : null} -
- -
- -
- -
-
- {title} -
- - { - showMovieInformation && -
- {joinedGenres} -
- } - - { - !!queueItem && - - - - } - - { - !queueItem && grabbed && - - } - - { - showCutoffUnmetIcon && !!movieFile && movieFile.qualityCutoffNotMet && - - } -
-
-
- ); - } -} - -AgendaEvent.propTypes = { - id: PropTypes.number.isRequired, - movieFile: PropTypes.object, - title: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - isAvailable: PropTypes.bool.isRequired, - inCinemas: PropTypes.string, - digitalRelease: PropTypes.string, - physicalRelease: PropTypes.string, - monitored: PropTypes.bool.isRequired, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - showDate: PropTypes.bool.isRequired, - showMovieInformation: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - colorImpairedMode: PropTypes.bool.isRequired, - cinemaDateParsed: PropTypes.number, - digitalDateParsed: PropTypes.number, - physicalDateParsed: PropTypes.number, - sortDate: PropTypes.number -}; - -AgendaEvent.defaultProps = { - genres: [] -}; - -export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx new file mode 100644 index 0000000000..a312f1017c --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -0,0 +1,160 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons, kinds } from 'Helpers/Props'; +import useMovieFile from 'MovieFile/useMovieFile'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import styles from './AgendaEvent.css'; + +interface AgendaEventProps { + id: number; + movieFileId: number; + title: string; + titleSlug: string; + genres: string[]; + inCinemas?: string; + digitalRelease?: string; + physicalRelease?: string; + sortDate: moment.Moment; + isAvailable: boolean; + monitored: boolean; + hasFile: boolean; + grabbed?: boolean; + showDate: boolean; +} + +function AgendaEvent({ + id, + movieFileId, + title, + titleSlug, + genres = [], + inCinemas, + digitalRelease, + physicalRelease, + sortDate, + isAvailable, + monitored: isMonitored, + hasFile, + grabbed, + showDate, +}: AgendaEventProps) { + const movieFile = useMovieFile(movieFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + const { longDateFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { showMovieInformation, showCutoffUnmetIcon } = useSelector( + (state: AppState) => state.calendar.options + ); + + const { eventDate, eventTitle, releaseIcon } = useMemo(() => { + if (physicalRelease && sortDate.isSame(moment(physicalRelease), 'day')) { + return { + eventDate: physicalRelease, + eventTitle: translate('PhysicalRelease'), + releaseIcon: icons.DISC, + }; + } + + if (digitalRelease && sortDate.isSame(moment(digitalRelease), 'day')) { + return { + eventDate: digitalRelease, + eventTitle: translate('DigitalRelease'), + releaseIcon: icons.MOVIE_FILE, + }; + } + + if (inCinemas && sortDate.isSame(moment(inCinemas), 'day')) { + return { + eventDate: inCinemas, + eventTitle: translate('InCinemas'), + releaseIcon: icons.IN_CINEMAS, + }; + } + + return { + eventDate: null, + eventTitle: null, + releaseIcon: null, + }; + }, [inCinemas, digitalRelease, physicalRelease, sortDate]); + + const downloading = !!(queueItem || grabbed); + const statusStyle = getStatusStyle( + hasFile, + downloading, + isMonitored, + isAvailable + ); + const joinedGenres = genres.slice(0, 2).join(', '); + const link = `/movie/${titleSlug}`; + + return ( +
+ + +
+
+ {showDate && eventDate + ? moment(eventDate).format(longDateFormat) + : null} +
+ +
+ {releaseIcon ? ( + + ) : null} +
+ +
+
{title}
+ + {showMovieInformation ? ( +
{joinedGenres}
+ ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && movieFile && movieFile.qualityCutoffNotMet ? ( + + ) : null} +
+
+
+ ); +} + +export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEventConnector.js b/frontend/src/Calendar/Agenda/AgendaEventConnector.js deleted file mode 100644 index ea653364ae..0000000000 --- a/frontend/src/Calendar/Agenda/AgendaEventConnector.js +++ /dev/null @@ -1,30 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createMovieFileSelector from 'Store/Selectors/createMovieFileSelector'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import AgendaEvent from './AgendaEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createMovieSelector(), - createMovieFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, movie, movieFile, queueItem, uiSettings) => { - return { - movie, - movieFile, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - longDateFormat: uiSettings.longDateFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(AgendaEvent); diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js deleted file mode 100644 index 0a2fd671d2..0000000000 --- a/frontend/src/Calendar/Calendar.js +++ /dev/null @@ -1,67 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Alert from 'Components/Alert'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import { kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import AgendaConnector from './Agenda/AgendaConnector'; -import * as calendarViews from './calendarViews'; -import CalendarDaysConnector from './Day/CalendarDaysConnector'; -import DaysOfWeekConnector from './Day/DaysOfWeekConnector'; -import CalendarHeaderConnector from './Header/CalendarHeaderConnector'; -import styles from './Calendar.css'; - -class Calendar extends Component { - - // - // Render - - render() { - const { - isFetching, - isPopulated, - error, - view - } = this.props; - - return ( -
- { - isFetching && !isPopulated && - - } - - { - !isFetching && !!error && - {translate('CalendarLoadError')} - } - - { - !error && isPopulated && view === calendarViews.AGENDA && -
- - -
- } - - { - !error && isPopulated && view !== calendarViews.AGENDA && -
- - - -
- } -
- ); - } -} - -Calendar.propTypes = { - isFetching: PropTypes.bool.isRequired, - isPopulated: PropTypes.bool.isRequired, - error: PropTypes.object, - view: PropTypes.string.isRequired -}; - -export default Calendar; diff --git a/frontend/src/Calendar/Calendar.tsx b/frontend/src/Calendar/Calendar.tsx new file mode 100644 index 0000000000..dca63c9c07 --- /dev/null +++ b/frontend/src/Calendar/Calendar.tsx @@ -0,0 +1,164 @@ +import React, { useCallback, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Alert from 'Components/Alert'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import Movie from 'Movie/Movie'; +import { + clearCalendar, + fetchCalendar, + gotoCalendarToday, +} from 'Store/Actions/calendarActions'; +import { + clearMovieFiles, + fetchMovieFiles, +} from 'Store/Actions/movieFileActions'; +import { + clearQueueDetails, + fetchQueueDetails, +} from 'Store/Actions/queueActions'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; +import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; +import { + registerPagePopulator, + unregisterPagePopulator, +} from 'Utilities/pagePopulator'; +import translate from 'Utilities/String/translate'; +import Agenda from './Agenda/Agenda'; +import CalendarDays from './Day/CalendarDays'; +import DaysOfWeek from './Day/DaysOfWeek'; +import CalendarHeader from './Header/CalendarHeader'; +import styles from './Calendar.css'; + +const UPDATE_DELAY = 3600000; // 1 hour + +function Calendar() { + const dispatch = useDispatch(); + const requestCurrentPage = useCurrentPage(); + const updateTimeout = useRef>(); + + const { isFetching, isPopulated, error, items, time, view } = useSelector( + (state: AppState) => state.calendar + ); + + const isRefreshingMovie = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_MOVIE) + ); + + const firstDayOfWeek = useSelector( + (state: AppState) => state.settings.ui.item.firstDayOfWeek + ); + + const wasRefreshingMovie = usePrevious(isRefreshingMovie); + const previousFirstDayOfWeek = usePrevious(firstDayOfWeek); + const previousItems = usePrevious(items); + + const handleScheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + function updateCalendar() { + dispatch(gotoCalendarToday()); + updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); + } + + updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY); + }, [dispatch]); + + useEffect(() => { + handleScheduleUpdate(); + + return () => { + dispatch(clearCalendar()); + dispatch(clearQueueDetails()); + dispatch(clearMovieFiles()); + clearTimeout(updateTimeout.current); + }; + }, [dispatch, handleScheduleUpdate]); + + useEffect(() => { + if (requestCurrentPage) { + dispatch(fetchCalendar()); + } else { + dispatch(gotoCalendarToday()); + } + }, [requestCurrentPage, dispatch]); + + useEffect(() => { + const repopulate = () => { + dispatch(fetchQueueDetails({ time, view })); + dispatch(fetchCalendar({ time, view })); + }; + + registerPagePopulator(repopulate, ['movieFileUpdated', 'movieFileDeleted']); + + return () => { + unregisterPagePopulator(repopulate); + }; + }, [time, view, dispatch]); + + useEffect(() => { + handleScheduleUpdate(); + }, [time, handleScheduleUpdate]); + + useEffect(() => { + if ( + previousFirstDayOfWeek != null && + firstDayOfWeek !== previousFirstDayOfWeek + ) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]); + + useEffect(() => { + if (wasRefreshingMovie && !isRefreshingMovie) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, isRefreshingMovie, wasRefreshingMovie, dispatch]); + + useEffect(() => { + if (!previousItems || hasDifferentItems(items, previousItems)) { + const movieIds = selectUniqueIds(items, 'id'); + const movieFileIds = selectUniqueIds(items, 'movieFileId'); + + if (items.length) { + dispatch(fetchQueueDetails({ movieIds })); + } + + if (movieFileIds.length) { + dispatch(fetchMovieFiles({ movieFileIds })); + } + } + }, [items, previousItems, dispatch]); + + return ( +
+ {isFetching && !isPopulated ? : null} + + {!isFetching && error ? ( + {translate('CalendarLoadError')} + ) : null} + + {!error && isPopulated && view === 'agenda' ? ( +
+ + +
+ ) : null} + + {!error && isPopulated && view !== 'agenda' ? ( +
+ + + +
+ ) : null} +
+ ); +} + +export default Calendar; diff --git a/frontend/src/Calendar/CalendarConnector.js b/frontend/src/Calendar/CalendarConnector.js deleted file mode 100644 index cf3722b244..0000000000 --- a/frontend/src/Calendar/CalendarConnector.js +++ /dev/null @@ -1,195 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import * as calendarActions from 'Store/Actions/calendarActions'; -import { clearMovieFiles, fetchMovieFiles } from 'Store/Actions/movieFileActions'; -import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import hasDifferentItems from 'Utilities/Object/hasDifferentItems'; -import selectUniqueIds from 'Utilities/Object/selectUniqueIds'; -import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator'; -import Calendar from './Calendar'; - -const UPDATE_DELAY = 3600000; // 1 hour - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (state) => state.settings.ui.item.firstDayOfWeek, - createCommandExecutingSelector(commandNames.REFRESH_MOVIE), - (calendar, firstDayOfWeek, isRefreshingMovie) => { - return { - ...calendar, - isRefreshingMovie, - firstDayOfWeek - }; - } - ); -} - -const mapDispatchToProps = { - ...calendarActions, - fetchMovieFiles, - clearMovieFiles, - fetchQueueDetails, - clearQueueDetails -}; - -class CalendarConnector extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.updateTimeoutId = null; - } - - componentDidMount() { - const { - useCurrentPage, - fetchCalendar, - gotoCalendarToday - } = this.props; - - registerPagePopulator(this.repopulate, ['movieFileUpdated', 'movieFileDeleted']); - - if (useCurrentPage) { - fetchCalendar(); - } else { - gotoCalendarToday(); - } - - this.scheduleUpdate(); - } - - componentDidUpdate(prevProps) { - const { - items, - time, - view, - isRefreshingMovie, - firstDayOfWeek - } = this.props; - - if (hasDifferentItems(prevProps.items, items)) { - const movieFileIds = selectUniqueIds(items, 'movieFileId'); - - if (movieFileIds.length) { - this.props.fetchMovieFiles({ movieFileIds }); - } - - if (items.length) { - this.props.fetchQueueDetails(); - } - } - - if (prevProps.time !== time) { - this.scheduleUpdate(); - } - - if (prevProps.firstDayOfWeek !== firstDayOfWeek) { - this.props.fetchCalendar({ time, view }); - } - - if (prevProps.isRefreshingMovie && !isRefreshingMovie) { - this.props.fetchCalendar({ time, view }); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearCalendar(); - this.props.clearQueueDetails(); - this.props.clearMovieFiles(); - this.clearUpdateTimeout(); - } - - // - // Control - - repopulate = () => { - const { - time, - view - } = this.props; - - this.props.fetchQueueDetails({ time, view }); - this.props.fetchCalendar({ time, view }); - }; - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - - this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - updateCalendar = () => { - this.props.gotoCalendarToday(); - this.scheduleUpdate(); - }; - - // - // Listeners - - onCalendarViewChange = (view) => { - this.props.setCalendarView({ view }); - }; - - onTodayPress = () => { - this.props.gotoCalendarToday(); - }; - - onPreviousPress = () => { - this.props.gotoCalendarPreviousRange(); - }; - - onNextPress = () => { - this.props.gotoCalendarNextRange(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarConnector.propTypes = { - useCurrentPage: PropTypes.bool.isRequired, - time: PropTypes.string, - view: PropTypes.string.isRequired, - firstDayOfWeek: PropTypes.number.isRequired, - items: PropTypes.arrayOf(PropTypes.object).isRequired, - isRefreshingMovie: PropTypes.bool.isRequired, - setCalendarView: PropTypes.func.isRequired, - gotoCalendarToday: PropTypes.func.isRequired, - gotoCalendarPreviousRange: PropTypes.func.isRequired, - gotoCalendarNextRange: PropTypes.func.isRequired, - clearCalendar: PropTypes.func.isRequired, - fetchCalendar: PropTypes.func.isRequired, - fetchMovieFiles: PropTypes.func.isRequired, - clearMovieFiles: PropTypes.func.isRequired, - fetchQueueDetails: PropTypes.func.isRequired, - clearQueueDetails: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector); diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js deleted file mode 100644 index 5a75e64cef..0000000000 --- a/frontend/src/Calendar/CalendarPage.js +++ /dev/null @@ -1,224 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Measure from 'Components/Measure'; -import FilterMenu from 'Components/Menu/FilterMenu'; -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 { align, icons } from 'Helpers/Props'; -import NoMovie from 'Movie/NoMovie'; -import getErrorMessage from 'Utilities/Object/getErrorMessage'; -import translate from 'Utilities/String/translate'; -import CalendarConnector from './CalendarConnector'; -import CalendarFilterModal from './CalendarFilterModal'; -import CalendarLinkModal from './iCal/CalendarLinkModal'; -import LegendConnector from './Legend/LegendConnector'; -import CalendarOptionsModal from './Options/CalendarOptionsModal'; -import styles from './CalendarPage.css'; - -const MINIMUM_DAY_WIDTH = 120; - -class CalendarPage extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isCalendarLinkModalOpen: false, - isOptionsModalOpen: false, - width: 0 - }; - } - - // - // Listeners - - onMeasure = ({ width }) => { - this.setState({ width }); - const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH))); - - this.props.onDaysCountChange(days); - }; - - onGetCalendarLinkPress = () => { - this.setState({ isCalendarLinkModalOpen: true }); - }; - - onGetCalendarLinkModalClose = () => { - this.setState({ isCalendarLinkModalOpen: false }); - }; - - onOptionsPress = () => { - this.setState({ isOptionsModalOpen: true }); - }; - - onOptionsModalClose = () => { - this.setState({ isOptionsModalOpen: false }); - }; - - onSearchMissingPress = () => { - const { - missingMovieIds, - onSearchMissingPress - } = this.props; - - onSearchMissingPress(missingMovieIds); - }; - - // - // Render - - render() { - const { - selectedFilterKey, - filters, - hasMovie, - movieError, - movieIsFetching, - movieIsPopulated, - missingMovieIds, - customFilters, - isRssSyncExecuting, - isSearchingForMissing, - useCurrentPage, - onRssSyncPress, - onFilterSelect - } = this.props; - - const { - isCalendarLinkModalOpen, - isOptionsModalOpen - } = this.state; - - const isMeasured = this.state.width > 0; - - return ( - - - - - - - - - - - - - - - - - - - - - { - movieIsFetching && !movieIsPopulated && - - } - - { - movieError && -
- {getErrorMessage(movieError, 'Failed to load movies from API')} -
- } - - { - !movieError && movieIsPopulated && hasMovie && - - { - isMeasured ? - : -
- } - - } - - { - !movieError && movieIsPopulated && !hasMovie && - - } - - { - hasMovie && !movieError && - - } - - - - - - - ); - } -} - -CalendarPage.propTypes = { - selectedFilterKey: PropTypes.string.isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - hasMovie: PropTypes.bool.isRequired, - movieError: PropTypes.object, - movieIsFetching: PropTypes.bool.isRequired, - movieIsPopulated: PropTypes.bool.isRequired, - missingMovieIds: PropTypes.arrayOf(PropTypes.number).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - isRssSyncExecuting: PropTypes.bool.isRequired, - isSearchingForMissing: PropTypes.bool.isRequired, - useCurrentPage: PropTypes.bool.isRequired, - onSearchMissingPress: PropTypes.func.isRequired, - onDaysCountChange: PropTypes.func.isRequired, - onRssSyncPress: PropTypes.func.isRequired, - onFilterSelect: PropTypes.func.isRequired -}; - -export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPage.tsx b/frontend/src/Calendar/CalendarPage.tsx new file mode 100644 index 0000000000..9c4167f661 --- /dev/null +++ b/frontend/src/Calendar/CalendarPage.tsx @@ -0,0 +1,226 @@ +import moment from 'moment'; +import React, { useCallback, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import * as commandNames from 'Commands/commandNames'; +import Measure from 'Components/Measure'; +import FilterMenu from 'Components/Menu/FilterMenu'; +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 { align, icons } from 'Helpers/Props'; +import NoMovie from 'Movie/NoMovie'; +import { + searchMissing, + setCalendarDaysCount, + setCalendarFilter, +} from 'Store/Actions/calendarActions'; +import { executeCommand } from 'Store/Actions/commandActions'; +import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; +import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; +import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; +import createMovieCountSelector from 'Store/Selectors/createMovieCountSelector'; +import { isCommandExecuting } from 'Utilities/Command'; +import isBefore from 'Utilities/Date/isBefore'; +import translate from 'Utilities/String/translate'; +import Calendar from './Calendar'; +import CalendarFilterModal from './CalendarFilterModal'; +import CalendarLinkModal from './iCal/CalendarLinkModal'; +import Legend from './Legend/Legend'; +import CalendarOptionsModal from './Options/CalendarOptionsModal'; +import styles from './CalendarPage.css'; + +const MINIMUM_DAY_WIDTH = 120; + +function createMissingMovieIdsSelector() { + return createSelector( + (state: AppState) => state.calendar.start, + (state: AppState) => state.calendar.end, + (state: AppState) => state.calendar.items, + (state: AppState) => state.queue.details.items, + (start, end, movies, queueDetails) => { + return movies.reduce((acc, movie) => { + const { inCinemas } = movie; + + if ( + !movie.movieFileId && + moment(inCinemas).isAfter(start) && + moment(inCinemas).isBefore(end) && + isBefore(movie.inCinemas) && + !queueDetails.some( + (details) => !!details.movie && details.movie.id === movie.id + ) + ) { + acc.push(movie.id); + } + + return acc; + }, []); + } + ); +} + +function createIsSearchingSelector() { + return createSelector( + (state: AppState) => state.calendar.searchMissingCommandId, + createCommandsSelector(), + (searchMissingCommandId, commands) => { + if (searchMissingCommandId == null) { + return false; + } + + return isCommandExecuting( + commands.find((command) => { + return command.id === searchMissingCommandId; + }) + ); + } + ); +} + +function CalendarPage() { + const dispatch = useDispatch(); + + const { selectedFilterKey, filters } = useSelector( + (state: AppState) => state.calendar + ); + const missingMovieIds = useSelector(createMissingMovieIdsSelector()); + const isSearchingForMissing = useSelector(createIsSearchingSelector()); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(commandNames.RSS_SYNC) + ); + const customFilters = useSelector(createCustomFiltersSelector('calendar')); + const hasMovies = !!useSelector(createMovieCountSelector()); + + const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [width, setWidth] = useState(0); + + const isMeasured = width > 0; + const PageComponent = hasMovies ? Calendar : NoMovie; + + const handleMeasure = useCallback( + ({ width: newWidth }: { width: number }) => { + setWidth(newWidth); + + const dayCount = Math.max( + 3, + Math.min(7, Math.floor(newWidth / MINIMUM_DAY_WIDTH)) + ); + + dispatch(setCalendarDaysCount({ dayCount })); + }, + [dispatch] + ); + + const handleGetCalendarLinkPress = useCallback(() => { + setIsCalendarLinkModalOpen(true); + }, []); + + const handleGetCalendarLinkModalClose = useCallback(() => { + setIsCalendarLinkModalOpen(false); + }, []); + + const handleOptionsPress = useCallback(() => { + setIsOptionsModalOpen(true); + }, []); + + const handleOptionsModalClose = useCallback(() => { + setIsOptionsModalOpen(false); + }, []); + + const handleRssSyncPress = useCallback(() => { + dispatch( + executeCommand({ + name: commandNames.RSS_SYNC, + }) + ); + }, [dispatch]); + + const handleSearchMissingPress = useCallback(() => { + dispatch(searchMissing({ movieIds: missingMovieIds })); + }, [missingMovieIds, dispatch]); + + const handleFilterSelect = useCallback( + (key: string | number) => { + dispatch(setCalendarFilter({ selectedFilterKey: key })); + }, + [dispatch] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + {isMeasured ? :
} + + + {hasMovies && } + + + + + + + ); +} + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js deleted file mode 100644 index fea28465e7..0000000000 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ /dev/null @@ -1,120 +0,0 @@ -import moment from 'moment'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import * as commandNames from 'Commands/commandNames'; -import withCurrentPage from 'Components/withCurrentPage'; -import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions'; -import { executeCommand } from 'Store/Actions/commandActions'; -import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector'; -import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector'; -import createCommandsSelector from 'Store/Selectors/createCommandsSelector'; -import createMovieCountSelector from 'Store/Selectors/createMovieCountSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import { isCommandExecuting } from 'Utilities/Command'; -import isBefore from 'Utilities/Date/isBefore'; -import CalendarPage from './CalendarPage'; - -function createMissingMovieIdsSelector() { - return createSelector( - (state) => state.calendar.start, - (state) => state.calendar.end, - (state) => state.calendar.items, - (state) => state.queue.details.items, - (start, end, movies, queueDetails) => { - return movies.reduce((acc, movie) => { - const inCinemas = movie.inCinemas; - - if ( - !movie.hasFile && - moment(inCinemas).isAfter(start) && - moment(inCinemas).isBefore(end) && - isBefore(movie.inCinemas) && - !queueDetails.some((details) => details.movieId === movie.id) - ) { - acc.push(movie.id); - } - - return acc; - }, []); - } - ); -} - -function createIsSearchingSelector() { - return createSelector( - (state) => state.calendar.searchMissingCommandId, - createCommandsSelector(), - (searchMissingCommandId, commands) => { - if (searchMissingCommandId == null) { - return false; - } - - return isCommandExecuting(commands.find((command) => { - return command.id === searchMissingCommandId; - })); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.selectedFilterKey, - (state) => state.calendar.filters, - createCustomFiltersSelector('calendar'), - createMovieCountSelector(), - createUISettingsSelector(), - createMissingMovieIdsSelector(), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createIsSearchingSelector(), - ( - selectedFilterKey, - filters, - customFilters, - movieCount, - uiSettings, - missingMovieIds, - isRssSyncExecuting, - isSearchingForMissing - ) => { - return { - selectedFilterKey, - filters, - customFilters, - colorImpairedMode: uiSettings.enableColorImpairedMode, - hasMovie: !!movieCount.count, - movieError: movieCount.error, - movieIsFetching: movieCount.isFetching, - movieIsPopulated: movieCount.isPopulated, - missingMovieIds, - isRssSyncExecuting, - isSearchingForMissing - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - }, - - onSearchMissingPress(movieIds) { - dispatch(searchMissing({ movieIds })); - }, - - onDaysCountChange(dayCount) { - dispatch(setCalendarDaysCount({ dayCount })); - }, - - onFilterSelect(selectedFilterKey) { - dispatch(setCalendarFilter({ selectedFilterKey })); - } - }; -} - -export default withCurrentPage( - connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage) -); diff --git a/frontend/src/Calendar/Day/CalendarDay.tsx b/frontend/src/Calendar/Day/CalendarDay.tsx index 36a2f02ccb..5ccc892672 100644 --- a/frontend/src/Calendar/Day/CalendarDay.tsx +++ b/frontend/src/Calendar/Day/CalendarDay.tsx @@ -1,23 +1,59 @@ import classNames from 'classnames'; import moment from 'moment'; import React from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; import * as calendarViews from 'Calendar/calendarViews'; -import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; -import CalendarEvent from 'typings/CalendarEvent'; +import CalendarEvent from 'Calendar/Events/CalendarEvent'; +import { CalendarEvent as CalendarEventModel } from 'typings/Calendar'; import styles from './CalendarDay.css'; +function sort(items: CalendarEventModel[]) { + return items.sort((a, b) => { + const aDate = moment(a.inCinemas).unix(); + const bDate = moment(b.inCinemas).unix(); + + return aDate - bDate; + }); +} + +function createCalendarEventsConnector(date: string) { + return createSelector( + (state: AppState) => state.calendar.items, + (items) => { + const momentDate = moment(date); + + const filtered = items.filter( + ({ inCinemas, digitalRelease, physicalRelease }) => { + return ( + (inCinemas && momentDate.isSame(moment(inCinemas), 'day')) || + (digitalRelease && + momentDate.isSame(moment(digitalRelease), 'day')) || + (physicalRelease && + momentDate.isSame(moment(physicalRelease), 'day')) + ); + } + ); + + return sort( + filtered.map((item) => ({ + isGroup: false, + ...item, + })) + ); + } + ); +} + interface CalendarDayProps { date: string; - time: string; isTodaysDate: boolean; - events: CalendarEvent[]; - view: string; - onEventModalOpenToggle(...args: unknown[]): unknown; } -function CalendarDay(props: CalendarDayProps) { - const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } = - props; +function CalendarDay({ date, isTodaysDate }: CalendarDayProps) { + const { time, view } = useSelector((state: AppState) => state.calendar); + const events = useSelector(createCalendarEventsConnector(date)); const ref = React.useRef(null); @@ -50,13 +86,7 @@ function CalendarDay(props: CalendarDayProps) {
{events.map((event) => { return ( - + ); })}
diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js deleted file mode 100644 index 33fa1baa4a..0000000000 --- a/frontend/src/Calendar/Day/CalendarDayConnector.js +++ /dev/null @@ -1,67 +0,0 @@ -import _ from 'lodash'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import CalendarDay from './CalendarDay'; - -function sort(items) { - return _.sortBy(items, (item) => { - if (item.isGroup) { - return moment(item.events[0].inCinemas).unix(); - } - - return moment(item.inCinemas).unix(); - }); -} - -function createCalendarEventsConnector() { - return createSelector( - (state, { date }) => date, - (state) => state.calendar.items, - (date, items) => { - const filtered = _.filter(items, (item) => { - return (item.inCinemas && moment(date).isSame(moment(item.inCinemas), 'day')) || - (item.physicalRelease && moment(date).isSame(moment(item.physicalRelease), 'day')) || - (item.digitalRelease && moment(date).isSame(moment(item.digitalRelease), 'day')); - }); - - return sort(filtered); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createCalendarEventsConnector(), - (calendar, events) => { - return { - time: calendar.time, - view: calendar.view, - events - }; - } - ); -} - -class CalendarDayConnector extends Component { - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarDayConnector.propTypes = { - date: PropTypes.string.isRequired -}; - -export default connect(createMapStateToProps)(CalendarDayConnector); diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js deleted file mode 100644 index f2bb4c8d45..0000000000 --- a/frontend/src/Calendar/Day/CalendarDays.js +++ /dev/null @@ -1,164 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import isToday from 'Utilities/Date/isToday'; -import CalendarDayConnector from './CalendarDayConnector'; -import styles from './CalendarDays.css'; - -class CalendarDays extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this._touchStart = null; - - this.state = { - todaysDate: moment().startOf('day').toISOString(), - isEventModalOpen: false - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view === calendarViews.MONTH) { - this.scheduleUpdate(); - } - - window.addEventListener('touchstart', this.onTouchStart); - window.addEventListener('touchend', this.onTouchEnd); - window.addEventListener('touchcancel', this.onTouchCancel); - window.addEventListener('touchmove', this.onTouchMove); - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - - window.removeEventListener('touchstart', this.onTouchStart); - window.removeEventListener('touchend', this.onTouchEnd); - window.removeEventListener('touchcancel', this.onTouchCancel); - window.removeEventListener('touchmove', this.onTouchMove); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = moment().diff(todaysDate.clone().add(1, 'day')); - - this.setState({ todaysDate: todaysDate.toISOString() }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Listeners - - onEventModalOpenToggle = (isEventModalOpen) => { - this.setState({ isEventModalOpen }); - }; - - onTouchStart = (event) => { - const touches = event.touches; - const touchStart = touches[0].pageX; - - if (touches.length !== 1) { - return; - } - - if ( - touchStart < 50 || - this.props.isSidebarVisible || - this.state.isEventModalOpen - ) { - return; - } - - this._touchStart = touchStart; - }; - - onTouchEnd = (event) => { - const touches = event.changedTouches; - const currentTouch = touches[0].pageX; - - if (!this._touchStart) { - return; - } - - if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) { - this.props.onNavigatePrevious(); - } else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) { - this.props.onNavigateNext(); - } - - this._touchStart = null; - }; - - onTouchCancel = (event) => { - this._touchStart = null; - }; - - onTouchMove = (event) => { - if (!this._touchStart) { - return; - } - }; - - // - // Render - - render() { - const { - dates, - view - } = this.props; - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -CalendarDays.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string).isRequired, - view: PropTypes.string.isRequired, - isSidebarVisible: PropTypes.bool.isRequired, - onNavigatePrevious: PropTypes.func.isRequired, - onNavigateNext: PropTypes.func.isRequired -}; - -export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDays.tsx b/frontend/src/Calendar/Day/CalendarDays.tsx new file mode 100644 index 0000000000..cd9367cd71 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.tsx @@ -0,0 +1,129 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import { + gotoCalendarNextRange, + gotoCalendarPreviousRange, +} from 'Store/Actions/calendarActions'; +import CalendarDay from './CalendarDay'; +import styles from './CalendarDays.css'; + +function CalendarDays() { + const dispatch = useDispatch(); + const { dates, view } = useSelector((state: AppState) => state.calendar); + const isSidebarVisible = useSelector( + (state: AppState) => state.app.isSidebarVisible + ); + + const updateTimeout = useRef>(); + const touchStart = useRef(null); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const scheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + setTodaysDate(todaysDate.toISOString()); + + updateTimeout.current = setTimeout(scheduleUpdate, diff); + }, []); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + const touches = event.touches; + const currentTouch = touches[0].pageX; + + if (touches.length !== 1) { + return; + } + + if (currentTouch < 50 || isSidebarVisible) { + return; + } + + touchStart.current = currentTouch; + }, + [isSidebarVisible] + ); + + const handleTouchEnd = useCallback( + (event: TouchEvent) => { + const touches = event.changedTouches; + const currentTouch = touches[0].pageX; + + if (!touchStart.current) { + return; + } + + if ( + currentTouch > touchStart.current && + currentTouch - touchStart.current > 100 + ) { + dispatch(gotoCalendarPreviousRange()); + } else if ( + currentTouch < touchStart.current && + touchStart.current - currentTouch > 100 + ) { + dispatch(gotoCalendarNextRange()); + } + + touchStart.current = null; + }, + [dispatch] + ); + + const handleTouchCancel = useCallback(() => { + touchStart.current = null; + }, []); + + const handleTouchMove = useCallback(() => { + if (!touchStart.current) { + return; + } + }, []); + + useEffect(() => { + if (view === calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + useEffect(() => { + window.addEventListener('touchstart', handleTouchStart); + window.addEventListener('touchend', handleTouchEnd); + window.addEventListener('touchcancel', handleTouchCancel); + window.addEventListener('touchmove', handleTouchMove); + + return () => { + window.removeEventListener('touchstart', handleTouchStart); + window.removeEventListener('touchend', handleTouchEnd); + window.removeEventListener('touchcancel', handleTouchCancel); + window.removeEventListener('touchmove', handleTouchMove); + }; + }, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]); + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default CalendarDays; diff --git a/frontend/src/Calendar/Day/CalendarDaysConnector.js b/frontend/src/Calendar/Day/CalendarDaysConnector.js deleted file mode 100644 index 0acce70b90..0000000000 --- a/frontend/src/Calendar/Day/CalendarDaysConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions'; -import CalendarDays from './CalendarDays'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - (state) => state.app.isSidebarVisible, - (calendar, isSidebarVisible) => { - return { - dates: calendar.dates, - view: calendar.view, - isSidebarVisible - }; - } - ); -} - -const mapDispatchToProps = { - onNavigatePrevious: gotoCalendarPreviousRange, - onNavigateNext: gotoCalendarNextRange -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays); diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js deleted file mode 100644 index 0f1d38f0b8..0000000000 --- a/frontend/src/Calendar/Day/DayOfWeek.js +++ /dev/null @@ -1,56 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import getRelativeDate from 'Utilities/Date/getRelativeDate'; -import styles from './DayOfWeek.css'; - -class DayOfWeek extends Component { - - // - // Render - - render() { - const { - date, - view, - isTodaysDate, - calendarWeekColumnHeader, - shortDateFormat, - showRelativeDates - } = this.props; - - const highlightToday = view !== calendarViews.MONTH && isTodaysDate; - const momentDate = moment(date); - let formatedDate = momentDate.format('dddd'); - - if (view === calendarViews.WEEK) { - formatedDate = momentDate.format(calendarWeekColumnHeader); - } else if (view === calendarViews.FORECAST) { - formatedDate = getRelativeDate({ date, shortDateFormat, showRelativeDates }); - } - - return ( -
- {formatedDate} -
- ); - } -} - -DayOfWeek.propTypes = { - date: PropTypes.string.isRequired, - view: PropTypes.string.isRequired, - isTodaysDate: PropTypes.bool.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - shortDateFormat: PropTypes.string.isRequired, - showRelativeDates: PropTypes.bool.isRequired -}; - -export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DayOfWeek.tsx b/frontend/src/Calendar/Day/DayOfWeek.tsx new file mode 100644 index 0000000000..c8b493b7c9 --- /dev/null +++ b/frontend/src/Calendar/Day/DayOfWeek.tsx @@ -0,0 +1,54 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React from 'react'; +import * as calendarViews from 'Calendar/calendarViews'; +import getRelativeDate from 'Utilities/Date/getRelativeDate'; +import styles from './DayOfWeek.css'; + +interface DayOfWeekProps { + date: string; + view: string; + isTodaysDate: boolean; + calendarWeekColumnHeader: string; + shortDateFormat: string; + showRelativeDates: boolean; +} + +function DayOfWeek(props: DayOfWeekProps) { + const { + date, + view, + isTodaysDate, + calendarWeekColumnHeader, + shortDateFormat, + showRelativeDates, + } = props; + + const highlightToday = view !== calendarViews.MONTH && isTodaysDate; + const momentDate = moment(date); + let formatedDate = momentDate.format('dddd'); + + if (view === calendarViews.WEEK) { + formatedDate = momentDate.format(calendarWeekColumnHeader); + } else if (view === calendarViews.FORECAST) { + formatedDate = getRelativeDate({ + date, + shortDateFormat, + showRelativeDates, + }); + } + + return ( +
+ {formatedDate} +
+ ); +} + +export default DayOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js deleted file mode 100644 index 9f94b1079d..0000000000 --- a/frontend/src/Calendar/Day/DaysOfWeek.js +++ /dev/null @@ -1,97 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import DayOfWeek from './DayOfWeek'; -import styles from './DaysOfWeek.css'; - -class DaysOfWeek extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - todaysDate: moment().startOf('day').toISOString() - }; - - this.updateTimeoutId = null; - } - - // Lifecycle - - componentDidMount() { - const view = this.props.view; - - if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) { - this.scheduleUpdate(); - } - } - - componentWillUnmount() { - this.clearUpdateTimeout(); - } - - // - // Control - - scheduleUpdate = () => { - this.clearUpdateTimeout(); - const todaysDate = moment().startOf('day'); - const diff = todaysDate.clone().add(1, 'day').diff(moment()); - - this.setState({ - todaysDate: todaysDate.toISOString() - }); - - this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff); - }; - - clearUpdateTimeout = () => { - if (this.updateTimeoutId) { - clearTimeout(this.updateTimeoutId); - } - }; - - // - // Render - - render() { - const { - dates, - view, - ...otherProps - } = this.props; - - if (view === calendarViews.AGENDA) { - return null; - } - - return ( -
- { - dates.map((date) => { - return ( - - ); - }) - } -
- ); - } -} - -DaysOfWeek.propTypes = { - dates: PropTypes.arrayOf(PropTypes.string), - view: PropTypes.string.isRequired -}; - -export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeek.tsx b/frontend/src/Calendar/Day/DaysOfWeek.tsx new file mode 100644 index 0000000000..64bc886ccd --- /dev/null +++ b/frontend/src/Calendar/Day/DaysOfWeek.tsx @@ -0,0 +1,60 @@ +import moment from 'moment'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import * as calendarViews from 'Calendar/calendarViews'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import DayOfWeek from './DayOfWeek'; +import styles from './DaysOfWeek.css'; + +function DaysOfWeek() { + const { dates, view } = useSelector((state: AppState) => state.calendar); + const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } = + useSelector(createUISettingsSelector()); + + const updateTimeout = useRef>(); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const scheduleUpdate = useCallback(() => { + clearTimeout(updateTimeout.current); + + const todaysDate = moment().startOf('day'); + const diff = moment().diff(todaysDate.clone().add(1, 'day')); + + setTodaysDate(todaysDate.toISOString()); + + updateTimeout.current = setTimeout(scheduleUpdate, diff); + }, []); + + useEffect(() => { + if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) { + scheduleUpdate(); + } + }, [view, scheduleUpdate]); + + if (view === calendarViews.AGENDA) { + return null; + } + + return ( +
+ {dates.map((date) => { + return ( + + ); + })} +
+ ); +} + +export default DaysOfWeek; diff --git a/frontend/src/Calendar/Day/DaysOfWeekConnector.js b/frontend/src/Calendar/Day/DaysOfWeekConnector.js deleted file mode 100644 index 7f5cdef198..0000000000 --- a/frontend/src/Calendar/Day/DaysOfWeekConnector.js +++ /dev/null @@ -1,22 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import DaysOfWeek from './DaysOfWeek'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createUISettingsSelector(), - (calendar, UiSettings) => { - return { - dates: calendar.dates.slice(0, 7), - view: calendar.view, - calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader, - shortDateFormat: UiSettings.shortDateFormat, - showRelativeDates: UiSettings.showRelativeDates - }; - } - ); -} - -export default connect(createMapStateToProps)(DaysOfWeek); diff --git a/frontend/src/Calendar/Events/CalendarEvent.css b/frontend/src/Calendar/Events/CalendarEvent.css index f5b914cb6d..2b173ce958 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.css +++ b/frontend/src/Calendar/Events/CalendarEvent.css @@ -34,7 +34,8 @@ $fullColorGradient: rgba(244, 245, 246, 0.2); } .movieTitle, -.genres { +.genres, +.eventType { @add-mixin truncate; flex: 1 0 1px; margin-right: 10px; diff --git a/frontend/src/Calendar/Events/CalendarEvent.css.d.ts b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts index b7867e7acb..7ee7c26591 100644 --- a/frontend/src/Calendar/Events/CalendarEvent.css.d.ts +++ b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts @@ -4,6 +4,7 @@ interface CssExports { 'continuing': string; 'downloaded': string; 'event': string; + 'eventType': string; 'genres': string; 'info': string; 'missingMonitored': string; diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js deleted file mode 100644 index a877d20619..0000000000 --- a/frontend/src/Calendar/Events/CalendarEvent.js +++ /dev/null @@ -1,177 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import { icons, kinds } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import CalendarEventQueueDetails from './CalendarEventQueueDetails'; -import styles from './CalendarEvent.css'; - -class CalendarEvent extends Component { - - // - // Render - - render() { - const { - movieFile, - isAvailable, - inCinemas, - physicalRelease, - digitalRelease, - title, - titleSlug, - genres, - date, - monitored, - certification, - hasFile, - grabbed, - queueItem, - showMovieInformation, - showCutoffUnmetIcon, - fullColorEvents, - colorImpairedMode - } = this.props; - - const isDownloading = !!(queueItem || grabbed); - const isMonitored = monitored; - const statusStyle = getStatusStyle(hasFile, isDownloading, isMonitored, isAvailable); - const joinedGenres = genres.slice(0, 2).join(', '); - const link = `/movie/${titleSlug}`; - const eventType = []; - - if (inCinemas && moment(date).isSame(moment(inCinemas), 'day')) { - eventType.push('Cinemas'); - } - - if (physicalRelease && moment(date).isSame(moment(physicalRelease), 'day')) { - eventType.push('Physical'); - } - - if (digitalRelease && moment(date).isSame(moment(digitalRelease), 'day')) { - eventType.push('Digital'); - } - - return ( -
- - -
-
-
- {title} -
- -
- { - queueItem ? - - - : - null - } - - { - !queueItem && grabbed ? - : - null - } - - { - showCutoffUnmetIcon && - !!movieFile && - movieFile.qualityCutoffNotMet ? - : - null - } -
-
- - { - showMovieInformation ? -
-
- {joinedGenres} -
-
: - null - } - - { - showMovieInformation ? -
-
- {eventType.join(', ')} -
-
- {certification} -
-
: - null - } -
-
- ); - } -} - -CalendarEvent.propTypes = { - id: PropTypes.number.isRequired, - genres: PropTypes.arrayOf(PropTypes.string).isRequired, - movieFile: PropTypes.object, - title: PropTypes.string.isRequired, - titleSlug: PropTypes.string.isRequired, - isAvailable: PropTypes.bool.isRequired, - inCinemas: PropTypes.string, - physicalRelease: PropTypes.string, - digitalRelease: PropTypes.string, - date: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - certification: PropTypes.string, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - // These props come from the connector, not marked as required to appease TS for now. - showMovieInformation: PropTypes.bool, - showCutoffUnmetIcon: PropTypes.bool, - fullColorEvents: PropTypes.bool, - timeFormat: PropTypes.string, - colorImpairedMode: PropTypes.bool -}; - -CalendarEvent.defaultProps = { - genres: [] -}; - -export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx new file mode 100644 index 0000000000..117130b18c --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -0,0 +1,154 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useMemo } from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import { icons, kinds } from 'Helpers/Props'; +import useMovieFile from 'MovieFile/useMovieFile'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +interface CalendarEventProps { + id: number; + movieFileId?: number; + title: string; + titleSlug: string; + genres: string[]; + certification?: string; + date: string; + inCinemas?: string; + digitalRelease?: string; + physicalRelease?: string; + isAvailable: boolean; + monitored: boolean; + hasFile: boolean; + grabbed?: boolean; +} + +function CalendarEvent({ + id, + movieFileId, + title, + titleSlug, + genres = [], + certification, + date, + inCinemas, + digitalRelease, + physicalRelease, + isAvailable, + monitored: isMonitored, + hasFile, + grabbed, +}: CalendarEventProps) { + const movieFile = useMovieFile(movieFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + + const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); + + const { showMovieInformation, showCutoffUnmetIcon, fullColorEvents } = + useSelector((state: AppState) => state.calendar.options); + + const isDownloading = !!(queueItem || grabbed); + const statusStyle = getStatusStyle( + hasFile, + isDownloading, + isMonitored, + isAvailable + ); + const joinedGenres = genres.slice(0, 2).join(', '); + const link = `/movie/${titleSlug}`; + + const eventTypes = useMemo(() => { + const momentDate = moment(date); + + const types = []; + + if (inCinemas && momentDate.isSame(moment(inCinemas), 'day')) { + types.push('Cinemas'); + } + + if (digitalRelease && momentDate.isSame(moment(digitalRelease), 'day')) { + types.push('Digital'); + } + + if (physicalRelease && momentDate.isSame(moment(physicalRelease), 'day')) { + types.push('Physical'); + } + + return types; + }, [date, inCinemas, digitalRelease, physicalRelease]); + + return ( +
+ + +
+
+
{title}
+ +
+ {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + !!movieFile && + movieFile.qualityCutoffNotMet ? ( + + ) : null} +
+
+ + {showMovieInformation ? ( + <> +
+
{joinedGenres}
+
+ +
+
{eventTypes.join(', ')}
+ +
{certification}
+
+ + ) : null} +
+
+ ); +} + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js deleted file mode 100644 index 0a38db7758..0000000000 --- a/frontend/src/Calendar/Events/CalendarEventConnector.js +++ /dev/null @@ -1,26 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createMovieSelector from 'Store/Selectors/createMovieSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEvent from './CalendarEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createMovieSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, movie, queueItem, uiSettings) => { - return { - movie, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js deleted file mode 100644 index db26eb1d28..0000000000 --- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js +++ /dev/null @@ -1,56 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import QueueDetails from 'Activity/Queue/QueueDetails'; -import CircularProgressBar from 'Components/CircularProgressBar'; - -function CalendarEventQueueDetails(props) { - const { - title, - size, - sizeleft, - estimatedCompletionTime, - status, - trackedDownloadState, - trackedDownloadStatus, - statusMessages, - errorMessage - } = props; - - const progress = size ? (100 - sizeleft / size * 100) : 0; - - return ( - - } - /> - ); -} - -CalendarEventQueueDetails.propTypes = { - title: PropTypes.string.isRequired, - size: PropTypes.number.isRequired, - sizeleft: PropTypes.number.isRequired, - estimatedCompletionTime: PropTypes.string, - status: PropTypes.string.isRequired, - trackedDownloadState: PropTypes.string.isRequired, - trackedDownloadStatus: PropTypes.string.isRequired, - statusMessages: PropTypes.arrayOf(PropTypes.object), - errorMessage: PropTypes.string -}; - -export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx new file mode 100644 index 0000000000..2372bc78ee --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx @@ -0,0 +1,58 @@ +import React from 'react'; +import QueueDetails from 'Activity/Queue/QueueDetails'; +import CircularProgressBar from 'Components/CircularProgressBar'; +import { + QueueTrackedDownloadState, + QueueTrackedDownloadStatus, + StatusMessage, +} from 'typings/Queue'; + +interface CalendarEventQueueDetailsProps { + title: string; + size: number; + sizeleft: number; + estimatedCompletionTime?: string; + status: string; + trackedDownloadState: QueueTrackedDownloadState; + trackedDownloadStatus: QueueTrackedDownloadStatus; + statusMessages?: StatusMessage[]; + errorMessage?: string; +} + +function CalendarEventQueueDetails({ + title, + size, + sizeleft, + estimatedCompletionTime, + status, + trackedDownloadState, + trackedDownloadStatus, + statusMessages, + errorMessage, +}: CalendarEventQueueDetailsProps) { + const progress = size ? 100 - (sizeleft / size) * 100 : 0; + + return ( + + } + /> + ); +} + +export default CalendarEventQueueDetails; diff --git a/frontend/src/Calendar/Header/CalendarHeader.js b/frontend/src/Calendar/Header/CalendarHeader.js deleted file mode 100644 index 1df7b0c4b1..0000000000 --- a/frontend/src/Calendar/Header/CalendarHeader.js +++ /dev/null @@ -1,268 +0,0 @@ -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import LoadingIndicator from 'Components/Loading/LoadingIndicator'; -import Menu from 'Components/Menu/Menu'; -import MenuButton from 'Components/Menu/MenuButton'; -import MenuContent from 'Components/Menu/MenuContent'; -import ViewMenuItem from 'Components/Menu/ViewMenuItem'; -import { align, icons } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; -import CalendarHeaderViewButton from './CalendarHeaderViewButton'; -import styles from './CalendarHeader.css'; - -function getTitle(time, start, end, view, longDateFormat) { - const timeMoment = moment(time); - const startMoment = moment(start); - const endMoment = moment(end); - - if (view === 'day') { - return timeMoment.format(longDateFormat); - } else if (view === 'month') { - return timeMoment.format('MMMM YYYY'); - } else if (view === 'agenda') { - return `Agenda: ${startMoment.format('MMM D')} - ${endMoment.format('MMM D')}`; - } - - let startFormat = 'MMM D YYYY'; - let endFormat = 'MMM D YYYY'; - - if (startMoment.isSame(endMoment, 'month')) { - startFormat = 'MMM D'; - endFormat = 'D YYYY'; - } else if (startMoment.isSame(endMoment, 'year')) { - startFormat = 'MMM D'; - endFormat = 'MMM D YYYY'; - } - - return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`; -} - -// TODO Convert to a stateful Component so we can track view internally when changed - -class CalendarHeader extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - view: props.view - }; - } - - componentDidUpdate(prevProps) { - const view = this.props.view; - - if (prevProps.view !== view) { - this.setState({ view }); - } - } - - // - // Listeners - - onViewChange = (view) => { - this.setState({ view }, () => { - this.props.onViewChange(view); - }); - }; - - // - // Render - - render() { - const { - isFetching, - time, - start, - end, - longDateFormat, - isSmallScreen, - collapseViewButtons, - onTodayPress, - onPreviousPress, - onNextPress - } = this.props; - - const view = this.state.view; - - const title = getTitle(time, start, end, view, longDateFormat); - - return ( -
- { - isSmallScreen && -
- {title} -
- } - -
-
- - - - - -
- - { - !isSmallScreen && -
- {title} -
- } - -
- { - isFetching && - - } - - { - collapseViewButtons ? - - - - - - - { - isSmallScreen ? - null : - - {translate('Month')} - - } - - - {translate('Week')} - - - - {translate('Forecast')} - - - - {translate('Day')} - - - - {translate('Agenda')} - - - : - -
- - - - - - - - - -
- } -
-
-
- ); - } -} - -CalendarHeader.propTypes = { - isFetching: PropTypes.bool.isRequired, - time: PropTypes.string.isRequired, - start: PropTypes.string.isRequired, - end: PropTypes.string.isRequired, - view: PropTypes.oneOf(calendarViews.all).isRequired, - isSmallScreen: PropTypes.bool.isRequired, - collapseViewButtons: PropTypes.bool.isRequired, - longDateFormat: PropTypes.string.isRequired, - onViewChange: PropTypes.func.isRequired, - onTodayPress: PropTypes.func.isRequired, - onPreviousPress: PropTypes.func.isRequired, - onNextPress: PropTypes.func.isRequired -}; - -export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeader.tsx b/frontend/src/Calendar/Header/CalendarHeader.tsx new file mode 100644 index 0000000000..62b200bd58 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.tsx @@ -0,0 +1,218 @@ +import moment from 'moment'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import LoadingIndicator from 'Components/Loading/LoadingIndicator'; +import Menu from 'Components/Menu/Menu'; +import MenuButton from 'Components/Menu/MenuButton'; +import MenuContent from 'Components/Menu/MenuContent'; +import ViewMenuItem from 'Components/Menu/ViewMenuItem'; +import { align, icons } from 'Helpers/Props'; +import { + gotoCalendarNextRange, + gotoCalendarPreviousRange, + gotoCalendarToday, + setCalendarView, +} from 'Store/Actions/calendarActions'; +import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import translate from 'Utilities/String/translate'; +import CalendarHeaderViewButton from './CalendarHeaderViewButton'; +import styles from './CalendarHeader.css'; + +function CalendarHeader() { + const dispatch = useDispatch(); + + const { isFetching, view, time, start, end } = useSelector( + (state: AppState) => state.calendar + ); + + const { isSmallScreen, isLargeScreen } = useSelector( + createDimensionsSelector() + ); + + const { longDateFormat } = useSelector(createUISettingsSelector()); + + const handleViewChange = useCallback( + (newView: string) => { + dispatch(setCalendarView({ view: newView })); + }, + [dispatch] + ); + + const handleTodayPress = useCallback(() => { + dispatch(gotoCalendarToday()); + }, [dispatch]); + + const handlePreviousPress = useCallback(() => { + dispatch(gotoCalendarPreviousRange()); + }, [dispatch]); + + const handleNextPress = useCallback(() => { + dispatch(gotoCalendarNextRange()); + }, [dispatch]); + + const title = useMemo(() => { + const timeMoment = moment(time); + const startMoment = moment(start); + const endMoment = moment(end); + + if (view === 'day') { + return timeMoment.format(longDateFormat); + } else if (view === 'month') { + return timeMoment.format('MMMM YYYY'); + } + + let startFormat = 'MMM D YYYY'; + let endFormat = 'MMM D YYYY'; + + if (startMoment.isSame(endMoment, 'month')) { + startFormat = 'MMM D'; + endFormat = 'D YYYY'; + } else if (startMoment.isSame(endMoment, 'year')) { + startFormat = 'MMM D'; + endFormat = 'MMM D YYYY'; + } + + return `${startMoment.format(startFormat)} \u2014 ${endMoment.format( + endFormat + )}`; + }, [time, start, end, view, longDateFormat]); + + return ( +
+ {isSmallScreen ?
{title}
: null} + +
+
+ + + + + +
+ + {isSmallScreen ? null : ( +
{title}
+ )} + +
+ {isFetching ? ( + + ) : null} + + {isLargeScreen ? ( + + + + + + + {isSmallScreen ? null : ( + + {translate('Month')} + + )} + + + {translate('Week')} + + + + {translate('Forecast')} + + + + {translate('Day')} + + + + {translate('Agenda')} + + + + ) : ( + <> + + + + + + + + + + + )} +
+
+
+ ); +} + +export default CalendarHeader; diff --git a/frontend/src/Calendar/Header/CalendarHeaderConnector.js b/frontend/src/Calendar/Header/CalendarHeaderConnector.js deleted file mode 100644 index d966c46fdc..0000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderConnector.js +++ /dev/null @@ -1,81 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions'; -import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarHeader from './CalendarHeader'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar, - createDimensionsSelector(), - createUISettingsSelector(), - (calendar, dimensions, uiSettings) => { - return { - isFetching: calendar.isFetching, - view: calendar.view, - time: calendar.time, - start: calendar.start, - end: calendar.end, - isSmallScreen: dimensions.isSmallScreen, - collapseViewButtons: dimensions.isLargeScreen, - longDateFormat: uiSettings.longDateFormat - }; - } - ); -} - -const mapDispatchToProps = { - setCalendarView, - gotoCalendarToday, - gotoCalendarPreviousRange, - gotoCalendarNextRange -}; - -class CalendarHeaderConnector extends Component { - - // - // Listeners - - onViewChange = (view) => { - this.props.setCalendarView({ view }); - }; - - onTodayPress = () => { - this.props.gotoCalendarToday(); - }; - - onPreviousPress = () => { - this.props.gotoCalendarPreviousRange(); - }; - - onNextPress = () => { - this.props.gotoCalendarNextRange(); - }; - - // - // Render - - render() { - return ( - - ); - } -} - -CalendarHeaderConnector.propTypes = { - setCalendarView: PropTypes.func.isRequired, - gotoCalendarToday: PropTypes.func.isRequired, - gotoCalendarPreviousRange: PropTypes.func.isRequired, - gotoCalendarNextRange: PropTypes.func.isRequired -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector); diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js b/frontend/src/Calendar/Header/CalendarHeaderViewButton.js deleted file mode 100644 index 98958af038..0000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderViewButton.js +++ /dev/null @@ -1,45 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import * as calendarViews from 'Calendar/calendarViews'; -import Button from 'Components/Link/Button'; -import titleCase from 'Utilities/String/titleCase'; -// import styles from './CalendarHeaderViewButton.css'; - -class CalendarHeaderViewButton extends Component { - - // - // Listeners - - onPress = () => { - this.props.onPress(this.props.view); - }; - - // - // Render - - render() { - const { - view, - selectedView, - ...otherProps - } = this.props; - - return ( - - ); - } -} - -CalendarHeaderViewButton.propTypes = { - view: PropTypes.oneOf(calendarViews.all).isRequired, - selectedView: PropTypes.oneOf(calendarViews.all).isRequired, - onPress: PropTypes.func.isRequired -}; - -export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx new file mode 100644 index 0000000000..c9366f9ef8 --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx @@ -0,0 +1,34 @@ +import React, { useCallback } from 'react'; +import { CalendarView } from 'Calendar/calendarViews'; +import Button, { ButtonProps } from 'Components/Link/Button'; +import titleCase from 'Utilities/String/titleCase'; + +interface CalendarHeaderViewButtonProps + extends Omit { + view: CalendarView; + selectedView: CalendarView; + onPress: (view: CalendarView) => void; +} + +function CalendarHeaderViewButton({ + view, + selectedView, + onPress, + ...otherProps +}: CalendarHeaderViewButtonProps) { + const handlePress = useCallback(() => { + onPress(view); + }, [view, onPress]); + + return ( + + ); +} + +export default CalendarHeaderViewButton; diff --git a/frontend/src/Calendar/Legend/Legend.js b/frontend/src/Calendar/Legend/Legend.tsx similarity index 69% rename from frontend/src/Calendar/Legend/Legend.js rename to frontend/src/Calendar/Legend/Legend.tsx index 1884055d28..8f2ec10c20 100644 --- a/frontend/src/Calendar/Legend/Legend.js +++ b/frontend/src/Calendar/Legend/Legend.tsx @@ -1,18 +1,19 @@ -import PropTypes from 'prop-types'; import React from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; import { icons, kinds } from 'Helpers/Props'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; import translate from 'Utilities/String/translate'; import LegendIconItem from './LegendIconItem'; import LegendItem from './LegendItem'; import styles from './Legend.css'; -function Legend(props) { - const { - view, - showCutoffUnmetIcon, - fullColorEvents, - colorImpairedMode - } = props; +function Legend() { + const view = useSelector((state: AppState) => state.calendar.view); + const { showCutoffUnmetIcon, fullColorEvents } = useSelector( + (state: AppState) => state.calendar.options + ); + const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); const iconsToShow = []; const isAgendaView = view === 'agenda'; @@ -37,7 +38,7 @@ function Legend(props) { name={translate('DownloadedAndMonitored')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} />
@@ -55,7 +56,7 @@ function Legend(props) { name={translate('MissingMonitoredAndConsideredAvailable')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} />
@@ -73,7 +74,7 @@ function Legend(props) { name={translate('Queued')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} /> - { - iconsToShow.length > 0 && -
- {iconsToShow[0]} -
- } + {iconsToShow.length > 0 ?
{iconsToShow[0]}
: null} ); } -Legend.propTypes = { - view: PropTypes.string.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - export default Legend; diff --git a/frontend/src/Calendar/Legend/LegendConnector.js b/frontend/src/Calendar/Legend/LegendConnector.js deleted file mode 100644 index 889b7a0024..0000000000 --- a/frontend/src/Calendar/Legend/LegendConnector.js +++ /dev/null @@ -1,21 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import Legend from './Legend'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.calendar.view, - createUISettingsSelector(), - (calendarOptions, view, uiSettings) => { - return { - ...calendarOptions, - view, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(Legend); diff --git a/frontend/src/Calendar/Legend/LegendIconItem.js b/frontend/src/Calendar/Legend/LegendIconItem.js deleted file mode 100644 index b6bdeeff78..0000000000 --- a/frontend/src/Calendar/Legend/LegendIconItem.js +++ /dev/null @@ -1,43 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import Icon from 'Components/Icon'; -import styles from './LegendIconItem.css'; - -function LegendIconItem(props) { - const { - name, - fullColorEvents, - icon, - kind, - tooltip - } = props; - - return ( -
- - - {name} -
- ); -} - -LegendIconItem.propTypes = { - name: PropTypes.string.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - icon: PropTypes.object.isRequired, - kind: PropTypes.string.isRequired, - tooltip: PropTypes.string.isRequired -}; - -export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendIconItem.tsx b/frontend/src/Calendar/Legend/LegendIconItem.tsx new file mode 100644 index 0000000000..88a758c449 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendIconItem.tsx @@ -0,0 +1,33 @@ +import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome'; +import classNames from 'classnames'; +import React from 'react'; +import Icon, { IconProps } from 'Components/Icon'; +import styles from './LegendIconItem.css'; + +interface LegendIconItemProps extends Pick { + name: string; + fullColorEvents: boolean; + icon: FontAwesomeIconProps['icon']; + tooltip: string; +} + +function LegendIconItem(props: LegendIconItemProps) { + const { name, fullColorEvents, icon, kind, tooltip } = props; + + return ( +
+ + + {name} +
+ ); +} + +export default LegendIconItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.js b/frontend/src/Calendar/Legend/LegendItem.js deleted file mode 100644 index 840a3674c0..0000000000 --- a/frontend/src/Calendar/Legend/LegendItem.js +++ /dev/null @@ -1,37 +0,0 @@ -import classNames from 'classnames'; -import PropTypes from 'prop-types'; -import React from 'react'; -import styles from './LegendItem.css'; - -function LegendItem(props) { - const { - name, - status, - isAgendaView, - fullColorEvents, - colorImpairedMode - } = props; - - return ( -
- {name} -
- ); -} - -LegendItem.propTypes = { - name: PropTypes.string.isRequired, - status: PropTypes.string.isRequired, - isAgendaView: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default LegendItem; diff --git a/frontend/src/Calendar/Legend/LegendItem.tsx b/frontend/src/Calendar/Legend/LegendItem.tsx new file mode 100644 index 0000000000..d532d85ed0 --- /dev/null +++ b/frontend/src/Calendar/Legend/LegendItem.tsx @@ -0,0 +1,35 @@ +import classNames from 'classnames'; +import React from 'react'; +import { CalendarStatus } from 'typings/Calendar'; +import styles from './LegendItem.css'; + +interface LegendItemProps { + name: string; + status: CalendarStatus; + isAgendaView: boolean; + fullColorEvents: boolean; + colorImpairedMode: boolean; +} + +function LegendItem({ + name, + status, + isAgendaView, + fullColorEvents, + colorImpairedMode, +}: LegendItemProps) { + return ( +
+ {name} +
+ ); +} + +export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js deleted file mode 100644 index b68c83f301..0000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector'; - -function CalendarOptionsModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarOptionsModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.tsx b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx new file mode 100644 index 0000000000..ae782a684b --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModal.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarOptionsModalContent from './CalendarOptionsModalContent'; + +interface CalendarOptionsModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarOptionsModal({ + isOpen, + onModalClose, +}: CalendarOptionsModalProps) { + return ( + + + + ); +} + +export default CalendarOptionsModal; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js b/frontend/src/Calendar/Options/CalendarOptionsModalContent.js deleted file mode 100644 index 5933281f71..0000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js +++ /dev/null @@ -1,234 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import FieldSet from 'Components/FieldSet'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Button from 'Components/Link/Button'; -import ModalBody from 'Components/Modal/ModalBody'; -import ModalContent from 'Components/Modal/ModalContent'; -import ModalFooter from 'Components/Modal/ModalFooter'; -import ModalHeader from 'Components/Modal/ModalHeader'; -import { inputTypes } from 'Helpers/Props'; -import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings'; -import translate from 'Utilities/String/translate'; - -class CalendarOptionsModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - } = props; - - this.state = { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode, - fullColorEvents - }; - } - - componentDidUpdate(prevProps) { - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.props; - - if ( - prevProps.firstDayOfWeek !== firstDayOfWeek || - prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader || - prevProps.timeFormat !== timeFormat || - prevProps.enableColorImpairedMode !== enableColorImpairedMode - ) { - this.setState({ - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - }); - } - } - - // - // Listeners - - onOptionInputChange = ({ name, value }) => { - const { - dispatchSetCalendarOption - } = this.props; - - dispatchSetCalendarOption({ [name]: value }); - }; - - onGlobalInputChange = ({ name, value }) => { - const { - dispatchSaveUISettings - } = this.props; - - const setting = { [name]: value }; - - this.setState(setting, () => { - dispatchSaveUISettings(setting); - }); - }; - - onLinkFocus = (event) => { - event.target.select(); - }; - - // - // Render - - render() { - const { - showMovieInformation, - showCutoffUnmetIcon, - fullColorEvents, - onModalClose - } = this.props; - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.state; - - return ( - - - {translate('CalendarOptions')} - - - -
-
- - {translate('ShowMovieInformation')} - - - - - - {translate('IconForCutoffUnmet')} - - - - - - {translate('FullColorEvents')} - - - -
-
- -
-
- - {translate('FirstDayOfWeek')} - - - - - - {translate('WeekColumnHeader')} - - - - - - {translate('TimeFormat')} - - - - - - {translate('EnableColorImpairedMode')} - - - -
-
-
- - - - -
- ); - } -} - -CalendarOptionsModalContent.propTypes = { - showMovieInformation: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - firstDayOfWeek: PropTypes.number.isRequired, - calendarWeekColumnHeader: PropTypes.string.isRequired, - timeFormat: PropTypes.string.isRequired, - enableColorImpairedMode: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - dispatchSetCalendarOption: PropTypes.func.isRequired, - dispatchSaveUISettings: PropTypes.func.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx new file mode 100644 index 0000000000..37fb96a9c6 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx @@ -0,0 +1,186 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import FieldSet from 'Components/FieldSet'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Button from 'Components/Link/Button'; +import ModalBody from 'Components/Modal/ModalBody'; +import ModalContent from 'Components/Modal/ModalContent'; +import ModalFooter from 'Components/Modal/ModalFooter'; +import ModalHeader from 'Components/Modal/ModalHeader'; +import { inputTypes } from 'Helpers/Props'; +import { + firstDayOfWeekOptions, + timeFormatOptions, + weekColumnOptions, +} from 'Settings/UI/UISettings'; +import { setCalendarOption } from 'Store/Actions/calendarActions'; +import { saveUISettings } from 'Store/Actions/settingsActions'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { InputChanged } from 'typings/inputs'; +import UiSettings from 'typings/Settings/UiSettings'; +import translate from 'Utilities/String/translate'; + +interface CalendarOptionsModalContentProps { + onModalClose: () => void; +} + +function CalendarOptionsModalContent({ + onModalClose, +}: CalendarOptionsModalContentProps) { + const dispatch = useDispatch(); + + const { showMovieInformation, showCutoffUnmetIcon, fullColorEvents } = + useSelector((state: AppState) => state.calendar.options); + + const uiSettings = useSelector(createUISettingsSelector()); + + const [state, setState] = useState>({ + firstDayOfWeek: uiSettings.firstDayOfWeek, + calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader, + timeFormat: uiSettings.timeFormat, + enableColorImpairedMode: uiSettings.enableColorImpairedMode, + }); + + const { + firstDayOfWeek, + calendarWeekColumnHeader, + timeFormat, + enableColorImpairedMode, + } = state; + + const handleOptionInputChange = useCallback( + ({ name, value }: InputChanged) => { + dispatch(setCalendarOption({ [name]: value })); + }, + [dispatch] + ); + + const handleGlobalInputChange = useCallback( + ({ name, value }: InputChanged) => { + setState((prevState) => ({ ...prevState, [name]: value })); + + dispatch(saveUISettings({ [name]: value })); + }, + [dispatch] + ); + + useEffect(() => { + setState({ + firstDayOfWeek: uiSettings.firstDayOfWeek, + calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader, + timeFormat: uiSettings.timeFormat, + enableColorImpairedMode: uiSettings.enableColorImpairedMode, + }); + }, [uiSettings]); + + return ( + + {translate('CalendarOptions')} + + +
+
+ + {translate('ShowMovieInformation')} + + + + + + {translate('IconForCutoffUnmet')} + + + + + + {translate('FullColorEvents')} + + + +
+
+ +
+
+ + {translate('FirstDayOfWeek')} + + + + + + {translate('WeekColumnHeader')} + + + + + + {translate('TimeFormat')} + + + + + + {translate('EnableColorImpairedMode')} + + + +
+
+
+ + + + +
+ ); +} + +export default CalendarOptionsModalContent; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js b/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js deleted file mode 100644 index 1f517b6989..0000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js +++ /dev/null @@ -1,25 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import { setCalendarOption } from 'Store/Actions/calendarActions'; -import { saveUISettings } from 'Store/Actions/settingsActions'; -import CalendarOptionsModalContent from './CalendarOptionsModalContent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - (state) => state.settings.ui.item, - (options, uiSettings) => { - return { - ...options, - ...uiSettings - }; - } - ); -} - -const mapDispatchToProps = { - dispatchSetCalendarOption: setCalendarOption, - dispatchSaveUISettings: saveUISettings -}; - -export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent); diff --git a/frontend/src/Calendar/calendarViews.js b/frontend/src/Calendar/calendarViews.ts similarity index 72% rename from frontend/src/Calendar/calendarViews.js rename to frontend/src/Calendar/calendarViews.ts index 929958b66c..4f5549dbd1 100644 --- a/frontend/src/Calendar/calendarViews.js +++ b/frontend/src/Calendar/calendarViews.ts @@ -5,3 +5,5 @@ export const FORECAST = 'forecast'; export const AGENDA = 'agenda'; export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA]; + +export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week'; diff --git a/frontend/src/Calendar/getStatusStyle.js b/frontend/src/Calendar/getStatusStyle.ts similarity index 75% rename from frontend/src/Calendar/getStatusStyle.js rename to frontend/src/Calendar/getStatusStyle.ts index 81ef830675..539faa4021 100644 --- a/frontend/src/Calendar/getStatusStyle.js +++ b/frontend/src/Calendar/getStatusStyle.ts @@ -1,4 +1,9 @@ -function getStatusStyle(hasFile, downloading, isMonitored, isAvailable) { +function getStatusStyle( + hasFile: boolean, + downloading: boolean, + isMonitored: boolean, + isAvailable: boolean +) { if (downloading) { return 'queue'; } diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js deleted file mode 100644 index 8cc487c162..0000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModal.js +++ /dev/null @@ -1,29 +0,0 @@ -import PropTypes from 'prop-types'; -import React from 'react'; -import Modal from 'Components/Modal/Modal'; -import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector'; - -function CalendarLinkModal(props) { - const { - isOpen, - onModalClose - } = props; - - return ( - - - - ); -} - -CalendarLinkModal.propTypes = { - isOpen: PropTypes.bool.isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.tsx b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx new file mode 100644 index 0000000000..f0eecbd4a4 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModal.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import Modal from 'Components/Modal/Modal'; +import CalendarLinkModalContent from './CalendarLinkModalContent'; + +interface CalendarLinkModalProps { + isOpen: boolean; + onModalClose: () => void; +} + +function CalendarLinkModal(props: CalendarLinkModalProps) { + const { isOpen, onModalClose } = props; + + return ( + + + + ); +} + +export default CalendarLinkModal; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js b/frontend/src/Calendar/iCal/CalendarLinkModalContent.js deleted file mode 100644 index dd239d6156..0000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js +++ /dev/null @@ -1,203 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Form from 'Components/Form/Form'; -import FormGroup from 'Components/Form/FormGroup'; -import FormInputButton from 'Components/Form/FormInputButton'; -import FormInputGroup from 'Components/Form/FormInputGroup'; -import FormLabel from 'Components/Form/FormLabel'; -import Icon from 'Components/Icon'; -import Button from 'Components/Link/Button'; -import ClipboardButton from 'Components/Link/ClipboardButton'; -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 { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; -import translate from 'Utilities/String/translate'; - -function getUrls(state) { - const { - unmonitored, - asAllDay, - tags - } = state; - - let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/v3/calendar/Radarr.ics?`; - - if (unmonitored) { - icalUrl += 'unmonitored=true&'; - } - - if (asAllDay) { - icalUrl += 'asAllDay=true&'; - } - - if (tags.length) { - icalUrl += `tags=${tags.toString()}&`; - } - - icalUrl += `apikey=${encodeURIComponent(window.Radarr.apiKey)}`; - - const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`; - const iCalWebCalUrl = `webcal://${icalUrl}`; - - return { - iCalHttpUrl, - iCalWebCalUrl - }; -} - -class CalendarLinkModalContent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - const defaultState = { - unmonitored: false, - asAllDay: false, - tags: [] - }; - - const urls = getUrls(defaultState); - - this.state = { - ...defaultState, - ...urls - }; - } - - // - // Listeners - - onInputChange = ({ name, value }) => { - const state = { - ...this.state, - [name]: value - }; - - const urls = getUrls(state); - - this.setState({ - [name]: value, - ...urls - }); - }; - - onLinkFocus = (event) => { - event.target.select(); - }; - - // - // Render - - render() { - const { - onModalClose - } = this.props; - - const { - unmonitored, - asAllDay, - tags, - iCalHttpUrl, - iCalWebCalUrl - } = this.state; - - return ( - - - {translate('CalendarFeed')} - - - -
- - {translate('IncludeUnmonitored')} - - - - - - {translate('ICalShowAsAllDayEvents')} - - - - - - {translate('Tags')} - - - - - - {translate('ICalFeed')} - - , - - - - - ]} - onChange={this.onInputChange} - onFocus={this.onLinkFocus} - /> - -
-
- - - - -
- ); - } -} - -CalendarLinkModalContent.propTypes = { - tagList: PropTypes.arrayOf(PropTypes.object).isRequired, - onModalClose: PropTypes.func.isRequired -}; - -export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx new file mode 100644 index 0000000000..d0f04d7d74 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx @@ -0,0 +1,149 @@ +import React, { FocusEvent, useCallback, useMemo, useState } from 'react'; +import Form from 'Components/Form/Form'; +import FormGroup from 'Components/Form/FormGroup'; +import FormInputButton from 'Components/Form/FormInputButton'; +import FormInputGroup from 'Components/Form/FormInputGroup'; +import FormLabel from 'Components/Form/FormLabel'; +import Icon from 'Components/Icon'; +import Button from 'Components/Link/Button'; +import ClipboardButton from 'Components/Link/ClipboardButton'; +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 { icons, inputTypes, kinds, sizes } from 'Helpers/Props'; +import { InputChanged } from 'typings/inputs'; +import translate from 'Utilities/String/translate'; + +interface CalendarLinkModalContentProps { + onModalClose: () => void; +} + +function CalendarLinkModalContent({ + onModalClose, +}: CalendarLinkModalContentProps) { + const [state, setState] = useState({ + unmonitored: false, + asAllDay: false, + tags: [], + }); + + const { unmonitored, asAllDay, tags } = state; + + const handleInputChange = useCallback(({ name, value }: InputChanged) => { + setState((prevState) => ({ ...prevState, [name]: value })); + }, []); + + const handleLinkFocus = useCallback( + (event: FocusEvent) => { + event.target.select(); + }, + [] + ); + + const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => { + let icalUrl = `${window.location.host}${window.Radarr.urlBase}/feed/v3/calendar/Radarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${encodeURIComponent(window.Radarr.apiKey)}`; + + return { + iCalHttpUrl: `${window.location.protocol}//${icalUrl}`, + iCalWebCalUrl: `webcal://${icalUrl}`, + }; + }, [unmonitored, asAllDay, tags]); + + return ( + + {translate('CalendarFeed')} + + +
+ + {translate('IncludeUnmonitored')} + + + + + + {translate('ICalShowAsAllDayEvents')} + + + + + + {translate('Tags')} + + + + + + {translate('ICalFeed')} + + , + + + + , + ]} + onChange={handleInputChange} + onFocus={handleLinkFocus} + /> + +
+
+ + + + +
+ ); +} + +export default CalendarLinkModalContent; diff --git a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js b/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js deleted file mode 100644 index e10c5c3f90..0000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js +++ /dev/null @@ -1,17 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createTagsSelector from 'Store/Selectors/createTagsSelector'; -import CalendarLinkModalContent from './CalendarLinkModalContent'; - -function createMapStateToProps() { - return createSelector( - createTagsSelector(), - (tagList) => { - return { - tagList - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarLinkModalContent); diff --git a/frontend/src/Components/Link/Button.tsx b/frontend/src/Components/Link/Button.tsx index cf2293f591..610350a8dc 100644 --- a/frontend/src/Components/Link/Button.tsx +++ b/frontend/src/Components/Link/Button.tsx @@ -1,16 +1,14 @@ import classNames from 'classnames'; import React from 'react'; -import { align, kinds, sizes } from 'Helpers/Props'; +import { kinds, sizes } from 'Helpers/Props'; +import { Align } from 'Helpers/Props/align'; import { Kind } from 'Helpers/Props/kinds'; import { Size } from 'Helpers/Props/sizes'; import Link, { LinkProps } from './Link'; import styles from './Button.css'; export interface ButtonProps extends Omit { - buttonGroupPosition?: Extract< - (typeof align.all)[number], - keyof typeof styles - >; + buttonGroupPosition?: Extract; kind?: Extract; size?: Extract; children: Required; diff --git a/frontend/src/Movie/Movie.ts b/frontend/src/Movie/Movie.ts index 6bb9b42797..b7b4ee6b28 100644 --- a/frontend/src/Movie/Movie.ts +++ b/frontend/src/Movie/Movie.ts @@ -89,6 +89,7 @@ interface Movie extends ModelBase { statistics?: Statistics; tags: number[]; images: Image[]; + movieFileId: number; movieFile?: MovieFile; hasFile: boolean; grabbed?: boolean; diff --git a/frontend/src/typings/Calendar.ts b/frontend/src/typings/Calendar.ts new file mode 100644 index 0000000000..711fdefa69 --- /dev/null +++ b/frontend/src/typings/Calendar.ts @@ -0,0 +1,13 @@ +import Movie from 'Movie/Movie'; + +export type CalendarItem = Movie; + +export type CalendarEvent = CalendarItem; + +export type CalendarStatus = + | 'downloaded' + | 'queue' + | 'unmonitored' + | 'missingMonitored' + | 'missingUnmonitored' + | 'continuing'; diff --git a/frontend/src/typings/CalendarEvent.ts b/frontend/src/typings/CalendarEvent.ts deleted file mode 100644 index 16ba28d6fd..0000000000 --- a/frontend/src/typings/CalendarEvent.ts +++ /dev/null @@ -1,5 +0,0 @@ -import Movie from 'Movie/Movie'; - -type CalendarEvent = Movie; - -export default CalendarEvent; diff --git a/frontend/src/typings/Queue.ts b/frontend/src/typings/Queue.ts index 861b402932..85e495fee6 100644 --- a/frontend/src/typings/Queue.ts +++ b/frontend/src/typings/Queue.ts @@ -1,6 +1,7 @@ import ModelBase from 'App/ModelBase'; import DownloadProtocol from 'DownloadClient/DownloadProtocol'; import Language from 'Language/Language'; +import Movie from 'Movie/Movie'; import { QualityModel } from 'Quality/Quality'; import CustomFormat from 'typings/CustomFormat'; @@ -44,5 +45,6 @@ interface Queue extends ModelBase { movieHasFile: boolean; movieId?: number; downloadClientHasPostImportCategory: boolean; + movie?: Movie; } export default Queue; diff --git a/frontend/src/typings/Settings/UiSettings.ts b/frontend/src/typings/Settings/UiSettings.ts index 2fb41b092b..2a49f962bb 100644 --- a/frontend/src/typings/Settings/UiSettings.ts +++ b/frontend/src/typings/Settings/UiSettings.ts @@ -4,7 +4,9 @@ export default interface UiSettings { shortDateFormat: string; longDateFormat: string; timeFormat: string; + firstDayOfWeek: number; enableColorImpairedMode: boolean; + calendarWeekColumnHeader: string; movieRuntimeFormat: string; movieInfoLanguage: number; uiLanguage: number; diff --git a/src/Radarr.Api.V3/Calendar/CalendarController.cs b/src/Radarr.Api.V3/Calendar/CalendarController.cs index 79bce2e4ba..83a0d3bd93 100644 --- a/src/Radarr.Api.V3/Calendar/CalendarController.cs +++ b/src/Radarr.Api.V3/Calendar/CalendarController.cs @@ -76,7 +76,11 @@ namespace Radarr.Api.V3.Calendar var resources = MapToResource(results); - return resources.OrderBy(e => e.InCinemas).ToList(); + return resources + .OrderBy(m => m.InCinemas) + .ThenBy(m => m.DigitalRelease) + .ThenBy(m => m.PhysicalRelease) + .ToList(); } } }