- Import series you already have
+ {translate('LibraryImportSeriesHeader')}
- Some tips to ensure the import goes smoothly:
+ {translate('LibraryImportTips')}
- Make sure that your files include the quality in their filenames. eg. episode.s02e15.bluray.mkv
+
- Point Sonarr to the folder containing all of your tv shows, not a specific one. eg. "{isWindows ? 'C:\\tv shows' : '/tv shows'}" and not "{isWindows ? 'C:\\tv shows\\the simpsons' : '/tv shows/the simpsons'}" Additionally, each series must be in its own folder within the root/library folder.
+
- Do not use for importing downloads from your download client, this is only for existing organized libraries, not unsorted files.
+ {translate('LibraryImportTipsDontUseDownloadsFolder')}
@@ -96,7 +100,7 @@ class ImportSeriesSelectFolder extends Component {
{
hasRootFolders ?
-
+
- Unable to add root folder
+ {translate('AddRootFolderError')}
{
- saveError.responseJSON.map((e, index) => {
- return (
-
- {e.errorMessage}
-
- );
- })
+ Array.isArray(saveError.responseJSON) ?
+ saveError.responseJSON.map((e, index) => {
+ return (
+
+ {e.errorMessage}
+
+ );
+ }) :
+
+ {
+ JSON.stringify(saveError.responseJSON)
+ }
+
}
:
@@ -143,8 +153,8 @@ class ImportSeriesSelectFolder extends Component {
/>
{
hasRootFolders ?
- 'Choose another folder' :
- 'Start Import'
+ translate('ChooseAnotherFolder') :
+ translate('StartImport')
}
diff --git a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
index 5ef79ec4e..1df231f4e 100644
--- a/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
+++ b/frontend/src/AddSeries/ImportSeries/SelectFolder/ImportSeriesSelectFolderConnector.js
@@ -1,16 +1,17 @@
+import { push } from 'connected-react-router';
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
-import { push } from 'connected-react-router';
+import { addRootFolder, fetchRootFolders } from 'Store/Actions/rootFolderActions';
+import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
-import { fetchRootFolders, addRootFolder } from 'Store/Actions/rootFolderActions';
import ImportSeriesSelectFolder from './ImportSeriesSelectFolder';
function createMapStateToProps() {
return createSelector(
- (state) => state.rootFolders,
+ createRootFoldersSelector(),
createSystemStatusSelector(),
(rootFolders, systemStatus) => {
return {
@@ -57,7 +58,7 @@ class ImportSeriesSelectFolderConnector extends Component {
onNewRootFolderSelect = (path) => {
this.props.addRootFolder({ path });
- }
+ };
//
// Render
diff --git a/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js
new file mode 100644
index 000000000..c70ec0dec
--- /dev/null
+++ b/frontend/src/AddSeries/SeriesMonitorNewItemsOptionsPopoverContent.js
@@ -0,0 +1,22 @@
+import React from 'react';
+import DescriptionList from 'Components/DescriptionList/DescriptionList';
+import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import translate from 'Utilities/String/translate';
+
+function SeriesMonitorNewItemsOptionsPopoverContent() {
+ return (
+
+
+
+
+
+ );
+}
+
+export default SeriesMonitorNewItemsOptionsPopoverContent;
diff --git a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
index e889fbb09..21289fcb8 100644
--- a/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
+++ b/frontend/src/AddSeries/SeriesMonitoringOptionsPopoverContent.js
@@ -1,43 +1,64 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import translate from 'Utilities/String/translate';
function SeriesMonitoringOptionsPopoverContent() {
return (
+
+
+
+
+
+
+
+
);
diff --git a/frontend/src/AddSeries/SeriesTypePopoverContent.js b/frontend/src/AddSeries/SeriesTypePopoverContent.js
index e57d49a9e..9771bd8db 100644
--- a/frontend/src/AddSeries/SeriesTypePopoverContent.js
+++ b/frontend/src/AddSeries/SeriesTypePopoverContent.js
@@ -1,23 +1,24 @@
import React from 'react';
import DescriptionList from 'Components/DescriptionList/DescriptionList';
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
+import translate from 'Utilities/String/translate';
function SeriesTypePopoverContent() {
return (
);
diff --git a/frontend/src/App/App.js b/frontend/src/App/App.js
deleted file mode 100644
index d8a83b1c0..000000000
--- a/frontend/src/App/App.js
+++ /dev/null
@@ -1,28 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import DocumentTitle from 'react-document-title';
-import { Provider } from 'react-redux';
-import { ConnectedRouter } from 'connected-react-router';
-import PageConnector from 'Components/Page/PageConnector';
-import AppRoutes from './AppRoutes';
-
-function App({ store, history }) {
- return (
-
-
-
-
-
-
-
-
-
- );
-}
-
-App.propTypes = {
- store: PropTypes.object.isRequired,
- history: PropTypes.object.isRequired
-};
-
-export default App;
diff --git a/frontend/src/App/App.tsx b/frontend/src/App/App.tsx
new file mode 100644
index 000000000..b71199bb3
--- /dev/null
+++ b/frontend/src/App/App.tsx
@@ -0,0 +1,35 @@
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
+import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
+import React from 'react';
+import DocumentTitle from 'react-document-title';
+import { Provider } from 'react-redux';
+import { Store } from 'redux';
+import PageConnector from 'Components/Page/PageConnector';
+import ApplyTheme from './ApplyTheme';
+import AppRoutes from './AppRoutes';
+
+interface AppProps {
+ store: Store;
+ history: ConnectedRouterProps['history'];
+}
+
+const queryClient = new QueryClient();
+
+function App({ store, history }: AppProps) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default App;
diff --git a/frontend/src/App/AppRoutes.js b/frontend/src/App/AppRoutes.js
deleted file mode 100644
index ad7b7865c..000000000
--- a/frontend/src/App/AppRoutes.js
+++ /dev/null
@@ -1,254 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { Route, Redirect } from 'react-router-dom';
-import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
-import NotFound from 'Components/NotFound';
-import Switch from 'Components/Router/Switch';
-import SeriesIndexConnector from 'Series/Index/SeriesIndexConnector';
-import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
-import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
-import SeriesEditorConnector from 'Series/Editor/SeriesEditorConnector';
-import SeasonPassConnector from 'SeasonPass/SeasonPassConnector';
-import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
-import CalendarPageConnector from 'Calendar/CalendarPageConnector';
-import HistoryConnector from 'Activity/History/HistoryConnector';
-import QueueConnector from 'Activity/Queue/QueueConnector';
-import BlocklistConnector from 'Activity/Blocklist/BlocklistConnector';
-import MissingConnector from 'Wanted/Missing/MissingConnector';
-import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
-import Settings from 'Settings/Settings';
-import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
-import Profiles from 'Settings/Profiles/Profiles';
-import QualityConnector from 'Settings/Quality/QualityConnector';
-import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
-import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
-import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
-import NotificationSettings from 'Settings/Notifications/NotificationSettings';
-import MetadataSettings from 'Settings/Metadata/MetadataSettings';
-import TagSettings from 'Settings/Tags/TagSettings';
-import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
-import UISettingsConnector from 'Settings/UI/UISettingsConnector';
-import Status from 'System/Status/Status';
-import Tasks from 'System/Tasks/Tasks';
-import BackupsConnector from 'System/Backup/BackupsConnector';
-import UpdatesConnector from 'System/Updates/UpdatesConnector';
-import LogsTableConnector from 'System/Events/LogsTableConnector';
-import Logs from 'System/Logs/Logs';
-
-function AppRoutes(props) {
- const {
- app
- } = props;
-
- return (
-
- {/*
- Series
- */}
-
-
-
- {
- window.Sonarr.urlBase &&
- {
- return (
-
- );
- }}
- />
- }
-
-
-
-
-
-
-
-
-
-
-
- {/*
- Calendar
- */}
-
-
-
- {/*
- Activity
- */}
-
-
-
-
-
-
-
- {/*
- Wanted
- */}
-
-
-
-
-
- {/*
- Settings
- */}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/*
- System
- */}
-
-
-
-
-
-
-
-
-
-
-
-
-
- {/*
- Not Found
- */}
-
-
-
- );
-}
-
-AppRoutes.propTypes = {
- app: PropTypes.func.isRequired
-};
-
-export default AppRoutes;
diff --git a/frontend/src/App/AppRoutes.tsx b/frontend/src/App/AppRoutes.tsx
new file mode 100644
index 000000000..fbe4a15bb
--- /dev/null
+++ b/frontend/src/App/AppRoutes.tsx
@@ -0,0 +1,167 @@
+import React from 'react';
+import { Redirect, Route } from 'react-router-dom';
+import Blocklist from 'Activity/Blocklist/Blocklist';
+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 CalendarPage from 'Calendar/CalendarPage';
+import NotFound from 'Components/NotFound';
+import Switch from 'Components/Router/Switch';
+import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
+import SeriesIndex from 'Series/Index/SeriesIndex';
+import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
+import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
+import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
+import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
+import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
+import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
+import MetadataSettings from 'Settings/Metadata/MetadataSettings';
+import MetadataSourceSettings from 'Settings/MetadataSource/MetadataSourceSettings';
+import NotificationSettings from 'Settings/Notifications/NotificationSettings';
+import Profiles from 'Settings/Profiles/Profiles';
+import QualityConnector from 'Settings/Quality/QualityConnector';
+import Settings from 'Settings/Settings';
+import TagSettings from 'Settings/Tags/TagSettings';
+import UISettingsConnector from 'Settings/UI/UISettingsConnector';
+import BackupsConnector from 'System/Backup/BackupsConnector';
+import LogsTableConnector from 'System/Events/LogsTableConnector';
+import Logs from 'System/Logs/Logs';
+import Status from 'System/Status/Status';
+import Tasks from 'System/Tasks/Tasks';
+import Updates from 'System/Updates/Updates';
+import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
+import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
+import MissingConnector from 'Wanted/Missing/MissingConnector';
+
+function RedirectWithUrlBase() {
+ return
;
+}
+
+function AppRoutes() {
+ return (
+
+ {/*
+ Series
+ */}
+
+
+
+ {window.Sonarr.urlBase && (
+
+ )}
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Calendar
+ */}
+
+
+
+ {/*
+ Activity
+ */}
+
+
+
+
+
+
+
+ {/*
+ Wanted
+ */}
+
+
+
+
+
+ {/*
+ Settings
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ System
+ */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {/*
+ Not Found
+ */}
+
+
+
+ );
+}
+
+export default AppRoutes;
diff --git a/frontend/src/App/AppUpdatedModal.js b/frontend/src/App/AppUpdatedModal.js
deleted file mode 100644
index abc7f8832..000000000
--- a/frontend/src/App/AppUpdatedModal.js
+++ /dev/null
@@ -1,30 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import Modal from 'Components/Modal/Modal';
-import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
-
-function AppUpdatedModal(props) {
- const {
- isOpen,
- onModalClose
- } = props;
-
- return (
-
-
-
- );
-}
-
-AppUpdatedModal.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModal.tsx b/frontend/src/App/AppUpdatedModal.tsx
new file mode 100644
index 000000000..696d36fb2
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModal.tsx
@@ -0,0 +1,28 @@
+import React, { useCallback } from 'react';
+import Modal from 'Components/Modal/Modal';
+import AppUpdatedModalContent from './AppUpdatedModalContent';
+
+interface AppUpdatedModalProps {
+ isOpen: boolean;
+ onModalClose: (...args: unknown[]) => unknown;
+}
+
+function AppUpdatedModal(props: AppUpdatedModalProps) {
+ const { isOpen, onModalClose } = props;
+
+ const handleModalClose = useCallback(() => {
+ location.reload();
+ }, []);
+
+ return (
+
+
+
+ );
+}
+
+export default AppUpdatedModal;
diff --git a/frontend/src/App/AppUpdatedModalConnector.js b/frontend/src/App/AppUpdatedModalConnector.js
deleted file mode 100644
index a21afbc5a..000000000
--- a/frontend/src/App/AppUpdatedModalConnector.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { connect } from 'react-redux';
-import AppUpdatedModal from './AppUpdatedModal';
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- onModalClose() {
- location.reload();
- }
- };
-}
-
-export default connect(null, createMapDispatchToProps)(AppUpdatedModal);
diff --git a/frontend/src/App/AppUpdatedModalContent.css b/frontend/src/App/AppUpdatedModalContent.css
index 37b89c9be..0df4183a6 100644
--- a/frontend/src/App/AppUpdatedModalContent.css
+++ b/frontend/src/App/AppUpdatedModalContent.css
@@ -1,6 +1,7 @@
.version {
margin: 0 3px;
font-weight: bold;
+ font-family: var(--defaultFontFamily);
}
.maintenance {
diff --git a/frontend/src/App/AppUpdatedModalContent.css.d.ts b/frontend/src/App/AppUpdatedModalContent.css.d.ts
new file mode 100644
index 000000000..70ddbf6a1
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'changes': string;
+ 'maintenance': string;
+ 'version': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/App/AppUpdatedModalContent.js b/frontend/src/App/AppUpdatedModalContent.js
deleted file mode 100644
index 562467288..000000000
--- a/frontend/src/App/AppUpdatedModalContent.js
+++ /dev/null
@@ -1,137 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { kinds } from 'Helpers/Props';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import Button from 'Components/Link/Button';
-import ModalContent from 'Components/Modal/ModalContent';
-import ModalHeader from 'Components/Modal/ModalHeader';
-import ModalBody from 'Components/Modal/ModalBody';
-import ModalFooter from 'Components/Modal/ModalFooter';
-import UpdateChanges from 'System/Updates/UpdateChanges';
-import styles from './AppUpdatedModalContent.css';
-
-function mergeUpdates(items, version, prevVersion) {
- let installedIndex = items.findIndex((u) => u.version === version);
- let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion);
-
- if (installedIndex === -1) {
- installedIndex = 0;
- }
-
- if (installedPreviouslyIndex === -1) {
- installedPreviouslyIndex = items.length;
- } else if (installedPreviouslyIndex === installedIndex && items.length) {
- installedPreviouslyIndex += 1;
- }
-
- const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
-
- if (!appliedUpdates.length) {
- return null;
- }
-
- const appliedChanges = { new: [], fixed: [] };
- appliedUpdates.forEach((u) => {
- if (u.changes) {
- appliedChanges.new.push(... u.changes.new);
- appliedChanges.fixed.push(... u.changes.fixed);
- }
- });
-
- const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges });
-
- if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
- mergedUpdate.changes = null;
- }
-
- return mergedUpdate;
-}
-
-function AppUpdatedModalContent(props) {
- const {
- version,
- prevVersion,
- isPopulated,
- error,
- items,
- onSeeChangesPress,
- onModalClose
- } = props;
-
- const update = mergeUpdates(items, version, prevVersion);
-
- return (
-
-
- Sonarr Updated
-
-
-
-
- Sonarr has been updated to version {version} , in order to get the latest changes you'll need to reload Sonarr.
-
-
- {
- isPopulated && !error && !!update &&
-
- {
- !update.changes &&
-
Maintenance release
- }
-
- {
- !!update.changes &&
-
-
- What's new?
-
-
-
-
-
-
- }
-
- }
-
- {
- !isPopulated && !error &&
-
- }
-
-
-
-
- Recent Changes
-
-
-
- Reload
-
-
-
- );
-}
-
-AppUpdatedModalContent.propTypes = {
- version: PropTypes.string.isRequired,
- prevVersion: PropTypes.string,
- isPopulated: PropTypes.bool.isRequired,
- error: PropTypes.object,
- items: PropTypes.arrayOf(PropTypes.object).isRequired,
- onSeeChangesPress: PropTypes.func.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default AppUpdatedModalContent;
diff --git a/frontend/src/App/AppUpdatedModalContent.tsx b/frontend/src/App/AppUpdatedModalContent.tsx
new file mode 100644
index 000000000..6553d6270
--- /dev/null
+++ b/frontend/src/App/AppUpdatedModalContent.tsx
@@ -0,0 +1,145 @@
+import React, { useCallback, useEffect } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import Button from 'Components/Link/Button';
+import LoadingIndicator from 'Components/Loading/LoadingIndicator';
+import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
+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 usePrevious from 'Helpers/Hooks/usePrevious';
+import { kinds } from 'Helpers/Props';
+import { fetchUpdates } from 'Store/Actions/systemActions';
+import UpdateChanges from 'System/Updates/UpdateChanges';
+import Update from 'typings/Update';
+import translate from 'Utilities/String/translate';
+import AppState from './State/AppState';
+import styles from './AppUpdatedModalContent.css';
+
+function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
+ let installedIndex = items.findIndex((u) => u.version === version);
+ let installedPreviouslyIndex = items.findIndex(
+ (u) => u.version === prevVersion
+ );
+
+ if (installedIndex === -1) {
+ installedIndex = 0;
+ }
+
+ if (installedPreviouslyIndex === -1) {
+ installedPreviouslyIndex = items.length;
+ } else if (installedPreviouslyIndex === installedIndex && items.length) {
+ installedPreviouslyIndex += 1;
+ }
+
+ const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
+
+ if (!appliedUpdates.length) {
+ return null;
+ }
+
+ const appliedChanges: Update['changes'] = { new: [], fixed: [] };
+
+ appliedUpdates.forEach((u: Update) => {
+ if (u.changes) {
+ appliedChanges.new.push(...u.changes.new);
+ appliedChanges.fixed.push(...u.changes.fixed);
+ }
+ });
+
+ const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], {
+ changes: appliedChanges,
+ });
+
+ if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
+ mergedUpdate.changes = null;
+ }
+
+ return mergedUpdate;
+}
+
+interface AppUpdatedModalContentProps {
+ onModalClose: () => void;
+}
+
+function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
+ const dispatch = useDispatch();
+ const { version, prevVersion } = useSelector((state: AppState) => state.app);
+ const { isPopulated, error, items } = useSelector(
+ (state: AppState) => state.system.updates
+ );
+ const previousVersion = usePrevious(version);
+
+ const { onModalClose } = props;
+
+ const update = mergeUpdates(items, version, prevVersion);
+
+ const handleSeeChangesPress = useCallback(() => {
+ window.location.href = `${window.Sonarr.urlBase}/system/updates`;
+ }, []);
+
+ useEffect(() => {
+ dispatch(fetchUpdates());
+ }, [dispatch]);
+
+ useEffect(() => {
+ if (version !== previousVersion) {
+ dispatch(fetchUpdates());
+ }
+ }, [version, previousVersion, dispatch]);
+
+ return (
+
+ {translate('AppUpdated')}
+
+
+
+
+
+
+ {isPopulated && !error && !!update ? (
+
+ {update.changes ? (
+
+ {translate('MaintenanceRelease')}
+
+ ) : null}
+
+ {update.changes ? (
+
+
{translate('WhatsNew')}
+
+
+
+
+
+ ) : null}
+
+ ) : null}
+
+ {!isPopulated && !error ? : null}
+
+
+
+
+ {translate('RecentChanges')}
+
+
+
+ {translate('Reload')}
+
+
+
+ );
+}
+
+export default AppUpdatedModalContent;
diff --git a/frontend/src/App/AppUpdatedModalContentConnector.js b/frontend/src/App/AppUpdatedModalContentConnector.js
deleted file mode 100644
index 4100ee674..000000000
--- a/frontend/src/App/AppUpdatedModalContentConnector.js
+++ /dev/null
@@ -1,78 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { fetchUpdates } from 'Store/Actions/systemActions';
-import AppUpdatedModalContent from './AppUpdatedModalContent';
-
-function createMapStateToProps() {
- return createSelector(
- (state) => state.app.version,
- (state) => state.app.prevVersion,
- (state) => state.system.updates,
- (version, prevVersion, updates) => {
- const {
- isPopulated,
- error,
- items
- } = updates;
-
- return {
- version,
- prevVersion,
- isPopulated,
- error,
- items
- };
- }
- );
-}
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- dispatchFetchUpdates() {
- dispatch(fetchUpdates());
- },
-
- onSeeChangesPress() {
- window.location = `${window.Sonarr.urlBase}/system/updates`;
- }
- };
-}
-
-class AppUpdatedModalContentConnector extends Component {
-
- //
- // Lifecycle
-
- componentDidMount() {
- this.props.dispatchFetchUpdates();
- }
-
- componentDidUpdate(prevProps) {
- if (prevProps.version !== this.props.version) {
- this.props.dispatchFetchUpdates();
- }
- }
-
- //
- // Render
-
- render() {
- const {
- dispatchFetchUpdates,
- ...otherProps
- } = this.props;
-
- return (
-
- );
- }
-}
-
-AppUpdatedModalContentConnector.propTypes = {
- version: PropTypes.string.isRequired,
- dispatchFetchUpdates: PropTypes.func.isRequired
-};
-
-export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);
diff --git a/frontend/src/App/ApplyTheme.tsx b/frontend/src/App/ApplyTheme.tsx
new file mode 100644
index 000000000..ce598f2dc
--- /dev/null
+++ b/frontend/src/App/ApplyTheme.tsx
@@ -0,0 +1,33 @@
+import { useCallback, useEffect } from 'react';
+import { useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import themes from 'Styles/Themes';
+import AppState from './State/AppState';
+
+function createThemeSelector() {
+ return createSelector(
+ (state: AppState) => state.settings.ui.item.theme || window.Sonarr.theme,
+ (theme) => {
+ return theme;
+ }
+ );
+}
+
+function ApplyTheme() {
+ const theme = useSelector(createThemeSelector());
+
+ const updateCSSVariables = useCallback(() => {
+ Object.entries(themes[theme]).forEach(([key, value]) => {
+ document.documentElement.style.setProperty(`--${key}`, value);
+ });
+ }, [theme]);
+
+ // On Component Mount and Component Update
+ useEffect(() => {
+ updateCSSVariables();
+ }, [updateCSSVariables, theme]);
+
+ return null;
+}
+
+export default ApplyTheme;
diff --git a/frontend/src/App/ColorImpairedContext.js b/frontend/src/App/ColorImpairedContext.ts
similarity index 100%
rename from frontend/src/App/ColorImpairedContext.js
rename to frontend/src/App/ColorImpairedContext.ts
diff --git a/frontend/src/App/ConnectionLostModal.css.d.ts b/frontend/src/App/ConnectionLostModal.css.d.ts
new file mode 100644
index 000000000..027f2a9a3
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModal.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'automatic': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/App/ConnectionLostModal.js b/frontend/src/App/ConnectionLostModal.js
deleted file mode 100644
index 32b0e0e25..000000000
--- a/frontend/src/App/ConnectionLostModal.js
+++ /dev/null
@@ -1,55 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import { kinds } from 'Helpers/Props';
-import Button from 'Components/Link/Button';
-import Modal from 'Components/Modal/Modal';
-import ModalContent from 'Components/Modal/ModalContent';
-import ModalHeader from 'Components/Modal/ModalHeader';
-import ModalBody from 'Components/Modal/ModalBody';
-import ModalFooter from 'Components/Modal/ModalFooter';
-import styles from './ConnectionLostModal.css';
-
-function ConnectionLostModal(props) {
- const {
- isOpen,
- onModalClose
- } = props;
-
- return (
-
-
-
- Connection Lost
-
-
-
-
- Sonarr has lost its connection to the backend and will need to be reloaded to restore functionality.
-
-
-
- Sonarr will try to connect automatically, or you can click reload below.
-
-
-
-
- Reload
-
-
-
-
- );
-}
-
-ConnectionLostModal.propTypes = {
- isOpen: PropTypes.bool.isRequired,
- onModalClose: PropTypes.func.isRequired
-};
-
-export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModal.tsx b/frontend/src/App/ConnectionLostModal.tsx
new file mode 100644
index 000000000..f08f2c0e2
--- /dev/null
+++ b/frontend/src/App/ConnectionLostModal.tsx
@@ -0,0 +1,45 @@
+import React, { useCallback } from 'react';
+import Button from 'Components/Link/Button';
+import Modal from 'Components/Modal/Modal';
+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 { kinds } from 'Helpers/Props';
+import translate from 'Utilities/String/translate';
+import styles from './ConnectionLostModal.css';
+
+interface ConnectionLostModalProps {
+ isOpen: boolean;
+}
+
+function ConnectionLostModal(props: ConnectionLostModalProps) {
+ const { isOpen } = props;
+
+ const handleModalClose = useCallback(() => {
+ location.reload();
+ }, []);
+
+ return (
+
+
+ {translate('ConnectionLost')}
+
+
+ {translate('ConnectionLostToBackend')}
+
+
+ {translate('ConnectionLostReconnect')}
+
+
+
+
+ {translate('Reload')}
+
+
+
+
+ );
+}
+
+export default ConnectionLostModal;
diff --git a/frontend/src/App/ConnectionLostModalConnector.js b/frontend/src/App/ConnectionLostModalConnector.js
deleted file mode 100644
index 8ab8e3cd0..000000000
--- a/frontend/src/App/ConnectionLostModalConnector.js
+++ /dev/null
@@ -1,12 +0,0 @@
-import { connect } from 'react-redux';
-import ConnectionLostModal from './ConnectionLostModal';
-
-function createMapDispatchToProps(dispatch, props) {
- return {
- onModalClose() {
- location.reload();
- }
- };
-}
-
-export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);
diff --git a/frontend/src/App/ModelBase.ts b/frontend/src/App/ModelBase.ts
new file mode 100644
index 000000000..187b12fb2
--- /dev/null
+++ b/frontend/src/App/ModelBase.ts
@@ -0,0 +1,5 @@
+interface ModelBase {
+ id: number;
+}
+
+export default ModelBase;
diff --git a/frontend/src/App/SelectContext.tsx b/frontend/src/App/SelectContext.tsx
new file mode 100644
index 000000000..66be388ce
--- /dev/null
+++ b/frontend/src/App/SelectContext.tsx
@@ -0,0 +1,83 @@
+import { cloneDeep } from 'lodash';
+import React, { useCallback, useEffect } from 'react';
+import useSelectState, { SelectState } from 'Helpers/Hooks/useSelectState';
+import ModelBase from './ModelBase';
+
+export type SelectContextAction =
+ | { type: 'reset' }
+ | { type: 'selectAll' }
+ | { type: 'unselectAll' }
+ | {
+ type: 'toggleSelected';
+ id: number;
+ isSelected: boolean;
+ shiftKey: boolean;
+ }
+ | {
+ type: 'removeItem';
+ id: number;
+ }
+ | {
+ type: 'updateItems';
+ items: ModelBase[];
+ };
+
+export type SelectDispatch = (action: SelectContextAction) => void;
+
+interface SelectProviderOptions
{
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ children: any;
+ items: Array;
+}
+
+const SelectContext = React.createContext<
+ [SelectState, SelectDispatch] | undefined
+>(cloneDeep(undefined));
+
+export function SelectProvider(
+ props: SelectProviderOptions
+) {
+ const { items } = props;
+ const [state, dispatch] = useSelectState();
+
+ const dispatchWrapper = useCallback(
+ (action: SelectContextAction) => {
+ switch (action.type) {
+ case 'reset':
+ case 'removeItem':
+ dispatch(action);
+ break;
+
+ default:
+ dispatch({
+ ...action,
+ items,
+ });
+ break;
+ }
+ },
+ [items, dispatch]
+ );
+
+ const value: [SelectState, SelectDispatch] = [state, dispatchWrapper];
+
+ useEffect(() => {
+ dispatch({ type: 'updateItems', items });
+ }, [items, dispatch]);
+
+ return (
+
+ {props.children}
+
+ );
+}
+
+export function useSelect() {
+ const context = React.useContext(SelectContext);
+
+ if (context === undefined) {
+ throw new Error('useSelect must be used within a SelectProvider');
+ }
+
+ return context;
+}
diff --git a/frontend/src/App/State/AppSectionState.ts b/frontend/src/App/State/AppSectionState.ts
new file mode 100644
index 000000000..4e9dbe7a0
--- /dev/null
+++ b/frontend/src/App/State/AppSectionState.ts
@@ -0,0 +1,85 @@
+import Column from 'Components/Table/Column';
+import { SortDirection } from 'Helpers/Props/sortDirections';
+import { ValidationFailure } from 'typings/pending';
+import { FilterBuilderProp, PropertyFilter } from './AppState';
+
+export interface Error {
+ status?: number;
+ responseJSON:
+ | {
+ message: string | undefined;
+ }
+ | ValidationFailure[]
+ | undefined;
+}
+
+export interface AppSectionDeleteState {
+ isDeleting: boolean;
+ deleteError: Error;
+}
+
+export interface AppSectionSaveState {
+ isSaving: boolean;
+ saveError: Error;
+}
+
+export interface PagedAppSectionState {
+ page: number;
+ pageSize: number;
+ totalPages: number;
+ totalRecords?: number;
+}
+export interface TableAppSectionState {
+ columns: Column[];
+}
+
+export interface AppSectionFilterState {
+ selectedFilterKey: string;
+ filters: PropertyFilter[];
+ filterBuilderProps: FilterBuilderProp[];
+}
+
+export interface AppSectionSchemaState {
+ isSchemaFetching: boolean;
+ isSchemaPopulated: boolean;
+ schemaError: Error;
+ schema: {
+ items: T[];
+ };
+}
+
+export interface AppSectionItemSchemaState {
+ isSchemaFetching: boolean;
+ isSchemaPopulated: boolean;
+ schemaError: Error;
+ schema: T;
+}
+
+export interface AppSectionItemState {
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ pendingChanges: Partial;
+ item: T;
+}
+
+export interface AppSectionProviderState
+ extends AppSectionDeleteState,
+ AppSectionSaveState {
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ items: T[];
+ pendingChanges: Partial;
+}
+
+interface AppSectionState {
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ items: T[];
+ sortKey: string;
+ sortDirection: SortDirection;
+}
+
+export default AppSectionState;
diff --git a/frontend/src/App/State/AppState.ts b/frontend/src/App/State/AppState.ts
new file mode 100644
index 000000000..84bd5d0b4
--- /dev/null
+++ b/frontend/src/App/State/AppState.ts
@@ -0,0 +1,95 @@
+import BlocklistAppState from './BlocklistAppState';
+import CalendarAppState from './CalendarAppState';
+import CaptchaAppState from './CaptchaAppState';
+import CommandAppState from './CommandAppState';
+import EpisodeFilesAppState from './EpisodeFilesAppState';
+import EpisodesAppState from './EpisodesAppState';
+import HistoryAppState from './HistoryAppState';
+import InteractiveImportAppState from './InteractiveImportAppState';
+import OAuthAppState from './OAuthAppState';
+import ParseAppState from './ParseAppState';
+import PathsAppState from './PathsAppState';
+import ProviderOptionsAppState from './ProviderOptionsAppState';
+import QueueAppState from './QueueAppState';
+import ReleasesAppState from './ReleasesAppState';
+import RootFolderAppState from './RootFolderAppState';
+import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
+import SettingsAppState from './SettingsAppState';
+import SystemAppState from './SystemAppState';
+import TagsAppState from './TagsAppState';
+import WantedAppState from './WantedAppState';
+
+interface FilterBuilderPropOption {
+ id: string;
+ name: string;
+}
+
+export interface FilterBuilderProp {
+ name: string;
+ label: string;
+ type: string;
+ valueType?: string;
+ optionsSelector?: (items: T[]) => FilterBuilderPropOption[];
+}
+
+export interface PropertyFilter {
+ key: string;
+ value: boolean | string | number | string[] | number[];
+ type: string;
+}
+
+export interface Filter {
+ key: string;
+ label: string;
+ filters: PropertyFilter[];
+}
+
+export interface CustomFilter {
+ id: number;
+ type: string;
+ label: string;
+ filters: PropertyFilter[];
+}
+
+export interface AppSectionState {
+ isConnected: boolean;
+ isReconnecting: boolean;
+ isSidebarVisible: boolean;
+ version: string;
+ prevVersion?: string;
+ dimensions: {
+ isSmallScreen: boolean;
+ isLargeScreen: boolean;
+ width: number;
+ height: number;
+ };
+}
+
+interface AppState {
+ app: AppSectionState;
+ blocklist: BlocklistAppState;
+ calendar: CalendarAppState;
+ captcha: CaptchaAppState;
+ commands: CommandAppState;
+ episodeFiles: EpisodeFilesAppState;
+ episodeHistory: HistoryAppState;
+ episodes: EpisodesAppState;
+ episodesSelection: EpisodesAppState;
+ history: HistoryAppState;
+ interactiveImport: InteractiveImportAppState;
+ oAuth: OAuthAppState;
+ parse: ParseAppState;
+ paths: PathsAppState;
+ providerOptions: ProviderOptionsAppState;
+ queue: QueueAppState;
+ releases: ReleasesAppState;
+ rootFolders: RootFolderAppState;
+ series: SeriesAppState;
+ seriesIndex: SeriesIndexAppState;
+ settings: SettingsAppState;
+ system: SystemAppState;
+ tags: TagsAppState;
+ wanted: WantedAppState;
+}
+
+export default AppState;
diff --git a/frontend/src/App/State/BlocklistAppState.ts b/frontend/src/App/State/BlocklistAppState.ts
new file mode 100644
index 000000000..004a30732
--- /dev/null
+++ b/frontend/src/App/State/BlocklistAppState.ts
@@ -0,0 +1,16 @@
+import Blocklist from 'typings/Blocklist';
+import AppSectionState, {
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState,
+} from './AppSectionState';
+
+interface BlocklistAppState
+ extends AppSectionState,
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState {
+ isRemoving: boolean;
+}
+
+export default BlocklistAppState;
diff --git a/frontend/src/App/State/CalendarAppState.ts b/frontend/src/App/State/CalendarAppState.ts
new file mode 100644
index 000000000..75c8b5e50
--- /dev/null
+++ b/frontend/src/App/State/CalendarAppState.ts
@@ -0,0 +1,29 @@
+import moment from 'moment';
+import AppSectionState, {
+ AppSectionFilterState,
+} from 'App/State/AppSectionState';
+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 {
+ 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/App/State/CaptchaAppState.ts b/frontend/src/App/State/CaptchaAppState.ts
new file mode 100644
index 000000000..7252937eb
--- /dev/null
+++ b/frontend/src/App/State/CaptchaAppState.ts
@@ -0,0 +1,11 @@
+interface CaptchaAppState {
+ refreshing: false;
+ token: string;
+ siteKey: unknown;
+ secretToken: unknown;
+ ray: unknown;
+ stoken: unknown;
+ responseUrl: unknown;
+}
+
+export default CaptchaAppState;
diff --git a/frontend/src/App/State/ClientSideCollectionAppState.ts b/frontend/src/App/State/ClientSideCollectionAppState.ts
new file mode 100644
index 000000000..f4110ef73
--- /dev/null
+++ b/frontend/src/App/State/ClientSideCollectionAppState.ts
@@ -0,0 +1,8 @@
+import { CustomFilter } from './AppState';
+
+interface ClientSideCollectionAppState {
+ totalItems: number;
+ customFilters: CustomFilter[];
+}
+
+export default ClientSideCollectionAppState;
diff --git a/frontend/src/App/State/CommandAppState.ts b/frontend/src/App/State/CommandAppState.ts
new file mode 100644
index 000000000..1bde37371
--- /dev/null
+++ b/frontend/src/App/State/CommandAppState.ts
@@ -0,0 +1,6 @@
+import AppSectionState from 'App/State/AppSectionState';
+import Command from 'Commands/Command';
+
+export type CommandAppState = AppSectionState;
+
+export default CommandAppState;
diff --git a/frontend/src/App/State/CustomFiltersAppState.ts b/frontend/src/App/State/CustomFiltersAppState.ts
new file mode 100644
index 000000000..6ac4820c7
--- /dev/null
+++ b/frontend/src/App/State/CustomFiltersAppState.ts
@@ -0,0 +1,10 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+} from 'App/State/AppSectionState';
+import { CustomFilter } from './AppState';
+
+interface CustomFiltersAppState
+ extends AppSectionState,
+ AppSectionDeleteState {}
+
+export default CustomFiltersAppState;
diff --git a/frontend/src/App/State/EpisodeFilesAppState.ts b/frontend/src/App/State/EpisodeFilesAppState.ts
new file mode 100644
index 000000000..5e6e94a06
--- /dev/null
+++ b/frontend/src/App/State/EpisodeFilesAppState.ts
@@ -0,0 +1,10 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+} from 'App/State/AppSectionState';
+import { EpisodeFile } from 'EpisodeFile/EpisodeFile';
+
+interface EpisodeFilesAppState
+ extends AppSectionState,
+ AppSectionDeleteState {}
+
+export default EpisodeFilesAppState;
diff --git a/frontend/src/App/State/EpisodesAppState.ts b/frontend/src/App/State/EpisodesAppState.ts
new file mode 100644
index 000000000..4234c0bcb
--- /dev/null
+++ b/frontend/src/App/State/EpisodesAppState.ts
@@ -0,0 +1,6 @@
+import AppSectionState from 'App/State/AppSectionState';
+import Episode from 'Episode/Episode';
+
+type EpisodesAppState = AppSectionState;
+
+export default EpisodesAppState;
diff --git a/frontend/src/App/State/HistoryAppState.ts b/frontend/src/App/State/HistoryAppState.ts
new file mode 100644
index 000000000..632b82179
--- /dev/null
+++ b/frontend/src/App/State/HistoryAppState.ts
@@ -0,0 +1,14 @@
+import AppSectionState, {
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState,
+} from 'App/State/AppSectionState';
+import History from 'typings/History';
+
+interface HistoryAppState
+ extends AppSectionState,
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState {}
+
+export default HistoryAppState;
diff --git a/frontend/src/App/State/InteractiveImportAppState.ts b/frontend/src/App/State/InteractiveImportAppState.ts
new file mode 100644
index 000000000..84fd9f4c1
--- /dev/null
+++ b/frontend/src/App/State/InteractiveImportAppState.ts
@@ -0,0 +1,21 @@
+import AppSectionState from 'App/State/AppSectionState';
+import ImportMode from 'InteractiveImport/ImportMode';
+import InteractiveImport from 'InteractiveImport/InteractiveImport';
+
+interface FavoriteFolder {
+ folder: string;
+}
+
+interface RecentFolder {
+ folder: string;
+ lastUsed: string;
+}
+
+interface InteractiveImportAppState extends AppSectionState {
+ originalItems: InteractiveImport[];
+ importMode: ImportMode;
+ favoriteFolders: FavoriteFolder[];
+ recentFolders: RecentFolder[];
+}
+
+export default InteractiveImportAppState;
diff --git a/frontend/src/App/State/MetadataAppState.ts b/frontend/src/App/State/MetadataAppState.ts
new file mode 100644
index 000000000..495f109d8
--- /dev/null
+++ b/frontend/src/App/State/MetadataAppState.ts
@@ -0,0 +1,6 @@
+import { AppSectionProviderState } from 'App/State/AppSectionState';
+import Metadata from 'typings/Metadata';
+
+type MetadataAppState = AppSectionProviderState;
+
+export default MetadataAppState;
diff --git a/frontend/src/App/State/OAuthAppState.ts b/frontend/src/App/State/OAuthAppState.ts
new file mode 100644
index 000000000..619767929
--- /dev/null
+++ b/frontend/src/App/State/OAuthAppState.ts
@@ -0,0 +1,9 @@
+import { Error } from './AppSectionState';
+
+interface OAuthAppState {
+ authorizing: boolean;
+ result: Record | null;
+ error: Error;
+}
+
+export default OAuthAppState;
diff --git a/frontend/src/App/State/ParseAppState.ts b/frontend/src/App/State/ParseAppState.ts
new file mode 100644
index 000000000..67fb4cc63
--- /dev/null
+++ b/frontend/src/App/State/ParseAppState.ts
@@ -0,0 +1,54 @@
+import ModelBase from 'App/ModelBase';
+import { AppSectionItemState } from 'App/State/AppSectionState';
+import Episode from 'Episode/Episode';
+import Language from 'Language/Language';
+import { QualityModel } from 'Quality/Quality';
+import Series from 'Series/Series';
+import CustomFormat from 'typings/CustomFormat';
+
+export interface SeriesTitleInfo {
+ title: string;
+ titleWithoutYear: string;
+ year: number;
+ allTitles: string[];
+}
+
+export interface ParsedEpisodeInfo {
+ releaseTitle: string;
+ seriesTitle: string;
+ seriesTitleInfo: SeriesTitleInfo;
+ quality: QualityModel;
+ seasonNumber: number;
+ episodeNumbers: number[];
+ absoluteEpisodeNumbers: number[];
+ specialAbsoluteEpisodeNumbers: number[];
+ languages: Language[];
+ fullSeason: boolean;
+ isPartialSeason: boolean;
+ isMultiSeason: boolean;
+ isSeasonExtra: boolean;
+ special: boolean;
+ releaseHash: string;
+ seasonPart: number;
+ releaseGroup?: string;
+ releaseTokens: string;
+ airDate?: string;
+ isDaily: boolean;
+ isAbsoluteNumbering: boolean;
+ isPossibleSpecialEpisode: boolean;
+ isPossibleSceneSeasonSpecial: boolean;
+}
+
+export interface ParseModel extends ModelBase {
+ title: string;
+ parsedEpisodeInfo: ParsedEpisodeInfo;
+ series?: Series;
+ episodes: Episode[];
+ languages?: Language[];
+ customFormats?: CustomFormat[];
+ customFormatScore?: number;
+}
+
+type ParseAppState = AppSectionItemState;
+
+export default ParseAppState;
diff --git a/frontend/src/App/State/PathsAppState.ts b/frontend/src/App/State/PathsAppState.ts
new file mode 100644
index 000000000..068a48dc0
--- /dev/null
+++ b/frontend/src/App/State/PathsAppState.ts
@@ -0,0 +1,29 @@
+interface BasePath {
+ name: string;
+ path: string;
+ size: number;
+ lastModified: string;
+}
+
+interface File extends BasePath {
+ type: 'file';
+}
+
+interface Folder extends BasePath {
+ type: 'folder';
+}
+
+export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
+export type Path = File | Folder;
+
+interface PathsAppState {
+ currentPath: string;
+ isFetching: boolean;
+ isPopulated: boolean;
+ error: Error;
+ directories: Folder[];
+ files: File[];
+ parent: string | null;
+}
+
+export default PathsAppState;
diff --git a/frontend/src/App/State/ProviderOptionsAppState.ts b/frontend/src/App/State/ProviderOptionsAppState.ts
new file mode 100644
index 000000000..7fb5df02b
--- /dev/null
+++ b/frontend/src/App/State/ProviderOptionsAppState.ts
@@ -0,0 +1,22 @@
+import AppSectionState from 'App/State/AppSectionState';
+import Field, { FieldSelectOption } from 'typings/Field';
+
+export interface ProviderOptions {
+ fields?: Field[];
+}
+
+interface ProviderOptionsDevice {
+ id: string;
+ name: string;
+}
+
+interface ProviderOptionsAppState {
+ devices: AppSectionState;
+ servers: AppSectionState>;
+ newznabCategories: AppSectionState>;
+ getProfiles: AppSectionState>;
+ getTags: AppSectionState>;
+ getRootFolders: AppSectionState>;
+}
+
+export default ProviderOptionsAppState;
diff --git a/frontend/src/App/State/QueueAppState.ts b/frontend/src/App/State/QueueAppState.ts
new file mode 100644
index 000000000..954d649a2
--- /dev/null
+++ b/frontend/src/App/State/QueueAppState.ts
@@ -0,0 +1,44 @@
+import Queue from 'typings/Queue';
+import AppSectionState, {
+ AppSectionFilterState,
+ AppSectionItemState,
+ Error,
+ PagedAppSectionState,
+ TableAppSectionState,
+} from './AppSectionState';
+
+export interface QueueStatus {
+ totalCount: number;
+ count: number;
+ unknownCount: number;
+ errors: boolean;
+ warnings: boolean;
+ unknownErrors: boolean;
+ unknownWarnings: boolean;
+}
+
+export interface QueueDetailsAppState extends AppSectionState {
+ params: unknown;
+}
+
+export interface QueuePagedAppState
+ extends AppSectionState,
+ AppSectionFilterState,
+ PagedAppSectionState,
+ TableAppSectionState {
+ isGrabbing: boolean;
+ grabError: Error;
+ isRemoving: boolean;
+ removeError: Error;
+}
+
+interface QueueAppState {
+ status: AppSectionItemState;
+ details: QueueDetailsAppState;
+ paged: QueuePagedAppState;
+ options: {
+ includeUnknownSeriesItems: boolean;
+ };
+}
+
+export default QueueAppState;
diff --git a/frontend/src/App/State/ReleasesAppState.ts b/frontend/src/App/State/ReleasesAppState.ts
new file mode 100644
index 000000000..350f6eac8
--- /dev/null
+++ b/frontend/src/App/State/ReleasesAppState.ts
@@ -0,0 +1,10 @@
+import AppSectionState, {
+ AppSectionFilterState,
+} from 'App/State/AppSectionState';
+import Release from 'typings/Release';
+
+interface ReleasesAppState
+ extends AppSectionState,
+ AppSectionFilterState {}
+
+export default ReleasesAppState;
diff --git a/frontend/src/App/State/RootFolderAppState.ts b/frontend/src/App/State/RootFolderAppState.ts
new file mode 100644
index 000000000..9e636c95f
--- /dev/null
+++ b/frontend/src/App/State/RootFolderAppState.ts
@@ -0,0 +1,12 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionSaveState,
+} from 'App/State/AppSectionState';
+import RootFolder from 'typings/RootFolder';
+
+interface RootFolderAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+export default RootFolderAppState;
diff --git a/frontend/src/App/State/SeriesAppState.ts b/frontend/src/App/State/SeriesAppState.ts
new file mode 100644
index 000000000..5da5987dd
--- /dev/null
+++ b/frontend/src/App/State/SeriesAppState.ts
@@ -0,0 +1,66 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionSaveState,
+} from 'App/State/AppSectionState';
+import Column from 'Components/Table/Column';
+import { SortDirection } from 'Helpers/Props/sortDirections';
+import Series from 'Series/Series';
+import { Filter, FilterBuilderProp } from './AppState';
+
+export interface SeriesIndexAppState {
+ sortKey: string;
+ sortDirection: SortDirection;
+ secondarySortKey: string;
+ secondarySortDirection: SortDirection;
+ view: string;
+
+ posterOptions: {
+ detailedProgressBar: boolean;
+ size: string;
+ showTitle: boolean;
+ showMonitored: boolean;
+ showQualityProfile: boolean;
+ showTags: boolean;
+ showSearchAction: boolean;
+ };
+
+ overviewOptions: {
+ detailedProgressBar: boolean;
+ size: string;
+ showMonitored: boolean;
+ showNetwork: boolean;
+ showQualityProfile: boolean;
+ showPreviousAiring: boolean;
+ showAdded: boolean;
+ showSeasonCount: boolean;
+ showPath: boolean;
+ showSizeOnDisk: boolean;
+ showTags: boolean;
+ showSearchAction: boolean;
+ };
+
+ tableOptions: {
+ showBanners: boolean;
+ showSearchAction: boolean;
+ };
+
+ selectedFilterKey: string;
+ filterBuilderProps: FilterBuilderProp[];
+ filters: Filter[];
+ columns: Column[];
+}
+
+interface SeriesAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {
+ itemMap: Record;
+
+ deleteOptions: {
+ addImportListExclusion: boolean;
+ };
+
+ pendingChanges: Partial;
+}
+
+export default SeriesAppState;
diff --git a/frontend/src/App/State/SettingsAppState.ts b/frontend/src/App/State/SettingsAppState.ts
new file mode 100644
index 000000000..b8e6f4954
--- /dev/null
+++ b/frontend/src/App/State/SettingsAppState.ts
@@ -0,0 +1,109 @@
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionItemSchemaState,
+ AppSectionItemState,
+ AppSectionSaveState,
+ PagedAppSectionState,
+} from 'App/State/AppSectionState';
+import Language from 'Language/Language';
+import CustomFormat from 'typings/CustomFormat';
+import DownloadClient from 'typings/DownloadClient';
+import ImportList from 'typings/ImportList';
+import ImportListExclusion from 'typings/ImportListExclusion';
+import ImportListOptionsSettings from 'typings/ImportListOptionsSettings';
+import Indexer from 'typings/Indexer';
+import IndexerFlag from 'typings/IndexerFlag';
+import Notification from 'typings/Notification';
+import QualityProfile from 'typings/QualityProfile';
+import General from 'typings/Settings/General';
+import NamingConfig from 'typings/Settings/NamingConfig';
+import NamingExample from 'typings/Settings/NamingExample';
+import ReleaseProfile from 'typings/Settings/ReleaseProfile';
+import UiSettings from 'typings/Settings/UiSettings';
+import MetadataAppState from './MetadataAppState';
+
+export interface DownloadClientAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {
+ isTestingAll: boolean;
+}
+
+export interface GeneralAppState
+ extends AppSectionItemState,
+ AppSectionSaveState {}
+
+export interface NamingAppState
+ extends AppSectionItemState,
+ AppSectionSaveState {}
+
+export type NamingExamplesAppState = AppSectionItemState;
+
+export interface ImportListAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+export interface IndexerAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {
+ isTestingAll: boolean;
+}
+
+export interface NotificationAppState
+ extends AppSectionState,
+ AppSectionDeleteState {}
+
+export interface QualityProfilesAppState
+ extends AppSectionState,
+ AppSectionItemSchemaState {}
+
+export interface ReleaseProfilesAppState
+ extends AppSectionState,
+ AppSectionSaveState {
+ pendingChanges: Partial;
+}
+
+export interface CustomFormatAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+export interface ImportListOptionsSettingsAppState
+ extends AppSectionItemState,
+ AppSectionSaveState {}
+
+export interface ImportListExclusionsSettingsAppState
+ extends AppSectionState,
+ AppSectionSaveState,
+ PagedAppSectionState,
+ AppSectionDeleteState {
+ pendingChanges: Partial;
+}
+
+export type IndexerFlagSettingsAppState = AppSectionState;
+export type LanguageSettingsAppState = AppSectionState;
+export type UiSettingsAppState = AppSectionItemState;
+
+interface SettingsAppState {
+ advancedSettings: boolean;
+ customFormats: CustomFormatAppState;
+ downloadClients: DownloadClientAppState;
+ general: GeneralAppState;
+ importListExclusions: ImportListExclusionsSettingsAppState;
+ importListOptions: ImportListOptionsSettingsAppState;
+ importLists: ImportListAppState;
+ indexerFlags: IndexerFlagSettingsAppState;
+ indexers: IndexerAppState;
+ languages: LanguageSettingsAppState;
+ metadata: MetadataAppState;
+ naming: NamingAppState;
+ namingExamples: NamingExamplesAppState;
+ notifications: NotificationAppState;
+ qualityProfiles: QualityProfilesAppState;
+ releaseProfiles: ReleaseProfilesAppState;
+ ui: UiSettingsAppState;
+}
+
+export default SettingsAppState;
diff --git a/frontend/src/App/State/SystemAppState.ts b/frontend/src/App/State/SystemAppState.ts
new file mode 100644
index 000000000..1161f0e1e
--- /dev/null
+++ b/frontend/src/App/State/SystemAppState.ts
@@ -0,0 +1,22 @@
+import DiskSpace from 'typings/DiskSpace';
+import Health from 'typings/Health';
+import SystemStatus from 'typings/SystemStatus';
+import Task from 'typings/Task';
+import Update from 'typings/Update';
+import AppSectionState, { AppSectionItemState } from './AppSectionState';
+
+export type DiskSpaceAppState = AppSectionState;
+export type HealthAppState = AppSectionState;
+export type SystemStatusAppState = AppSectionItemState;
+export type TaskAppState = AppSectionState;
+export type UpdateAppState = AppSectionState;
+
+interface SystemAppState {
+ diskSpace: DiskSpaceAppState;
+ health: HealthAppState;
+ status: SystemStatusAppState;
+ tasks: TaskAppState;
+ updates: UpdateAppState;
+}
+
+export default SystemAppState;
diff --git a/frontend/src/App/State/TagsAppState.ts b/frontend/src/App/State/TagsAppState.ts
new file mode 100644
index 000000000..914df9044
--- /dev/null
+++ b/frontend/src/App/State/TagsAppState.ts
@@ -0,0 +1,32 @@
+import ModelBase from 'App/ModelBase';
+import AppSectionState, {
+ AppSectionDeleteState,
+ AppSectionSaveState,
+} from 'App/State/AppSectionState';
+
+export interface Tag extends ModelBase {
+ label: string;
+}
+
+export interface TagDetail extends ModelBase {
+ label: string;
+ autoTagIds: number[];
+ delayProfileIds: number[];
+ downloadClientIds: [];
+ importListIds: number[];
+ indexerIds: number[];
+ notificationIds: number[];
+ restrictionIds: number[];
+ seriesIds: number[];
+}
+
+export interface TagDetailAppState
+ extends AppSectionState,
+ AppSectionDeleteState,
+ AppSectionSaveState {}
+
+interface TagsAppState extends AppSectionState, AppSectionDeleteState {
+ details: TagDetailAppState;
+}
+
+export default TagsAppState;
diff --git a/frontend/src/App/State/WantedAppState.ts b/frontend/src/App/State/WantedAppState.ts
new file mode 100644
index 000000000..b543d3879
--- /dev/null
+++ b/frontend/src/App/State/WantedAppState.ts
@@ -0,0 +1,13 @@
+import AppSectionState from 'App/State/AppSectionState';
+import Episode from 'Episode/Episode';
+
+type WantedCutoffUnmetAppState = AppSectionState;
+
+type WantedMissingAppState = AppSectionState;
+
+interface WantedAppState {
+ cutoffUnmet: WantedCutoffUnmetAppState;
+ missing: WantedMissingAppState;
+}
+
+export default WantedAppState;
diff --git a/frontend/src/Calendar/Agenda/Agenda.css.d.ts b/frontend/src/Calendar/Agenda/Agenda.css.d.ts
new file mode 100644
index 000000000..44421cc99
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/Agenda.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'agenda': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
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.css b/frontend/src/Calendar/Agenda/AgendaEvent.css
index 38b4f50f3..7ad9ccf6a 100644
--- a/frontend/src/Calendar/Agenda/AgendaEvent.css
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css
@@ -1,12 +1,27 @@
.event {
- display: flex;
- overflow-x: hidden;
+ position: relative;
padding: 5px;
- border-bottom: 1px solid $borderColor;
- font-size: $defaultFontSize;
+ border-bottom: 1px solid var(--borderColor);
+}
+
+.underlay {
+ @add-mixin cover;
&:hover {
- background-color: $tableRowHoverBackgroundColor;
+ background-color: var(--tableRowHoverBackgroundColor);
+ }
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ position: relative;
+ display: flex;
+ overflow-x: hidden;
+ font-size: $defaultFontSize;
+
+ &:global(.colorImpaired) {
+ border-left-width: 5px;
}
}
@@ -56,6 +71,8 @@
.statusIcon {
margin-left: 3px;
+ cursor: default;
+ pointer-events: all;
}
/*
@@ -86,8 +103,12 @@
composes: premiere from '~Calendar/Events/CalendarEvent.css';
}
+.unaired {
+ composes: unaired from '~Calendar/Events/CalendarEvent.css';
+}
+
@media only screen and (max-width: $breakpointSmall) {
- .event {
+ .overlay {
flex-direction: column;
}
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts b/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts
new file mode 100644
index 000000000..288e11824
--- /dev/null
+++ b/frontend/src/Calendar/Agenda/AgendaEvent.css.d.ts
@@ -0,0 +1,25 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'absoluteEpisodeNumber': string;
+ 'date': string;
+ 'downloaded': string;
+ 'downloading': string;
+ 'episodeSeparator': string;
+ 'episodeTitle': string;
+ 'event': string;
+ 'eventWrapper': string;
+ 'missing': string;
+ 'onAir': string;
+ 'overlay': string;
+ 'premiere': string;
+ 'seasonEpisodeNumber': string;
+ 'seriesTitle': string;
+ 'statusIcon': string;
+ 'time': string;
+ 'unaired': string;
+ 'underlay': string;
+ 'unmonitored': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Agenda/AgendaEvent.js b/frontend/src/Calendar/Agenda/AgendaEvent.js
deleted file mode 100644
index 726c55b76..000000000
--- a/frontend/src/Calendar/Agenda/AgendaEvent.js
+++ /dev/null
@@ -1,265 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import classNames from 'classnames';
-import formatTime from 'Utilities/Date/formatTime';
-import padNumber from 'Utilities/Number/padNumber';
-import { icons, kinds } from 'Helpers/Props';
-import getStatusStyle from 'Calendar/getStatusStyle';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import episodeEntities from 'Episode/episodeEntities';
-import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
-import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
-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,
- 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;
- const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
- const seasonStatistics = season?.statistics || {};
-
- 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 &&
-
- }
-
- {
- showCutoffUnmetIcon &&
- !!episodeFile &&
- episodeFile.languageCutoffNotMet &&
- !episodeFile.qualityCutoffNotMet &&
-
- }
-
- {
- episodeNumber === 1 && seasonNumber > 0 &&
-
- }
-
- {
- showFinaleIcon &&
- episodeNumber !== 1 &&
- seasonNumber > 0 &&
- episodeNumber === seasonStatistics.totalEpisodeCount &&
-
- }
-
- {
- 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,
- 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 e1d996225..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 createSeriesSelector from 'Store/Selectors/createSeriesSelector';
-import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
-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.css.d.ts b/frontend/src/Calendar/Calendar.css.d.ts
new file mode 100644
index 000000000..503034402
--- /dev/null
+++ b/frontend/src/Calendar/Calendar.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'calendar': string;
+ 'calendarContent': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Calendar.js b/frontend/src/Calendar/Calendar.js
deleted file mode 100644
index 6ceb1f3bb..000000000
--- a/frontend/src/Calendar/Calendar.js
+++ /dev/null
@@ -1,64 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import LoadingIndicator from 'Components/Loading/LoadingIndicator';
-import * as calendarViews from './calendarViews';
-import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
-import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
-import CalendarDaysConnector from './Day/CalendarDaysConnector';
-import AgendaConnector from './Agenda/AgendaConnector';
-import styles from './Calendar.css';
-
-class Calendar extends Component {
-
- //
- // Render
-
- render() {
- const {
- isFetching,
- isPopulated,
- error,
- view
- } = this.props;
-
- return (
-
- {
- isFetching && !isPopulated &&
-
- }
-
- {
- !isFetching && !!error &&
-
Unable to load the calendar
- }
-
- {
- !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 636026c56..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 { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
-import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
-import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
-import * as calendarActions from 'Store/Actions/calendarActions';
-import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
-import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
-import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
-import * as commandNames from 'Commands/commandNames';
-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);
-
- 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/CalendarFilterModal.tsx b/frontend/src/Calendar/CalendarFilterModal.tsx
new file mode 100644
index 000000000..e26b2928b
--- /dev/null
+++ b/frontend/src/Calendar/CalendarFilterModal.tsx
@@ -0,0 +1,54 @@
+import React, { useCallback } from 'react';
+import { useDispatch, useSelector } from 'react-redux';
+import { createSelector } from 'reselect';
+import AppState from 'App/State/AppState';
+import FilterModal from 'Components/Filter/FilterModal';
+import { setCalendarFilter } from 'Store/Actions/calendarActions';
+
+function createCalendarSelector() {
+ return createSelector(
+ (state: AppState) => state.calendar.items,
+ (calendar) => {
+ return calendar;
+ }
+ );
+}
+
+function createFilterBuilderPropsSelector() {
+ return createSelector(
+ (state: AppState) => state.calendar.filterBuilderProps,
+ (filterBuilderProps) => {
+ return filterBuilderProps;
+ }
+ );
+}
+
+interface CalendarFilterModalProps {
+ isOpen: boolean;
+}
+
+export default function CalendarFilterModal(props: CalendarFilterModalProps) {
+ const sectionItems = useSelector(createCalendarSelector());
+ const filterBuilderProps = useSelector(createFilterBuilderPropsSelector());
+ const customFilterType = 'calendar';
+
+ const dispatch = useDispatch();
+
+ const dispatchSetFilter = useCallback(
+ (payload: unknown) => {
+ dispatch(setCalendarFilter(payload));
+ },
+ [dispatch]
+ );
+
+ return (
+
+ );
+}
diff --git a/frontend/src/Calendar/CalendarPage.css.d.ts b/frontend/src/Calendar/CalendarPage.css.d.ts
new file mode 100644
index 000000000..30befba55
--- /dev/null
+++ b/frontend/src/Calendar/CalendarPage.css.d.ts
@@ -0,0 +1,8 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'calendarInnerPageBody': string;
+ 'calendarPageBody': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/CalendarPage.js b/frontend/src/Calendar/CalendarPage.js
deleted file mode 100644
index 5e4f1c6db..000000000
--- a/frontend/src/Calendar/CalendarPage.js
+++ /dev/null
@@ -1,192 +0,0 @@
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import { align, icons } from 'Helpers/Props';
-import PageContent from 'Components/Page/PageContent';
-import Measure from 'Components/Measure';
-import PageContentBody from 'Components/Page/PageContentBody';
-import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
-import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
-import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
-import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
-import FilterMenu from 'Components/Menu/FilterMenu';
-import NoSeries from 'Series/NoSeries';
-import CalendarLinkModal from './iCal/CalendarLinkModal';
-import CalendarOptionsModal from './Options/CalendarOptionsModal';
-import LegendConnector from './Legend/LegendConnector';
-import CalendarConnector from './CalendarConnector';
-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,
- 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,
- 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 e039b2824..000000000
--- a/frontend/src/Calendar/CalendarPageConnector.js
+++ /dev/null
@@ -1,113 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import moment from 'moment';
-import { isCommandExecuting } from 'Utilities/Command';
-import isBefore from 'Utilities/Date/isBefore';
-import * as commandNames from 'Commands/commandNames';
-import withCurrentPage from 'Components/withCurrentPage';
-import { executeCommand } from 'Store/Actions/commandActions';
-import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
-import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
-import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
-import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
-import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
-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,
- createSeriesCountSelector(),
- createUISettingsSelector(),
- createMissingEpisodeIdsSelector(),
- createCommandExecutingSelector(commandNames.RSS_SYNC),
- createIsSearchingSelector(),
- (
- selectedFilterKey,
- filters,
- seriesCount,
- uiSettings,
- missingEpisodeIds,
- isRssSyncExecuting,
- isSearchingForMissing
- ) => {
- return {
- selectedFilterKey,
- filters,
- 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.css b/frontend/src/Calendar/Day/CalendarDay.css
index 79eb67ae7..22d1b1ccf 100644
--- a/frontend/src/Calendar/Day/CalendarDay.css
+++ b/frontend/src/Calendar/Day/CalendarDay.css
@@ -2,8 +2,8 @@
flex: 1 0 14.28%;
overflow: hidden;
min-height: 70px;
- border-bottom: 1px solid $calendarBorderColor;
- border-left: 1px solid $calendarBorderColor;
+ border-bottom: 1px solid var(--calendarBorderColor);
+ border-left: 1px solid var(--calendarBorderColor);
}
.isSingleDay {
@@ -12,14 +12,14 @@
.dayOfMonth {
padding-right: 5px;
- border-bottom: 1px solid $calendarBorderColor;
+ border-bottom: 1px solid var(--calendarBorderColor);
text-align: right;
}
.isToday {
- background-color: $calendarTodayBackgroundColor;
+ background-color: var(--calendarTodayBackgroundColor);
}
.isDifferentMonth {
- color: $disabledColor;
+ color: var(--disabledColor);
}
diff --git a/frontend/src/Calendar/Day/CalendarDay.css.d.ts b/frontend/src/Calendar/Day/CalendarDay.css.d.ts
new file mode 100644
index 000000000..f32def3dd
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'day': string;
+ 'dayOfMonth': string;
+ 'isDifferentMonth': string;
+ 'isSingleDay': string;
+ 'isToday': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/CalendarDay.js b/frontend/src/Calendar/Day/CalendarDay.js
deleted file mode 100644
index faa45b28b..000000000
--- a/frontend/src/Calendar/Day/CalendarDay.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React from 'react';
-import classNames from 'classnames';
-import * as calendarViews from 'Calendar/calendarViews';
-import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
-import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
-import styles from './CalendarDay.css';
-
-function CalendarDay(props) {
- const {
- date,
- time,
- isTodaysDate,
- events,
- view,
- onEventModalOpenToggle
- } = props;
-
- return (
-
- {
- view === calendarViews.MONTH &&
-
- {moment(date).date()}
-
- }
-
- {
- events.map((event) => {
- if (event.isGroup) {
- return (
-
- );
- }
-
- return (
-
- );
- })
- }
-
-
- );
-}
-
-CalendarDay.propTypes = {
- date: PropTypes.string.isRequired,
- time: PropTypes.string.isRequired,
- isTodaysDate: PropTypes.bool.isRequired,
- events: PropTypes.arrayOf(PropTypes.object).isRequired,
- view: PropTypes.string.isRequired,
- onEventModalOpenToggle: PropTypes.func.isRequired
-};
-
-export default CalendarDay;
diff --git a/frontend/src/Calendar/Day/CalendarDay.tsx b/frontend/src/Calendar/Day/CalendarDay.tsx
new file mode 100644
index 000000000..a619109ca
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDay.tsx
@@ -0,0 +1,159 @@
+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 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;
+ isTodaysDate: boolean;
+ onEventModalOpenToggle(isOpen: boolean): unknown;
+}
+
+function CalendarDay({
+ date,
+ isTodaysDate,
+ onEventModalOpenToggle,
+}: CalendarDayProps) {
+ const { time, view } = useSelector((state: AppState) => state.calendar);
+ const events = useSelector(createCalendarEventsConnector(date));
+
+ const ref = React.useRef(null);
+
+ React.useEffect(() => {
+ if (isTodaysDate && view === calendarViews.MONTH && ref.current) {
+ ref.current.scrollIntoView();
+ }
+ }, [time, isTodaysDate, view]);
+
+ return (
+
+ {view === calendarViews.MONTH && (
+
+ {moment(date).date()}
+
+ )}
+
+ {events.map((event) => {
+ if (event.isGroup) {
+ return (
+
+ );
+ }
+
+ return (
+
+ );
+ })}
+
+
+ );
+}
+
+export default CalendarDay;
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.css b/frontend/src/Calendar/Day/CalendarDays.css
index b6dd2100c..507dd0ede 100644
--- a/frontend/src/Calendar/Day/CalendarDays.css
+++ b/frontend/src/Calendar/Day/CalendarDays.css
@@ -1,6 +1,6 @@
.days {
display: flex;
- border-right: 1px solid $calendarBorderColor;
+ border-right: 1px solid var(--calendarBorderColor);
}
.day,
diff --git a/frontend/src/Calendar/Day/CalendarDays.css.d.ts b/frontend/src/Calendar/Day/CalendarDays.css.d.ts
new file mode 100644
index 000000000..ae3e7aebc
--- /dev/null
+++ b/frontend/src/Calendar/Day/CalendarDays.css.d.ts
@@ -0,0 +1,11 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'day': string;
+ 'days': string;
+ 'forecast': string;
+ 'month': string;
+ 'week': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/CalendarDays.js b/frontend/src/Calendar/Day/CalendarDays.js
deleted file mode 100644
index 0a1a36172..000000000
--- a/frontend/src/Calendar/Day/CalendarDays.js
+++ /dev/null
@@ -1,164 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import classNames from 'classnames';
-import isToday from 'Utilities/Date/isToday';
-import * as calendarViews from 'Calendar/calendarViews';
-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 3dea906a7..000000000
--- a/frontend/src/Calendar/Day/CalendarDaysConnector.js
+++ /dev/null
@@ -1,25 +0,0 @@
-import { connect } from 'react-redux';
-import { createSelector } from 'reselect';
-import { gotoCalendarPreviousRange, gotoCalendarNextRange } 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.css b/frontend/src/Calendar/Day/DayOfWeek.css
index 8c3552e55..2d31a30be 100644
--- a/frontend/src/Calendar/Day/DayOfWeek.css
+++ b/frontend/src/Calendar/Day/DayOfWeek.css
@@ -1,6 +1,6 @@
.dayOfWeek {
flex: 1 0 14.28%;
- background-color: #e4eaec;
+ background-color: var(--calendarBackgroundColor);
text-align: center;
}
@@ -9,5 +9,5 @@
}
.isToday {
- background-color: $calendarTodayBackgroundColor;
+ background-color: var(--calendarTodayBackgroundColor);
}
diff --git a/frontend/src/Calendar/Day/DayOfWeek.css.d.ts b/frontend/src/Calendar/Day/DayOfWeek.css.d.ts
new file mode 100644
index 000000000..a377e4a8e
--- /dev/null
+++ b/frontend/src/Calendar/Day/DayOfWeek.css.d.ts
@@ -0,0 +1,9 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'dayOfWeek': string;
+ 'isSingleDay': string;
+ 'isToday': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/DayOfWeek.js b/frontend/src/Calendar/Day/DayOfWeek.js
deleted file mode 100644
index d97671522..000000000
--- a/frontend/src/Calendar/Day/DayOfWeek.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import classNames from 'classnames';
-import getRelativeDate from 'Utilities/Date/getRelativeDate';
-import * as calendarViews from 'Calendar/calendarViews';
-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.css.d.ts b/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts
new file mode 100644
index 000000000..5bc224b68
--- /dev/null
+++ b/frontend/src/Calendar/Day/DaysOfWeek.css.d.ts
@@ -0,0 +1,7 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'daysOfWeek': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Day/DaysOfWeek.js b/frontend/src/Calendar/Day/DaysOfWeek.js
deleted file mode 100644
index a67777f7c..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 DayOfWeek from './DayOfWeek';
-import * as calendarViews from 'Calendar/calendarViews';
-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.css b/frontend/src/Calendar/Events/CalendarEvent.css
index fb7a39855..679b4cc51 100644
--- a/frontend/src/Calendar/Events/CalendarEvent.css
+++ b/frontend/src/Calendar/Events/CalendarEvent.css
@@ -1,11 +1,22 @@
$fullColorGradient: rgba(244, 245, 246, 0.2);
.event {
- overflow-x: hidden;
+ position: relative;
margin: 4px 2px;
padding: 5px;
- border-bottom: 1px solid $borderColor;
- border-left: 4px solid $borderColor;
+ border-bottom: 1px solid var(--calendarBorderColor);
+ border-left: 4px solid var(--calendarBorderColor);
+}
+
+.underlay {
+ @add-mixin cover;
+}
+
+.overlay {
+ @add-mixin linkOverlay;
+
+ position: relative;
+ overflow-x: hidden;
font-size: 12px;
&:global(.colorImpaired) {
@@ -19,7 +30,7 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
}
.episodeInfo {
- color: $calendarTextDim;
+ color: var(--calendarTextDim);
}
.seriesTitle,
@@ -30,7 +41,7 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
}
.seriesTitle {
- color: #3a3f51;
+ color: var(--calendarTextDimAlternate);
font-size: $defaultFontSize;
}
@@ -41,14 +52,20 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
.statusContainer {
display: flex;
align-items: center;
+
+ &:global(.fullColor) {
+ filter: var(--calendarFullColorFilter)
+ }
}
.statusIcon {
margin-left: 3px;
+ cursor: default;
+ pointer-events: all;
}
.airTime {
- color: $calendarTextDim;
+ color: var(--calendarTextDim);
}
/*
@@ -56,19 +73,19 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
*/
.downloaded {
- border-left-color: $successColor !important;
+ border-left-color: var(--successColor) !important;
&:global(.fullColor) {
background-color: rgba(39, 194, 76, 0.4) !important;
}
&:global(.colorImpaired) {
- border-left-color: color($successColor, saturation(+15%)) !important;
+ border-left-color: color(#27c24c saturation(+15%)) !important;
}
}
.downloading {
- border-left-color: $purple !important;
+ border-left-color: var(--purple) !important;
&:global(.fullColor) {
background-color: rgba(122, 67, 182, 0.4) !important;
@@ -76,14 +93,14 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
}
.unmonitored {
- border-left-color: $gray !important;
+ border-left-color: var(--gray) !important;
&:global(.fullColor) {
background-color: rgba(173, 173, 173, 0.5) !important;
}
&:global(.colorImpaired) {
- background: repeating-linear-gradient(45deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ background: repeating-linear-gradient(45deg, var(--colorImpairedGradientDark), var(--colorImpairedGradientDark) 5px, var(--colorImpairedGradient) 5px, var(--colorImpairedGradient) 10px);
}
&:global(.fullColor.colorImpaired) {
@@ -92,14 +109,14 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
}
.onAir {
- border-left-color: $warningColor !important;
+ border-left-color: var(--warningColor) !important;
&:global(.fullColor) {
background-color: rgba(255, 165, 0, 0.6) !important;
}
&:global(.colorImpaired) {
- background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ background: repeating-linear-gradient(90deg, var(--colorImpairedGradientDark), var(--colorImpairedGradientDark) 5px, var(--colorImpairedGradient) 5px, var(--colorImpairedGradient) 10px);
}
&:global(.fullColor.colorImpaired) {
@@ -108,15 +125,15 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
}
.missing {
- border-left-color: $dangerColor !important;
+ border-left-color: var(--dangerColor) !important;
&:global(.fullColor) {
background-color: rgba(240, 80, 80, 0.6) !important;
}
&:global(.colorImpaired) {
- border-left-color: color($dangerColor saturation(+15%)) !important;
- background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ border-left-color: color(#f05050 saturation(+15%)) !important;
+ background: repeating-linear-gradient(90deg, var(--colorImpairedGradientDark), var(--colorImpairedGradientDark) 5px, var(--colorImpairedGradient) 5px, var(--colorImpairedGradient) 10px);
}
&:global(.fullColor.colorImpaired) {
@@ -125,14 +142,14 @@ $fullColorGradient: rgba(244, 245, 246, 0.2);
}
.unaired {
- border-left-color: $primaryColor !important;
+ border-left-color: var(--primaryColor) !important;
&:global(.fullColor) {
background-color: rgba(93, 156, 236, 0.4) !important;
}
&:global(.colorImpaired) {
- background: repeating-linear-gradient(90deg, $colorImpairedGradientDark, $colorImpairedGradientDark 5px, $colorImpairedGradient 5px, $colorImpairedGradient 10px);
+ background: repeating-linear-gradient(90deg, var(--colorImpairedGradientDark), var(--colorImpairedGradientDark) 5px, var(--colorImpairedGradient) 5px, var(--colorImpairedGradient) 10px);
}
&:global(.fullColor.colorImpaired) {
diff --git a/frontend/src/Calendar/Events/CalendarEvent.css.d.ts b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts
new file mode 100644
index 000000000..f099df211
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEvent.css.d.ts
@@ -0,0 +1,23 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'absoluteEpisodeNumber': string;
+ 'airTime': string;
+ 'downloaded': string;
+ 'downloading': string;
+ 'episodeInfo': string;
+ 'episodeTitle': string;
+ 'event': string;
+ 'info': string;
+ 'missing': string;
+ 'onAir': string;
+ 'overlay': string;
+ 'seriesTitle': string;
+ 'statusContainer': string;
+ 'statusIcon': string;
+ 'unaired': string;
+ 'underlay': string;
+ 'unmonitored': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Events/CalendarEvent.js b/frontend/src/Calendar/Events/CalendarEvent.js
deleted file mode 100644
index b2c206841..000000000
--- a/frontend/src/Calendar/Events/CalendarEvent.js
+++ /dev/null
@@ -1,272 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component, Fragment } from 'react';
-import classNames from 'classnames';
-import { icons, kinds } from 'Helpers/Props';
-import formatTime from 'Utilities/Date/formatTime';
-import padNumber from 'Utilities/Number/padNumber';
-import getStatusStyle from 'Calendar/getStatusStyle';
-import episodeEntities from 'Episode/episodeEntities';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
-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,
- 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;
- const season = series.seasons.find((s) => s.seasonNumber === seasonNumber);
- const seasonStatistics = season?.statistics || {};
-
- return (
-
-
-
-
- {series.title}
-
-
-
- {
- missingAbsoluteNumber ?
- :
- null
- }
-
- {
- unverifiedSceneNumbering && !missingAbsoluteNumber ?
- :
- null
- }
-
- {
- queueItem ?
-
-
- :
- null
- }
-
- {
- !queueItem && grabbed ?
- :
- null
- }
-
- {
- showCutoffUnmetIcon &&
- !!episodeFile &&
- episodeFile.qualityCutoffNotMet ?
- :
- null
- }
-
- {
- showCutoffUnmetIcon &&
- !!episodeFile &&
- episodeFile.languageCutoffNotMet &&
- !episodeFile.qualityCutoffNotMet ?
- :
- null
- }
-
- {
- episodeNumber === 1 && seasonNumber > 0 ?
- :
- null
- }
-
- {
- showFinaleIcon &&
- episodeNumber !== 1 &&
- seasonNumber > 0 &&
- episodeNumber === seasonStatistics.totalEpisodeCount ?
- :
- 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,
- 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,
- hasFile: PropTypes.bool.isRequired,
- grabbed: PropTypes.bool,
- queueItem: PropTypes.object,
- showEpisodeInformation: PropTypes.bool.isRequired,
- showFinaleIcon: PropTypes.bool.isRequired,
- showSpecialIcon: PropTypes.bool.isRequired,
- showCutoffUnmetIcon: PropTypes.bool.isRequired,
- fullColorEvents: PropTypes.bool.isRequired,
- timeFormat: PropTypes.string.isRequired,
- colorImpairedMode: PropTypes.bool.isRequired,
- onEventModalOpenToggle: PropTypes.func.isRequired
-};
-
-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..079256a0e
--- /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 handlePress = useCallback(() => {
+ setIsDetailsModalOpen(true);
+ onEventModalOpenToggle(true);
+ }, [onEventModalOpenToggle]);
+
+ const handleDetailsModalClose = 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 f3b663dae..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 createSeriesSelector from 'Store/Selectors/createSeriesSelector';
-import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
-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.css b/frontend/src/Calendar/Events/CalendarEventGroup.css
index e5be0fd92..990d994ec 100644
--- a/frontend/src/Calendar/Events/CalendarEventGroup.css
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.css
@@ -2,8 +2,8 @@
overflow-x: hidden;
margin: 4px 2px;
padding: 5px;
- border-bottom: 1px solid $borderColor;
- border-left: 4px solid $borderColor;
+ border-bottom: 1px solid var(--borderColor);
+ border-left: 4px solid var(--borderColor);
font-size: 12px;
}
@@ -16,18 +16,18 @@
@add-mixin truncate;
flex: 1 0 1px;
margin-right: 10px;
- color: #3a3f51;
+ color: var(--calendarTextDimAlternate);
font-size: $defaultFontSize;
}
.airTime {
flex: 1 0 1px;
- color: $calendarTextDim;
+ color: var(--calendarTextDim);
}
.episodeInfo {
margin-left: 10px;
- color: $calendarTextDim;
+ color: var(--calendarTextDim);
}
.absoluteEpisodeNumber {
@@ -43,6 +43,7 @@
.expandContainer,
.collapseContainer {
display: flex;
+ align-items: center;
justify-content: center;
}
@@ -50,6 +51,15 @@
margin-bottom: 5px;
}
+.statusContainer {
+ display: flex;
+ align-items: center;
+
+ &:global(.fullColor) {
+ filter: var(--calendarFullColorFilter)
+ }
+}
+
.statusIcon {
margin-left: 3px;
}
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts b/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts
new file mode 100644
index 000000000..c527feff1
--- /dev/null
+++ b/frontend/src/Calendar/Events/CalendarEventGroup.css.d.ts
@@ -0,0 +1,25 @@
+// This file is automatically generated.
+// Please do not change this file!
+interface CssExports {
+ 'absoluteEpisodeNumber': string;
+ 'airTime': string;
+ 'airingInfo': string;
+ 'collapseContainer': string;
+ 'downloaded': string;
+ 'downloading': string;
+ 'episodeInfo': string;
+ 'eventGroup': string;
+ 'expandContainer': string;
+ 'expandContainerInline': string;
+ 'info': string;
+ 'missing': string;
+ 'onAir': string;
+ 'premiere': string;
+ 'seriesTitle': string;
+ 'statusContainer': string;
+ 'statusIcon': string;
+ 'unaired': string;
+ 'unmonitored': string;
+}
+export const cssExports: CssExports;
+export default cssExports;
diff --git a/frontend/src/Calendar/Events/CalendarEventGroup.js b/frontend/src/Calendar/Events/CalendarEventGroup.js
deleted file mode 100644
index 320ae8cc6..000000000
--- a/frontend/src/Calendar/Events/CalendarEventGroup.js
+++ /dev/null
@@ -1,249 +0,0 @@
-import moment from 'moment';
-import PropTypes from 'prop-types';
-import React, { Component } from 'react';
-import classNames from 'classnames';
-import formatTime from 'Utilities/Date/formatTime';
-import padNumber from 'Utilities/Number/padNumber';
-import { icons, kinds } from 'Helpers/Props';
-import Icon from 'Components/Icon';
-import Link from 'Components/Link/Link';
-import getStatusStyle from 'Calendar/getStatusStyle';
-import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
-import styles from './CalendarEventGroup.css';
-
-function getEventsInfo(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 (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(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.episodeNumber !== 1 &&
- seasonNumber > 0 &&
- lastEpisode.episodeNumber === series.seasons.find((season) => season.seasonNumber === seasonNumber).statistics.totalEpisodeCount &&
-
- }
-
-
-
-
- {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 &&
-
-
-
- }
-
- );
- }
-}
-
-CalendarEventGroup.propTypes = {
- series: PropTypes.object.isRequired,
- events: PropTypes.arrayOf(PropTypes.object).isRequired,
- isDownloading: PropTypes.bool.isRequired,
- showEpisodeInformation: PropTypes.bool.isRequired,
- showFinaleIcon: PropTypes.bool.isRequired,
- fullColorEvents: PropTypes.bool.isRequired,
- timeFormat: PropTypes.string.isRequired,
- colorImpairedMode: PropTypes.bool.isRequired,
- 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 dbd967784..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.episode && episodeIds.includes(item.episode.id);
- });
- }
- );
-}
-
-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 7440b9d67..000000000
--- a/frontend/src/Calendar/Events/CalendarEventQueueDetails.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import colors from 'Styles/Variables/colors';
-import CircularProgressBar from 'Components/CircularProgressBar';
-import QueueDetails from 'Activity/Queue/QueueDetails';
-
-function CalendarEventQueueDetails(props) {
- const {
- title,
- size,
- sizeleft,
- estimatedCompletionTime,
- status,
- trackedDownloadState,
- trackedDownloadStatus,
- 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,
- 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 (
+