mirror of
https://github.com/Sonarr/Sonarr.git
synced 2024-12-23 02:05:27 +02:00
Convert Calendar to TypeScript
This commit is contained in:
parent
1484809099
commit
811eb36c7b
@ -5,7 +5,7 @@ import History from 'Activity/History/History';
|
|||||||
import Queue from 'Activity/Queue/Queue';
|
import Queue from 'Activity/Queue/Queue';
|
||||||
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
|
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
|
||||||
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
|
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
|
||||||
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
|
import CalendarPage from 'Calendar/CalendarPage';
|
||||||
import NotFound from 'Components/NotFound';
|
import NotFound from 'Components/NotFound';
|
||||||
import Switch from 'Components/Router/Switch';
|
import Switch from 'Components/Router/Switch';
|
||||||
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
|
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
|
||||||
@ -72,7 +72,7 @@ function AppRoutes() {
|
|||||||
Calendar
|
Calendar
|
||||||
*/}
|
*/}
|
||||||
|
|
||||||
<Route path="/calendar" component={CalendarPageConnector} />
|
<Route path="/calendar" component={CalendarPage} />
|
||||||
|
|
||||||
{/*
|
{/*
|
||||||
Activity
|
Activity
|
||||||
|
@ -54,10 +54,12 @@ export interface CustomFilter {
|
|||||||
export interface AppSectionState {
|
export interface AppSectionState {
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
isReconnecting: boolean;
|
isReconnecting: boolean;
|
||||||
|
isSidebarVisible: boolean;
|
||||||
version: string;
|
version: string;
|
||||||
prevVersion?: string;
|
prevVersion?: string;
|
||||||
dimensions: {
|
dimensions: {
|
||||||
isSmallScreen: boolean;
|
isSmallScreen: boolean;
|
||||||
|
isLargeScreen: boolean;
|
||||||
width: number;
|
width: number;
|
||||||
height: number;
|
height: number;
|
||||||
};
|
};
|
||||||
|
@ -1,10 +1,29 @@
|
|||||||
|
import moment from 'moment';
|
||||||
import AppSectionState, {
|
import AppSectionState, {
|
||||||
AppSectionFilterState,
|
AppSectionFilterState,
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
import Episode from 'Episode/Episode';
|
import { CalendarView } from 'Calendar/calendarViews';
|
||||||
|
import { CalendarItem } from 'typings/Calendar';
|
||||||
|
|
||||||
|
interface CalendarOptions {
|
||||||
|
showEpisodeInformation: boolean;
|
||||||
|
showFinaleIcon: boolean;
|
||||||
|
showSpecialIcon: boolean;
|
||||||
|
showCutoffUnmetIcon: boolean;
|
||||||
|
collapseMultipleEpisodes: boolean;
|
||||||
|
fullColorEvents: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
interface CalendarAppState
|
interface CalendarAppState
|
||||||
extends AppSectionState<Episode>,
|
extends AppSectionState<CalendarItem>,
|
||||||
AppSectionFilterState<Episode> {}
|
AppSectionFilterState<CalendarItem> {
|
||||||
|
searchMissingCommandId: number | null;
|
||||||
|
start: moment.Moment;
|
||||||
|
end: moment.Moment;
|
||||||
|
dates: string[];
|
||||||
|
time: string;
|
||||||
|
view: CalendarView;
|
||||||
|
options: CalendarOptions;
|
||||||
|
}
|
||||||
|
|
||||||
export default CalendarAppState;
|
export default CalendarAppState;
|
||||||
|
@ -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 (
|
|
||||||
<div className={styles.agenda}>
|
|
||||||
{
|
|
||||||
items.map((item, index) => {
|
|
||||||
const momentDate = moment(item.airDateUtc);
|
|
||||||
const showDate = index === 0 ||
|
|
||||||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AgendaEventConnector
|
|
||||||
key={item.id}
|
|
||||||
episodeId={item.id}
|
|
||||||
showDate={showDate}
|
|
||||||
{...item}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Agenda.propTypes = {
|
|
||||||
items: PropTypes.arrayOf(PropTypes.object).isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Agenda;
|
|
25
frontend/src/Calendar/Agenda/Agenda.tsx
Normal file
25
frontend/src/Calendar/Agenda/Agenda.tsx
Normal file
@ -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 (
|
||||||
|
<div className={styles.agenda}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const momentDate = moment(item.airDateUtc);
|
||||||
|
const showDate =
|
||||||
|
index === 0 ||
|
||||||
|
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
|
||||||
|
|
||||||
|
return <AgendaEvent key={item.id} showDate={showDate} {...item} />;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Agenda;
|
@ -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);
|
|
@ -1,254 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import moment from 'moment';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
|
||||||
import episodeEntities from 'Episode/episodeEntities';
|
|
||||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import styles from './AgendaEvent.css';
|
|
||||||
|
|
||||||
class AgendaEvent extends Component {
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isDetailsModalOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onPress = () => {
|
|
||||||
this.setState({ isDetailsModalOpen: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
onDetailsModalClose = () => {
|
|
||||||
this.setState({ isDetailsModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
series,
|
|
||||||
episodeFile,
|
|
||||||
title,
|
|
||||||
seasonNumber,
|
|
||||||
episodeNumber,
|
|
||||||
absoluteEpisodeNumber,
|
|
||||||
airDateUtc,
|
|
||||||
monitored,
|
|
||||||
unverifiedSceneNumbering,
|
|
||||||
finaleType,
|
|
||||||
hasFile,
|
|
||||||
grabbed,
|
|
||||||
queueItem,
|
|
||||||
showDate,
|
|
||||||
showEpisodeInformation,
|
|
||||||
showFinaleIcon,
|
|
||||||
showSpecialIcon,
|
|
||||||
showCutoffUnmetIcon,
|
|
||||||
timeFormat,
|
|
||||||
longDateFormat,
|
|
||||||
colorImpairedMode
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const startTime = moment(airDateUtc);
|
|
||||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
|
||||||
const downloading = !!(queueItem || grabbed);
|
|
||||||
const isMonitored = series.monitored && monitored;
|
|
||||||
const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored);
|
|
||||||
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.event}>
|
|
||||||
<Link
|
|
||||||
className={styles.underlay}
|
|
||||||
onPress={this.onPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.overlay}>
|
|
||||||
<div className={styles.date}>
|
|
||||||
{
|
|
||||||
showDate &&
|
|
||||||
startTime.format(longDateFormat)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.eventWrapper,
|
|
||||||
styles[statusStyle],
|
|
||||||
colorImpairedMode && 'colorImpaired'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={styles.time}>
|
|
||||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.seriesTitle}>
|
|
||||||
{series.title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
showEpisodeInformation &&
|
|
||||||
<div className={styles.seasonEpisodeNumber}>
|
|
||||||
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
|
||||||
|
|
||||||
{
|
|
||||||
series.seriesType === 'anime' && absoluteEpisodeNumber &&
|
|
||||||
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div className={styles.episodeSeparator}> - </div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div className={styles.episodeTitle}>
|
|
||||||
{
|
|
||||||
showEpisodeInformation &&
|
|
||||||
title
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
missingAbsoluteNumber &&
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.WARNING}
|
|
||||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
unverifiedSceneNumbering && !missingAbsoluteNumber ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.WARNING}
|
|
||||||
title={translate('SceneNumberNotVerified')}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!!queueItem &&
|
|
||||||
<span className={styles.statusIcon}>
|
|
||||||
<CalendarEventQueueDetails
|
|
||||||
seriesType={series.seriesType}
|
|
||||||
seasonNumber={seasonNumber}
|
|
||||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
|
||||||
{...queueItem}
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!queueItem && grabbed &&
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.DOWNLOADING}
|
|
||||||
title={translate('EpisodeIsDownloading')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
showCutoffUnmetIcon &&
|
|
||||||
!!episodeFile &&
|
|
||||||
episodeFile.qualityCutoffNotMet &&
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.EPISODE_FILE}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
title={translate('QualityCutoffNotMet')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
episodeNumber === 1 && seasonNumber > 0 &&
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.INFO}
|
|
||||||
kind={kinds.INFO}
|
|
||||||
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
showFinaleIcon &&
|
|
||||||
finaleType ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.INFO}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
title={getFinaleTypeName(finaleType)}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
showSpecialIcon &&
|
|
||||||
(episodeNumber === 0 || seasonNumber === 0) &&
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.INFO}
|
|
||||||
kind={kinds.PINK}
|
|
||||||
title={translate('Special')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EpisodeDetailsModal
|
|
||||||
isOpen={this.state.isDetailsModalOpen}
|
|
||||||
episodeId={id}
|
|
||||||
episodeEntity={episodeEntities.CALENDAR}
|
|
||||||
seriesId={series.id}
|
|
||||||
episodeTitle={title}
|
|
||||||
showOpenSeriesButton={true}
|
|
||||||
onModalClose={this.onDetailsModalClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
AgendaEvent.propTypes = {
|
|
||||||
id: PropTypes.number.isRequired,
|
|
||||||
series: PropTypes.object.isRequired,
|
|
||||||
episodeFile: PropTypes.object,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
seasonNumber: PropTypes.number.isRequired,
|
|
||||||
episodeNumber: PropTypes.number.isRequired,
|
|
||||||
absoluteEpisodeNumber: PropTypes.number,
|
|
||||||
airDateUtc: PropTypes.string.isRequired,
|
|
||||||
monitored: PropTypes.bool.isRequired,
|
|
||||||
unverifiedSceneNumbering: PropTypes.bool,
|
|
||||||
finaleType: PropTypes.string,
|
|
||||||
hasFile: PropTypes.bool.isRequired,
|
|
||||||
grabbed: PropTypes.bool,
|
|
||||||
queueItem: PropTypes.object,
|
|
||||||
showDate: PropTypes.bool.isRequired,
|
|
||||||
showEpisodeInformation: PropTypes.bool.isRequired,
|
|
||||||
showFinaleIcon: PropTypes.bool.isRequired,
|
|
||||||
showSpecialIcon: PropTypes.bool.isRequired,
|
|
||||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
|
||||||
timeFormat: PropTypes.string.isRequired,
|
|
||||||
longDateFormat: PropTypes.string.isRequired,
|
|
||||||
colorImpairedMode: PropTypes.bool.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default AgendaEvent;
|
|
227
frontend/src/Calendar/Agenda/AgendaEvent.tsx
Normal file
227
frontend/src/Calendar/Agenda/AgendaEvent.tsx
Normal file
@ -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 (
|
||||||
|
<div className={styles.event}>
|
||||||
|
<Link className={styles.underlay} onPress={handlePress} />
|
||||||
|
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.date}>
|
||||||
|
{showDate && startTime.format(longDateFormat)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.eventWrapper,
|
||||||
|
styles[statusStyle],
|
||||||
|
enableColorImpairedMode && 'colorImpaired'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={styles.time}>
|
||||||
|
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||||
|
{formatTime(endTime.toISOString(), timeFormat, {
|
||||||
|
includeMinuteZero: true,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.seriesTitle}>{series.title}</div>
|
||||||
|
|
||||||
|
{showEpisodeInformation ? (
|
||||||
|
<div className={styles.seasonEpisodeNumber}>
|
||||||
|
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
||||||
|
{series.seriesType === 'anime' && absoluteEpisodeNumber && (
|
||||||
|
<span className={styles.absoluteEpisodeNumber}>
|
||||||
|
({absoluteEpisodeNumber})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<div className={styles.episodeSeparator}> - </div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.episodeTitle}>
|
||||||
|
{showEpisodeInformation ? title : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{missingAbsoluteNumber ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.WARNING}
|
||||||
|
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.WARNING}
|
||||||
|
title={translate('SceneNumberNotVerified')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{queueItem ? (
|
||||||
|
<span className={styles.statusIcon}>
|
||||||
|
<CalendarEventQueueDetails
|
||||||
|
seasonNumber={seasonNumber}
|
||||||
|
{...queueItem}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!queueItem && grabbed ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.DOWNLOADING}
|
||||||
|
title={translate('EpisodeIsDownloading')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showCutoffUnmetIcon &&
|
||||||
|
episodeFile &&
|
||||||
|
episodeFile.qualityCutoffNotMet ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.EPISODE_FILE}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
title={translate('QualityCutoffNotMet')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{episodeNumber === 1 && seasonNumber > 0 && (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
kind={kinds.INFO}
|
||||||
|
title={
|
||||||
|
seasonNumber === 1
|
||||||
|
? translate('SeriesPremiere')
|
||||||
|
: translate('SeasonPremiere')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showFinaleIcon && finaleType ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
title={getFinaleTypeName(finaleType)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
kind={kinds.PINK}
|
||||||
|
title={translate('Special')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EpisodeDetailsModal
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
episodeId={id}
|
||||||
|
episodeEntity={episodeEntities.CALENDAR}
|
||||||
|
seriesId={series.id}
|
||||||
|
episodeTitle={title}
|
||||||
|
showOpenSeriesButton={true}
|
||||||
|
onModalClose={handleDetailsModalClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default AgendaEvent;
|
@ -1,30 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
|
||||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
|
||||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
|
||||||
import AgendaEvent from './AgendaEvent';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.options,
|
|
||||||
createSeriesSelector(),
|
|
||||||
createEpisodeFileSelector(),
|
|
||||||
createQueueItemSelector(),
|
|
||||||
createUISettingsSelector(),
|
|
||||||
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
|
|
||||||
return {
|
|
||||||
series,
|
|
||||||
episodeFile,
|
|
||||||
queueItem,
|
|
||||||
...calendarOptions,
|
|
||||||
timeFormat: uiSettings.timeFormat,
|
|
||||||
longDateFormat: uiSettings.longDateFormat,
|
|
||||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(AgendaEvent);
|
|
@ -1,67 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Alert from 'Components/Alert';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import AgendaConnector from './Agenda/AgendaConnector';
|
|
||||||
import * as calendarViews from './calendarViews';
|
|
||||||
import CalendarDaysConnector from './Day/CalendarDaysConnector';
|
|
||||||
import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
|
|
||||||
import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
|
|
||||||
import styles from './Calendar.css';
|
|
||||||
|
|
||||||
class Calendar extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
isFetching,
|
|
||||||
isPopulated,
|
|
||||||
error,
|
|
||||||
view
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.calendar}>
|
|
||||||
{
|
|
||||||
isFetching && !isPopulated &&
|
|
||||||
<LoadingIndicator />
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!isFetching && !!error &&
|
|
||||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!error && isPopulated && view === calendarViews.AGENDA &&
|
|
||||||
<div className={styles.calendarContent}>
|
|
||||||
<CalendarHeaderConnector />
|
|
||||||
<AgendaConnector />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!error && isPopulated && view !== calendarViews.AGENDA &&
|
|
||||||
<div className={styles.calendarContent}>
|
|
||||||
<CalendarHeaderConnector />
|
|
||||||
<DaysOfWeekConnector />
|
|
||||||
<CalendarDaysConnector />
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Calendar.propTypes = {
|
|
||||||
isFetching: PropTypes.bool.isRequired,
|
|
||||||
isPopulated: PropTypes.bool.isRequired,
|
|
||||||
error: PropTypes.object,
|
|
||||||
view: PropTypes.string.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Calendar;
|
|
170
frontend/src/Calendar/Calendar.tsx
Normal file
170
frontend/src/Calendar/Calendar.tsx
Normal file
@ -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<ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
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<Episode, number>(items, 'id');
|
||||||
|
const episodeFileIds = selectUniqueIds<Episode, number>(
|
||||||
|
items,
|
||||||
|
'episodeFileId'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (items.length) {
|
||||||
|
dispatch(fetchQueueDetails({ episodeIds }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (episodeFileIds.length) {
|
||||||
|
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [items, previousItems, dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.calendar}>
|
||||||
|
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||||
|
|
||||||
|
{!isFetching && error ? (
|
||||||
|
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!error && isPopulated && view === 'agenda' ? (
|
||||||
|
<div className={styles.calendarContent}>
|
||||||
|
<CalendarHeader />
|
||||||
|
<Agenda />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!error && isPopulated && view !== 'agenda' ? (
|
||||||
|
<div className={styles.calendarContent}>
|
||||||
|
<CalendarHeader />
|
||||||
|
<DaysOfWeek />
|
||||||
|
<CalendarDays />
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Calendar;
|
@ -1,196 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import * as commandNames from 'Commands/commandNames';
|
|
||||||
import * as calendarActions from 'Store/Actions/calendarActions';
|
|
||||||
import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
|
||||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
|
||||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
|
||||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
|
||||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
|
||||||
import Calendar from './Calendar';
|
|
||||||
|
|
||||||
const UPDATE_DELAY = 3600000; // 1 hour
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar,
|
|
||||||
(state) => state.settings.ui.item.firstDayOfWeek,
|
|
||||||
createCommandExecutingSelector(commandNames.REFRESH_SERIES),
|
|
||||||
(calendar, firstDayOfWeek, isRefreshingSeries) => {
|
|
||||||
return {
|
|
||||||
...calendar,
|
|
||||||
isRefreshingSeries,
|
|
||||||
firstDayOfWeek
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
...calendarActions,
|
|
||||||
fetchEpisodeFiles,
|
|
||||||
clearEpisodeFiles,
|
|
||||||
fetchQueueDetails,
|
|
||||||
clearQueueDetails
|
|
||||||
};
|
|
||||||
|
|
||||||
class CalendarConnector extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.updateTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const {
|
|
||||||
useCurrentPage,
|
|
||||||
fetchCalendar,
|
|
||||||
gotoCalendarToday
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
registerPagePopulator(this.repopulate, ['episodeFileUpdated', 'episodeFileDeleted']);
|
|
||||||
|
|
||||||
if (useCurrentPage) {
|
|
||||||
fetchCalendar();
|
|
||||||
} else {
|
|
||||||
gotoCalendarToday();
|
|
||||||
}
|
|
||||||
|
|
||||||
this.scheduleUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const {
|
|
||||||
items,
|
|
||||||
time,
|
|
||||||
view,
|
|
||||||
isRefreshingSeries,
|
|
||||||
firstDayOfWeek
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (hasDifferentItems(prevProps.items, items)) {
|
|
||||||
const episodeIds = selectUniqueIds(items, 'id');
|
|
||||||
const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
|
|
||||||
|
|
||||||
if (items.length) {
|
|
||||||
this.props.fetchQueueDetails({ episodeIds });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (episodeFileIds.length) {
|
|
||||||
this.props.fetchEpisodeFiles({ episodeFileIds });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps.time !== time) {
|
|
||||||
this.scheduleUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
|
|
||||||
this.props.fetchCalendar({ time, view });
|
|
||||||
}
|
|
||||||
|
|
||||||
if (prevProps.isRefreshingSeries && !isRefreshingSeries) {
|
|
||||||
this.props.fetchCalendar({ time, view });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
unregisterPagePopulator(this.repopulate);
|
|
||||||
this.props.clearCalendar();
|
|
||||||
this.props.clearQueueDetails();
|
|
||||||
this.props.clearEpisodeFiles();
|
|
||||||
this.clearUpdateTimeout();
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
repopulate = () => {
|
|
||||||
const {
|
|
||||||
time,
|
|
||||||
view
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
this.props.fetchQueueDetails({ time, view });
|
|
||||||
this.props.fetchCalendar({ time, view });
|
|
||||||
};
|
|
||||||
|
|
||||||
scheduleUpdate = () => {
|
|
||||||
this.clearUpdateTimeout();
|
|
||||||
|
|
||||||
this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
|
|
||||||
};
|
|
||||||
|
|
||||||
clearUpdateTimeout = () => {
|
|
||||||
if (this.updateTimeoutId) {
|
|
||||||
clearTimeout(this.updateTimeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
updateCalendar = () => {
|
|
||||||
this.props.gotoCalendarToday();
|
|
||||||
this.scheduleUpdate();
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onCalendarViewChange = (view) => {
|
|
||||||
this.props.setCalendarView({ view });
|
|
||||||
};
|
|
||||||
|
|
||||||
onTodayPress = () => {
|
|
||||||
this.props.gotoCalendarToday();
|
|
||||||
};
|
|
||||||
|
|
||||||
onPreviousPress = () => {
|
|
||||||
this.props.gotoCalendarPreviousRange();
|
|
||||||
};
|
|
||||||
|
|
||||||
onNextPress = () => {
|
|
||||||
this.props.gotoCalendarNextRange();
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<Calendar
|
|
||||||
{...this.props}
|
|
||||||
onCalendarViewChange={this.onCalendarViewChange}
|
|
||||||
onTodayPress={this.onTodayPress}
|
|
||||||
onPreviousPress={this.onPreviousPress}
|
|
||||||
onNextPress={this.onNextPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
@ -1,197 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Measure from 'Components/Measure';
|
|
||||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
|
||||||
import PageContent from 'Components/Page/PageContent';
|
|
||||||
import PageContentBody from 'Components/Page/PageContentBody';
|
|
||||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
|
||||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
|
||||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
|
||||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
|
||||||
import { align, icons } from 'Helpers/Props';
|
|
||||||
import NoSeries from 'Series/NoSeries';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import CalendarConnector from './CalendarConnector';
|
|
||||||
import CalendarFilterModal from './CalendarFilterModal';
|
|
||||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
|
||||||
import LegendConnector from './Legend/LegendConnector';
|
|
||||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
|
||||||
import styles from './CalendarPage.css';
|
|
||||||
|
|
||||||
const MINIMUM_DAY_WIDTH = 120;
|
|
||||||
|
|
||||||
class CalendarPage extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isCalendarLinkModalOpen: false,
|
|
||||||
isOptionsModalOpen: false,
|
|
||||||
width: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onMeasure = ({ width }) => {
|
|
||||||
this.setState({ width });
|
|
||||||
const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
|
|
||||||
|
|
||||||
this.props.onDaysCountChange(days);
|
|
||||||
};
|
|
||||||
|
|
||||||
onGetCalendarLinkPress = () => {
|
|
||||||
this.setState({ isCalendarLinkModalOpen: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
onGetCalendarLinkModalClose = () => {
|
|
||||||
this.setState({ isCalendarLinkModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
onOptionsPress = () => {
|
|
||||||
this.setState({ isOptionsModalOpen: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
onOptionsModalClose = () => {
|
|
||||||
this.setState({ isOptionsModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
onSearchMissingPress = () => {
|
|
||||||
const {
|
|
||||||
missingEpisodeIds,
|
|
||||||
onSearchMissingPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
onSearchMissingPress(missingEpisodeIds);
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
selectedFilterKey,
|
|
||||||
filters,
|
|
||||||
customFilters,
|
|
||||||
hasSeries,
|
|
||||||
missingEpisodeIds,
|
|
||||||
isRssSyncExecuting,
|
|
||||||
isSearchingForMissing,
|
|
||||||
useCurrentPage,
|
|
||||||
onRssSyncPress,
|
|
||||||
onFilterSelect
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
isCalendarLinkModalOpen,
|
|
||||||
isOptionsModalOpen
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
const isMeasured = this.state.width > 0;
|
|
||||||
const PageComponent = hasSeries ? CalendarConnector : NoSeries;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<PageContent title={translate('Calendar')}>
|
|
||||||
<PageToolbar>
|
|
||||||
<PageToolbarSection>
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('ICalLink')}
|
|
||||||
iconName={icons.CALENDAR}
|
|
||||||
onPress={this.onGetCalendarLinkPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarSeparator />
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('RssSync')}
|
|
||||||
iconName={icons.RSS}
|
|
||||||
isSpinning={isRssSyncExecuting}
|
|
||||||
onPress={onRssSyncPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('SearchForMissing')}
|
|
||||||
iconName={icons.SEARCH}
|
|
||||||
isDisabled={!missingEpisodeIds.length}
|
|
||||||
isSpinning={isSearchingForMissing}
|
|
||||||
onPress={this.onSearchMissingPress}
|
|
||||||
/>
|
|
||||||
</PageToolbarSection>
|
|
||||||
|
|
||||||
<PageToolbarSection alignContent={align.RIGHT}>
|
|
||||||
<PageToolbarButton
|
|
||||||
label={translate('Options')}
|
|
||||||
iconName={icons.POSTER}
|
|
||||||
onPress={this.onOptionsPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<FilterMenu
|
|
||||||
alignMenu={align.RIGHT}
|
|
||||||
isDisabled={!hasSeries}
|
|
||||||
selectedFilterKey={selectedFilterKey}
|
|
||||||
filters={filters}
|
|
||||||
customFilters={customFilters}
|
|
||||||
filterModalConnectorComponent={CalendarFilterModal}
|
|
||||||
onFilterSelect={onFilterSelect}
|
|
||||||
/>
|
|
||||||
</PageToolbarSection>
|
|
||||||
</PageToolbar>
|
|
||||||
|
|
||||||
<PageContentBody
|
|
||||||
className={styles.calendarPageBody}
|
|
||||||
innerClassName={styles.calendarInnerPageBody}
|
|
||||||
>
|
|
||||||
<Measure
|
|
||||||
whitelist={['width']}
|
|
||||||
onMeasure={this.onMeasure}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
isMeasured ?
|
|
||||||
<PageComponent
|
|
||||||
useCurrentPage={useCurrentPage}
|
|
||||||
/> :
|
|
||||||
<div />
|
|
||||||
}
|
|
||||||
</Measure>
|
|
||||||
|
|
||||||
{
|
|
||||||
hasSeries &&
|
|
||||||
<LegendConnector />
|
|
||||||
}
|
|
||||||
</PageContentBody>
|
|
||||||
|
|
||||||
<CalendarLinkModal
|
|
||||||
isOpen={isCalendarLinkModalOpen}
|
|
||||||
onModalClose={this.onGetCalendarLinkModalClose}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CalendarOptionsModal
|
|
||||||
isOpen={isOptionsModalOpen}
|
|
||||||
onModalClose={this.onOptionsModalClose}
|
|
||||||
/>
|
|
||||||
</PageContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarPage.propTypes = {
|
|
||||||
selectedFilterKey: PropTypes.string.isRequired,
|
|
||||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
hasSeries: PropTypes.bool.isRequired,
|
|
||||||
missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
|
||||||
isRssSyncExecuting: PropTypes.bool.isRequired,
|
|
||||||
isSearchingForMissing: PropTypes.bool.isRequired,
|
|
||||||
useCurrentPage: PropTypes.bool.isRequired,
|
|
||||||
onSearchMissingPress: PropTypes.func.isRequired,
|
|
||||||
onDaysCountChange: PropTypes.func.isRequired,
|
|
||||||
onRssSyncPress: PropTypes.func.isRequired,
|
|
||||||
onFilterSelect: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarPage;
|
|
226
frontend/src/Calendar/CalendarPage.tsx
Normal file
226
frontend/src/Calendar/CalendarPage.tsx
Normal file
@ -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<number[]>((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 (
|
||||||
|
<PageContent title={translate('Calendar')}>
|
||||||
|
<PageToolbar>
|
||||||
|
<PageToolbarSection>
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('ICalLink')}
|
||||||
|
iconName={icons.CALENDAR}
|
||||||
|
onPress={handleGetCalendarLinkPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarSeparator />
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('RssSync')}
|
||||||
|
iconName={icons.RSS}
|
||||||
|
isSpinning={isRssSyncExecuting}
|
||||||
|
onPress={handleRssSyncPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('SearchForMissing')}
|
||||||
|
iconName={icons.SEARCH}
|
||||||
|
isDisabled={!missingEpisodeIds.length}
|
||||||
|
isSpinning={isSearchingForMissing}
|
||||||
|
onPress={handleSearchMissingPress}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
|
||||||
|
<PageToolbarSection alignContent={align.RIGHT}>
|
||||||
|
<PageToolbarButton
|
||||||
|
label={translate('Options')}
|
||||||
|
iconName={icons.POSTER}
|
||||||
|
onPress={handleOptionsPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FilterMenu
|
||||||
|
alignMenu={align.RIGHT}
|
||||||
|
isDisabled={!hasSeries}
|
||||||
|
selectedFilterKey={selectedFilterKey}
|
||||||
|
filters={filters}
|
||||||
|
customFilters={customFilters}
|
||||||
|
filterModalConnectorComponent={CalendarFilterModal}
|
||||||
|
onFilterSelect={handleFilterSelect}
|
||||||
|
/>
|
||||||
|
</PageToolbarSection>
|
||||||
|
</PageToolbar>
|
||||||
|
|
||||||
|
<PageContentBody
|
||||||
|
className={styles.calendarPageBody}
|
||||||
|
innerClassName={styles.calendarInnerPageBody}
|
||||||
|
>
|
||||||
|
<Measure whitelist={['width']} onMeasure={handleMeasure}>
|
||||||
|
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||||
|
</Measure>
|
||||||
|
|
||||||
|
{hasSeries && <Legend />}
|
||||||
|
</PageContentBody>
|
||||||
|
|
||||||
|
<CalendarLinkModal
|
||||||
|
isOpen={isCalendarLinkModalOpen}
|
||||||
|
onModalClose={handleGetCalendarLinkModalClose}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarOptionsModal
|
||||||
|
isOpen={isOptionsModalOpen}
|
||||||
|
onModalClose={handleOptionsModalClose}
|
||||||
|
/>
|
||||||
|
</PageContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarPage;
|
@ -1,117 +0,0 @@
|
|||||||
import moment from 'moment';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import * as commandNames from 'Commands/commandNames';
|
|
||||||
import withCurrentPage from 'Components/withCurrentPage';
|
|
||||||
import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
|
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
|
||||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
|
||||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
|
||||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
|
||||||
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
|
||||||
import { isCommandExecuting } from 'Utilities/Command';
|
|
||||||
import isBefore from 'Utilities/Date/isBefore';
|
|
||||||
import CalendarPage from './CalendarPage';
|
|
||||||
|
|
||||||
function createMissingEpisodeIdsSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.start,
|
|
||||||
(state) => state.calendar.end,
|
|
||||||
(state) => state.calendar.items,
|
|
||||||
(state) => state.queue.details.items,
|
|
||||||
(start, end, episodes, queueDetails) => {
|
|
||||||
return episodes.reduce((acc, episode) => {
|
|
||||||
const airDateUtc = episode.airDateUtc;
|
|
||||||
|
|
||||||
if (
|
|
||||||
!episode.episodeFileId &&
|
|
||||||
moment(airDateUtc).isAfter(start) &&
|
|
||||||
moment(airDateUtc).isBefore(end) &&
|
|
||||||
isBefore(episode.airDateUtc) &&
|
|
||||||
!queueDetails.some((details) => !!details.episode && details.episode.id === episode.id)
|
|
||||||
) {
|
|
||||||
acc.push(episode.id);
|
|
||||||
}
|
|
||||||
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createIsSearchingSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.searchMissingCommandId,
|
|
||||||
createCommandsSelector(),
|
|
||||||
(searchMissingCommandId, commands) => {
|
|
||||||
if (searchMissingCommandId == null) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return isCommandExecuting(commands.find((command) => {
|
|
||||||
return command.id === searchMissingCommandId;
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.selectedFilterKey,
|
|
||||||
(state) => state.calendar.filters,
|
|
||||||
createCustomFiltersSelector('calendar'),
|
|
||||||
createSeriesCountSelector(),
|
|
||||||
createUISettingsSelector(),
|
|
||||||
createMissingEpisodeIdsSelector(),
|
|
||||||
createCommandExecutingSelector(commandNames.RSS_SYNC),
|
|
||||||
createIsSearchingSelector(),
|
|
||||||
(
|
|
||||||
selectedFilterKey,
|
|
||||||
filters,
|
|
||||||
customFilters,
|
|
||||||
seriesCount,
|
|
||||||
uiSettings,
|
|
||||||
missingEpisodeIds,
|
|
||||||
isRssSyncExecuting,
|
|
||||||
isSearchingForMissing
|
|
||||||
) => {
|
|
||||||
return {
|
|
||||||
selectedFilterKey,
|
|
||||||
filters,
|
|
||||||
customFilters,
|
|
||||||
colorImpairedMode: uiSettings.enableColorImpairedMode,
|
|
||||||
hasSeries: !!seriesCount,
|
|
||||||
missingEpisodeIds,
|
|
||||||
isRssSyncExecuting,
|
|
||||||
isSearchingForMissing
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapDispatchToProps(dispatch, props) {
|
|
||||||
return {
|
|
||||||
onRssSyncPress() {
|
|
||||||
dispatch(executeCommand({
|
|
||||||
name: commandNames.RSS_SYNC
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
onSearchMissingPress(episodeIds) {
|
|
||||||
dispatch(searchMissing({ episodeIds }));
|
|
||||||
},
|
|
||||||
|
|
||||||
onDaysCountChange(dayCount) {
|
|
||||||
dispatch(setCalendarDaysCount({ dayCount }));
|
|
||||||
},
|
|
||||||
|
|
||||||
onFilterSelect(selectedFilterKey) {
|
|
||||||
dispatch(setCalendarFilter({ selectedFilterKey }));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default withCurrentPage(
|
|
||||||
connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
|
|
||||||
);
|
|
@ -1,25 +1,104 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import React from 'react';
|
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 * as calendarViews from 'Calendar/calendarViews';
|
||||||
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
|
import CalendarEvent from 'Calendar/Events/CalendarEvent';
|
||||||
import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
|
import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup';
|
||||||
import Series from 'Series/Series';
|
import {
|
||||||
import CalendarEventGroup, { CalendarEvent } from 'typings/CalendarEventGroup';
|
CalendarEvent as CalendarEventModel,
|
||||||
|
CalendarEventGroup as CalendarEventGroupModel,
|
||||||
|
CalendarItem,
|
||||||
|
} from 'typings/Calendar';
|
||||||
import styles from './CalendarDay.css';
|
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 {
|
interface CalendarDayProps {
|
||||||
date: string;
|
date: string;
|
||||||
time: string;
|
|
||||||
isTodaysDate: boolean;
|
isTodaysDate: boolean;
|
||||||
events: (CalendarEvent | CalendarEventGroup)[];
|
onEventModalOpenToggle(isOpen: boolean): unknown;
|
||||||
view: string;
|
|
||||||
onEventModalOpenToggle(...args: unknown[]): unknown;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function CalendarDay(props: CalendarDayProps) {
|
function CalendarDay({
|
||||||
const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } =
|
date,
|
||||||
props;
|
isTodaysDate,
|
||||||
|
onEventModalOpenToggle,
|
||||||
|
}: CalendarDayProps) {
|
||||||
|
const { time, view } = useSelector((state: AppState) => state.calendar);
|
||||||
|
const events = useSelector(createCalendarEventsConnector(date));
|
||||||
|
|
||||||
const ref = React.useRef<HTMLDivElement>(null);
|
const ref = React.useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
@ -53,7 +132,7 @@ function CalendarDay(props: CalendarDayProps) {
|
|||||||
{events.map((event) => {
|
{events.map((event) => {
|
||||||
if (event.isGroup) {
|
if (event.isGroup) {
|
||||||
return (
|
return (
|
||||||
<CalendarEventGroupConnector
|
<CalendarEventGroup
|
||||||
key={event.seriesId}
|
key={event.seriesId}
|
||||||
{...event}
|
{...event}
|
||||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||||
@ -62,11 +141,11 @@ function CalendarDay(props: CalendarDayProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CalendarEventConnector
|
<CalendarEvent
|
||||||
key={event.id}
|
key={event.id}
|
||||||
{...event}
|
{...event}
|
||||||
episodeId={event.id}
|
episodeId={event.id}
|
||||||
series={event.series as Series}
|
seriesId={event.seriesId}
|
||||||
airDateUtc={event.airDateUtc as string}
|
airDateUtc={event.airDateUtc as string}
|
||||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||||
/>
|
/>
|
||||||
|
@ -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 (
|
|
||||||
<CalendarDay
|
|
||||||
{...this.props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarDayConnector.propTypes = {
|
|
||||||
date: PropTypes.string.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(CalendarDayConnector);
|
|
@ -1,164 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import moment from 'moment';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
|
||||||
import isToday from 'Utilities/Date/isToday';
|
|
||||||
import CalendarDayConnector from './CalendarDayConnector';
|
|
||||||
import styles from './CalendarDays.css';
|
|
||||||
|
|
||||||
class CalendarDays extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this._touchStart = null;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
todaysDate: moment().startOf('day').toISOString(),
|
|
||||||
isEventModalOpen: false
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const view = this.props.view;
|
|
||||||
|
|
||||||
if (view === calendarViews.MONTH) {
|
|
||||||
this.scheduleUpdate();
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('touchstart', this.onTouchStart);
|
|
||||||
window.addEventListener('touchend', this.onTouchEnd);
|
|
||||||
window.addEventListener('touchcancel', this.onTouchCancel);
|
|
||||||
window.addEventListener('touchmove', this.onTouchMove);
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.clearUpdateTimeout();
|
|
||||||
|
|
||||||
window.removeEventListener('touchstart', this.onTouchStart);
|
|
||||||
window.removeEventListener('touchend', this.onTouchEnd);
|
|
||||||
window.removeEventListener('touchcancel', this.onTouchCancel);
|
|
||||||
window.removeEventListener('touchmove', this.onTouchMove);
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
scheduleUpdate = () => {
|
|
||||||
this.clearUpdateTimeout();
|
|
||||||
const todaysDate = moment().startOf('day');
|
|
||||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
|
||||||
|
|
||||||
this.setState({ todaysDate: todaysDate.toISOString() });
|
|
||||||
|
|
||||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
|
||||||
};
|
|
||||||
|
|
||||||
clearUpdateTimeout = () => {
|
|
||||||
if (this.updateTimeoutId) {
|
|
||||||
clearTimeout(this.updateTimeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onEventModalOpenToggle = (isEventModalOpen) => {
|
|
||||||
this.setState({ isEventModalOpen });
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchStart = (event) => {
|
|
||||||
const touches = event.touches;
|
|
||||||
const touchStart = touches[0].pageX;
|
|
||||||
|
|
||||||
if (touches.length !== 1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
touchStart < 50 ||
|
|
||||||
this.props.isSidebarVisible ||
|
|
||||||
this.state.isEventModalOpen
|
|
||||||
) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this._touchStart = touchStart;
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchEnd = (event) => {
|
|
||||||
const touches = event.changedTouches;
|
|
||||||
const currentTouch = touches[0].pageX;
|
|
||||||
|
|
||||||
if (!this._touchStart) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
|
|
||||||
this.props.onNavigatePrevious();
|
|
||||||
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
|
|
||||||
this.props.onNavigateNext();
|
|
||||||
}
|
|
||||||
|
|
||||||
this._touchStart = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchCancel = (event) => {
|
|
||||||
this._touchStart = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
onTouchMove = (event) => {
|
|
||||||
if (!this._touchStart) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
dates,
|
|
||||||
view
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames(
|
|
||||||
styles.days,
|
|
||||||
styles[view]
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
dates.map((date) => {
|
|
||||||
return (
|
|
||||||
<CalendarDayConnector
|
|
||||||
key={date}
|
|
||||||
date={date}
|
|
||||||
isTodaysDate={isToday(date)}
|
|
||||||
onEventModalOpenToggle={this.onEventModalOpenToggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
135
frontend/src/Calendar/Day/CalendarDays.tsx
Normal file
135
frontend/src/Calendar/Day/CalendarDays.tsx
Normal file
@ -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<ReturnType<typeof setTimeout>>();
|
||||||
|
const touchStart = useRef<number | null>(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 (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.days, styles[view as keyof typeof styles])}
|
||||||
|
>
|
||||||
|
{dates.map((date) => {
|
||||||
|
return (
|
||||||
|
<CalendarDay
|
||||||
|
key={date}
|
||||||
|
date={date}
|
||||||
|
isTodaysDate={date === todaysDate}
|
||||||
|
onEventModalOpenToggle={handleEventModalOpenToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarDays;
|
@ -1,25 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions';
|
|
||||||
import CalendarDays from './CalendarDays';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar,
|
|
||||||
(state) => state.app.isSidebarVisible,
|
|
||||||
(calendar, isSidebarVisible) => {
|
|
||||||
return {
|
|
||||||
dates: calendar.dates,
|
|
||||||
view: calendar.view,
|
|
||||||
isSidebarVisible
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
onNavigatePrevious: gotoCalendarPreviousRange,
|
|
||||||
onNavigateNext: gotoCalendarNextRange
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);
|
|
@ -1,56 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import moment from 'moment';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
|
||||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
|
||||||
import styles from './DayOfWeek.css';
|
|
||||||
|
|
||||||
class DayOfWeek extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
date,
|
|
||||||
view,
|
|
||||||
isTodaysDate,
|
|
||||||
calendarWeekColumnHeader,
|
|
||||||
shortDateFormat,
|
|
||||||
showRelativeDates
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
|
|
||||||
const momentDate = moment(date);
|
|
||||||
let formatedDate = momentDate.format('dddd');
|
|
||||||
|
|
||||||
if (view === calendarViews.WEEK) {
|
|
||||||
formatedDate = momentDate.format(calendarWeekColumnHeader);
|
|
||||||
} else if (view === calendarViews.FORECAST) {
|
|
||||||
formatedDate = getRelativeDate({ date, shortDateFormat, showRelativeDates });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={classNames(
|
|
||||||
styles.dayOfWeek,
|
|
||||||
view === calendarViews.DAY && styles.isSingleDay,
|
|
||||||
highlightToday && styles.isToday
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatedDate}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
54
frontend/src/Calendar/Day/DayOfWeek.tsx
Normal file
54
frontend/src/Calendar/Day/DayOfWeek.tsx
Normal file
@ -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 (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.dayOfWeek,
|
||||||
|
view === calendarViews.DAY && styles.isSingleDay,
|
||||||
|
highlightToday && styles.isToday
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formatedDate}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DayOfWeek;
|
@ -1,97 +0,0 @@
|
|||||||
import moment from 'moment';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
|
||||||
import DayOfWeek from './DayOfWeek';
|
|
||||||
import styles from './DaysOfWeek.css';
|
|
||||||
|
|
||||||
class DaysOfWeek extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
todaysDate: moment().startOf('day').toISOString()
|
|
||||||
};
|
|
||||||
|
|
||||||
this.updateTimeoutId = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
const view = this.props.view;
|
|
||||||
|
|
||||||
if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
|
|
||||||
this.scheduleUpdate();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this.clearUpdateTimeout();
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
scheduleUpdate = () => {
|
|
||||||
this.clearUpdateTimeout();
|
|
||||||
const todaysDate = moment().startOf('day');
|
|
||||||
const diff = todaysDate.clone().add(1, 'day').diff(moment());
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
todaysDate: todaysDate.toISOString()
|
|
||||||
});
|
|
||||||
|
|
||||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
|
||||||
};
|
|
||||||
|
|
||||||
clearUpdateTimeout = () => {
|
|
||||||
if (this.updateTimeoutId) {
|
|
||||||
clearTimeout(this.updateTimeoutId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
dates,
|
|
||||||
view,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (view === calendarViews.AGENDA) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={styles.daysOfWeek}>
|
|
||||||
{
|
|
||||||
dates.map((date) => {
|
|
||||||
return (
|
|
||||||
<DayOfWeek
|
|
||||||
key={date}
|
|
||||||
date={date}
|
|
||||||
view={view}
|
|
||||||
isTodaysDate={date === this.state.todaysDate}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
DaysOfWeek.propTypes = {
|
|
||||||
dates: PropTypes.arrayOf(PropTypes.string),
|
|
||||||
view: PropTypes.string.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default DaysOfWeek;
|
|
60
frontend/src/Calendar/Day/DaysOfWeek.tsx
Normal file
60
frontend/src/Calendar/Day/DaysOfWeek.tsx
Normal file
@ -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<ReturnType<typeof setTimeout>>();
|
||||||
|
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 (
|
||||||
|
<div className={styles.daysOfWeek}>
|
||||||
|
{dates.map((date) => {
|
||||||
|
return (
|
||||||
|
<DayOfWeek
|
||||||
|
key={date}
|
||||||
|
date={date}
|
||||||
|
view={view}
|
||||||
|
isTodaysDate={date === todaysDate}
|
||||||
|
calendarWeekColumnHeader={calendarWeekColumnHeader}
|
||||||
|
shortDateFormat={shortDateFormat}
|
||||||
|
showRelativeDates={showRelativeDates}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DaysOfWeek;
|
@ -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);
|
|
@ -1,267 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import moment from 'moment';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
|
||||||
import episodeEntities from 'Episode/episodeEntities';
|
|
||||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
|
|
||||||
import styles from './CalendarEvent.css';
|
|
||||||
|
|
||||||
class CalendarEvent extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isDetailsModalOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onPress = () => {
|
|
||||||
this.setState({ isDetailsModalOpen: true }, () => {
|
|
||||||
this.props.onEventModalOpenToggle(true);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onDetailsModalClose = () => {
|
|
||||||
this.setState({ isDetailsModalOpen: false }, () => {
|
|
||||||
this.props.onEventModalOpenToggle(false);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
id,
|
|
||||||
series,
|
|
||||||
episodeFile,
|
|
||||||
title,
|
|
||||||
seasonNumber,
|
|
||||||
episodeNumber,
|
|
||||||
absoluteEpisodeNumber,
|
|
||||||
airDateUtc,
|
|
||||||
monitored,
|
|
||||||
unverifiedSceneNumbering,
|
|
||||||
finaleType,
|
|
||||||
hasFile,
|
|
||||||
grabbed,
|
|
||||||
queueItem,
|
|
||||||
showEpisodeInformation,
|
|
||||||
showFinaleIcon,
|
|
||||||
showSpecialIcon,
|
|
||||||
showCutoffUnmetIcon,
|
|
||||||
fullColorEvents,
|
|
||||||
timeFormat,
|
|
||||||
colorImpairedMode
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (!series) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const startTime = moment(airDateUtc);
|
|
||||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
|
||||||
const isDownloading = !!(queueItem || grabbed);
|
|
||||||
const isMonitored = series.monitored && monitored;
|
|
||||||
const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored);
|
|
||||||
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.event,
|
|
||||||
styles[statusStyle],
|
|
||||||
colorImpairedMode && 'colorImpaired',
|
|
||||||
fullColorEvents && 'fullColor'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
className={styles.underlay}
|
|
||||||
onPress={this.onPress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className={styles.overlay} >
|
|
||||||
<div className={styles.info}>
|
|
||||||
<div className={styles.seriesTitle}>
|
|
||||||
{series.title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.statusContainer,
|
|
||||||
fullColorEvents && 'fullColor'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
missingAbsoluteNumber ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.WARNING}
|
|
||||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
unverifiedSceneNumbering && !missingAbsoluteNumber ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.WARNING}
|
|
||||||
title={translate('SceneNumberNotVerified')}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
queueItem ?
|
|
||||||
<span className={styles.statusIcon}>
|
|
||||||
<CalendarEventQueueDetails
|
|
||||||
{...queueItem}
|
|
||||||
fullColorEvents={fullColorEvents}
|
|
||||||
/>
|
|
||||||
</span> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
!queueItem && grabbed ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.DOWNLOADING}
|
|
||||||
title={translate('EpisodeIsDownloading')}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
showCutoffUnmetIcon &&
|
|
||||||
!!episodeFile &&
|
|
||||||
episodeFile.qualityCutoffNotMet ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.EPISODE_FILE}
|
|
||||||
kind={kinds.WARNING}
|
|
||||||
title={translate('QualityCutoffNotMet')}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
episodeNumber === 1 && seasonNumber > 0 ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.PREMIERE}
|
|
||||||
kind={kinds.INFO}
|
|
||||||
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
showFinaleIcon &&
|
|
||||||
finaleType ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
|
|
||||||
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
|
|
||||||
title={getFinaleTypeName(finaleType)}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
showSpecialIcon &&
|
|
||||||
(episodeNumber === 0 || seasonNumber === 0) ?
|
|
||||||
<Icon
|
|
||||||
className={styles.statusIcon}
|
|
||||||
name={icons.INFO}
|
|
||||||
kind={kinds.PINK}
|
|
||||||
title={translate('Special')}
|
|
||||||
/> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
showEpisodeInformation ?
|
|
||||||
<div className={styles.episodeInfo}>
|
|
||||||
<div className={styles.episodeTitle}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
|
||||||
|
|
||||||
{
|
|
||||||
series.seriesType === 'anime' && absoluteEpisodeNumber ?
|
|
||||||
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span> : null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
<div className={styles.airTime}>
|
|
||||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<EpisodeDetailsModal
|
|
||||||
isOpen={this.state.isDetailsModalOpen}
|
|
||||||
episodeId={id}
|
|
||||||
episodeEntity={episodeEntities.CALENDAR}
|
|
||||||
seriesId={series.id}
|
|
||||||
episodeTitle={title}
|
|
||||||
showOpenSeriesButton={true}
|
|
||||||
onModalClose={this.onDetailsModalClose}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarEvent.propTypes = {
|
|
||||||
id: PropTypes.number.isRequired,
|
|
||||||
episodeId: PropTypes.number.isRequired,
|
|
||||||
series: PropTypes.object.isRequired,
|
|
||||||
episodeFile: PropTypes.object,
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
seasonNumber: PropTypes.number.isRequired,
|
|
||||||
episodeNumber: PropTypes.number.isRequired,
|
|
||||||
absoluteEpisodeNumber: PropTypes.number,
|
|
||||||
airDateUtc: PropTypes.string.isRequired,
|
|
||||||
monitored: PropTypes.bool.isRequired,
|
|
||||||
unverifiedSceneNumbering: PropTypes.bool,
|
|
||||||
finaleType: PropTypes.string,
|
|
||||||
hasFile: PropTypes.bool.isRequired,
|
|
||||||
grabbed: PropTypes.bool,
|
|
||||||
queueItem: PropTypes.object,
|
|
||||||
// These props come from the connector, not marked as required to appease TS for now.
|
|
||||||
showEpisodeInformation: PropTypes.bool,
|
|
||||||
showFinaleIcon: PropTypes.bool,
|
|
||||||
showSpecialIcon: PropTypes.bool,
|
|
||||||
showCutoffUnmetIcon: PropTypes.bool,
|
|
||||||
fullColorEvents: PropTypes.bool,
|
|
||||||
timeFormat: PropTypes.string,
|
|
||||||
colorImpairedMode: PropTypes.bool,
|
|
||||||
onEventModalOpenToggle: PropTypes.func
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarEvent;
|
|
240
frontend/src/Calendar/Events/CalendarEvent.tsx
Normal file
240
frontend/src/Calendar/Events/CalendarEvent.tsx
Normal file
@ -0,0 +1,240 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import moment from 'moment';
|
||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Link from 'Components/Link/Link';
|
||||||
|
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||||
|
import episodeEntities from 'Episode/episodeEntities';
|
||||||
|
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||||
|
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||||
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import useSeries from 'Series/useSeries';
|
||||||
|
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||||
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
|
import formatTime from 'Utilities/Date/formatTime';
|
||||||
|
import padNumber from 'Utilities/Number/padNumber';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
|
||||||
|
import styles from './CalendarEvent.css';
|
||||||
|
|
||||||
|
interface CalendarEventProps {
|
||||||
|
id: number;
|
||||||
|
episodeId: number;
|
||||||
|
seriesId: number;
|
||||||
|
episodeFileId?: number;
|
||||||
|
title: string;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeNumber: number;
|
||||||
|
absoluteEpisodeNumber?: number;
|
||||||
|
airDateUtc: string;
|
||||||
|
monitored: boolean;
|
||||||
|
unverifiedSceneNumbering?: boolean;
|
||||||
|
finaleType?: string;
|
||||||
|
hasFile: boolean;
|
||||||
|
grabbed?: boolean;
|
||||||
|
onEventModalOpenToggle: (isOpen: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarEvent(props: CalendarEventProps) {
|
||||||
|
const {
|
||||||
|
id,
|
||||||
|
seriesId,
|
||||||
|
episodeFileId,
|
||||||
|
title,
|
||||||
|
seasonNumber,
|
||||||
|
episodeNumber,
|
||||||
|
absoluteEpisodeNumber,
|
||||||
|
airDateUtc,
|
||||||
|
monitored,
|
||||||
|
unverifiedSceneNumbering,
|
||||||
|
finaleType,
|
||||||
|
hasFile,
|
||||||
|
grabbed,
|
||||||
|
onEventModalOpenToggle,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const series = useSeries(seriesId);
|
||||||
|
const episodeFile = useEpisodeFile(episodeFileId);
|
||||||
|
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||||
|
|
||||||
|
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||||
|
createUISettingsSelector()
|
||||||
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
showEpisodeInformation,
|
||||||
|
showFinaleIcon,
|
||||||
|
showSpecialIcon,
|
||||||
|
showCutoffUnmetIcon,
|
||||||
|
fullColorEvents,
|
||||||
|
} = useSelector((state: AppState) => state.calendar.options);
|
||||||
|
|
||||||
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleDetailsModalClose = useCallback(() => {
|
||||||
|
setIsDetailsModalOpen(true);
|
||||||
|
onEventModalOpenToggle(true);
|
||||||
|
}, [onEventModalOpenToggle]);
|
||||||
|
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
setIsDetailsModalOpen(false);
|
||||||
|
onEventModalOpenToggle(false);
|
||||||
|
}, [onEventModalOpenToggle]);
|
||||||
|
|
||||||
|
if (!series) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const startTime = moment(airDateUtc);
|
||||||
|
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
||||||
|
const isDownloading = !!(queueItem || grabbed);
|
||||||
|
const isMonitored = series.monitored && monitored;
|
||||||
|
const statusStyle = getStatusStyle(
|
||||||
|
hasFile,
|
||||||
|
isDownloading,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
isMonitored
|
||||||
|
);
|
||||||
|
const missingAbsoluteNumber =
|
||||||
|
series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.event,
|
||||||
|
styles[statusStyle],
|
||||||
|
enableColorImpairedMode && 'colorImpaired',
|
||||||
|
fullColorEvents && 'fullColor'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Link className={styles.underlay} onPress={handlePress} />
|
||||||
|
|
||||||
|
<div className={styles.overlay}>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.seriesTitle}>{series.title}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.statusContainer,
|
||||||
|
fullColorEvents && 'fullColor'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{missingAbsoluteNumber ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.WARNING}
|
||||||
|
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.WARNING}
|
||||||
|
title={translate('SceneNumberNotVerified')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{queueItem ? (
|
||||||
|
<span className={styles.statusIcon}>
|
||||||
|
<CalendarEventQueueDetails {...queueItem} />
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!queueItem && grabbed ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.DOWNLOADING}
|
||||||
|
title={translate('EpisodeIsDownloading')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showCutoffUnmetIcon &&
|
||||||
|
!!episodeFile &&
|
||||||
|
episodeFile.qualityCutoffNotMet ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.EPISODE_FILE}
|
||||||
|
kind={kinds.WARNING}
|
||||||
|
title={translate('QualityCutoffNotMet')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{episodeNumber === 1 && seasonNumber > 0 ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.PREMIERE}
|
||||||
|
kind={kinds.INFO}
|
||||||
|
title={
|
||||||
|
seasonNumber === 1
|
||||||
|
? translate('SeriesPremiere')
|
||||||
|
: translate('SeasonPremiere')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showFinaleIcon && finaleType ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={
|
||||||
|
finaleType === 'series'
|
||||||
|
? icons.FINALE_SERIES
|
||||||
|
: icons.FINALE_SEASON
|
||||||
|
}
|
||||||
|
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
|
||||||
|
title={getFinaleTypeName(finaleType)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
|
||||||
|
<Icon
|
||||||
|
className={styles.statusIcon}
|
||||||
|
name={icons.INFO}
|
||||||
|
kind={kinds.PINK}
|
||||||
|
title={translate('Special')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEpisodeInformation ? (
|
||||||
|
<div className={styles.episodeInfo}>
|
||||||
|
<div className={styles.episodeTitle}>{title}</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
||||||
|
{series.seriesType === 'anime' && absoluteEpisodeNumber ? (
|
||||||
|
<span className={styles.absoluteEpisodeNumber}>
|
||||||
|
({absoluteEpisodeNumber})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className={styles.airTime}>
|
||||||
|
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||||
|
{formatTime(endTime.toISOString(), timeFormat, {
|
||||||
|
includeMinuteZero: true,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EpisodeDetailsModal
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
episodeId={id}
|
||||||
|
episodeEntity={episodeEntities.CALENDAR}
|
||||||
|
seriesId={series.id}
|
||||||
|
episodeTitle={title}
|
||||||
|
showOpenSeriesButton={true}
|
||||||
|
onModalClose={handleDetailsModalClose}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarEvent;
|
@ -1,29 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
|
||||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
|
||||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
|
||||||
import CalendarEvent from './CalendarEvent';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.options,
|
|
||||||
createSeriesSelector(),
|
|
||||||
createEpisodeFileSelector(),
|
|
||||||
createQueueItemSelector(),
|
|
||||||
createUISettingsSelector(),
|
|
||||||
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
|
|
||||||
return {
|
|
||||||
series,
|
|
||||||
episodeFile,
|
|
||||||
queueItem,
|
|
||||||
...calendarOptions,
|
|
||||||
timeFormat: uiSettings.timeFormat,
|
|
||||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(CalendarEvent);
|
|
@ -1,259 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import moment from 'moment';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
|
|
||||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Link from 'Components/Link/Link';
|
|
||||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
|
||||||
import formatTime from 'Utilities/Date/formatTime';
|
|
||||||
import padNumber from 'Utilities/Number/padNumber';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import styles from './CalendarEventGroup.css';
|
|
||||||
|
|
||||||
function getEventsInfo(series, events) {
|
|
||||||
let files = 0;
|
|
||||||
let queued = 0;
|
|
||||||
let monitored = 0;
|
|
||||||
let absoluteEpisodeNumbers = 0;
|
|
||||||
|
|
||||||
events.forEach((event) => {
|
|
||||||
if (event.episodeFileId) {
|
|
||||||
files++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.queued) {
|
|
||||||
queued++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (series.monitored && event.monitored) {
|
|
||||||
monitored++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (event.absoluteEpisodeNumber) {
|
|
||||||
absoluteEpisodeNumbers++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
allDownloaded: files === events.length,
|
|
||||||
anyQueued: queued > 0,
|
|
||||||
anyMonitored: monitored > 0,
|
|
||||||
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class CalendarEventGroup extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isExpanded: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onExpandPress = () => {
|
|
||||||
this.setState({ isExpanded: !this.state.isExpanded });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
series,
|
|
||||||
events,
|
|
||||||
isDownloading,
|
|
||||||
showEpisodeInformation,
|
|
||||||
showFinaleIcon,
|
|
||||||
timeFormat,
|
|
||||||
fullColorEvents,
|
|
||||||
colorImpairedMode,
|
|
||||||
onEventModalOpenToggle
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const { isExpanded } = this.state;
|
|
||||||
const {
|
|
||||||
allDownloaded,
|
|
||||||
anyQueued,
|
|
||||||
anyMonitored,
|
|
||||||
allAbsoluteEpisodeNumbers
|
|
||||||
} = getEventsInfo(series, events);
|
|
||||||
const anyDownloading = isDownloading || anyQueued;
|
|
||||||
const firstEpisode = events[0];
|
|
||||||
const lastEpisode = events[events.length -1];
|
|
||||||
const airDateUtc = firstEpisode.airDateUtc;
|
|
||||||
const startTime = moment(airDateUtc);
|
|
||||||
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
|
|
||||||
const seasonNumber = firstEpisode.seasonNumber;
|
|
||||||
const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored);
|
|
||||||
const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers;
|
|
||||||
|
|
||||||
if (isExpanded) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
events.map((event) => {
|
|
||||||
if (event.isGroup) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CalendarEventConnector
|
|
||||||
key={event.id}
|
|
||||||
episodeId={event.id}
|
|
||||||
{...event}
|
|
||||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
<Link
|
|
||||||
className={styles.collapseContainer}
|
|
||||||
component="div"
|
|
||||||
onPress={this.onExpandPress}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name={icons.COLLAPSE}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.eventGroup,
|
|
||||||
styles[statusStyle],
|
|
||||||
colorImpairedMode && 'colorImpaired',
|
|
||||||
fullColorEvents && 'fullColor'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className={styles.info}>
|
|
||||||
<div className={styles.seriesTitle}>
|
|
||||||
{series.title}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
styles.statusContainer,
|
|
||||||
fullColorEvents && 'fullColor'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
isMissingAbsoluteNumber &&
|
|
||||||
<Icon
|
|
||||||
containerClassName={styles.statusIcon}
|
|
||||||
name={icons.WARNING}
|
|
||||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
anyDownloading &&
|
|
||||||
<Icon
|
|
||||||
containerClassName={styles.statusIcon}
|
|
||||||
name={icons.DOWNLOADING}
|
|
||||||
title={translate('AnEpisodeIsDownloading')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
|
|
||||||
<Icon
|
|
||||||
containerClassName={styles.statusIcon}
|
|
||||||
name={icons.PREMIERE}
|
|
||||||
kind={kinds.INFO}
|
|
||||||
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
showFinaleIcon &&
|
|
||||||
lastEpisode.finaleType ?
|
|
||||||
<Icon
|
|
||||||
containerClassName={styles.statusIcon}
|
|
||||||
name={lastEpisode.finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
|
|
||||||
kind={lastEpisode.finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
|
|
||||||
title={getFinaleTypeName(lastEpisode.finaleType)}
|
|
||||||
/> : null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.airingInfo}>
|
|
||||||
<div className={styles.airTime}>
|
|
||||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
showEpisodeInformation ?
|
|
||||||
<div className={styles.episodeInfo}>
|
|
||||||
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)}
|
|
||||||
|
|
||||||
{
|
|
||||||
series.seriesType === 'anime' &&
|
|
||||||
firstEpisode.absoluteEpisodeNumber &&
|
|
||||||
lastEpisode.absoluteEpisodeNumber &&
|
|
||||||
<span className={styles.absoluteEpisodeNumber}>
|
|
||||||
({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber})
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
</div> :
|
|
||||||
<Link
|
|
||||||
className={styles.expandContainerInline}
|
|
||||||
component="div"
|
|
||||||
onPress={this.onExpandPress}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
name={icons.EXPAND}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
showEpisodeInformation ?
|
|
||||||
<Link
|
|
||||||
className={styles.expandContainer}
|
|
||||||
component="div"
|
|
||||||
onPress={this.onExpandPress}
|
|
||||||
>
|
|
||||||
|
|
||||||
<Icon
|
|
||||||
name={icons.EXPAND}
|
|
||||||
/>
|
|
||||||
|
|
||||||
</Link> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarEventGroup.propTypes = {
|
|
||||||
// Most of these props come from the connector and are required, but TS is confused.
|
|
||||||
series: PropTypes.object,
|
|
||||||
events: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
isDownloading: PropTypes.bool,
|
|
||||||
showEpisodeInformation: PropTypes.bool,
|
|
||||||
showFinaleIcon: PropTypes.bool,
|
|
||||||
fullColorEvents: PropTypes.bool,
|
|
||||||
timeFormat: PropTypes.string,
|
|
||||||
colorImpairedMode: PropTypes.bool,
|
|
||||||
onEventModalOpenToggle: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarEventGroup;
|
|
253
frontend/src/Calendar/Events/CalendarEventGroup.tsx
Normal file
253
frontend/src/Calendar/Events/CalendarEventGroup.tsx
Normal file
@ -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 (
|
||||||
|
<div>
|
||||||
|
{events.map((event) => {
|
||||||
|
return (
|
||||||
|
<CalendarEvent
|
||||||
|
key={event.id}
|
||||||
|
episodeId={event.id}
|
||||||
|
{...event}
|
||||||
|
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
<Link
|
||||||
|
className={styles.collapseContainer}
|
||||||
|
component="div"
|
||||||
|
onPress={handleExpandPress}
|
||||||
|
>
|
||||||
|
<Icon name={icons.COLLAPSE} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.eventGroup,
|
||||||
|
styles[statusStyle],
|
||||||
|
enableColorImpairedMode && 'colorImpaired',
|
||||||
|
fullColorEvents && 'fullColor'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={styles.info}>
|
||||||
|
<div className={styles.seriesTitle}>{series.title}</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
styles.statusContainer,
|
||||||
|
fullColorEvents && 'fullColor'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isMissingAbsoluteNumber ? (
|
||||||
|
<Icon
|
||||||
|
containerClassName={styles.statusIcon}
|
||||||
|
name={icons.WARNING}
|
||||||
|
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{anyDownloading ? (
|
||||||
|
<Icon
|
||||||
|
containerClassName={styles.statusIcon}
|
||||||
|
name={icons.DOWNLOADING}
|
||||||
|
title={translate('AnEpisodeIsDownloading')}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? (
|
||||||
|
<Icon
|
||||||
|
containerClassName={styles.statusIcon}
|
||||||
|
name={icons.PREMIERE}
|
||||||
|
kind={kinds.INFO}
|
||||||
|
title={
|
||||||
|
seasonNumber === 1
|
||||||
|
? translate('SeriesPremiere')
|
||||||
|
: translate('SeasonPremiere')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showFinaleIcon && lastEpisode.finaleType ? (
|
||||||
|
<Icon
|
||||||
|
containerClassName={styles.statusIcon}
|
||||||
|
name={
|
||||||
|
lastEpisode.finaleType === 'series'
|
||||||
|
? icons.FINALE_SERIES
|
||||||
|
: icons.FINALE_SEASON
|
||||||
|
}
|
||||||
|
kind={
|
||||||
|
lastEpisode.finaleType === 'series'
|
||||||
|
? kinds.DANGER
|
||||||
|
: kinds.WARNING
|
||||||
|
}
|
||||||
|
title={getFinaleTypeName(lastEpisode.finaleType)}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={styles.airingInfo}>
|
||||||
|
<div className={styles.airTime}>
|
||||||
|
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||||
|
{formatTime(endTime.toISOString(), timeFormat, {
|
||||||
|
includeMinuteZero: true,
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEpisodeInformation ? (
|
||||||
|
<div className={styles.episodeInfo}>
|
||||||
|
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-
|
||||||
|
{padNumber(lastEpisode.episodeNumber, 2)}
|
||||||
|
{series.seriesType === 'anime' &&
|
||||||
|
firstEpisode.absoluteEpisodeNumber &&
|
||||||
|
lastEpisode.absoluteEpisodeNumber ? (
|
||||||
|
<span className={styles.absoluteEpisodeNumber}>
|
||||||
|
({firstEpisode.absoluteEpisodeNumber}-
|
||||||
|
{lastEpisode.absoluteEpisodeNumber})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
className={styles.expandContainerInline}
|
||||||
|
component="div"
|
||||||
|
onPress={handleExpandPress}
|
||||||
|
>
|
||||||
|
<Icon name={icons.EXPAND} />
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showEpisodeInformation ? (
|
||||||
|
<Link
|
||||||
|
className={styles.expandContainer}
|
||||||
|
component="div"
|
||||||
|
onPress={handleExpandPress}
|
||||||
|
>
|
||||||
|
|
||||||
|
<Icon name={icons.EXPAND} />
|
||||||
|
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarEventGroup;
|
@ -1,37 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
|
||||||
import CalendarEventGroup from './CalendarEventGroup';
|
|
||||||
|
|
||||||
function createIsDownloadingSelector() {
|
|
||||||
return createSelector(
|
|
||||||
(state, { episodeIds }) => episodeIds,
|
|
||||||
(state) => state.queue.details,
|
|
||||||
(episodeIds, details) => {
|
|
||||||
return details.items.some((item) => {
|
|
||||||
return !!(item.episodeId && episodeIds.includes(item.episodeId));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.options,
|
|
||||||
createSeriesSelector(),
|
|
||||||
createIsDownloadingSelector(),
|
|
||||||
createUISettingsSelector(),
|
|
||||||
(calendarOptions, series, isDownloading, uiSettings) => {
|
|
||||||
return {
|
|
||||||
series,
|
|
||||||
isDownloading,
|
|
||||||
...calendarOptions,
|
|
||||||
timeFormat: uiSettings.timeFormat,
|
|
||||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(CalendarEventGroup);
|
|
@ -1,56 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
|
||||||
import CircularProgressBar from 'Components/CircularProgressBar';
|
|
||||||
|
|
||||||
function CalendarEventQueueDetails(props) {
|
|
||||||
const {
|
|
||||||
title,
|
|
||||||
size,
|
|
||||||
sizeleft,
|
|
||||||
estimatedCompletionTime,
|
|
||||||
status,
|
|
||||||
trackedDownloadState,
|
|
||||||
trackedDownloadStatus,
|
|
||||||
statusMessages,
|
|
||||||
errorMessage
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const progress = size ? (100 - sizeleft / size * 100) : 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<QueueDetails
|
|
||||||
title={title}
|
|
||||||
size={size}
|
|
||||||
sizeleft={sizeleft}
|
|
||||||
estimatedCompletionTime={estimatedCompletionTime}
|
|
||||||
status={status}
|
|
||||||
trackedDownloadState={trackedDownloadState}
|
|
||||||
trackedDownloadStatus={trackedDownloadStatus}
|
|
||||||
statusMessages={statusMessages}
|
|
||||||
errorMessage={errorMessage}
|
|
||||||
progressBar={
|
|
||||||
<CircularProgressBar
|
|
||||||
progress={progress}
|
|
||||||
size={20}
|
|
||||||
strokeWidth={2}
|
|
||||||
strokeColor={'#7a43b6'}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarEventQueueDetails.propTypes = {
|
|
||||||
title: PropTypes.string.isRequired,
|
|
||||||
size: PropTypes.number.isRequired,
|
|
||||||
sizeleft: PropTypes.number.isRequired,
|
|
||||||
estimatedCompletionTime: PropTypes.string,
|
|
||||||
status: PropTypes.string.isRequired,
|
|
||||||
trackedDownloadState: PropTypes.string.isRequired,
|
|
||||||
trackedDownloadStatus: PropTypes.string.isRequired,
|
|
||||||
statusMessages: PropTypes.arrayOf(PropTypes.object),
|
|
||||||
errorMessage: PropTypes.string
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarEventQueueDetails;
|
|
58
frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
Normal file
58
frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
Normal file
@ -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 (
|
||||||
|
<QueueDetails
|
||||||
|
title={title}
|
||||||
|
size={size}
|
||||||
|
sizeleft={sizeleft}
|
||||||
|
estimatedCompletionTime={estimatedCompletionTime}
|
||||||
|
status={status}
|
||||||
|
trackedDownloadState={trackedDownloadState}
|
||||||
|
trackedDownloadStatus={trackedDownloadStatus}
|
||||||
|
statusMessages={statusMessages}
|
||||||
|
errorMessage={errorMessage}
|
||||||
|
progressBar={
|
||||||
|
<CircularProgressBar
|
||||||
|
progress={progress}
|
||||||
|
size={20}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeColor="#7a43b6"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarEventQueueDetails;
|
@ -1,268 +0,0 @@
|
|||||||
import moment from 'moment';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
|
||||||
import Menu from 'Components/Menu/Menu';
|
|
||||||
import MenuButton from 'Components/Menu/MenuButton';
|
|
||||||
import MenuContent from 'Components/Menu/MenuContent';
|
|
||||||
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
|
||||||
import { align, icons } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
|
||||||
import styles from './CalendarHeader.css';
|
|
||||||
|
|
||||||
function getTitle(time, start, end, view, longDateFormat) {
|
|
||||||
const timeMoment = moment(time);
|
|
||||||
const startMoment = moment(start);
|
|
||||||
const endMoment = moment(end);
|
|
||||||
|
|
||||||
if (view === 'day') {
|
|
||||||
return timeMoment.format(longDateFormat);
|
|
||||||
} else if (view === 'month') {
|
|
||||||
return timeMoment.format('MMMM YYYY');
|
|
||||||
} else if (view === 'agenda') {
|
|
||||||
return translate('Agenda');
|
|
||||||
}
|
|
||||||
|
|
||||||
let startFormat = 'MMM D YYYY';
|
|
||||||
let endFormat = 'MMM D YYYY';
|
|
||||||
|
|
||||||
if (startMoment.isSame(endMoment, 'month')) {
|
|
||||||
startFormat = 'MMM D';
|
|
||||||
endFormat = 'D YYYY';
|
|
||||||
} else if (startMoment.isSame(endMoment, 'year')) {
|
|
||||||
startFormat = 'MMM D';
|
|
||||||
endFormat = 'MMM D YYYY';
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO Convert to a stateful Component so we can track view internally when changed
|
|
||||||
|
|
||||||
class CalendarHeader extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
view: props.view
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const view = this.props.view;
|
|
||||||
|
|
||||||
if (prevProps.view !== view) {
|
|
||||||
this.setState({ view });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onViewChange = (view) => {
|
|
||||||
this.setState({ view }, () => {
|
|
||||||
this.props.onViewChange(view);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
isFetching,
|
|
||||||
time,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
longDateFormat,
|
|
||||||
isSmallScreen,
|
|
||||||
collapseViewButtons,
|
|
||||||
onTodayPress,
|
|
||||||
onPreviousPress,
|
|
||||||
onNextPress
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const view = this.state.view;
|
|
||||||
|
|
||||||
const title = getTitle(time, start, end, view, longDateFormat);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
isSmallScreen &&
|
|
||||||
<div className={styles.titleMobile}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div className={styles.header}>
|
|
||||||
<div className={styles.navigationButtons}>
|
|
||||||
<Button
|
|
||||||
buttonGroupPosition={align.LEFT}
|
|
||||||
isDisabled={view === calendarViews.AGENDA}
|
|
||||||
onPress={onPreviousPress}
|
|
||||||
>
|
|
||||||
<Icon name={icons.PAGE_PREVIOUS} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
buttonGroupPosition={align.RIGHT}
|
|
||||||
isDisabled={view === calendarViews.AGENDA}
|
|
||||||
onPress={onNextPress}
|
|
||||||
>
|
|
||||||
<Icon name={icons.PAGE_NEXT} />
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
className={styles.todayButton}
|
|
||||||
isDisabled={view === calendarViews.AGENDA}
|
|
||||||
onPress={onTodayPress}
|
|
||||||
>
|
|
||||||
{translate('Today')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{
|
|
||||||
!isSmallScreen &&
|
|
||||||
<div className={styles.titleDesktop}>
|
|
||||||
{title}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div className={styles.viewButtonsContainer}>
|
|
||||||
{
|
|
||||||
isFetching &&
|
|
||||||
<LoadingIndicator
|
|
||||||
className={styles.loading}
|
|
||||||
size={20}
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
collapseViewButtons ?
|
|
||||||
<Menu
|
|
||||||
className={styles.viewMenu}
|
|
||||||
alignMenu={align.RIGHT}
|
|
||||||
>
|
|
||||||
<MenuButton>
|
|
||||||
<Icon
|
|
||||||
name={icons.VIEW}
|
|
||||||
size={22}
|
|
||||||
/>
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
<MenuContent>
|
|
||||||
{
|
|
||||||
isSmallScreen ?
|
|
||||||
null :
|
|
||||||
<ViewMenuItem
|
|
||||||
name={calendarViews.MONTH}
|
|
||||||
selectedView={view}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
>
|
|
||||||
{translate('Month')}
|
|
||||||
</ViewMenuItem>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ViewMenuItem
|
|
||||||
name={calendarViews.WEEK}
|
|
||||||
selectedView={view}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
>
|
|
||||||
{translate('Week')}
|
|
||||||
</ViewMenuItem>
|
|
||||||
|
|
||||||
<ViewMenuItem
|
|
||||||
name={calendarViews.FORECAST}
|
|
||||||
selectedView={view}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
>
|
|
||||||
{translate('Forecast')}
|
|
||||||
</ViewMenuItem>
|
|
||||||
|
|
||||||
<ViewMenuItem
|
|
||||||
name={calendarViews.DAY}
|
|
||||||
selectedView={view}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
>
|
|
||||||
{translate('Day')}
|
|
||||||
</ViewMenuItem>
|
|
||||||
|
|
||||||
<ViewMenuItem
|
|
||||||
name={calendarViews.AGENDA}
|
|
||||||
selectedView={view}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
>
|
|
||||||
{translate('Agenda')}
|
|
||||||
</ViewMenuItem>
|
|
||||||
</MenuContent>
|
|
||||||
</Menu> :
|
|
||||||
|
|
||||||
<div className={styles.viewButtons}>
|
|
||||||
<CalendarHeaderViewButton
|
|
||||||
view={calendarViews.MONTH}
|
|
||||||
selectedView={view}
|
|
||||||
buttonGroupPosition={align.LEFT}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CalendarHeaderViewButton
|
|
||||||
view={calendarViews.WEEK}
|
|
||||||
selectedView={view}
|
|
||||||
buttonGroupPosition={align.CENTER}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CalendarHeaderViewButton
|
|
||||||
view={calendarViews.FORECAST}
|
|
||||||
selectedView={view}
|
|
||||||
buttonGroupPosition={align.CENTER}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CalendarHeaderViewButton
|
|
||||||
view={calendarViews.DAY}
|
|
||||||
selectedView={view}
|
|
||||||
buttonGroupPosition={align.CENTER}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CalendarHeaderViewButton
|
|
||||||
view={calendarViews.AGENDA}
|
|
||||||
selectedView={view}
|
|
||||||
buttonGroupPosition={align.RIGHT}
|
|
||||||
onPress={this.onViewChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarHeader.propTypes = {
|
|
||||||
isFetching: PropTypes.bool.isRequired,
|
|
||||||
time: PropTypes.string.isRequired,
|
|
||||||
start: PropTypes.string.isRequired,
|
|
||||||
end: PropTypes.string.isRequired,
|
|
||||||
view: PropTypes.oneOf(calendarViews.all).isRequired,
|
|
||||||
isSmallScreen: PropTypes.bool.isRequired,
|
|
||||||
collapseViewButtons: PropTypes.bool.isRequired,
|
|
||||||
longDateFormat: PropTypes.string.isRequired,
|
|
||||||
onViewChange: PropTypes.func.isRequired,
|
|
||||||
onTodayPress: PropTypes.func.isRequired,
|
|
||||||
onPreviousPress: PropTypes.func.isRequired,
|
|
||||||
onNextPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarHeader;
|
|
221
frontend/src/Calendar/Header/CalendarHeader.tsx
Normal file
221
frontend/src/Calendar/Header/CalendarHeader.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import moment from 'moment';
|
||||||
|
import React, { useCallback, useMemo } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import { CalendarView } from 'Calendar/calendarViews';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||||
|
import Menu from 'Components/Menu/Menu';
|
||||||
|
import MenuButton from 'Components/Menu/MenuButton';
|
||||||
|
import MenuContent from 'Components/Menu/MenuContent';
|
||||||
|
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
||||||
|
import { align, icons } from 'Helpers/Props';
|
||||||
|
import {
|
||||||
|
gotoCalendarNextRange,
|
||||||
|
gotoCalendarPreviousRange,
|
||||||
|
gotoCalendarToday,
|
||||||
|
setCalendarView,
|
||||||
|
} from 'Store/Actions/calendarActions';
|
||||||
|
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||||
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
||||||
|
import styles from './CalendarHeader.css';
|
||||||
|
|
||||||
|
function CalendarHeader() {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const { isFetching, view, time, start, end } = useSelector(
|
||||||
|
(state: AppState) => state.calendar
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isSmallScreen, isLargeScreen } = useSelector(
|
||||||
|
createDimensionsSelector()
|
||||||
|
);
|
||||||
|
|
||||||
|
const { longDateFormat } = useSelector(createUISettingsSelector());
|
||||||
|
|
||||||
|
const handleViewChange = useCallback(
|
||||||
|
(newView: CalendarView) => {
|
||||||
|
dispatch(setCalendarView({ view: newView }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTodayPress = useCallback(() => {
|
||||||
|
dispatch(gotoCalendarToday());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handlePreviousPress = useCallback(() => {
|
||||||
|
dispatch(gotoCalendarPreviousRange());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleNextPress = useCallback(() => {
|
||||||
|
dispatch(gotoCalendarNextRange());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const title = useMemo(() => {
|
||||||
|
const timeMoment = moment(time);
|
||||||
|
const startMoment = moment(start);
|
||||||
|
const endMoment = moment(end);
|
||||||
|
|
||||||
|
if (view === 'day') {
|
||||||
|
return timeMoment.format(longDateFormat);
|
||||||
|
} else if (view === 'month') {
|
||||||
|
return timeMoment.format('MMMM YYYY');
|
||||||
|
} else if (view === 'agenda') {
|
||||||
|
return translate('Agenda');
|
||||||
|
}
|
||||||
|
|
||||||
|
let startFormat = 'MMM D YYYY';
|
||||||
|
let endFormat = 'MMM D YYYY';
|
||||||
|
|
||||||
|
if (startMoment.isSame(endMoment, 'month')) {
|
||||||
|
startFormat = 'MMM D';
|
||||||
|
endFormat = 'D YYYY';
|
||||||
|
} else if (startMoment.isSame(endMoment, 'year')) {
|
||||||
|
startFormat = 'MMM D';
|
||||||
|
endFormat = 'MMM D YYYY';
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(
|
||||||
|
endFormat
|
||||||
|
)}`;
|
||||||
|
}, [time, start, end, view, longDateFormat]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{isSmallScreen ? <div className={styles.titleMobile}>{title}</div> : null}
|
||||||
|
|
||||||
|
<div className={styles.header}>
|
||||||
|
<div className={styles.navigationButtons}>
|
||||||
|
<Button
|
||||||
|
buttonGroupPosition="left"
|
||||||
|
isDisabled={view === 'agenda'}
|
||||||
|
onPress={handlePreviousPress}
|
||||||
|
>
|
||||||
|
<Icon name={icons.PAGE_PREVIOUS} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
buttonGroupPosition="right"
|
||||||
|
isDisabled={view === 'agenda'}
|
||||||
|
onPress={handleNextPress}
|
||||||
|
>
|
||||||
|
<Icon name={icons.PAGE_NEXT} />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
className={styles.todayButton}
|
||||||
|
isDisabled={view === 'agenda'}
|
||||||
|
onPress={handleTodayPress}
|
||||||
|
>
|
||||||
|
{translate('Today')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSmallScreen ? null : (
|
||||||
|
<div className={styles.titleDesktop}>{title}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className={styles.viewButtonsContainer}>
|
||||||
|
{isFetching ? (
|
||||||
|
<LoadingIndicator className={styles.loading} size={20} />
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLargeScreen ? (
|
||||||
|
<Menu className={styles.viewMenu} alignMenu={align.RIGHT}>
|
||||||
|
<MenuButton>
|
||||||
|
<Icon name={icons.VIEW} size={22} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuContent>
|
||||||
|
{isSmallScreen ? null : (
|
||||||
|
<ViewMenuItem
|
||||||
|
name="month"
|
||||||
|
selectedView={view}
|
||||||
|
onPress={handleViewChange}
|
||||||
|
>
|
||||||
|
{translate('Month')}
|
||||||
|
</ViewMenuItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ViewMenuItem
|
||||||
|
name="week"
|
||||||
|
selectedView={view}
|
||||||
|
onPress={handleViewChange}
|
||||||
|
>
|
||||||
|
{translate('Week')}
|
||||||
|
</ViewMenuItem>
|
||||||
|
|
||||||
|
<ViewMenuItem
|
||||||
|
name="forecast"
|
||||||
|
selectedView={view}
|
||||||
|
onPress={handleViewChange}
|
||||||
|
>
|
||||||
|
{translate('Forecast')}
|
||||||
|
</ViewMenuItem>
|
||||||
|
|
||||||
|
<ViewMenuItem
|
||||||
|
name="day"
|
||||||
|
selectedView={view}
|
||||||
|
onPress={handleViewChange}
|
||||||
|
>
|
||||||
|
{translate('Day')}
|
||||||
|
</ViewMenuItem>
|
||||||
|
|
||||||
|
<ViewMenuItem
|
||||||
|
name="agenda"
|
||||||
|
selectedView={view}
|
||||||
|
onPress={handleViewChange}
|
||||||
|
>
|
||||||
|
{translate('Agenda')}
|
||||||
|
</ViewMenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<CalendarHeaderViewButton
|
||||||
|
view="month"
|
||||||
|
selectedView={view}
|
||||||
|
buttonGroupPosition="left"
|
||||||
|
onPress={handleViewChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarHeaderViewButton
|
||||||
|
view="week"
|
||||||
|
selectedView={view}
|
||||||
|
buttonGroupPosition="center"
|
||||||
|
onPress={handleViewChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarHeaderViewButton
|
||||||
|
view="forecast"
|
||||||
|
selectedView={view}
|
||||||
|
buttonGroupPosition="center"
|
||||||
|
onPress={handleViewChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarHeaderViewButton
|
||||||
|
view="day"
|
||||||
|
selectedView={view}
|
||||||
|
buttonGroupPosition="center"
|
||||||
|
onPress={handleViewChange}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CalendarHeaderViewButton
|
||||||
|
view="agenda"
|
||||||
|
selectedView={view}
|
||||||
|
buttonGroupPosition="right"
|
||||||
|
onPress={handleViewChange}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarHeader;
|
@ -1,85 +0,0 @@
|
|||||||
import _ from 'lodash';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions';
|
|
||||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
|
||||||
import CalendarHeader from './CalendarHeader';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar,
|
|
||||||
createDimensionsSelector(),
|
|
||||||
createUISettingsSelector(),
|
|
||||||
(calendar, dimensions, uiSettings) => {
|
|
||||||
const result = _.pick(calendar, [
|
|
||||||
'isFetching',
|
|
||||||
'view',
|
|
||||||
'time',
|
|
||||||
'start',
|
|
||||||
'end'
|
|
||||||
]);
|
|
||||||
|
|
||||||
result.isSmallScreen = dimensions.isSmallScreen;
|
|
||||||
result.collapseViewButtons = dimensions.isLargeScreen;
|
|
||||||
result.longDateFormat = uiSettings.longDateFormat;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
setCalendarView,
|
|
||||||
gotoCalendarToday,
|
|
||||||
gotoCalendarPreviousRange,
|
|
||||||
gotoCalendarNextRange
|
|
||||||
};
|
|
||||||
|
|
||||||
class CalendarHeaderConnector extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onViewChange = (view) => {
|
|
||||||
this.props.setCalendarView({ view });
|
|
||||||
};
|
|
||||||
|
|
||||||
onTodayPress = () => {
|
|
||||||
this.props.gotoCalendarToday();
|
|
||||||
};
|
|
||||||
|
|
||||||
onPreviousPress = () => {
|
|
||||||
this.props.gotoCalendarPreviousRange();
|
|
||||||
};
|
|
||||||
|
|
||||||
onNextPress = () => {
|
|
||||||
this.props.gotoCalendarNextRange();
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<CalendarHeader
|
|
||||||
{...this.props}
|
|
||||||
onViewChange={this.onViewChange}
|
|
||||||
onTodayPress={this.onTodayPress}
|
|
||||||
onPreviousPress={this.onPreviousPress}
|
|
||||||
onNextPress={this.onNextPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarHeaderConnector.propTypes = {
|
|
||||||
setCalendarView: PropTypes.func.isRequired,
|
|
||||||
gotoCalendarToday: PropTypes.func.isRequired,
|
|
||||||
gotoCalendarPreviousRange: PropTypes.func.isRequired,
|
|
||||||
gotoCalendarNextRange: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);
|
|
@ -1,45 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import * as calendarViews from 'Calendar/calendarViews';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import titleCase from 'Utilities/String/titleCase';
|
|
||||||
// import styles from './CalendarHeaderViewButton.css';
|
|
||||||
|
|
||||||
class CalendarHeaderViewButton extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onPress = () => {
|
|
||||||
this.props.onPress(this.props.view);
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
view,
|
|
||||||
selectedView,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
isDisabled={selectedView === view}
|
|
||||||
{...otherProps}
|
|
||||||
onPress={this.onPress}
|
|
||||||
>
|
|
||||||
{titleCase(view)}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarHeaderViewButton.propTypes = {
|
|
||||||
view: PropTypes.oneOf(calendarViews.all).isRequired,
|
|
||||||
selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
|
|
||||||
onPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarHeaderViewButton;
|
|
34
frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
Normal file
34
frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { CalendarView } from 'Calendar/calendarViews';
|
||||||
|
import Button, { ButtonProps } from 'Components/Link/Button';
|
||||||
|
import titleCase from 'Utilities/String/titleCase';
|
||||||
|
|
||||||
|
interface CalendarHeaderViewButtonProps
|
||||||
|
extends Omit<ButtonProps, 'children' | 'onPress'> {
|
||||||
|
view: CalendarView;
|
||||||
|
selectedView: CalendarView;
|
||||||
|
onPress: (view: CalendarView) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarHeaderViewButton({
|
||||||
|
view,
|
||||||
|
selectedView,
|
||||||
|
onPress,
|
||||||
|
...otherProps
|
||||||
|
}: CalendarHeaderViewButtonProps) {
|
||||||
|
const handlePress = useCallback(() => {
|
||||||
|
onPress(view);
|
||||||
|
}, [view, onPress]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
isDisabled={selectedView === view}
|
||||||
|
{...otherProps}
|
||||||
|
onPress={handlePress}
|
||||||
|
>
|
||||||
|
{titleCase(view)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarHeaderViewButton;
|
@ -1,20 +1,22 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import LegendIconItem from './LegendIconItem';
|
import LegendIconItem from './LegendIconItem';
|
||||||
import LegendItem from './LegendItem';
|
import LegendItem from './LegendItem';
|
||||||
import styles from './Legend.css';
|
import styles from './Legend.css';
|
||||||
|
|
||||||
function Legend(props) {
|
function Legend() {
|
||||||
|
const view = useSelector((state: AppState) => state.calendar.view);
|
||||||
const {
|
const {
|
||||||
view,
|
|
||||||
showFinaleIcon,
|
showFinaleIcon,
|
||||||
showSpecialIcon,
|
showSpecialIcon,
|
||||||
showCutoffUnmetIcon,
|
showCutoffUnmetIcon,
|
||||||
fullColorEvents,
|
fullColorEvents,
|
||||||
colorImpairedMode
|
} = useSelector((state: AppState) => state.calendar.options);
|
||||||
} = props;
|
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
|
||||||
|
|
||||||
const iconsToShow = [];
|
const iconsToShow = [];
|
||||||
const isAgendaView = view === 'agenda';
|
const isAgendaView = view === 'agenda';
|
||||||
@ -73,7 +75,7 @@ function Legend(props) {
|
|||||||
tooltip={translate('CalendarLegendEpisodeUnairedTooltip')}
|
tooltip={translate('CalendarLegendEpisodeUnairedTooltip')}
|
||||||
isAgendaView={isAgendaView}
|
isAgendaView={isAgendaView}
|
||||||
fullColorEvents={fullColorEvents}
|
fullColorEvents={fullColorEvents}
|
||||||
colorImpairedMode={colorImpairedMode}
|
colorImpairedMode={enableColorImpairedMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LegendItem
|
<LegendItem
|
||||||
@ -81,7 +83,7 @@ function Legend(props) {
|
|||||||
tooltip={translate('CalendarLegendEpisodeUnmonitoredTooltip')}
|
tooltip={translate('CalendarLegendEpisodeUnmonitoredTooltip')}
|
||||||
isAgendaView={isAgendaView}
|
isAgendaView={isAgendaView}
|
||||||
fullColorEvents={fullColorEvents}
|
fullColorEvents={fullColorEvents}
|
||||||
colorImpairedMode={colorImpairedMode}
|
colorImpairedMode={enableColorImpairedMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -92,7 +94,7 @@ function Legend(props) {
|
|||||||
tooltip={translate('CalendarLegendEpisodeOnAirTooltip')}
|
tooltip={translate('CalendarLegendEpisodeOnAirTooltip')}
|
||||||
isAgendaView={isAgendaView}
|
isAgendaView={isAgendaView}
|
||||||
fullColorEvents={fullColorEvents}
|
fullColorEvents={fullColorEvents}
|
||||||
colorImpairedMode={colorImpairedMode}
|
colorImpairedMode={enableColorImpairedMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LegendItem
|
<LegendItem
|
||||||
@ -100,7 +102,7 @@ function Legend(props) {
|
|||||||
tooltip={translate('CalendarLegendEpisodeMissingTooltip')}
|
tooltip={translate('CalendarLegendEpisodeMissingTooltip')}
|
||||||
isAgendaView={isAgendaView}
|
isAgendaView={isAgendaView}
|
||||||
fullColorEvents={fullColorEvents}
|
fullColorEvents={fullColorEvents}
|
||||||
colorImpairedMode={colorImpairedMode}
|
colorImpairedMode={enableColorImpairedMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -110,7 +112,7 @@ function Legend(props) {
|
|||||||
tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')}
|
tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')}
|
||||||
isAgendaView={isAgendaView}
|
isAgendaView={isAgendaView}
|
||||||
fullColorEvents={fullColorEvents}
|
fullColorEvents={fullColorEvents}
|
||||||
colorImpairedMode={colorImpairedMode}
|
colorImpairedMode={enableColorImpairedMode}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<LegendItem
|
<LegendItem
|
||||||
@ -118,7 +120,7 @@ function Legend(props) {
|
|||||||
tooltip={translate('CalendarLegendEpisodeDownloadedTooltip')}
|
tooltip={translate('CalendarLegendEpisodeDownloadedTooltip')}
|
||||||
isAgendaView={isAgendaView}
|
isAgendaView={isAgendaView}
|
||||||
fullColorEvents={fullColorEvents}
|
fullColorEvents={fullColorEvents}
|
||||||
colorImpairedMode={colorImpairedMode}
|
colorImpairedMode={enableColorImpairedMode}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -134,30 +136,15 @@ function Legend(props) {
|
|||||||
{iconsToShow[0]}
|
{iconsToShow[0]}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{
|
{iconsToShow.length > 1 ? (
|
||||||
iconsToShow.length > 1 &&
|
<div>
|
||||||
<div>
|
{iconsToShow[1]}
|
||||||
{iconsToShow[1]}
|
{iconsToShow[2]}
|
||||||
{iconsToShow[2]}
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
}
|
{iconsToShow.length > 3 ? <div>{iconsToShow[3]}</div> : null}
|
||||||
{
|
|
||||||
iconsToShow.length > 3 &&
|
|
||||||
<div>
|
|
||||||
{iconsToShow[3]}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Legend.propTypes = {
|
|
||||||
view: PropTypes.string.isRequired,
|
|
||||||
showFinaleIcon: PropTypes.bool.isRequired,
|
|
||||||
showSpecialIcon: PropTypes.bool.isRequired,
|
|
||||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
|
||||||
fullColorEvents: PropTypes.bool.isRequired,
|
|
||||||
colorImpairedMode: PropTypes.bool.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Legend;
|
export default Legend;
|
@ -1,21 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
|
||||||
import Legend from './Legend';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.options,
|
|
||||||
(state) => state.calendar.view,
|
|
||||||
createUISettingsSelector(),
|
|
||||||
(calendarOptions, view, uiSettings) => {
|
|
||||||
return {
|
|
||||||
...calendarOptions,
|
|
||||||
view,
|
|
||||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(Legend);
|
|
@ -1,43 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import styles from './LegendIconItem.css';
|
|
||||||
|
|
||||||
function LegendIconItem(props) {
|
|
||||||
const {
|
|
||||||
name,
|
|
||||||
fullColorEvents,
|
|
||||||
icon,
|
|
||||||
kind,
|
|
||||||
tooltip
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={styles.legendIconItem}
|
|
||||||
title={tooltip}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={classNames(
|
|
||||||
styles.icon,
|
|
||||||
fullColorEvents && 'fullColorEvents'
|
|
||||||
)}
|
|
||||||
name={icon}
|
|
||||||
kind={kind}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
LegendIconItem.propTypes = {
|
|
||||||
name: PropTypes.string.isRequired,
|
|
||||||
fullColorEvents: PropTypes.bool.isRequired,
|
|
||||||
icon: PropTypes.object.isRequired,
|
|
||||||
kind: PropTypes.string.isRequired,
|
|
||||||
tooltip: PropTypes.string.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LegendIconItem;
|
|
33
frontend/src/Calendar/Legend/LegendIconItem.tsx
Normal file
33
frontend/src/Calendar/Legend/LegendIconItem.tsx
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import Icon, { IconProps } from 'Components/Icon';
|
||||||
|
import styles from './LegendIconItem.css';
|
||||||
|
|
||||||
|
interface LegendIconItemProps extends Pick<IconProps, 'kind'> {
|
||||||
|
name: string;
|
||||||
|
fullColorEvents: boolean;
|
||||||
|
icon: FontAwesomeIconProps['icon'];
|
||||||
|
tooltip: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendIconItem(props: LegendIconItemProps) {
|
||||||
|
const { name, fullColorEvents, icon, kind, tooltip } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={styles.legendIconItem} title={tooltip}>
|
||||||
|
<Icon
|
||||||
|
className={classNames(
|
||||||
|
styles.icon,
|
||||||
|
fullColorEvents && 'fullColorEvents'
|
||||||
|
)}
|
||||||
|
name={icon}
|
||||||
|
kind={kind}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{name}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default LegendIconItem;
|
@ -1,17 +1,26 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { CalendarStatus } from 'typings/Calendar';
|
||||||
import titleCase from 'Utilities/String/titleCase';
|
import titleCase from 'Utilities/String/titleCase';
|
||||||
import styles from './LegendItem.css';
|
import styles from './LegendItem.css';
|
||||||
|
|
||||||
function LegendItem(props) {
|
interface LegendItemProps {
|
||||||
|
name?: string;
|
||||||
|
status: CalendarStatus;
|
||||||
|
tooltip: string;
|
||||||
|
isAgendaView: boolean;
|
||||||
|
fullColorEvents: boolean;
|
||||||
|
colorImpairedMode: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LegendItem(props: LegendItemProps) {
|
||||||
const {
|
const {
|
||||||
name,
|
name,
|
||||||
status,
|
status,
|
||||||
tooltip,
|
tooltip,
|
||||||
isAgendaView,
|
isAgendaView,
|
||||||
fullColorEvents,
|
fullColorEvents,
|
||||||
colorImpairedMode
|
colorImpairedMode,
|
||||||
} = props;
|
} = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -29,13 +38,4 @@ function LegendItem(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
LegendItem.propTypes = {
|
|
||||||
name: PropTypes.string,
|
|
||||||
status: PropTypes.string.isRequired,
|
|
||||||
tooltip: PropTypes.string.isRequired,
|
|
||||||
isAgendaView: PropTypes.bool.isRequired,
|
|
||||||
fullColorEvents: PropTypes.bool.isRequired,
|
|
||||||
colorImpairedMode: PropTypes.bool.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default LegendItem;
|
export default LegendItem;
|
@ -1,29 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Modal from 'Components/Modal/Modal';
|
|
||||||
import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
|
|
||||||
|
|
||||||
function CalendarOptionsModal(props) {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
onModalClose
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
>
|
|
||||||
<CalendarOptionsModalContentConnector
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarOptionsModal.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarOptionsModal;
|
|
21
frontend/src/Calendar/Options/CalendarOptionsModal.tsx
Normal file
21
frontend/src/Calendar/Options/CalendarOptionsModal.tsx
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
|
||||||
|
|
||||||
|
interface CalendarOptionsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onModalClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarOptionsModal({
|
||||||
|
isOpen,
|
||||||
|
onModalClose,
|
||||||
|
}: CalendarOptionsModalProps) {
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||||
|
<CalendarOptionsModalContent onModalClose={onModalClose} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarOptionsModal;
|
@ -1,276 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import FieldSet from 'Components/FieldSet';
|
|
||||||
import Form from 'Components/Form/Form';
|
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
|
||||||
import { inputTypes } from 'Helpers/Props';
|
|
||||||
import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
|
|
||||||
class CalendarOptionsModalContent extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
const {
|
|
||||||
firstDayOfWeek,
|
|
||||||
calendarWeekColumnHeader,
|
|
||||||
timeFormat,
|
|
||||||
enableColorImpairedMode,
|
|
||||||
fullColorEvents
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
firstDayOfWeek,
|
|
||||||
calendarWeekColumnHeader,
|
|
||||||
timeFormat,
|
|
||||||
enableColorImpairedMode,
|
|
||||||
fullColorEvents
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const {
|
|
||||||
firstDayOfWeek,
|
|
||||||
calendarWeekColumnHeader,
|
|
||||||
timeFormat,
|
|
||||||
enableColorImpairedMode
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
if (
|
|
||||||
prevProps.firstDayOfWeek !== firstDayOfWeek ||
|
|
||||||
prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
|
|
||||||
prevProps.timeFormat !== timeFormat ||
|
|
||||||
prevProps.enableColorImpairedMode !== enableColorImpairedMode
|
|
||||||
) {
|
|
||||||
this.setState({
|
|
||||||
firstDayOfWeek,
|
|
||||||
calendarWeekColumnHeader,
|
|
||||||
timeFormat,
|
|
||||||
enableColorImpairedMode
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onOptionInputChange = ({ name, value }) => {
|
|
||||||
const {
|
|
||||||
dispatchSetCalendarOption
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
dispatchSetCalendarOption({ [name]: value });
|
|
||||||
};
|
|
||||||
|
|
||||||
onGlobalInputChange = ({ name, value }) => {
|
|
||||||
const {
|
|
||||||
dispatchSaveUISettings
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const setting = { [name]: value };
|
|
||||||
|
|
||||||
this.setState(setting, () => {
|
|
||||||
dispatchSaveUISettings(setting);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onLinkFocus = (event) => {
|
|
||||||
event.target.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
collapseMultipleEpisodes,
|
|
||||||
showEpisodeInformation,
|
|
||||||
showFinaleIcon,
|
|
||||||
showSpecialIcon,
|
|
||||||
showCutoffUnmetIcon,
|
|
||||||
fullColorEvents,
|
|
||||||
onModalClose
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
firstDayOfWeek,
|
|
||||||
calendarWeekColumnHeader,
|
|
||||||
timeFormat,
|
|
||||||
enableColorImpairedMode
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalContent onModalClose={onModalClose}>
|
|
||||||
<ModalHeader>
|
|
||||||
{translate('CalendarOptions')}
|
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
|
||||||
<FieldSet legend={translate('Local')}>
|
|
||||||
<Form>
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="collapseMultipleEpisodes"
|
|
||||||
value={collapseMultipleEpisodes}
|
|
||||||
helpText={translate('CollapseMultipleEpisodesHelpText')}
|
|
||||||
onChange={this.onOptionInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="showEpisodeInformation"
|
|
||||||
value={showEpisodeInformation}
|
|
||||||
helpText={translate('ShowEpisodeInformationHelpText')}
|
|
||||||
onChange={this.onOptionInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('IconForFinales')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="showFinaleIcon"
|
|
||||||
value={showFinaleIcon}
|
|
||||||
helpText={translate('IconForFinalesHelpText')}
|
|
||||||
onChange={this.onOptionInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('IconForSpecials')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="showSpecialIcon"
|
|
||||||
value={showSpecialIcon}
|
|
||||||
helpText={translate('IconForSpecialsHelpText')}
|
|
||||||
onChange={this.onOptionInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="showCutoffUnmetIcon"
|
|
||||||
value={showCutoffUnmetIcon}
|
|
||||||
helpText={translate('IconForCutoffUnmetHelpText')}
|
|
||||||
onChange={this.onOptionInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('FullColorEvents')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="fullColorEvents"
|
|
||||||
value={fullColorEvents}
|
|
||||||
helpText={translate('FullColorEventsHelpText')}
|
|
||||||
onChange={this.onOptionInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</Form>
|
|
||||||
</FieldSet>
|
|
||||||
|
|
||||||
<FieldSet legend={translate('Global')}>
|
|
||||||
<Form>
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.SELECT}
|
|
||||||
name="firstDayOfWeek"
|
|
||||||
values={firstDayOfWeekOptions}
|
|
||||||
value={firstDayOfWeek}
|
|
||||||
onChange={this.onGlobalInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.SELECT}
|
|
||||||
name="calendarWeekColumnHeader"
|
|
||||||
values={weekColumnOptions}
|
|
||||||
value={calendarWeekColumnHeader}
|
|
||||||
onChange={this.onGlobalInputChange}
|
|
||||||
helpText={translate('WeekColumnHeaderHelpText')}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('TimeFormat')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.SELECT}
|
|
||||||
name="timeFormat"
|
|
||||||
values={timeFormatOptions}
|
|
||||||
value={timeFormat}
|
|
||||||
onChange={this.onGlobalInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="enableColorImpairedMode"
|
|
||||||
value={enableColorImpairedMode}
|
|
||||||
helpText={translate('EnableColorImpairedModeHelpText')}
|
|
||||||
onChange={this.onGlobalInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</Form>
|
|
||||||
</FieldSet>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
<Button onPress={onModalClose}>
|
|
||||||
{translate('Close')}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarOptionsModalContent.propTypes = {
|
|
||||||
collapseMultipleEpisodes: PropTypes.bool.isRequired,
|
|
||||||
showEpisodeInformation: PropTypes.bool.isRequired,
|
|
||||||
showFinaleIcon: PropTypes.bool.isRequired,
|
|
||||||
showSpecialIcon: PropTypes.bool.isRequired,
|
|
||||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
|
||||||
firstDayOfWeek: PropTypes.number.isRequired,
|
|
||||||
calendarWeekColumnHeader: PropTypes.string.isRequired,
|
|
||||||
timeFormat: PropTypes.string.isRequired,
|
|
||||||
enableColorImpairedMode: PropTypes.bool.isRequired,
|
|
||||||
fullColorEvents: PropTypes.bool.isRequired,
|
|
||||||
dispatchSetCalendarOption: PropTypes.func.isRequired,
|
|
||||||
dispatchSaveUISettings: PropTypes.func.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarOptionsModalContent;
|
|
228
frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
Normal file
228
frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import FieldSet from 'Components/FieldSet';
|
||||||
|
import Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { inputTypes } from 'Helpers/Props';
|
||||||
|
import {
|
||||||
|
firstDayOfWeekOptions,
|
||||||
|
timeFormatOptions,
|
||||||
|
weekColumnOptions,
|
||||||
|
} from 'Settings/UI/UISettings';
|
||||||
|
import { setCalendarOption } from 'Store/Actions/calendarActions';
|
||||||
|
import { saveUISettings } from 'Store/Actions/settingsActions';
|
||||||
|
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||||
|
import { InputChanged } from 'typings/inputs';
|
||||||
|
import UiSettings from 'typings/Settings/UiSettings';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
interface CalendarOptionsModalContentProps {
|
||||||
|
onModalClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarOptionsModalContent({
|
||||||
|
onModalClose,
|
||||||
|
}: CalendarOptionsModalContentProps) {
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const {
|
||||||
|
collapseMultipleEpisodes,
|
||||||
|
showEpisodeInformation,
|
||||||
|
showFinaleIcon,
|
||||||
|
showSpecialIcon,
|
||||||
|
showCutoffUnmetIcon,
|
||||||
|
fullColorEvents,
|
||||||
|
} = useSelector((state: AppState) => state.calendar.options);
|
||||||
|
|
||||||
|
const uiSettings = useSelector(createUISettingsSelector());
|
||||||
|
|
||||||
|
const [state, setState] = useState<Partial<UiSettings>>({
|
||||||
|
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
||||||
|
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
||||||
|
timeFormat: uiSettings.timeFormat,
|
||||||
|
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
firstDayOfWeek,
|
||||||
|
calendarWeekColumnHeader,
|
||||||
|
timeFormat,
|
||||||
|
enableColorImpairedMode,
|
||||||
|
} = state;
|
||||||
|
|
||||||
|
const handleOptionInputChange = useCallback(
|
||||||
|
({ name, value }: InputChanged) => {
|
||||||
|
dispatch(setCalendarOption({ [name]: value }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleGlobalInputChange = useCallback(
|
||||||
|
({ name, value }: InputChanged) => {
|
||||||
|
setState((prevState) => ({ ...prevState, [name]: value }));
|
||||||
|
|
||||||
|
dispatch(saveUISettings({ [name]: value }));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setState({
|
||||||
|
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
||||||
|
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
||||||
|
timeFormat: uiSettings.timeFormat,
|
||||||
|
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||||
|
});
|
||||||
|
}, [uiSettings]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>{translate('CalendarOptions')}</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<FieldSet legend={translate('Local')}>
|
||||||
|
<Form>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="collapseMultipleEpisodes"
|
||||||
|
value={collapseMultipleEpisodes}
|
||||||
|
helpText={translate('CollapseMultipleEpisodesHelpText')}
|
||||||
|
onChange={handleOptionInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="showEpisodeInformation"
|
||||||
|
value={showEpisodeInformation}
|
||||||
|
helpText={translate('ShowEpisodeInformationHelpText')}
|
||||||
|
onChange={handleOptionInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('IconForFinales')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="showFinaleIcon"
|
||||||
|
value={showFinaleIcon}
|
||||||
|
helpText={translate('IconForFinalesHelpText')}
|
||||||
|
onChange={handleOptionInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('IconForSpecials')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="showSpecialIcon"
|
||||||
|
value={showSpecialIcon}
|
||||||
|
helpText={translate('IconForSpecialsHelpText')}
|
||||||
|
onChange={handleOptionInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="showCutoffUnmetIcon"
|
||||||
|
value={showCutoffUnmetIcon}
|
||||||
|
helpText={translate('IconForCutoffUnmetHelpText')}
|
||||||
|
onChange={handleOptionInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('FullColorEvents')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="fullColorEvents"
|
||||||
|
value={fullColorEvents}
|
||||||
|
helpText={translate('FullColorEventsHelpText')}
|
||||||
|
onChange={handleOptionInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
</FieldSet>
|
||||||
|
|
||||||
|
<FieldSet legend={translate('Global')}>
|
||||||
|
<Form>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SELECT}
|
||||||
|
name="firstDayOfWeek"
|
||||||
|
values={firstDayOfWeekOptions}
|
||||||
|
value={firstDayOfWeek}
|
||||||
|
onChange={handleGlobalInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SELECT}
|
||||||
|
name="calendarWeekColumnHeader"
|
||||||
|
values={weekColumnOptions}
|
||||||
|
value={calendarWeekColumnHeader}
|
||||||
|
helpText={translate('WeekColumnHeaderHelpText')}
|
||||||
|
onChange={handleGlobalInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('TimeFormat')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SELECT}
|
||||||
|
name="timeFormat"
|
||||||
|
values={timeFormatOptions}
|
||||||
|
value={timeFormat}
|
||||||
|
onChange={handleGlobalInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="enableColorImpairedMode"
|
||||||
|
value={enableColorImpairedMode}
|
||||||
|
helpText={translate('EnableColorImpairedModeHelpText')}
|
||||||
|
onChange={handleGlobalInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
</FieldSet>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarOptionsModalContent;
|
@ -1,25 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { setCalendarOption } from 'Store/Actions/calendarActions';
|
|
||||||
import { saveUISettings } from 'Store/Actions/settingsActions';
|
|
||||||
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.calendar.options,
|
|
||||||
(state) => state.settings.ui.item,
|
|
||||||
(options, uiSettings) => {
|
|
||||||
return {
|
|
||||||
...options,
|
|
||||||
...uiSettings
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
dispatchSetCalendarOption: setCalendarOption,
|
|
||||||
dispatchSaveUISettings: saveUISettings
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);
|
|
@ -5,3 +5,5 @@ export const FORECAST = 'forecast';
|
|||||||
export const AGENDA = 'agenda';
|
export const AGENDA = 'agenda';
|
||||||
|
|
||||||
export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
|
export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
|
||||||
|
|
||||||
|
export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week';
|
@ -1,7 +1,13 @@
|
|||||||
/* eslint max-params: 0 */
|
|
||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
|
import { CalendarStatus } from 'typings/Calendar';
|
||||||
|
|
||||||
function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) {
|
function getStatusStyle(
|
||||||
|
hasFile: boolean,
|
||||||
|
downloading: boolean,
|
||||||
|
startTime: moment.Moment,
|
||||||
|
endTime: moment.Moment,
|
||||||
|
isMonitored: boolean
|
||||||
|
): CalendarStatus {
|
||||||
const currentTime = moment();
|
const currentTime = moment();
|
||||||
|
|
||||||
if (hasFile) {
|
if (hasFile) {
|
@ -1,29 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Modal from 'Components/Modal/Modal';
|
|
||||||
import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
|
|
||||||
|
|
||||||
function CalendarLinkModal(props) {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
onModalClose
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
>
|
|
||||||
<CalendarLinkModalContentConnector
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarLinkModal.propTypes = {
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarLinkModal;
|
|
20
frontend/src/Calendar/iCal/CalendarLinkModal.tsx
Normal file
20
frontend/src/Calendar/iCal/CalendarLinkModal.tsx
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import CalendarLinkModalContent from './CalendarLinkModalContent';
|
||||||
|
|
||||||
|
interface CalendarLinkModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onModalClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarLinkModal(props: CalendarLinkModalProps) {
|
||||||
|
const { isOpen, onModalClose } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||||
|
<CalendarLinkModalContent onModalClose={onModalClose} />
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarLinkModal;
|
@ -1,222 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Form from 'Components/Form/Form';
|
|
||||||
import FormGroup from 'Components/Form/FormGroup';
|
|
||||||
import FormInputButton from 'Components/Form/FormInputButton';
|
|
||||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
|
||||||
import FormLabel from 'Components/Form/FormLabel';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
|
||||||
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
|
|
||||||
function getUrls(state) {
|
|
||||||
const {
|
|
||||||
unmonitored,
|
|
||||||
premieresOnly,
|
|
||||||
asAllDay,
|
|
||||||
tags
|
|
||||||
} = state;
|
|
||||||
|
|
||||||
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
|
|
||||||
|
|
||||||
if (unmonitored) {
|
|
||||||
icalUrl += 'unmonitored=true&';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (premieresOnly) {
|
|
||||||
icalUrl += 'premieresOnly=true&';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asAllDay) {
|
|
||||||
icalUrl += 'asAllDay=true&';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tags.length) {
|
|
||||||
icalUrl += `tags=${tags.toString()}&`;
|
|
||||||
}
|
|
||||||
|
|
||||||
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
|
|
||||||
|
|
||||||
const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
|
|
||||||
const iCalWebCalUrl = `webcal://${icalUrl}`;
|
|
||||||
|
|
||||||
return {
|
|
||||||
iCalHttpUrl,
|
|
||||||
iCalWebCalUrl
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class CalendarLinkModalContent extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
const defaultState = {
|
|
||||||
unmonitored: false,
|
|
||||||
premieresOnly: false,
|
|
||||||
asAllDay: false,
|
|
||||||
tags: []
|
|
||||||
};
|
|
||||||
|
|
||||||
const urls = getUrls(defaultState);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
...defaultState,
|
|
||||||
...urls
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onInputChange = ({ name, value }) => {
|
|
||||||
const state = {
|
|
||||||
...this.state,
|
|
||||||
[name]: value
|
|
||||||
};
|
|
||||||
|
|
||||||
const urls = getUrls(state);
|
|
||||||
|
|
||||||
this.setState({
|
|
||||||
[name]: value,
|
|
||||||
...urls
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
onLinkFocus = (event) => {
|
|
||||||
event.target.select();
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
onModalClose
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const {
|
|
||||||
unmonitored,
|
|
||||||
premieresOnly,
|
|
||||||
asAllDay,
|
|
||||||
tags,
|
|
||||||
iCalHttpUrl,
|
|
||||||
iCalWebCalUrl
|
|
||||||
} = this.state;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalContent onModalClose={onModalClose}>
|
|
||||||
<ModalHeader>
|
|
||||||
{translate('CalendarFeed')}
|
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
|
||||||
<Form>
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="unmonitored"
|
|
||||||
value={unmonitored}
|
|
||||||
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="premieresOnly"
|
|
||||||
value={premieresOnly}
|
|
||||||
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.CHECK}
|
|
||||||
name="asAllDay"
|
|
||||||
value={asAllDay}
|
|
||||||
helpText={translate('ICalShowAsAllDayEventsHelpText')}
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup>
|
|
||||||
<FormLabel>{translate('Tags')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.TAG}
|
|
||||||
name="tags"
|
|
||||||
value={tags}
|
|
||||||
helpText={translate('ICalTagsSeriesHelpText')}
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
|
|
||||||
<FormGroup
|
|
||||||
size={sizes.LARGE}
|
|
||||||
>
|
|
||||||
<FormLabel>{translate('ICalFeed')}</FormLabel>
|
|
||||||
|
|
||||||
<FormInputGroup
|
|
||||||
type={inputTypes.TEXT}
|
|
||||||
name="iCalHttpUrl"
|
|
||||||
value={iCalHttpUrl}
|
|
||||||
readOnly={true}
|
|
||||||
helpText={translate('ICalFeedHelpText')}
|
|
||||||
buttons={[
|
|
||||||
<ClipboardButton
|
|
||||||
key="copy"
|
|
||||||
value={iCalHttpUrl}
|
|
||||||
kind={kinds.DEFAULT}
|
|
||||||
/>,
|
|
||||||
|
|
||||||
<FormInputButton
|
|
||||||
key="webcal"
|
|
||||||
kind={kinds.DEFAULT}
|
|
||||||
to={iCalWebCalUrl}
|
|
||||||
target="_blank"
|
|
||||||
noRouter={true}
|
|
||||||
>
|
|
||||||
<Icon name={icons.CALENDAR_O} />
|
|
||||||
</FormInputButton>
|
|
||||||
]}
|
|
||||||
onChange={this.onInputChange}
|
|
||||||
onFocus={this.onLinkFocus}
|
|
||||||
/>
|
|
||||||
</FormGroup>
|
|
||||||
</Form>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
<Button onPress={onModalClose}>
|
|
||||||
{translate('Close')}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CalendarLinkModalContent.propTypes = {
|
|
||||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CalendarLinkModalContent;
|
|
166
frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
Normal file
166
frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
Normal file
@ -0,0 +1,166 @@
|
|||||||
|
import React, { FocusEvent, useCallback, useMemo, useState } from 'react';
|
||||||
|
import Form from 'Components/Form/Form';
|
||||||
|
import FormGroup from 'Components/Form/FormGroup';
|
||||||
|
import FormInputButton from 'Components/Form/FormInputButton';
|
||||||
|
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||||
|
import FormLabel from 'Components/Form/FormLabel';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||||
|
import { InputChanged } from 'typings/inputs';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
|
||||||
|
interface CalendarLinkModalContentProps {
|
||||||
|
onModalClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CalendarLinkModalContent({
|
||||||
|
onModalClose,
|
||||||
|
}: CalendarLinkModalContentProps) {
|
||||||
|
const [state, setState] = useState({
|
||||||
|
unmonitored: false,
|
||||||
|
premieresOnly: false,
|
||||||
|
asAllDay: false,
|
||||||
|
tags: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
const { unmonitored, premieresOnly, asAllDay, tags } = state;
|
||||||
|
|
||||||
|
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
|
||||||
|
setState((prevState) => ({ ...prevState, [name]: value }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleLinkFocus = useCallback(
|
||||||
|
(event: FocusEvent<HTMLInputElement, Element>) => {
|
||||||
|
event.target.select();
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => {
|
||||||
|
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
|
||||||
|
|
||||||
|
if (unmonitored) {
|
||||||
|
icalUrl += 'unmonitored=true&';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (premieresOnly) {
|
||||||
|
icalUrl += 'premieresOnly=true&';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (asAllDay) {
|
||||||
|
icalUrl += 'asAllDay=true&';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags.length) {
|
||||||
|
icalUrl += `tags=${tags.toString()}&`;
|
||||||
|
}
|
||||||
|
|
||||||
|
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
|
||||||
|
|
||||||
|
return {
|
||||||
|
iCalHttpUrl: `${window.location.protocol}//${icalUrl}`,
|
||||||
|
iCalWebCalUrl: `webcal://${icalUrl}`,
|
||||||
|
};
|
||||||
|
}, [unmonitored, premieresOnly, asAllDay, tags]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>{translate('CalendarFeed')}</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<Form>
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="unmonitored"
|
||||||
|
value={unmonitored}
|
||||||
|
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="premieresOnly"
|
||||||
|
value={premieresOnly}
|
||||||
|
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.CHECK}
|
||||||
|
name="asAllDay"
|
||||||
|
value={asAllDay}
|
||||||
|
helpText={translate('ICalShowAsAllDayEventsHelpText')}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup>
|
||||||
|
<FormLabel>{translate('Tags')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.SERIES_TAG}
|
||||||
|
name="tags"
|
||||||
|
value={tags}
|
||||||
|
helpText={translate('ICalTagsSeriesHelpText')}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
|
||||||
|
<FormGroup size={sizes.LARGE}>
|
||||||
|
<FormLabel>{translate('ICalFeed')}</FormLabel>
|
||||||
|
|
||||||
|
<FormInputGroup
|
||||||
|
type={inputTypes.TEXT}
|
||||||
|
name="iCalHttpUrl"
|
||||||
|
value={iCalHttpUrl}
|
||||||
|
readOnly={true}
|
||||||
|
helpText={translate('ICalFeedHelpText')}
|
||||||
|
buttons={[
|
||||||
|
<ClipboardButton
|
||||||
|
key="copy"
|
||||||
|
value={iCalHttpUrl}
|
||||||
|
kind={kinds.DEFAULT}
|
||||||
|
/>,
|
||||||
|
|
||||||
|
<FormInputButton
|
||||||
|
key="webcal"
|
||||||
|
kind={kinds.DEFAULT}
|
||||||
|
to={iCalWebCalUrl}
|
||||||
|
target="_blank"
|
||||||
|
noRouter={true}
|
||||||
|
>
|
||||||
|
<Icon name={icons.CALENDAR_O} />
|
||||||
|
</FormInputButton>,
|
||||||
|
]}
|
||||||
|
onChange={handleInputChange}
|
||||||
|
onFocus={handleLinkFocus}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</Form>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CalendarLinkModalContent;
|
@ -1,17 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
|
||||||
import CalendarLinkModalContent from './CalendarLinkModalContent';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
createTagsSelector(),
|
|
||||||
(tagList) => {
|
|
||||||
return {
|
|
||||||
tagList
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps)(CalendarLinkModalContent);
|
|
@ -1,4 +1,4 @@
|
|||||||
import React, { ReactNode } from 'react';
|
import React, { FocusEvent, ReactNode } from 'react';
|
||||||
import Link from 'Components/Link/Link';
|
import Link from 'Components/Link/Link';
|
||||||
import { inputTypes } from 'Helpers/Props';
|
import { inputTypes } from 'Helpers/Props';
|
||||||
import { InputType } from 'Helpers/Props/inputTypes';
|
import { InputType } from 'Helpers/Props/inputTypes';
|
||||||
@ -152,9 +152,11 @@ interface FormInputGroupProps<T> {
|
|||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
includeAny?: boolean;
|
includeAny?: boolean;
|
||||||
delimiters?: string[];
|
delimiters?: string[];
|
||||||
|
readOnly?: boolean;
|
||||||
errors?: (ValidationMessage | ValidationError)[];
|
errors?: (ValidationMessage | ValidationError)[];
|
||||||
warnings?: (ValidationMessage | ValidationWarning)[];
|
warnings?: (ValidationMessage | ValidationWarning)[];
|
||||||
onChange: (args: T) => void;
|
onChange: (args: T) => void;
|
||||||
|
onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function FormInputGroup<T>(props: FormInputGroupProps<T>) {
|
function FormInputGroup<T>(props: FormInputGroupProps<T>) {
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, {
|
import React, {
|
||||||
ChangeEvent,
|
ChangeEvent,
|
||||||
|
FocusEvent,
|
||||||
SyntheticEvent,
|
SyntheticEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
useEffect,
|
useEffect,
|
||||||
@ -25,7 +26,7 @@ export interface TextInputProps<T> {
|
|||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
onChange: (change: InputChanged<T> | FileInputChanged) => void;
|
onChange: (change: InputChanged<T> | FileInputChanged) => void;
|
||||||
onFocus?: (event: SyntheticEvent) => void;
|
onFocus?: (event: FocusEvent) => void;
|
||||||
onBlur?: (event: SyntheticEvent) => void;
|
onBlur?: (event: SyntheticEvent) => void;
|
||||||
onCopy?: (event: SyntheticEvent) => void;
|
onCopy?: (event: SyntheticEvent) => void;
|
||||||
onSelectionChange?: (start: number | null, end: number | null) => void;
|
onSelectionChange?: (start: number | null, end: number | null) => void;
|
||||||
@ -94,7 +95,7 @@ function TextInput<T>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleFocus = useCallback(
|
const handleFocus = useCallback(
|
||||||
(event: SyntheticEvent) => {
|
(event: FocusEvent) => {
|
||||||
onFocus?.(event);
|
onFocus?.(event);
|
||||||
|
|
||||||
selectionChanged();
|
selectionChanged();
|
||||||
|
@ -18,7 +18,7 @@ export interface IconProps
|
|||||||
kind?: Extract<Kind, keyof typeof styles>;
|
kind?: Extract<Kind, keyof typeof styles>;
|
||||||
size?: number;
|
size?: number;
|
||||||
isSpinning?: FontAwesomeIconProps['spin'];
|
isSpinning?: FontAwesomeIconProps['spin'];
|
||||||
title?: string | (() => string);
|
title?: string | (() => string) | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Icon({
|
export default function Icon({
|
||||||
|
@ -1,16 +1,14 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { align, kinds, sizes } from 'Helpers/Props';
|
import { kinds, sizes } from 'Helpers/Props';
|
||||||
|
import { Align } from 'Helpers/Props/align';
|
||||||
import { Kind } from 'Helpers/Props/kinds';
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
import { Size } from 'Helpers/Props/sizes';
|
import { Size } from 'Helpers/Props/sizes';
|
||||||
import Link, { LinkProps } from './Link';
|
import Link, { LinkProps } from './Link';
|
||||||
import styles from './Button.css';
|
import styles from './Button.css';
|
||||||
|
|
||||||
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
|
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
|
||||||
buttonGroupPosition?: Extract<
|
buttonGroupPosition?: Extract<Align, keyof typeof styles>;
|
||||||
(typeof align.all)[number],
|
|
||||||
keyof typeof styles
|
|
||||||
>;
|
|
||||||
kind?: Extract<Kind, keyof typeof styles>;
|
kind?: Extract<Kind, keyof typeof styles>;
|
||||||
size?: Extract<Size, keyof typeof styles>;
|
size?: Extract<Size, keyof typeof styles>;
|
||||||
children: Required<LinkProps['children']>;
|
children: Required<LinkProps['children']>;
|
||||||
|
@ -25,7 +25,9 @@ interface Episode extends ModelBase {
|
|||||||
endTime?: string;
|
endTime?: string;
|
||||||
grabDate?: string;
|
grabDate?: string;
|
||||||
seriesTitle?: string;
|
seriesTitle?: string;
|
||||||
|
queued?: boolean;
|
||||||
series?: Series;
|
series?: Series;
|
||||||
|
finaleType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Episode;
|
export default Episode;
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
|
import Episode from './Episode';
|
||||||
|
|
||||||
export type EpisodeEntities =
|
export type EpisodeEntities =
|
||||||
| 'calendar'
|
| 'calendar'
|
||||||
@ -20,7 +21,7 @@ function createEpisodeSelector(episodeId?: number) {
|
|||||||
|
|
||||||
function createCalendarEpisodeSelector(episodeId?: number) {
|
function createCalendarEpisodeSelector(episodeId?: number) {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state: AppState) => state.calendar.items,
|
(state: AppState) => state.calendar.items as Episode[],
|
||||||
(episodes) => {
|
(episodes) => {
|
||||||
return episodes.find(({ id }) => id === episodeId);
|
return episodes.find(({ id }) => id === episodeId);
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,14 @@
|
|||||||
import { createSelector } from 'reselect';
|
import { createSelector } from 'reselect';
|
||||||
import AppState from 'App/State/AppState';
|
import AppState from 'App/State/AppState';
|
||||||
import Series from 'Series/Series';
|
import Series from 'Series/Series';
|
||||||
|
import QualityProfile from 'typings/QualityProfile';
|
||||||
import { createSeriesSelectorForHook } from './createSeriesSelector';
|
import { createSeriesSelectorForHook } from './createSeriesSelector';
|
||||||
|
|
||||||
function createSeriesQualityProfileSelector(seriesId: number) {
|
function createSeriesQualityProfileSelector(seriesId: number) {
|
||||||
return createSelector(
|
return createSelector(
|
||||||
(state: AppState) => state.settings.qualityProfiles.items,
|
(state: AppState) => state.settings.qualityProfiles.items,
|
||||||
createSeriesSelectorForHook(seriesId),
|
createSeriesSelectorForHook(seriesId),
|
||||||
(qualityProfiles, series = {} as Series) => {
|
(qualityProfiles: QualityProfile[], series = {} as Series) => {
|
||||||
return qualityProfiles.find(
|
return qualityProfiles.find(
|
||||||
(profile) => profile.id === series.qualityProfileId
|
(profile) => profile.id === series.qualityProfileId
|
||||||
);
|
);
|
||||||
|
25
frontend/src/typings/Calendar.ts
Normal file
25
frontend/src/typings/Calendar.ts
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
import Episode from 'Episode/Episode';
|
||||||
|
|
||||||
|
export interface CalendarItem extends Omit<Episode, 'airDateUtc'> {
|
||||||
|
airDateUtc: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEvent extends CalendarItem {
|
||||||
|
isGroup: false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CalendarEventGroup {
|
||||||
|
isGroup: true;
|
||||||
|
seriesId: number;
|
||||||
|
seasonNumber: number;
|
||||||
|
episodeIds: number[];
|
||||||
|
events: CalendarItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CalendarStatus =
|
||||||
|
| 'downloaded'
|
||||||
|
| 'downloading'
|
||||||
|
| 'unmonitored'
|
||||||
|
| 'onAir'
|
||||||
|
| 'missing'
|
||||||
|
| 'unaired';
|
@ -1,15 +0,0 @@
|
|||||||
import Episode from 'Episode/Episode';
|
|
||||||
|
|
||||||
export interface CalendarEvent extends Episode {
|
|
||||||
isGroup: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CalendarEventGroup {
|
|
||||||
isGroup: true;
|
|
||||||
seriesId: number;
|
|
||||||
seasonNumber: number;
|
|
||||||
episodeIds: number[];
|
|
||||||
events: Episode[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export default CalendarEventGroup;
|
|
@ -1,5 +1,6 @@
|
|||||||
import ModelBase from 'App/ModelBase';
|
import ModelBase from 'App/ModelBase';
|
||||||
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
import DownloadProtocol from 'DownloadClient/DownloadProtocol';
|
||||||
|
import Episode from 'Episode/Episode';
|
||||||
import Language from 'Language/Language';
|
import Language from 'Language/Language';
|
||||||
import { QualityModel } from 'Quality/Quality';
|
import { QualityModel } from 'Quality/Quality';
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
@ -46,6 +47,7 @@ interface Queue extends ModelBase {
|
|||||||
episodeId?: number;
|
episodeId?: number;
|
||||||
seasonNumber?: number;
|
seasonNumber?: number;
|
||||||
downloadClientHasPostImportCategory: boolean;
|
downloadClientHasPostImportCategory: boolean;
|
||||||
|
episode?: Episode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Queue;
|
export default Queue;
|
||||||
|
@ -4,4 +4,7 @@ export default interface UiSettings {
|
|||||||
shortDateFormat: string;
|
shortDateFormat: string;
|
||||||
longDateFormat: string;
|
longDateFormat: string;
|
||||||
timeFormat: string;
|
timeFormat: string;
|
||||||
|
firstDayOfWeek: number;
|
||||||
|
enableColorImpairedMode: boolean;
|
||||||
|
calendarWeekColumnHeader: string;
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user