From 811eb36c7b1a5124270ff93d18d16944e654de81 Mon Sep 17 00:00:00 2001 From: Mark McDowall Date: Sun, 8 Dec 2024 17:24:58 -0800 Subject: [PATCH] Convert Calendar to TypeScript --- frontend/src/App/AppRoutes.tsx | 4 +- frontend/src/App/State/AppState.ts | 2 + frontend/src/App/State/CalendarAppState.ts | 25 +- frontend/src/Calendar/Agenda/Agenda.js | 38 --- frontend/src/Calendar/Agenda/Agenda.tsx | 25 ++ .../src/Calendar/Agenda/AgendaConnector.js | 14 - frontend/src/Calendar/Agenda/AgendaEvent.js | 254 ---------------- frontend/src/Calendar/Agenda/AgendaEvent.tsx | 227 ++++++++++++++ .../Calendar/Agenda/AgendaEventConnector.js | 30 -- frontend/src/Calendar/Calendar.js | 67 ----- frontend/src/Calendar/Calendar.tsx | 170 +++++++++++ frontend/src/Calendar/CalendarConnector.js | 196 ------------- frontend/src/Calendar/CalendarPage.js | 197 ------------- frontend/src/Calendar/CalendarPage.tsx | 226 ++++++++++++++ .../src/Calendar/CalendarPageConnector.js | 117 -------- frontend/src/Calendar/Day/CalendarDay.tsx | 107 ++++++- .../src/Calendar/Day/CalendarDayConnector.js | 91 ------ frontend/src/Calendar/Day/CalendarDays.js | 164 ----------- frontend/src/Calendar/Day/CalendarDays.tsx | 135 +++++++++ .../src/Calendar/Day/CalendarDaysConnector.js | 25 -- frontend/src/Calendar/Day/DayOfWeek.js | 56 ---- frontend/src/Calendar/Day/DayOfWeek.tsx | 54 ++++ frontend/src/Calendar/Day/DaysOfWeek.js | 97 ------ frontend/src/Calendar/Day/DaysOfWeek.tsx | 60 ++++ .../src/Calendar/Day/DaysOfWeekConnector.js | 22 -- frontend/src/Calendar/Events/CalendarEvent.js | 267 ----------------- .../src/Calendar/Events/CalendarEvent.tsx | 240 +++++++++++++++ .../Calendar/Events/CalendarEventConnector.js | 29 -- .../src/Calendar/Events/CalendarEventGroup.js | 259 ---------------- .../Calendar/Events/CalendarEventGroup.tsx | 253 ++++++++++++++++ .../Events/CalendarEventGroupConnector.js | 37 --- .../Events/CalendarEventQueueDetails.js | 56 ---- .../Events/CalendarEventQueueDetails.tsx | 58 ++++ .../src/Calendar/Header/CalendarHeader.js | 268 ----------------- .../src/Calendar/Header/CalendarHeader.tsx | 221 ++++++++++++++ .../Header/CalendarHeaderConnector.js | 85 ------ .../Header/CalendarHeaderViewButton.js | 45 --- .../Header/CalendarHeaderViewButton.tsx | 34 +++ .../Calendar/Legend/{Legend.js => Legend.tsx} | 53 ++-- .../src/Calendar/Legend/LegendConnector.js | 21 -- .../src/Calendar/Legend/LegendIconItem.js | 43 --- .../src/Calendar/Legend/LegendIconItem.tsx | 33 +++ .../Legend/{LegendItem.js => LegendItem.tsx} | 24 +- .../Calendar/Options/CalendarOptionsModal.js | 29 -- .../Calendar/Options/CalendarOptionsModal.tsx | 21 ++ .../Options/CalendarOptionsModalContent.js | 276 ------------------ .../Options/CalendarOptionsModalContent.tsx | 228 +++++++++++++++ .../CalendarOptionsModalContentConnector.js | 25 -- .../{calendarViews.js => calendarViews.ts} | 2 + .../{getStatusStyle.js => getStatusStyle.ts} | 12 +- .../src/Calendar/iCal/CalendarLinkModal.js | 29 -- .../src/Calendar/iCal/CalendarLinkModal.tsx | 20 ++ .../Calendar/iCal/CalendarLinkModalContent.js | 222 -------------- .../iCal/CalendarLinkModalContent.tsx | 166 +++++++++++ .../iCal/CalendarLinkModalContentConnector.js | 17 -- .../src/Components/Form/FormInputGroup.tsx | 4 +- frontend/src/Components/Form/TextInput.tsx | 5 +- frontend/src/Components/Icon.tsx | 2 +- frontend/src/Components/Link/Button.tsx | 8 +- frontend/src/Episode/Episode.ts | 2 + frontend/src/Episode/useEpisode.ts | 3 +- .../createSeriesQualityProfileSelector.ts | 3 +- frontend/src/typings/Calendar.ts | 25 ++ frontend/src/typings/CalendarEventGroup.ts | 15 - frontend/src/typings/Queue.ts | 2 + frontend/src/typings/Settings/UiSettings.ts | 3 + 66 files changed, 2379 insertions(+), 3169 deletions(-) delete mode 100644 frontend/src/Calendar/Agenda/Agenda.js create mode 100644 frontend/src/Calendar/Agenda/Agenda.tsx delete mode 100644 frontend/src/Calendar/Agenda/AgendaConnector.js delete mode 100644 frontend/src/Calendar/Agenda/AgendaEvent.js create mode 100644 frontend/src/Calendar/Agenda/AgendaEvent.tsx delete mode 100644 frontend/src/Calendar/Agenda/AgendaEventConnector.js delete mode 100644 frontend/src/Calendar/Calendar.js create mode 100644 frontend/src/Calendar/Calendar.tsx delete mode 100644 frontend/src/Calendar/CalendarConnector.js delete mode 100644 frontend/src/Calendar/CalendarPage.js create mode 100644 frontend/src/Calendar/CalendarPage.tsx delete mode 100644 frontend/src/Calendar/CalendarPageConnector.js delete mode 100644 frontend/src/Calendar/Day/CalendarDayConnector.js delete mode 100644 frontend/src/Calendar/Day/CalendarDays.js create mode 100644 frontend/src/Calendar/Day/CalendarDays.tsx delete mode 100644 frontend/src/Calendar/Day/CalendarDaysConnector.js delete mode 100644 frontend/src/Calendar/Day/DayOfWeek.js create mode 100644 frontend/src/Calendar/Day/DayOfWeek.tsx delete mode 100644 frontend/src/Calendar/Day/DaysOfWeek.js create mode 100644 frontend/src/Calendar/Day/DaysOfWeek.tsx delete mode 100644 frontend/src/Calendar/Day/DaysOfWeekConnector.js delete mode 100644 frontend/src/Calendar/Events/CalendarEvent.js create mode 100644 frontend/src/Calendar/Events/CalendarEvent.tsx delete mode 100644 frontend/src/Calendar/Events/CalendarEventConnector.js delete mode 100644 frontend/src/Calendar/Events/CalendarEventGroup.js create mode 100644 frontend/src/Calendar/Events/CalendarEventGroup.tsx delete mode 100644 frontend/src/Calendar/Events/CalendarEventGroupConnector.js delete mode 100644 frontend/src/Calendar/Events/CalendarEventQueueDetails.js create mode 100644 frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx delete mode 100644 frontend/src/Calendar/Header/CalendarHeader.js create mode 100644 frontend/src/Calendar/Header/CalendarHeader.tsx delete mode 100644 frontend/src/Calendar/Header/CalendarHeaderConnector.js delete mode 100644 frontend/src/Calendar/Header/CalendarHeaderViewButton.js create mode 100644 frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx rename frontend/src/Calendar/Legend/{Legend.js => Legend.tsx} (77%) delete mode 100644 frontend/src/Calendar/Legend/LegendConnector.js delete mode 100644 frontend/src/Calendar/Legend/LegendIconItem.js create mode 100644 frontend/src/Calendar/Legend/LegendIconItem.tsx rename frontend/src/Calendar/Legend/{LegendItem.js => LegendItem.tsx} (61%) delete mode 100644 frontend/src/Calendar/Options/CalendarOptionsModal.js create mode 100644 frontend/src/Calendar/Options/CalendarOptionsModal.tsx delete mode 100644 frontend/src/Calendar/Options/CalendarOptionsModalContent.js create mode 100644 frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx delete mode 100644 frontend/src/Calendar/Options/CalendarOptionsModalContentConnector.js rename frontend/src/Calendar/{calendarViews.js => calendarViews.ts} (72%) rename frontend/src/Calendar/{getStatusStyle.js => getStatusStyle.ts} (67%) delete mode 100644 frontend/src/Calendar/iCal/CalendarLinkModal.js create mode 100644 frontend/src/Calendar/iCal/CalendarLinkModal.tsx delete mode 100644 frontend/src/Calendar/iCal/CalendarLinkModalContent.js create mode 100644 frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx delete mode 100644 frontend/src/Calendar/iCal/CalendarLinkModalContentConnector.js create mode 100644 frontend/src/typings/Calendar.ts delete mode 100644 frontend/src/typings/CalendarEventGroup.ts diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx index 1b4fea9c2..fbe4a15bb 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 AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector'; import ImportSeries from 'AddSeries/ImportSeries/ImportSeries'; -import CalendarPageConnector from 'Calendar/CalendarPageConnector'; +import CalendarPage from 'Calendar/CalendarPage'; import NotFound from 'Components/NotFound'; import Switch from 'Components/Router/Switch'; import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector'; @@ -72,7 +72,7 @@ function AppRoutes() { Calendar */} - + {/* Activity diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts index 36047cc4e..84bd5d0b4 100644 --- a/frontend/src/App/State/AppState.ts +++ b/frontend/src/App/State/AppState.ts @@ -54,10 +54,12 @@ export interface CustomFilter { export interface AppSectionState { isConnected: boolean; isReconnecting: boolean; + isSidebarVisible: boolean; version: string; 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 de6a523b3..75c8b5e50 100644 --- a/frontend/src/App/State/CalendarAppState.ts +++ b/frontend/src/App/State/CalendarAppState.ts @@ -1,10 +1,29 @@ +import moment from 'moment'; import AppSectionState, { AppSectionFilterState, } from 'App/State/AppSectionState'; -import Episode from 'Episode/Episode'; +import { CalendarView } from 'Calendar/calendarViews'; +import { CalendarItem } from 'typings/Calendar'; + +interface CalendarOptions { + showEpisodeInformation: boolean; + showFinaleIcon: boolean; + showSpecialIcon: boolean; + showCutoffUnmetIcon: boolean; + collapseMultipleEpisodes: 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 89472301d..000000000 --- a/frontend/src/Calendar/Agenda/Agenda.js +++ /dev/null @@ -1,38 +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 - } = props; - - return ( -
- { - items.map((item, index) => { - const momentDate = moment(item.airDateUtc); - const showDate = index === 0 || - !moment(items[index - 1].airDateUtc).isSame(momentDate, 'day'); - - return ( - - ); - }) - } -
- ); -} - -Agenda.propTypes = { - items: PropTypes.arrayOf(PropTypes.object).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 000000000..fdef40466 --- /dev/null +++ b/frontend/src/Calendar/Agenda/Agenda.tsx @@ -0,0 +1,25 @@ +import moment from 'moment'; +import React from 'react'; +import { useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import AgendaEvent from './AgendaEvent'; +import styles from './Agenda.css'; + +function Agenda() { + const { items } = useSelector((state: AppState) => state.calendar); + + return ( +
+ {items.map((item, index) => { + const momentDate = moment(item.airDateUtc); + const showDate = + index === 0 || + !moment(items[index - 1].airDateUtc).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 b6f238873..000000000 --- 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.js b/frontend/src/Calendar/Agenda/AgendaEvent.js deleted file mode 100644 index 608528478..000000000 --- a/frontend/src/Calendar/Agenda/AgendaEvent.js +++ /dev/null @@ -1,254 +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 EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; -import episodeEntities from 'Episode/episodeEntities'; -import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -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 { - id, - series, - episodeFile, - title, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - airDateUtc, - monitored, - unverifiedSceneNumbering, - finaleType, - hasFile, - grabbed, - queueItem, - showDate, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - timeFormat, - longDateFormat, - colorImpairedMode - } = this.props; - - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); - const downloading = !!(queueItem || grabbed); - const isMonitored = series.monitored && monitored; - const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored); - const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; - - return ( -
- - -
-
- { - showDate && - startTime.format(longDateFormat) - } -
- -
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
- -
- {series.title} -
- - { - showEpisodeInformation && -
- {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - series.seriesType === 'anime' && absoluteEpisodeNumber && - ({absoluteEpisodeNumber}) - } - -
-
-
- } - -
- { - showEpisodeInformation && - title - } -
- - { - missingAbsoluteNumber && - - } - - { - unverifiedSceneNumbering && !missingAbsoluteNumber ? - : - null - } - - { - !!queueItem && - - - - } - - { - !queueItem && grabbed && - - } - - { - showCutoffUnmetIcon && - !!episodeFile && - episodeFile.qualityCutoffNotMet && - - } - - { - episodeNumber === 1 && seasonNumber > 0 && - - } - - { - showFinaleIcon && - finaleType ? - : - null - } - - { - showSpecialIcon && - (episodeNumber === 0 || seasonNumber === 0) && - - } -
-
- - -
- ); - } -} - -AgendaEvent.propTypes = { - id: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - episodeFile: PropTypes.object, - title: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - airDateUtc: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - unverifiedSceneNumbering: PropTypes.bool, - finaleType: PropTypes.string, - hasFile: PropTypes.bool.isRequired, - grabbed: PropTypes.bool, - queueItem: PropTypes.object, - showDate: PropTypes.bool.isRequired, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.isRequired, - showCutoffUnmetIcon: PropTypes.bool.isRequired, - timeFormat: PropTypes.string.isRequired, - longDateFormat: PropTypes.string.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - -export default AgendaEvent; diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.tsx b/frontend/src/Calendar/Agenda/AgendaEvent.tsx new file mode 100644 index 000000000..2fd2d7c54 --- /dev/null +++ b/frontend/src/Calendar/Agenda/AgendaEvent.tsx @@ -0,0 +1,227 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useState } 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 EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import episodeEntities from 'Episode/episodeEntities'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import styles from './AgendaEvent.css'; + +interface AgendaEventProps { + id: number; + seriesId: number; + episodeFileId: number; + title: string; + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + airDateUtc: string; + monitored: boolean; + unverifiedSceneNumbering?: boolean; + finaleType?: string; + hasFile: boolean; + grabbed?: boolean; + showDate: boolean; +} + +function AgendaEvent(props: AgendaEventProps) { + const { + id, + seriesId, + episodeFileId, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + unverifiedSceneNumbering, + finaleType, + hasFile, + grabbed, + showDate, + } = props; + + const series = useSeries(seriesId)!; + const episodeFile = useEpisodeFile(episodeFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + } = useSelector((state: AppState) => state.calendar.options); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const downloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle( + hasFile, + downloading, + startTime, + endTime, + isMonitored + ); + const missingAbsoluteNumber = + series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + + const handlePress = useCallback(() => { + setIsDetailsModalOpen(true); + }, []); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(false); + }, []); + + return ( +
+ + +
+
+ {showDate && startTime.format(longDateFormat)} +
+ +
+
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+ +
{series.title}
+ + {showEpisodeInformation ? ( +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + {series.seriesType === 'anime' && absoluteEpisodeNumber && ( + + ({absoluteEpisodeNumber}) + + )} +
-
+
+ ) : null} + +
+ {showEpisodeInformation ? title : null} +
+ + {missingAbsoluteNumber ? ( + + ) : null} + + {unverifiedSceneNumbering && !missingAbsoluteNumber ? ( + + ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + episodeFile && + episodeFile.qualityCutoffNotMet ? ( + + ) : null} + + {episodeNumber === 1 && seasonNumber > 0 && ( + + )} + + {showFinaleIcon && finaleType ? ( + + ) : null} + + {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? ( + + ) : 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 d476acf80..000000000 --- a/frontend/src/Calendar/Agenda/AgendaEventConnector.js +++ /dev/null @@ -1,30 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import AgendaEvent from './AgendaEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createEpisodeFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, series, episodeFile, queueItem, uiSettings) => { - return { - series, - episodeFile, - 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 0a2fd671d..000000000 --- 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 000000000..caa337cf0 --- /dev/null +++ b/frontend/src/Calendar/Calendar.tsx @@ -0,0 +1,170 @@ +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 Episode from 'Episode/Episode'; +import useCurrentPage from 'Helpers/Hooks/useCurrentPage'; +import usePrevious from 'Helpers/Hooks/usePrevious'; +import { kinds } from 'Helpers/Props'; +import { + clearCalendar, + fetchCalendar, + gotoCalendarToday, +} from 'Store/Actions/calendarActions'; +import { + clearEpisodeFiles, + fetchEpisodeFiles, +} from 'Store/Actions/episodeFileActions'; +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 isRefreshingSeries = useSelector( + createCommandExecutingSelector(commandNames.REFRESH_SERIES) + ); + + const firstDayOfWeek = useSelector( + (state: AppState) => state.settings.ui.item.firstDayOfWeek + ); + + const wasRefreshingSeries = usePrevious(isRefreshingSeries); + 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(clearEpisodeFiles()); + 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, [ + 'episodeFileUpdated', + 'episodeFileDeleted', + ]); + + 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 (wasRefreshingSeries && !isRefreshingSeries) { + dispatch(fetchCalendar({ time, view })); + } + }, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]); + + useEffect(() => { + if (!previousItems || hasDifferentItems(items, previousItems)) { + const episodeIds = selectUniqueIds(items, 'id'); + const episodeFileIds = selectUniqueIds( + items, + 'episodeFileId' + ); + + if (items.length) { + dispatch(fetchQueueDetails({ episodeIds })); + } + + if (episodeFileIds.length) { + dispatch(fetchEpisodeFiles({ episodeFileIds })); + } + } + }, [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 47c769126..000000000 --- a/frontend/src/Calendar/CalendarConnector.js +++ /dev/null @@ -1,196 +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 { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions'; -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_SERIES), - (calendar, firstDayOfWeek, isRefreshingSeries) => { - return { - ...calendar, - isRefreshingSeries, - firstDayOfWeek - }; - } - ); -} - -const mapDispatchToProps = { - ...calendarActions, - fetchEpisodeFiles, - clearEpisodeFiles, - 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, ['episodeFileUpdated', 'episodeFileDeleted']); - - if (useCurrentPage) { - fetchCalendar(); - } else { - gotoCalendarToday(); - } - - this.scheduleUpdate(); - } - - componentDidUpdate(prevProps) { - const { - items, - time, - view, - isRefreshingSeries, - firstDayOfWeek - } = this.props; - - if (hasDifferentItems(prevProps.items, items)) { - const episodeIds = selectUniqueIds(items, 'id'); - const episodeFileIds = selectUniqueIds(items, 'episodeFileId'); - - if (items.length) { - this.props.fetchQueueDetails({ episodeIds }); - } - - if (episodeFileIds.length) { - this.props.fetchEpisodeFiles({ episodeFileIds }); - } - } - - if (prevProps.time !== time) { - this.scheduleUpdate(); - } - - if (prevProps.firstDayOfWeek !== firstDayOfWeek) { - this.props.fetchCalendar({ time, view }); - } - - if (prevProps.isRefreshingSeries && !isRefreshingSeries) { - this.props.fetchCalendar({ time, view }); - } - } - - componentWillUnmount() { - unregisterPagePopulator(this.repopulate); - this.props.clearCalendar(); - this.props.clearQueueDetails(); - this.props.clearEpisodeFiles(); - 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, - isRefreshingSeries: 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, - fetchEpisodeFiles: PropTypes.func.isRequired, - clearEpisodeFiles: 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 2e4d56b6b..000000000 --- a/frontend/src/Calendar/CalendarPage.js +++ /dev/null @@ -1,197 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -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 NoSeries from 'Series/NoSeries'; -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 { - missingEpisodeIds, - onSearchMissingPress - } = this.props; - - onSearchMissingPress(missingEpisodeIds); - }; - - // - // Render - - render() { - const { - selectedFilterKey, - filters, - customFilters, - hasSeries, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing, - useCurrentPage, - onRssSyncPress, - onFilterSelect - } = this.props; - - const { - isCalendarLinkModalOpen, - isOptionsModalOpen - } = this.state; - - const isMeasured = this.state.width > 0; - const PageComponent = hasSeries ? CalendarConnector : NoSeries; - - return ( - - - - - - - - - - - - - - - - - - - - - - { - isMeasured ? - : -
- } - - - { - hasSeries && - - } - - - - - - - ); - } -} - -CalendarPage.propTypes = { - selectedFilterKey: PropTypes.string.isRequired, - filters: PropTypes.arrayOf(PropTypes.object).isRequired, - customFilters: PropTypes.arrayOf(PropTypes.object).isRequired, - hasSeries: PropTypes.bool.isRequired, - missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).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 000000000..f408b6a60 --- /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 NoSeries from 'Series/NoSeries'; +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 createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; +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 createMissingEpisodeIdsSelector() { + 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, episodes, queueDetails) => { + return episodes.reduce((acc, episode) => { + const airDateUtc = episode.airDateUtc; + + if ( + !episode.episodeFileId && + moment(airDateUtc).isAfter(start) && + moment(airDateUtc).isBefore(end) && + isBefore(episode.airDateUtc) && + !queueDetails.some( + (details) => !!details.episode && details.episode.id === episode.id + ) + ) { + acc.push(episode.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 missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector()); + const isSearchingForMissing = useSelector(createIsSearchingSelector()); + const isRssSyncExecuting = useSelector( + createCommandExecutingSelector(commandNames.RSS_SYNC) + ); + const customFilters = useSelector(createCustomFiltersSelector('calendar')); + const hasSeries = !!useSelector(createSeriesCountSelector()); + + const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false); + const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false); + const [width, setWidth] = useState(0); + + const isMeasured = width > 0; + const PageComponent = hasSeries ? Calendar : NoSeries; + + 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({ episodeIds: missingEpisodeIds })); + }, [missingEpisodeIds, dispatch]); + + const handleFilterSelect = useCallback( + (key: string) => { + dispatch(setCalendarFilter({ selectedFilterKey: key })); + }, + [dispatch] + ); + + return ( + + + + + + + + + + + + + + + + + + + + + + {isMeasured ? :
} + + + {hasSeries && } + + + + + + + ); +} + +export default CalendarPage; diff --git a/frontend/src/Calendar/CalendarPageConnector.js b/frontend/src/Calendar/CalendarPageConnector.js deleted file mode 100644 index b47142b64..000000000 --- a/frontend/src/Calendar/CalendarPageConnector.js +++ /dev/null @@ -1,117 +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 createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import { isCommandExecuting } from 'Utilities/Command'; -import isBefore from 'Utilities/Date/isBefore'; -import CalendarPage from './CalendarPage'; - -function createMissingEpisodeIdsSelector() { - return createSelector( - (state) => state.calendar.start, - (state) => state.calendar.end, - (state) => state.calendar.items, - (state) => state.queue.details.items, - (start, end, episodes, queueDetails) => { - return episodes.reduce((acc, episode) => { - const airDateUtc = episode.airDateUtc; - - if ( - !episode.episodeFileId && - moment(airDateUtc).isAfter(start) && - moment(airDateUtc).isBefore(end) && - isBefore(episode.airDateUtc) && - !queueDetails.some((details) => !!details.episode && details.episode.id === episode.id) - ) { - acc.push(episode.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'), - createSeriesCountSelector(), - createUISettingsSelector(), - createMissingEpisodeIdsSelector(), - createCommandExecutingSelector(commandNames.RSS_SYNC), - createIsSearchingSelector(), - ( - selectedFilterKey, - filters, - customFilters, - seriesCount, - uiSettings, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing - ) => { - return { - selectedFilterKey, - filters, - customFilters, - colorImpairedMode: uiSettings.enableColorImpairedMode, - hasSeries: !!seriesCount, - missingEpisodeIds, - isRssSyncExecuting, - isSearchingForMissing - }; - } - ); -} - -function createMapDispatchToProps(dispatch, props) { - return { - onRssSyncPress() { - dispatch(executeCommand({ - name: commandNames.RSS_SYNC - })); - }, - - onSearchMissingPress(episodeIds) { - dispatch(searchMissing({ episodeIds })); - }, - - 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 7538b0467..a619109ca 100644 --- a/frontend/src/Calendar/Day/CalendarDay.tsx +++ b/frontend/src/Calendar/Day/CalendarDay.tsx @@ -1,25 +1,104 @@ 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 CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector'; -import Series from 'Series/Series'; -import CalendarEventGroup, { CalendarEvent } from 'typings/CalendarEventGroup'; +import CalendarEvent from 'Calendar/Events/CalendarEvent'; +import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup'; +import { + CalendarEvent as CalendarEventModel, + CalendarEventGroup as CalendarEventGroupModel, + CalendarItem, +} from 'typings/Calendar'; import styles from './CalendarDay.css'; +function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) { + return items.sort((a, b) => { + const aDate = a.isGroup + ? moment(a.events[0].airDateUtc).unix() + : moment(a.airDateUtc).unix(); + + const bDate = b.isGroup + ? moment(b.events[0].airDateUtc).unix() + : moment(b.airDateUtc).unix(); + + return aDate - bDate; + }); +} + +function createCalendarEventsConnector(date: string) { + return createSelector( + (state: AppState) => state.calendar.items, + (state: AppState) => state.calendar.options.collapseMultipleEpisodes, + (items, collapseMultipleEpisodes) => { + const momentDate = moment(date); + + const filtered = items.filter((item) => { + return momentDate.isSame(moment(item.airDateUtc), 'day'); + }); + + if (!collapseMultipleEpisodes) { + return sort( + filtered.map((item) => ({ + isGroup: false, + ...item, + })) + ); + } + + const groupedObject = Object.groupBy( + filtered, + (item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}` + ); + + const grouped = Object.entries(groupedObject).reduce< + (CalendarEventModel | CalendarEventGroupModel)[] + >((acc, [, events]) => { + if (!events) { + return acc; + } + + if (events.length === 1) { + acc.push({ + isGroup: false, + ...events[0], + }); + } else { + acc.push({ + isGroup: true, + seriesId: events[0].seriesId, + seasonNumber: events[0].seasonNumber, + episodeIds: events.map((event) => event.id), + events: events.sort( + (a, b) => + moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix() + ), + }); + } + + return acc; + }, []); + + return sort(grouped); + } + ); +} + interface CalendarDayProps { date: string; - time: string; isTodaysDate: boolean; - events: (CalendarEvent | CalendarEventGroup)[]; - view: string; - onEventModalOpenToggle(...args: unknown[]): unknown; + onEventModalOpenToggle(isOpen: boolean): unknown; } -function CalendarDay(props: CalendarDayProps) { - const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } = - props; +function CalendarDay({ + date, + isTodaysDate, + onEventModalOpenToggle, +}: CalendarDayProps) { + const { time, view } = useSelector((state: AppState) => state.calendar); + const events = useSelector(createCalendarEventsConnector(date)); const ref = React.useRef(null); @@ -53,7 +132,7 @@ function CalendarDay(props: CalendarDayProps) { {events.map((event) => { if (event.isGroup) { return ( - diff --git a/frontend/src/Calendar/Day/CalendarDayConnector.js b/frontend/src/Calendar/Day/CalendarDayConnector.js deleted file mode 100644 index 8fd6cc5a1..000000000 --- a/frontend/src/Calendar/Day/CalendarDayConnector.js +++ /dev/null @@ -1,91 +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].airDateUtc).unix(); - } - - return moment(item.airDateUtc).unix(); - }); -} - -function createCalendarEventsConnector() { - return createSelector( - (state, { date }) => date, - (state) => state.calendar.items, - (state) => state.calendar.options.collapseMultipleEpisodes, - (date, items, collapseMultipleEpisodes) => { - const filtered = _.filter(items, (item) => { - return moment(date).isSame(moment(item.airDateUtc), 'day'); - }); - - if (!collapseMultipleEpisodes) { - return sort(filtered); - } - - const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`); - const grouped = []; - - Object.keys(groupedObject).forEach((key) => { - const events = groupedObject[key]; - - if (events.length === 1) { - grouped.push(events[0]); - } else { - grouped.push({ - isGroup: true, - seriesId: events[0].seriesId, - seasonNumber: events[0].seasonNumber, - episodeIds: events.map((event) => event.id), - events: _.sortBy(events, (item) => moment(item.airDateUtc).unix()) - }); - } - }); - - const sorted = sort(grouped); - - return sorted; - } - ); -} - -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 f2bb4c8d4..000000000 --- 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 000000000..149dc1455 --- /dev/null +++ b/frontend/src/Calendar/Day/CalendarDays.tsx @@ -0,0 +1,135 @@ +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 isEventModalOpen = useRef(false); + const [todaysDate, setTodaysDate] = useState( + moment().startOf('day').toISOString() + ); + + const handleEventModalOpenToggle = useCallback((isOpen: boolean) => { + isEventModalOpen.current = isOpen; + }, []); + + 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 || isEventModalOpen.current) { + 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 0acce70b9..000000000 --- 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 0f1d38f0b..000000000 --- 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 000000000..c8b493b7c --- /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 9f94b1079..000000000 --- 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 000000000..64bc886cc --- /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 7f5cdef19..000000000 --- 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.js b/frontend/src/Calendar/Events/CalendarEvent.js deleted file mode 100644 index 1f9d59b2b..000000000 --- a/frontend/src/Calendar/Events/CalendarEvent.js +++ /dev/null @@ -1,267 +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 EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; -import episodeEntities from 'Episode/episodeEntities'; -import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import translate from 'Utilities/String/translate'; -import CalendarEventQueueDetails from './CalendarEventQueueDetails'; -import styles from './CalendarEvent.css'; - -class CalendarEvent extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isDetailsModalOpen: false - }; - } - - // - // Listeners - - onPress = () => { - this.setState({ isDetailsModalOpen: true }, () => { - this.props.onEventModalOpenToggle(true); - }); - }; - - onDetailsModalClose = () => { - this.setState({ isDetailsModalOpen: false }, () => { - this.props.onEventModalOpenToggle(false); - }); - }; - - // - // Render - - render() { - const { - id, - series, - episodeFile, - title, - seasonNumber, - episodeNumber, - absoluteEpisodeNumber, - airDateUtc, - monitored, - unverifiedSceneNumbering, - finaleType, - hasFile, - grabbed, - queueItem, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - fullColorEvents, - timeFormat, - colorImpairedMode - } = this.props; - - if (!series) { - return null; - } - - const startTime = moment(airDateUtc); - const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); - const isDownloading = !!(queueItem || grabbed); - const isMonitored = series.monitored && monitored; - const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored); - const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; - - return ( -
- - -
-
-
- {series.title} -
- -
- { - missingAbsoluteNumber ? - : - null - } - - { - unverifiedSceneNumbering && !missingAbsoluteNumber ? - : - null - } - - { - queueItem ? - - - : - null - } - - { - !queueItem && grabbed ? - : - null - } - - { - showCutoffUnmetIcon && - !!episodeFile && - episodeFile.qualityCutoffNotMet ? - : - null - } - - { - episodeNumber === 1 && seasonNumber > 0 ? - : - null - } - - { - showFinaleIcon && - finaleType ? - : - null - } - - { - showSpecialIcon && - (episodeNumber === 0 || seasonNumber === 0) ? - : - null - } -
-
- - { - showEpisodeInformation ? -
-
- {title} -
- -
- {seasonNumber}x{padNumber(episodeNumber, 2)} - - { - series.seriesType === 'anime' && absoluteEpisodeNumber ? - ({absoluteEpisodeNumber}) : null - } -
-
: - null - } - -
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
-
- - -
- ); - } -} - -CalendarEvent.propTypes = { - id: PropTypes.number.isRequired, - episodeId: PropTypes.number.isRequired, - series: PropTypes.object.isRequired, - episodeFile: PropTypes.object, - title: PropTypes.string.isRequired, - seasonNumber: PropTypes.number.isRequired, - episodeNumber: PropTypes.number.isRequired, - absoluteEpisodeNumber: PropTypes.number, - airDateUtc: PropTypes.string.isRequired, - monitored: PropTypes.bool.isRequired, - unverifiedSceneNumbering: PropTypes.bool, - finaleType: 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. - showEpisodeInformation: PropTypes.bool, - showFinaleIcon: PropTypes.bool, - showSpecialIcon: PropTypes.bool, - showCutoffUnmetIcon: PropTypes.bool, - fullColorEvents: PropTypes.bool, - timeFormat: PropTypes.string, - colorImpairedMode: PropTypes.bool, - onEventModalOpenToggle: PropTypes.func -}; - -export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEvent.tsx b/frontend/src/Calendar/Events/CalendarEvent.tsx new file mode 100644 index 000000000..83452a5ce --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEvent.tsx @@ -0,0 +1,240 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useState } 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 EpisodeDetailsModal from 'Episode/EpisodeDetailsModal'; +import episodeEntities from 'Episode/episodeEntities'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import useEpisodeFile from 'EpisodeFile/useEpisodeFile'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import CalendarEventQueueDetails from './CalendarEventQueueDetails'; +import styles from './CalendarEvent.css'; + +interface CalendarEventProps { + id: number; + episodeId: number; + seriesId: number; + episodeFileId?: number; + title: string; + seasonNumber: number; + episodeNumber: number; + absoluteEpisodeNumber?: number; + airDateUtc: string; + monitored: boolean; + unverifiedSceneNumbering?: boolean; + finaleType?: string; + hasFile: boolean; + grabbed?: boolean; + onEventModalOpenToggle: (isOpen: boolean) => void; +} + +function CalendarEvent(props: CalendarEventProps) { + const { + id, + seriesId, + episodeFileId, + title, + seasonNumber, + episodeNumber, + absoluteEpisodeNumber, + airDateUtc, + monitored, + unverifiedSceneNumbering, + finaleType, + hasFile, + grabbed, + onEventModalOpenToggle, + } = props; + + const series = useSeries(seriesId); + const episodeFile = useEpisodeFile(episodeFileId); + const queueItem = useSelector(createQueueItemSelectorForHook(id)); + + const { timeFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + showCutoffUnmetIcon, + fullColorEvents, + } = useSelector((state: AppState) => state.calendar.options); + + const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false); + + const handleDetailsModalClose = useCallback(() => { + setIsDetailsModalOpen(true); + onEventModalOpenToggle(true); + }, [onEventModalOpenToggle]); + + const handlePress = useCallback(() => { + setIsDetailsModalOpen(false); + onEventModalOpenToggle(false); + }, [onEventModalOpenToggle]); + + if (!series) { + return null; + } + + const startTime = moment(airDateUtc); + const endTime = moment(airDateUtc).add(series.runtime, 'minutes'); + const isDownloading = !!(queueItem || grabbed); + const isMonitored = series.monitored && monitored; + const statusStyle = getStatusStyle( + hasFile, + isDownloading, + startTime, + endTime, + isMonitored + ); + const missingAbsoluteNumber = + series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber; + + return ( +
+ + +
+
+
{series.title}
+ +
+ {missingAbsoluteNumber ? ( + + ) : null} + + {unverifiedSceneNumbering && !missingAbsoluteNumber ? ( + + ) : null} + + {queueItem ? ( + + + + ) : null} + + {!queueItem && grabbed ? ( + + ) : null} + + {showCutoffUnmetIcon && + !!episodeFile && + episodeFile.qualityCutoffNotMet ? ( + + ) : null} + + {episodeNumber === 1 && seasonNumber > 0 ? ( + + ) : null} + + {showFinaleIcon && finaleType ? ( + + ) : null} + + {showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? ( + + ) : null} +
+
+ + {showEpisodeInformation ? ( +
+
{title}
+ +
+ {seasonNumber}x{padNumber(episodeNumber, 2)} + {series.seriesType === 'anime' && absoluteEpisodeNumber ? ( + + ({absoluteEpisodeNumber}) + + ) : null} +
+
+ ) : null} + +
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+
+ + +
+ ); +} + +export default CalendarEvent; diff --git a/frontend/src/Calendar/Events/CalendarEventConnector.js b/frontend/src/Calendar/Events/CalendarEventConnector.js deleted file mode 100644 index e1ac2096d..000000000 --- a/frontend/src/Calendar/Events/CalendarEventConnector.js +++ /dev/null @@ -1,29 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector'; -import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEvent from './CalendarEvent'; - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createEpisodeFileSelector(), - createQueueItemSelector(), - createUISettingsSelector(), - (calendarOptions, series, episodeFile, queueItem, uiSettings) => { - return { - series, - episodeFile, - queueItem, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEvent); diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js deleted file mode 100644 index 2bec49df2..000000000 --- a/frontend/src/Calendar/Events/CalendarEventGroup.js +++ /dev/null @@ -1,259 +0,0 @@ -import classNames from 'classnames'; -import moment from 'moment'; -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector'; -import getStatusStyle from 'Calendar/getStatusStyle'; -import Icon from 'Components/Icon'; -import Link from 'Components/Link/Link'; -import getFinaleTypeName from 'Episode/getFinaleTypeName'; -import { icons, kinds } from 'Helpers/Props'; -import formatTime from 'Utilities/Date/formatTime'; -import padNumber from 'Utilities/Number/padNumber'; -import translate from 'Utilities/String/translate'; -import styles from './CalendarEventGroup.css'; - -function getEventsInfo(series, events) { - let files = 0; - let queued = 0; - let monitored = 0; - let absoluteEpisodeNumbers = 0; - - events.forEach((event) => { - if (event.episodeFileId) { - files++; - } - - if (event.queued) { - queued++; - } - - if (series.monitored && event.monitored) { - monitored++; - } - - if (event.absoluteEpisodeNumber) { - absoluteEpisodeNumbers++; - } - }); - - return { - allDownloaded: files === events.length, - anyQueued: queued > 0, - anyMonitored: monitored > 0, - allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length - }; -} - -class CalendarEventGroup extends Component { - - // - // Lifecycle - - constructor(props, context) { - super(props, context); - - this.state = { - isExpanded: false - }; - } - - // - // Listeners - - onExpandPress = () => { - this.setState({ isExpanded: !this.state.isExpanded }); - }; - - // - // Render - - render() { - const { - series, - events, - isDownloading, - showEpisodeInformation, - showFinaleIcon, - timeFormat, - fullColorEvents, - colorImpairedMode, - onEventModalOpenToggle - } = this.props; - - const { isExpanded } = this.state; - const { - allDownloaded, - anyQueued, - anyMonitored, - allAbsoluteEpisodeNumbers - } = getEventsInfo(series, events); - const anyDownloading = isDownloading || anyQueued; - const firstEpisode = events[0]; - const lastEpisode = events[events.length -1]; - const airDateUtc = firstEpisode.airDateUtc; - const startTime = moment(airDateUtc); - const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); - const seasonNumber = firstEpisode.seasonNumber; - const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored); - const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers; - - if (isExpanded) { - return ( -
- { - events.map((event) => { - if (event.isGroup) { - return null; - } - - return ( - - ); - }) - } - - - - -
- ); - } - - return ( -
-
-
- {series.title} -
- -
- { - isMissingAbsoluteNumber && - - } - - { - anyDownloading && - - } - - { - firstEpisode.episodeNumber === 1 && seasonNumber > 0 && - - } - - { - showFinaleIcon && - lastEpisode.finaleType ? - : null - } -
-
- -
-
- {formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })} -
- - { - showEpisodeInformation ? -
- {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)} - - { - series.seriesType === 'anime' && - firstEpisode.absoluteEpisodeNumber && - lastEpisode.absoluteEpisodeNumber && - - ({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber}) - - } -
: - - - - } -
- - { - showEpisodeInformation ? - -   - -   - : - null - } -
- ); - } -} - -CalendarEventGroup.propTypes = { - // Most of these props come from the connector and are required, but TS is confused. - series: PropTypes.object, - events: PropTypes.arrayOf(PropTypes.object).isRequired, - isDownloading: PropTypes.bool, - showEpisodeInformation: PropTypes.bool, - showFinaleIcon: PropTypes.bool, - fullColorEvents: PropTypes.bool, - timeFormat: PropTypes.string, - colorImpairedMode: PropTypes.bool, - onEventModalOpenToggle: PropTypes.func.isRequired -}; - -export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.tsx b/frontend/src/Calendar/Events/CalendarEventGroup.tsx new file mode 100644 index 000000000..1ee981cfd --- /dev/null +++ b/frontend/src/Calendar/Events/CalendarEventGroup.tsx @@ -0,0 +1,253 @@ +import classNames from 'classnames'; +import moment from 'moment'; +import React, { useCallback, useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; +import { createSelector } from 'reselect'; +import AppState from 'App/State/AppState'; +import getStatusStyle from 'Calendar/getStatusStyle'; +import Icon from 'Components/Icon'; +import Link from 'Components/Link/Link'; +import getFinaleTypeName from 'Episode/getFinaleTypeName'; +import { icons, kinds } from 'Helpers/Props'; +import useSeries from 'Series/useSeries'; +import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; +import { CalendarItem } from 'typings/Calendar'; +import formatTime from 'Utilities/Date/formatTime'; +import padNumber from 'Utilities/Number/padNumber'; +import translate from 'Utilities/String/translate'; +import CalendarEvent from './CalendarEvent'; +import styles from './CalendarEventGroup.css'; + +function createIsDownloadingSelector(episodeIds: number[]) { + return createSelector( + (state: AppState) => state.queue.details, + (details) => { + return details.items.some((item) => { + return !!(item.episodeId && episodeIds.includes(item.episodeId)); + }); + } + ); +} + +interface CalendarEventGroupProps { + episodeIds: number[]; + seriesId: number; + events: CalendarItem[]; + onEventModalOpenToggle: (isOpen: boolean) => void; +} + +function CalendarEventGroup({ + episodeIds, + seriesId, + events, + onEventModalOpenToggle, +}: CalendarEventGroupProps) { + const isDownloading = useSelector(createIsDownloadingSelector(episodeIds)); + const series = useSeries(seriesId)!; + + const { timeFormat, enableColorImpairedMode } = useSelector( + createUISettingsSelector() + ); + + const { showEpisodeInformation, showFinaleIcon, fullColorEvents } = + useSelector((state: AppState) => state.calendar.options); + + const [isExpanded, setIsExpanded] = useState(false); + + const firstEpisode = events[0]; + const lastEpisode = events[events.length - 1]; + const airDateUtc = firstEpisode.airDateUtc; + const startTime = moment(airDateUtc); + const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes'); + const seasonNumber = firstEpisode.seasonNumber; + + const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } = + useMemo(() => { + let files = 0; + let queued = 0; + let monitored = 0; + let absoluteEpisodeNumbers = 0; + + events.forEach((event) => { + if (event.episodeFileId) { + files++; + } + + if (event.queued) { + queued++; + } + + if (series.monitored && event.monitored) { + monitored++; + } + + if (event.absoluteEpisodeNumber) { + absoluteEpisodeNumbers++; + } + }); + + return { + allDownloaded: files === events.length, + anyQueued: queued > 0, + anyMonitored: monitored > 0, + allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length, + }; + }, [series, events]); + + const anyDownloading = isDownloading || anyQueued; + + const statusStyle = getStatusStyle( + allDownloaded, + anyDownloading, + startTime, + endTime, + anyMonitored + ); + const isMissingAbsoluteNumber = + series.seriesType === 'anime' && + seasonNumber > 0 && + !allAbsoluteEpisodeNumbers; + + const handleExpandPress = useCallback(() => { + setIsExpanded((state) => !state); + }, []); + + if (isExpanded) { + return ( +
+ {events.map((event) => { + return ( + + ); + })} + + + + +
+ ); + } + + return ( +
+
+
{series.title}
+ +
+ {isMissingAbsoluteNumber ? ( + + ) : null} + + {anyDownloading ? ( + + ) : null} + + {firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? ( + + ) : null} + + {showFinaleIcon && lastEpisode.finaleType ? ( + + ) : null} +
+
+ +
+
+ {formatTime(airDateUtc, timeFormat)} -{' '} + {formatTime(endTime.toISOString(), timeFormat, { + includeMinuteZero: true, + })} +
+ + {showEpisodeInformation ? ( +
+ {seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}- + {padNumber(lastEpisode.episodeNumber, 2)} + {series.seriesType === 'anime' && + firstEpisode.absoluteEpisodeNumber && + lastEpisode.absoluteEpisodeNumber ? ( + + ({firstEpisode.absoluteEpisodeNumber}- + {lastEpisode.absoluteEpisodeNumber}) + + ) : null} +
+ ) : ( + + + + )} +
+ + {showEpisodeInformation ? ( + +   + +   + + ) : null} +
+ ); +} + +export default CalendarEventGroup; diff --git a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js b/frontend/src/Calendar/Events/CalendarEventGroupConnector.js deleted file mode 100644 index dca227a85..000000000 --- a/frontend/src/Calendar/Events/CalendarEventGroupConnector.js +++ /dev/null @@ -1,37 +0,0 @@ -import { connect } from 'react-redux'; -import { createSelector } from 'reselect'; -import createSeriesSelector from 'Store/Selectors/createSeriesSelector'; -import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector'; -import CalendarEventGroup from './CalendarEventGroup'; - -function createIsDownloadingSelector() { - return createSelector( - (state, { episodeIds }) => episodeIds, - (state) => state.queue.details, - (episodeIds, details) => { - return details.items.some((item) => { - return !!(item.episodeId && episodeIds.includes(item.episodeId)); - }); - } - ); -} - -function createMapStateToProps() { - return createSelector( - (state) => state.calendar.options, - createSeriesSelector(), - createIsDownloadingSelector(), - createUISettingsSelector(), - (calendarOptions, series, isDownloading, uiSettings) => { - return { - series, - isDownloading, - ...calendarOptions, - timeFormat: uiSettings.timeFormat, - colorImpairedMode: uiSettings.enableColorImpairedMode - }; - } - ); -} - -export default connect(createMapStateToProps)(CalendarEventGroup); diff --git a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js b/frontend/src/Calendar/Events/CalendarEventQueueDetails.js deleted file mode 100644 index db26eb1d2..000000000 --- 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 000000000..2372bc78e --- /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 4555fc63b..000000000 --- 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 translate('Agenda'); - } - - 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 000000000..2faaca25e --- /dev/null +++ b/frontend/src/Calendar/Header/CalendarHeader.tsx @@ -0,0 +1,221 @@ +import moment from 'moment'; +import React, { useCallback, useMemo } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import AppState from 'App/State/AppState'; +import { CalendarView } 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 { + 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: CalendarView) => { + 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'); + } else if (view === 'agenda') { + return translate('Agenda'); + } + + 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 616e48650..000000000 --- a/frontend/src/Calendar/Header/CalendarHeaderConnector.js +++ /dev/null @@ -1,85 +0,0 @@ -import _ from 'lodash'; -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) => { - const result = _.pick(calendar, [ - 'isFetching', - 'view', - 'time', - 'start', - 'end' - ]); - - result.isSmallScreen = dimensions.isSmallScreen; - result.collapseViewButtons = dimensions.isLargeScreen; - result.longDateFormat = uiSettings.longDateFormat; - - return result; - } - ); -} - -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 98958af03..000000000 --- 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 000000000..c9366f9ef --- /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 77% rename from frontend/src/Calendar/Legend/Legend.js rename to frontend/src/Calendar/Legend/Legend.tsx index 6413665d3..b9887f856 100644 --- a/frontend/src/Calendar/Legend/Legend.js +++ b/frontend/src/Calendar/Legend/Legend.tsx @@ -1,20 +1,22 @@ -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) { +function Legend() { + const view = useSelector((state: AppState) => state.calendar.view); const { - view, showFinaleIcon, showSpecialIcon, showCutoffUnmetIcon, fullColorEvents, - colorImpairedMode - } = props; + } = useSelector((state: AppState) => state.calendar.options); + const { enableColorImpairedMode } = useSelector(createUISettingsSelector()); const iconsToShow = []; const isAgendaView = view === 'agenda'; @@ -73,7 +75,7 @@ function Legend(props) { tooltip={translate('CalendarLegendEpisodeUnairedTooltip')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} />
@@ -92,7 +94,7 @@ function Legend(props) { tooltip={translate('CalendarLegendEpisodeOnAirTooltip')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} />
@@ -110,7 +112,7 @@ function Legend(props) { tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')} isAgendaView={isAgendaView} fullColorEvents={fullColorEvents} - colorImpairedMode={colorImpairedMode} + colorImpairedMode={enableColorImpairedMode} /> @@ -134,30 +136,15 @@ function Legend(props) { {iconsToShow[0]} - { - iconsToShow.length > 1 && -
- {iconsToShow[1]} - {iconsToShow[2]} -
- } - { - iconsToShow.length > 3 && -
- {iconsToShow[3]} -
- } + {iconsToShow.length > 1 ? ( +
+ {iconsToShow[1]} + {iconsToShow[2]} +
+ ) : null} + {iconsToShow.length > 3 ?
{iconsToShow[3]}
: null} ); } -Legend.propTypes = { - view: PropTypes.string.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: PropTypes.bool.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 889b7a002..000000000 --- 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 b6bdeeff7..000000000 --- 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 000000000..88a758c44 --- /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.tsx similarity index 61% rename from frontend/src/Calendar/Legend/LegendItem.js rename to frontend/src/Calendar/Legend/LegendItem.tsx index f0304b9e6..40466ab9d 100644 --- a/frontend/src/Calendar/Legend/LegendItem.js +++ b/frontend/src/Calendar/Legend/LegendItem.tsx @@ -1,17 +1,26 @@ import classNames from 'classnames'; -import PropTypes from 'prop-types'; import React from 'react'; +import { CalendarStatus } from 'typings/Calendar'; import titleCase from 'Utilities/String/titleCase'; import styles from './LegendItem.css'; -function LegendItem(props) { +interface LegendItemProps { + name?: string; + status: CalendarStatus; + tooltip: string; + isAgendaView: boolean; + fullColorEvents: boolean; + colorImpairedMode: boolean; +} + +function LegendItem(props: LegendItemProps) { const { name, status, tooltip, isAgendaView, fullColorEvents, - colorImpairedMode + colorImpairedMode, } = props; return ( @@ -29,13 +38,4 @@ function LegendItem(props) { ); } -LegendItem.propTypes = { - name: PropTypes.string, - status: PropTypes.string.isRequired, - tooltip: PropTypes.string.isRequired, - isAgendaView: PropTypes.bool.isRequired, - fullColorEvents: PropTypes.bool.isRequired, - colorImpairedMode: PropTypes.bool.isRequired -}; - export default LegendItem; diff --git a/frontend/src/Calendar/Options/CalendarOptionsModal.js b/frontend/src/Calendar/Options/CalendarOptionsModal.js deleted file mode 100644 index b68c83f30..000000000 --- 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 000000000..ae782a684 --- /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 c34401315..000000000 --- a/frontend/src/Calendar/Options/CalendarOptionsModalContent.js +++ /dev/null @@ -1,276 +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 { - collapseMultipleEpisodes, - showEpisodeInformation, - showFinaleIcon, - showSpecialIcon, - showCutoffUnmetIcon, - fullColorEvents, - onModalClose - } = this.props; - - const { - firstDayOfWeek, - calendarWeekColumnHeader, - timeFormat, - enableColorImpairedMode - } = this.state; - - return ( - - - {translate('CalendarOptions')} - - - -
-
- - {translate('CollapseMultipleEpisodes')} - - - - - - {translate('ShowEpisodeInformation')} - - - - - - {translate('IconForFinales')} - - - - - - {translate('IconForSpecials')} - - - - - - {translate('IconForCutoffUnmet')} - - - - - - {translate('FullColorEvents')} - - - -
-
- -
-
- - {translate('FirstDayOfWeek')} - - - - - - {translate('WeekColumnHeader')} - - - - - - {translate('TimeFormat')} - - - - - - {translate('EnableColorImpairedMode')} - - - -
-
-
- - - - -
- ); - } -} - -CalendarOptionsModalContent.propTypes = { - collapseMultipleEpisodes: PropTypes.bool.isRequired, - showEpisodeInformation: PropTypes.bool.isRequired, - showFinaleIcon: PropTypes.bool.isRequired, - showSpecialIcon: 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 000000000..4f974dda3 --- /dev/null +++ b/frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx @@ -0,0 +1,228 @@ +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 { + collapseMultipleEpisodes, + showEpisodeInformation, + showFinaleIcon, + showSpecialIcon, + 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('CollapseMultipleEpisodes')} + + + + + + {translate('ShowEpisodeInformation')} + + + + + + {translate('IconForFinales')} + + + + + + {translate('IconForSpecials')} + + + + + + {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 1f517b698..000000000 --- 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 929958b66..4f5549dbd 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 67% rename from frontend/src/Calendar/getStatusStyle.js rename to frontend/src/Calendar/getStatusStyle.ts index b149a8aab..678e6c2a1 100644 --- a/frontend/src/Calendar/getStatusStyle.js +++ b/frontend/src/Calendar/getStatusStyle.ts @@ -1,7 +1,13 @@ -/* eslint max-params: 0 */ import moment from 'moment'; - -function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) { +import { CalendarStatus } from 'typings/Calendar'; + +function getStatusStyle( + hasFile: boolean, + downloading: boolean, + startTime: moment.Moment, + endTime: moment.Moment, + isMonitored: boolean +): CalendarStatus { const currentTime = moment(); if (hasFile) { diff --git a/frontend/src/Calendar/iCal/CalendarLinkModal.js b/frontend/src/Calendar/iCal/CalendarLinkModal.js deleted file mode 100644 index 8cc487c16..000000000 --- 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 000000000..f0eecbd4a --- /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 eb64cb207..000000000 --- a/frontend/src/Calendar/iCal/CalendarLinkModalContent.js +++ /dev/null @@ -1,222 +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, - premieresOnly, - asAllDay, - tags - } = state; - - let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`; - - if (unmonitored) { - icalUrl += 'unmonitored=true&'; - } - - if (premieresOnly) { - icalUrl += 'premieresOnly=true&'; - } - - if (asAllDay) { - icalUrl += 'asAllDay=true&'; - } - - if (tags.length) { - icalUrl += `tags=${tags.toString()}&`; - } - - icalUrl += `apikey=${encodeURIComponent(window.Sonarr.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, - premieresOnly: 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, - premieresOnly, - asAllDay, - tags, - iCalHttpUrl, - iCalWebCalUrl - } = this.state; - - return ( - - - {translate('CalendarFeed')} - - - -
- - {translate('IncludeUnmonitored')} - - - - - - {translate('SeasonPremieresOnly')} - - - - - - {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 000000000..aa90db301 --- /dev/null +++ b/frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx @@ -0,0 +1,166 @@ +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, + premieresOnly: false, + asAllDay: false, + tags: [], + }); + + const { unmonitored, premieresOnly, 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.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`; + + if (unmonitored) { + icalUrl += 'unmonitored=true&'; + } + + if (premieresOnly) { + icalUrl += 'premieresOnly=true&'; + } + + if (asAllDay) { + icalUrl += 'asAllDay=true&'; + } + + if (tags.length) { + icalUrl += `tags=${tags.toString()}&`; + } + + icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`; + + return { + iCalHttpUrl: `${window.location.protocol}//${icalUrl}`, + iCalWebCalUrl: `webcal://${icalUrl}`, + }; + }, [unmonitored, premieresOnly, asAllDay, tags]); + + return ( + + {translate('CalendarFeed')} + + +
+ + {translate('IncludeUnmonitored')} + + + + + + {translate('SeasonPremieresOnly')} + + + + + + {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 e10c5c3f9..000000000 --- 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/Form/FormInputGroup.tsx b/frontend/src/Components/Form/FormInputGroup.tsx index 0881e571a..4ed86e8e6 100644 --- a/frontend/src/Components/Form/FormInputGroup.tsx +++ b/frontend/src/Components/Form/FormInputGroup.tsx @@ -1,4 +1,4 @@ -import React, { ReactNode } from 'react'; +import React, { FocusEvent, ReactNode } from 'react'; import Link from 'Components/Link/Link'; import { inputTypes } from 'Helpers/Props'; import { InputType } from 'Helpers/Props/inputTypes'; @@ -152,9 +152,11 @@ interface FormInputGroupProps { canEdit?: boolean; includeAny?: boolean; delimiters?: string[]; + readOnly?: boolean; errors?: (ValidationMessage | ValidationError)[]; warnings?: (ValidationMessage | ValidationWarning)[]; onChange: (args: T) => void; + onFocus?: (event: FocusEvent) => void; } function FormInputGroup(props: FormInputGroupProps) { diff --git a/frontend/src/Components/Form/TextInput.tsx b/frontend/src/Components/Form/TextInput.tsx index 007651d30..647b9f2ac 100644 --- a/frontend/src/Components/Form/TextInput.tsx +++ b/frontend/src/Components/Form/TextInput.tsx @@ -1,6 +1,7 @@ import classNames from 'classnames'; import React, { ChangeEvent, + FocusEvent, SyntheticEvent, useCallback, useEffect, @@ -25,7 +26,7 @@ export interface TextInputProps { min?: number; max?: number; onChange: (change: InputChanged | FileInputChanged) => void; - onFocus?: (event: SyntheticEvent) => void; + onFocus?: (event: FocusEvent) => void; onBlur?: (event: SyntheticEvent) => void; onCopy?: (event: SyntheticEvent) => void; onSelectionChange?: (start: number | null, end: number | null) => void; @@ -94,7 +95,7 @@ function TextInput({ ); const handleFocus = useCallback( - (event: SyntheticEvent) => { + (event: FocusEvent) => { onFocus?.(event); selectionChanged(); diff --git a/frontend/src/Components/Icon.tsx b/frontend/src/Components/Icon.tsx index ea5279840..a04463b51 100644 --- a/frontend/src/Components/Icon.tsx +++ b/frontend/src/Components/Icon.tsx @@ -18,7 +18,7 @@ export interface IconProps kind?: Extract; size?: number; isSpinning?: FontAwesomeIconProps['spin']; - title?: string | (() => string); + title?: string | (() => string) | null; } export default function Icon({ diff --git a/frontend/src/Components/Link/Button.tsx b/frontend/src/Components/Link/Button.tsx index cf2293f59..610350a8d 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/Episode/Episode.ts b/frontend/src/Episode/Episode.ts index c154e0278..0a8f69419 100644 --- a/frontend/src/Episode/Episode.ts +++ b/frontend/src/Episode/Episode.ts @@ -25,7 +25,9 @@ interface Episode extends ModelBase { endTime?: string; grabDate?: string; seriesTitle?: string; + queued?: boolean; series?: Series; + finaleType?: string; } export default Episode; diff --git a/frontend/src/Episode/useEpisode.ts b/frontend/src/Episode/useEpisode.ts index 01062b2a6..3b0801c2c 100644 --- a/frontend/src/Episode/useEpisode.ts +++ b/frontend/src/Episode/useEpisode.ts @@ -1,6 +1,7 @@ import { useSelector } from 'react-redux'; import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; +import Episode from './Episode'; export type EpisodeEntities = | 'calendar' @@ -20,7 +21,7 @@ function createEpisodeSelector(episodeId?: number) { function createCalendarEpisodeSelector(episodeId?: number) { return createSelector( - (state: AppState) => state.calendar.items, + (state: AppState) => state.calendar.items as Episode[], (episodes) => { return episodes.find(({ id }) => id === episodeId); } diff --git a/frontend/src/Store/Selectors/createSeriesQualityProfileSelector.ts b/frontend/src/Store/Selectors/createSeriesQualityProfileSelector.ts index 48528873a..01cbe3788 100644 --- a/frontend/src/Store/Selectors/createSeriesQualityProfileSelector.ts +++ b/frontend/src/Store/Selectors/createSeriesQualityProfileSelector.ts @@ -1,13 +1,14 @@ import { createSelector } from 'reselect'; import AppState from 'App/State/AppState'; import Series from 'Series/Series'; +import QualityProfile from 'typings/QualityProfile'; import { createSeriesSelectorForHook } from './createSeriesSelector'; function createSeriesQualityProfileSelector(seriesId: number) { return createSelector( (state: AppState) => state.settings.qualityProfiles.items, createSeriesSelectorForHook(seriesId), - (qualityProfiles, series = {} as Series) => { + (qualityProfiles: QualityProfile[], series = {} as Series) => { return qualityProfiles.find( (profile) => profile.id === series.qualityProfileId ); diff --git a/frontend/src/typings/Calendar.ts b/frontend/src/typings/Calendar.ts new file mode 100644 index 000000000..8c4cc698a --- /dev/null +++ b/frontend/src/typings/Calendar.ts @@ -0,0 +1,25 @@ +import Episode from 'Episode/Episode'; + +export interface CalendarItem extends Omit { + airDateUtc: string; +} + +export interface CalendarEvent extends CalendarItem { + isGroup: false; +} + +export interface CalendarEventGroup { + isGroup: true; + seriesId: number; + seasonNumber: number; + episodeIds: number[]; + events: CalendarItem[]; +} + +export type CalendarStatus = + | 'downloaded' + | 'downloading' + | 'unmonitored' + | 'onAir' + | 'missing' + | 'unaired'; diff --git a/frontend/src/typings/CalendarEventGroup.ts b/frontend/src/typings/CalendarEventGroup.ts deleted file mode 100644 index 2039f4615..000000000 --- a/frontend/src/typings/CalendarEventGroup.ts +++ /dev/null @@ -1,15 +0,0 @@ -import Episode from 'Episode/Episode'; - -export interface CalendarEvent extends Episode { - isGroup: false; -} - -interface CalendarEventGroup { - isGroup: true; - seriesId: number; - seasonNumber: number; - episodeIds: number[]; - events: Episode[]; -} - -export default CalendarEventGroup; diff --git a/frontend/src/typings/Queue.ts b/frontend/src/typings/Queue.ts index dd266d132..8b392601f 100644 --- a/frontend/src/typings/Queue.ts +++ b/frontend/src/typings/Queue.ts @@ -1,5 +1,6 @@ import ModelBase from 'App/ModelBase'; import DownloadProtocol from 'DownloadClient/DownloadProtocol'; +import Episode from 'Episode/Episode'; import Language from 'Language/Language'; import { QualityModel } from 'Quality/Quality'; import CustomFormat from 'typings/CustomFormat'; @@ -46,6 +47,7 @@ interface Queue extends ModelBase { episodeId?: number; seasonNumber?: number; downloadClientHasPostImportCategory: boolean; + episode?: Episode; } export default Queue; diff --git a/frontend/src/typings/Settings/UiSettings.ts b/frontend/src/typings/Settings/UiSettings.ts index 656c4518b..1ba62187b 100644 --- a/frontend/src/typings/Settings/UiSettings.ts +++ b/frontend/src/typings/Settings/UiSettings.ts @@ -4,4 +4,7 @@ export default interface UiSettings { shortDateFormat: string; longDateFormat: string; timeFormat: string; + firstDayOfWeek: number; + enableColorImpairedMode: boolean; + calendarWeekColumnHeader: string; }