mirror of
https://github.com/lidarr/lidarr.git
synced 2025-07-29 19:28:27 -07:00
Initial Commit Rework
This commit is contained in:
parent
74a4cc048c
commit
95051cbd63
2483 changed files with 101351 additions and 111396 deletions
3
frontend/src/Calendar/Agenda/Agenda.css
Normal file
3
frontend/src/Calendar/Agenda/Agenda.css
Normal file
|
@ -0,0 +1,3 @@
|
|||
.agenda {
|
||||
margin-top: 10px;
|
||||
}
|
38
frontend/src/Calendar/Agenda/Agenda.js
Normal file
38
frontend/src/Calendar/Agenda/Agenda.js
Normal file
|
@ -0,0 +1,38 @@
|
|||
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;
|
14
frontend/src/Calendar/Agenda/AgendaConnector.js
Normal file
14
frontend/src/Calendar/Agenda/AgendaConnector.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
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);
|
113
frontend/src/Calendar/Agenda/AgendaEvent.css
Normal file
113
frontend/src/Calendar/Agenda/AgendaEvent.css
Normal file
|
@ -0,0 +1,113 @@
|
|||
.event {
|
||||
display: flex;
|
||||
overflow-x: hidden;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
font-size: 14px;
|
||||
|
||||
&:hover {
|
||||
background-color: $tableRowHoverBackgroundColor;
|
||||
}
|
||||
}
|
||||
|
||||
.status {
|
||||
width: 10px;
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
}
|
||||
|
||||
.date {
|
||||
flex: 0 0 250px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.time {
|
||||
flex: 0 0 120px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.seriesTitle,
|
||||
.episodeTitle {
|
||||
composes: truncate from 'Styles/Mixins/truncate.css';
|
||||
|
||||
flex: 0 1 300px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.episodeTitle {
|
||||
flex: 1 1 1px;
|
||||
}
|
||||
|
||||
.seasonEpisodeNumber {
|
||||
flex: 0 0 100px;
|
||||
}
|
||||
|
||||
.episodeSeparator {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.absoluteEpisodeNumber {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Status
|
||||
*/
|
||||
|
||||
.downloaded {
|
||||
composes: downloaded from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.downloading {
|
||||
composes: downloading from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.unmonitored {
|
||||
composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.onAir {
|
||||
composes: onAir from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.missing {
|
||||
composes: missing from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.premiere {
|
||||
composes: premiere from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.unaired {
|
||||
composes: unaired from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.event {
|
||||
position: relative;
|
||||
flex-wrap: wrap;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.status {
|
||||
position: absolute;
|
||||
top: 7%;
|
||||
left: 0;
|
||||
height: 86%;
|
||||
}
|
||||
|
||||
.date,
|
||||
.time,
|
||||
.seriesTitle {
|
||||
flex: 0 0 100%;
|
||||
}
|
||||
|
||||
.seasonEpisodeNumber {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.episodeSeparator {
|
||||
display: inline-block;
|
||||
margin: 0 5px;
|
||||
}
|
||||
}
|
160
frontend/src/Calendar/Agenda/AgendaEvent.js
Normal file
160
frontend/src/Calendar/Agenda/AgendaEvent.js
Normal file
|
@ -0,0 +1,160 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import styles from './AgendaEvent.css';
|
||||
|
||||
class AgendaEvent extends Component {
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
}
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
series,
|
||||
title,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
monitored,
|
||||
hasFile,
|
||||
grabbed,
|
||||
queueItem,
|
||||
showDate,
|
||||
timeFormat,
|
||||
longDateFormat
|
||||
} = this.props;
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = startTime.add(series.runtime, 'minutes');
|
||||
const downloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(episodeNumber, hasFile, downloading, startTime, endTime, isMonitored);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
className={styles.event}
|
||||
component="div"
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<div className={styles.date}>
|
||||
{
|
||||
showDate &&
|
||||
startTime.format(longDateFormat)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.status,
|
||||
styles[statusStyle]
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className={styles.time}>
|
||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
||||
</div>
|
||||
|
||||
<div className={styles.seriesTitle}>
|
||||
{series.title}
|
||||
</div>
|
||||
|
||||
<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}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
{
|
||||
!!queueItem &&
|
||||
<CalendarEventQueueDetails
|
||||
seriesType={series.seriesType}
|
||||
seasonNumber={seasonNumber}
|
||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||
{...queueItem}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!queueItem && grabbed &&
|
||||
<Icon
|
||||
name={icons.DOWNLOADING}
|
||||
title="Episode is downloading"
|
||||
/>
|
||||
}
|
||||
</Link>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
episodeId={id}
|
||||
episodeEntity={episodeEntities.CALENDAR}
|
||||
artistId={series.id}
|
||||
episodeTitle={title}
|
||||
showOpenSeriesButton={true}
|
||||
onModalClose={this.onDetailsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AgendaEvent.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
series: PropTypes.object.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
hasFile: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
showDate: PropTypes.bool.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
longDateFormat: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default AgendaEvent;
|
24
frontend/src/Calendar/Agenda/AgendaEventConnector.js
Normal file
24
frontend/src/Calendar/Agenda/AgendaEventConnector.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createArtistSelector from 'Store/Selectors/createArtistSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import AgendaEvent from './AgendaEvent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createArtistSelector(),
|
||||
createQueueItemSelector(),
|
||||
createUISettingsSelector(),
|
||||
(series, queueItem, uiSettings) => {
|
||||
return {
|
||||
series,
|
||||
queueItem,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
longDateFormat: uiSettings.longDateFormat
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(AgendaEvent);
|
8
frontend/src/Calendar/Calendar.css
Normal file
8
frontend/src/Calendar/Calendar.css
Normal file
|
@ -0,0 +1,8 @@
|
|||
.calendar {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.calendarContent {
|
||||
width: 100%;
|
||||
}
|
64
frontend/src/Calendar/Calendar.js
Normal file
64
frontend/src/Calendar/Calendar.js
Normal file
|
@ -0,0 +1,64 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import * as calendarViews from './calendarViews';
|
||||
import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
|
||||
import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
|
||||
import CalendarDaysConnector from './Day/CalendarDaysConnector';
|
||||
import AgendaConnector from './Agenda/AgendaConnector';
|
||||
import styles from './Calendar.css';
|
||||
|
||||
class Calendar extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{
|
||||
isFetching && !isPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<div>Unable to load the calendar</div>
|
||||
}
|
||||
|
||||
{
|
||||
!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;
|
145
frontend/src/Calendar/CalendarConnector.js
Normal file
145
frontend/src/Calendar/CalendarConnector.js
Normal file
|
@ -0,0 +1,145 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import * as calendarActions from 'Store/Actions/calendarActions';
|
||||
import { fetchEpisodeFiles, clearEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||
import { fetchQueueDetails, clearQueueDetails } from 'Store/Actions/queueActions';
|
||||
import Calendar from './Calendar';
|
||||
|
||||
const UPDATE_DELAY = 3600000; // 1 hour
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(calendar) => {
|
||||
return calendar;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
...calendarActions,
|
||||
fetchEpisodeFiles,
|
||||
clearEpisodeFiles,
|
||||
fetchQueueDetails,
|
||||
clearQueueDetails
|
||||
};
|
||||
|
||||
class CalendarConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.gotoCalendarToday();
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
items,
|
||||
time
|
||||
} = this.props;
|
||||
|
||||
if (hasDifferentItems(prevProps.items, items)) {
|
||||
const episodeIds = selectUniqueIds(items, 'id');
|
||||
const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
|
||||
|
||||
this.props.fetchQueueDetails({ episodeIds });
|
||||
|
||||
if (episodeFileIds.length) {
|
||||
this.props.fetchEpisodeFiles({ episodeFileIds });
|
||||
}
|
||||
}
|
||||
|
||||
if (prevProps.time !== time) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.clearCalendar();
|
||||
this.props.clearQueueDetails();
|
||||
this.props.clearEpisodeFiles();
|
||||
this.clearUpdateTimeout();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, 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 = {
|
||||
time: PropTypes.string,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
setCalendarView: PropTypes.func.isRequired,
|
||||
gotoCalendarToday: PropTypes.func.isRequired,
|
||||
gotoCalendarPreviousRange: PropTypes.func.isRequired,
|
||||
gotoCalendarNextRange: PropTypes.func.isRequired,
|
||||
clearCalendar: 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);
|
14
frontend/src/Calendar/CalendarPage.css
Normal file
14
frontend/src/Calendar/CalendarPage.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
.calendarPageBody {
|
||||
composes: contentBody from 'Components/Page/PageContentBody.css';
|
||||
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.calendarInnerPageBody {
|
||||
composes: innerContentBody from 'Components/Page/PageContentBody.css';
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
}
|
134
frontend/src/Calendar/CalendarPage.js
Normal file
134
frontend/src/Calendar/CalendarPage.js
Normal file
|
@ -0,0 +1,134 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Measure from 'react-measure';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBodyConnector from 'Components/Page/PageContentBodyConnector';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import FilterMenuItem from 'Components/Menu/FilterMenuItem';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import Legend from './Legend/Legend';
|
||||
import CalendarConnector from './CalendarConnector';
|
||||
import styles from './CalendarPage.css';
|
||||
|
||||
const MINIMUM_DAY_WIDTH = 120;
|
||||
|
||||
class CalendarPage extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isCalendarLinkModalOpen: false,
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width }, () => {
|
||||
const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
|
||||
console.log(`${width} || ${days}`);
|
||||
this.props.onDaysCountChange(days);
|
||||
});
|
||||
}
|
||||
|
||||
onFilterMenuItemPress = (filterKey, unmonitored) => {
|
||||
this.props.onUnmonitoredChange(unmonitored);
|
||||
}
|
||||
|
||||
onGetCalendarLinkPress = () => {
|
||||
this.setState({ isCalendarLinkModalOpen: true });
|
||||
}
|
||||
|
||||
onGetCalendarLinkModalClose = () => {
|
||||
this.setState({ isCalendarLinkModalOpen: false });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
unmonitored,
|
||||
colorImpairedMode
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<PageContent title="Calendar">
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label="iCal Link"
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={this.onGetCalendarLinkPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<FilterMenu alignMenu={align.RIGHT}>
|
||||
<MenuContent>
|
||||
<FilterMenuItem
|
||||
name="unmonitored"
|
||||
value={true}
|
||||
filterKey="unmonitored"
|
||||
filterValue={unmonitored}
|
||||
onPress={this.onFilterMenuItemPress}
|
||||
>
|
||||
All
|
||||
</FilterMenuItem>
|
||||
|
||||
<FilterMenuItem
|
||||
name="unmonitored"
|
||||
value={false}
|
||||
filterKey="unmonitored"
|
||||
filterValue={unmonitored}
|
||||
onPress={this.onFilterMenuItemPress}
|
||||
>
|
||||
Monitored Only
|
||||
</FilterMenuItem>
|
||||
</MenuContent>
|
||||
</FilterMenu>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBodyConnector
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
<Measure
|
||||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
<CalendarConnector />
|
||||
</Measure>
|
||||
|
||||
<Legend colorImpairedMode={colorImpairedMode} />
|
||||
</PageContentBodyConnector>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={this.state.isCalendarLinkModalOpen}
|
||||
onModalClose={this.onGetCalendarLinkModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarPage.propTypes = {
|
||||
unmonitored: PropTypes.bool.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired,
|
||||
onDaysCountChange: PropTypes.func.isRequired,
|
||||
onUnmonitoredChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
33
frontend/src/Calendar/CalendarPageConnector.js
Normal file
33
frontend/src/Calendar/CalendarPageConnector.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { setCalendarDaysCount, setCalendarIncludeUnmonitored } from 'Store/Actions/calendarActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CalendarPage from './CalendarPage';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
createUISettingsSelector(),
|
||||
(calendar, uiSettings) => {
|
||||
return {
|
||||
unmonitored: calendar.unmonitored,
|
||||
showUpcoming: calendar.showUpcoming,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onDaysCountChange(dayCount) {
|
||||
dispatch(setCalendarDaysCount({ dayCount }));
|
||||
},
|
||||
|
||||
onUnmonitoredChange(unmonitored) {
|
||||
dispatch(setCalendarIncludeUnmonitored({ unmonitored }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage);
|
25
frontend/src/Calendar/Day/CalendarDay.css
Normal file
25
frontend/src/Calendar/Day/CalendarDay.css
Normal file
|
@ -0,0 +1,25 @@
|
|||
.day {
|
||||
flex: 1 0 14.28%;
|
||||
overflow: hidden;
|
||||
min-height: 70px;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
border-left: 1px solid $borderColor;
|
||||
}
|
||||
|
||||
.isSingleDay {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.dayOfMonth {
|
||||
padding-right: 5px;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.isToday {
|
||||
background-color: $calendarTodayBackgroundColor;
|
||||
}
|
||||
|
||||
.isDifferentMonth {
|
||||
color: $disabledColor;
|
||||
}
|
61
frontend/src/Calendar/Day/CalendarDay.js
Normal file
61
frontend/src/Calendar/Day/CalendarDay.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
|
||||
import styles from './CalendarDay.css';
|
||||
|
||||
function CalendarDay(props) {
|
||||
const {
|
||||
date,
|
||||
time,
|
||||
isTodaysDate,
|
||||
events,
|
||||
view,
|
||||
onEventModalOpenToggle
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.day,
|
||||
view === calendarViews.DAY && styles.isSingleDay
|
||||
)}>
|
||||
{
|
||||
view === calendarViews.MONTH &&
|
||||
<div className={classNames(
|
||||
styles.dayOfMonth,
|
||||
isTodaysDate && styles.isToday,
|
||||
!moment(date).isSame(moment(time), 'month') && styles.isDifferentMonth
|
||||
)}>
|
||||
{moment(date).date()}
|
||||
</div>
|
||||
}
|
||||
<div>
|
||||
{
|
||||
events.map((event) => {
|
||||
return (
|
||||
<CalendarEventConnector
|
||||
key={event.id}
|
||||
episodeId={event.id}
|
||||
{...event}
|
||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarDay.propTypes = {
|
||||
date: PropTypes.string.isRequired,
|
||||
time: PropTypes.string.isRequired,
|
||||
isTodaysDate: PropTypes.bool.isRequired,
|
||||
events: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
view: PropTypes.string.isRequired,
|
||||
onEventModalOpenToggle: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarDay;
|
55
frontend/src/Calendar/Day/CalendarDayConnector.js
Normal file
55
frontend/src/Calendar/Day/CalendarDayConnector.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
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 createCalendarEventsConnector() {
|
||||
return createSelector(
|
||||
(state, { date }) => date,
|
||||
(state) => state.calendar,
|
||||
(date, calendar) => {
|
||||
const filtered = _.filter(calendar.items, (item) => {
|
||||
return moment(date).isSame(moment(item.airDateUtc), 'day');
|
||||
});
|
||||
|
||||
return _.sortBy(filtered, (item) => moment(item.airDateUtc).unix());
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
14
frontend/src/Calendar/Day/CalendarDays.css
Normal file
14
frontend/src/Calendar/Day/CalendarDays.css
Normal file
|
@ -0,0 +1,14 @@
|
|||
.days {
|
||||
display: flex;
|
||||
border-right: 1px solid $borderColor;
|
||||
}
|
||||
|
||||
.day,
|
||||
.week,
|
||||
.forecast {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.month {
|
||||
flex-wrap: wrap;
|
||||
}
|
163
frontend/src/Calendar/Day/CalendarDays.js
Normal file
163
frontend/src/Calendar/Day/CalendarDays.js
Normal file
|
@ -0,0 +1,163 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import isToday from 'Utilities/Date/isToday';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import CalendarDayConnector from './CalendarDayConnector';
|
||||
import styles from './CalendarDays.css';
|
||||
|
||||
class CalendarDays extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._touchStart = null;
|
||||
|
||||
this.state = {
|
||||
todaysDate: moment().startOf('day').toISOString(),
|
||||
isEventModalOpen: false
|
||||
};
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const view = this.props.view;
|
||||
|
||||
if (view === calendarViews.MONTH) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
window.addEventListener('touchstart', this.onTouchStart);
|
||||
window.addEventListener('touchend', this.onTouchEnd);
|
||||
window.addEventListener('touchcancel', this.onTouchCancel);
|
||||
window.addEventListener('touchmove', this.onTouchMove);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearUpdateTimeout();
|
||||
|
||||
window.removeEventListener('touchstart', this.onTouchStart);
|
||||
window.removeEventListener('touchend', this.onTouchEnd);
|
||||
window.removeEventListener('touchcancel', this.onTouchCancel);
|
||||
window.removeEventListener('touchmove', this.onTouchMove);
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
||||
|
||||
this.setState({ todaysDate: todaysDate.toISOString() });
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
||||
}
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEventModalOpenToggle = (isEventModalOpen) => {
|
||||
this.setState({ isEventModalOpen });
|
||||
}
|
||||
|
||||
onTouchStart = (event) => {
|
||||
const touches = event.touches;
|
||||
const touchStart = touches[0].pageX;
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
touchStart < 50 ||
|
||||
this.props.isSidebarVisible ||
|
||||
this.state.isEventModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchStart = touchStart;
|
||||
}
|
||||
|
||||
onTouchEnd = (event) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
|
||||
this.props.onNavigatePrevious();
|
||||
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
|
||||
this.props.onNavigateNext();
|
||||
}
|
||||
|
||||
this._touchStart = null;
|
||||
}
|
||||
|
||||
onTouchCancel = (event) => {
|
||||
this._touchStart = null;
|
||||
}
|
||||
|
||||
onTouchMove = (event) => {
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dates,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<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;
|
32
frontend/src/Calendar/Day/CalendarDaysConnector.js
Normal file
32
frontend/src/Calendar/Day/CalendarDaysConnector.js
Normal file
|
@ -0,0 +1,32 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions';
|
||||
import CalendarDays from './CalendarDays';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(state) => state.app.isSidebarVisible,
|
||||
(calendar, isSidebarVisible) => {
|
||||
return {
|
||||
dates: calendar.dates,
|
||||
view: calendar.view,
|
||||
isSidebarVisible
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch) {
|
||||
return {
|
||||
onNavigatePrevious() {
|
||||
dispatch(gotoCalendarPreviousRange());
|
||||
},
|
||||
|
||||
onNavigateNext() {
|
||||
dispatch(gotoCalendarNextRange());
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(CalendarDays);
|
13
frontend/src/Calendar/Day/DayOfWeek.css
Normal file
13
frontend/src/Calendar/Day/DayOfWeek.css
Normal file
|
@ -0,0 +1,13 @@
|
|||
.dayOfWeek {
|
||||
flex: 1 0 14.28%;
|
||||
background-color: #e4eaec;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.isSingleDay {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.isToday {
|
||||
background-color: $calendarTodayBackgroundColor;
|
||||
}
|
55
frontend/src/Calendar/Day/DayOfWeek.js
Normal file
55
frontend/src/Calendar/Day/DayOfWeek.js
Normal file
|
@ -0,0 +1,55 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import styles from './DayOfWeek.css';
|
||||
|
||||
class DayOfWeek extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
date,
|
||||
view,
|
||||
isTodaysDate,
|
||||
calendarWeekColumnHeader,
|
||||
shortDateFormat,
|
||||
showRelativeDates
|
||||
} = this.props;
|
||||
|
||||
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
|
||||
const momentDate = moment(date);
|
||||
let formatedDate = momentDate.format('dddd');
|
||||
|
||||
if (view === calendarViews.WEEK) {
|
||||
formatedDate = momentDate.format(calendarWeekColumnHeader);
|
||||
} else if (view === calendarViews.FORECAST) {
|
||||
formatedDate = getRelativeDate(date, shortDateFormat, showRelativeDates);
|
||||
}
|
||||
|
||||
return (
|
||||
<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;
|
4
frontend/src/Calendar/Day/DaysOfWeek.css
Normal file
4
frontend/src/Calendar/Day/DaysOfWeek.css
Normal file
|
@ -0,0 +1,4 @@
|
|||
.daysOfWeek {
|
||||
display: flex;
|
||||
margin-top: 10px;
|
||||
}
|
97
frontend/src/Calendar/Day/DaysOfWeek.js
Normal file
97
frontend/src/Calendar/Day/DaysOfWeek.js
Normal file
|
@ -0,0 +1,97 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import DayOfWeek from './DayOfWeek';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import styles from './DaysOfWeek.css';
|
||||
|
||||
class DaysOfWeek extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
todaysDate: moment().startOf('day').toISOString()
|
||||
};
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const view = this.props.view;
|
||||
|
||||
if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearUpdateTimeout();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = todaysDate.clone().add(1, 'day').diff(moment());
|
||||
|
||||
this.setState({
|
||||
todaysDate: todaysDate.toISOString()
|
||||
});
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
||||
}
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dates,
|
||||
view,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
if (view === calendarViews.AGENDA) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<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;
|
22
frontend/src/Calendar/Day/DaysOfWeekConnector.js
Normal file
22
frontend/src/Calendar/Day/DaysOfWeekConnector.js
Normal file
|
@ -0,0 +1,22 @@
|
|||
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);
|
86
frontend/src/Calendar/Events/CalendarEvent.css
Normal file
86
frontend/src/Calendar/Events/CalendarEvent.css
Normal file
|
@ -0,0 +1,86 @@
|
|||
.event {
|
||||
overflow-x: hidden;
|
||||
margin: 4px 2px;
|
||||
padding: 5px;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
border-left: 4px solid $borderColor;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.info,
|
||||
.episodeInfo {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.seriesTitle,
|
||||
.episodeTitle {
|
||||
composes: truncate from 'Styles/Mixins/truncate.css';
|
||||
|
||||
flex: 1 0 1px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.seriesTitle {
|
||||
color: #3a3f51;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.absoluteEpisodeNumber {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
.statusIcon {
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
/*
|
||||
* Status
|
||||
*/
|
||||
|
||||
.downloaded {
|
||||
border-left-color: $successColor;
|
||||
}
|
||||
|
||||
.downloading {
|
||||
border-left-color: $purple;
|
||||
}
|
||||
|
||||
.unmonitored {
|
||||
border-left-color: $gray;
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(45deg, transparent, transparent 5px, #eee 5px, #eee 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.onAir {
|
||||
border-left-color: $warningColor;
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.missing {
|
||||
border-left-color: $dangerColor;
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.premiere {
|
||||
border-left-color: $sonarrBlue;
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
|
||||
}
|
||||
}
|
||||
|
||||
.unaired {
|
||||
border-left-color: $primaryColor;
|
||||
|
||||
&:global(.colorImpaired) {
|
||||
background: repeating-linear-gradient(90deg, transparent, transparent 5px, #eee 5px, #eee 10px);
|
||||
}
|
||||
}
|
165
frontend/src/Calendar/Events/CalendarEvent.js
Normal file
165
frontend/src/Calendar/Events/CalendarEvent.js
Normal file
|
@ -0,0 +1,165 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
|
||||
import styles from './CalendarEvent.css';
|
||||
|
||||
class CalendarEvent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true }, () => {
|
||||
this.props.onEventModalOpenToggle(true);
|
||||
});
|
||||
}
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false }, () => {
|
||||
this.props.onEventModalOpenToggle(false);
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
series,
|
||||
title,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
monitored,
|
||||
hasFile,
|
||||
grabbed,
|
||||
queueItem,
|
||||
timeFormat,
|
||||
colorImpairedMode
|
||||
} = this.props;
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = startTime.add(series.runtime, 'minutes');
|
||||
const downloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(episodeNumber, hasFile, downloading, startTime, endTime, isMonitored);
|
||||
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.event,
|
||||
styles[statusStyle],
|
||||
colorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
component="div"
|
||||
onPress={this.onPress}
|
||||
>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.seriesTitle}>
|
||||
{series.title}
|
||||
</div>
|
||||
|
||||
{
|
||||
missingAbsoluteNumber &&
|
||||
<Icon
|
||||
name={icons.WARNING}
|
||||
title="Episode does not have an absolute episode number"
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!!queueItem &&
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails
|
||||
{...queueItem}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
!queueItem && grabbed &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title="Episode is downloading"
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
||||
</div>
|
||||
</Link>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
episodeId={id}
|
||||
episodeEntity={episodeEntities.CALENDAR}
|
||||
artistId={series.id}
|
||||
episodeTitle={title}
|
||||
showOpenSeriesButton={true}
|
||||
onModalClose={this.onDetailsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarEvent.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
series: PropTypes.object.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
hasFile: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired,
|
||||
onEventModalOpenToggle: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarEvent;
|
24
frontend/src/Calendar/Events/CalendarEventConnector.js
Normal file
24
frontend/src/Calendar/Events/CalendarEventConnector.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createArtistSelector from 'Store/Selectors/createArtistSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CalendarEvent from './CalendarEvent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createArtistSelector(),
|
||||
createQueueItemSelector(),
|
||||
createUISettingsSelector(),
|
||||
(series, queueItem, uiSettings) => {
|
||||
return {
|
||||
series,
|
||||
queueItem,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarEvent);
|
50
frontend/src/Calendar/Events/CalendarEventQueueDetails.js
Normal file
50
frontend/src/Calendar/Events/CalendarEventQueueDetails.js
Normal file
|
@ -0,0 +1,50 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import colors from 'Styles/Variables/colors';
|
||||
import CircularProgressBar from 'Components/CircularProgressBar';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
|
||||
function CalendarEventQueueDetails(props) {
|
||||
const {
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
errorMessage
|
||||
} = props;
|
||||
|
||||
const progress = (100 - sizeleft / size * 100);
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
errorMessage={errorMessage}
|
||||
progressBar={
|
||||
<div title={`Episode is downloading - ${progress.toFixed(1)}% ${title}`}>
|
||||
<CircularProgressBar
|
||||
progress={progress}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
strokeColor={colors.purple}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarEventQueueDetails.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
sizeleft: PropTypes.number.isRequired,
|
||||
estimatedCompletionTime: PropTypes.string,
|
||||
status: PropTypes.string.isRequired,
|
||||
errorMessage: PropTypes.string
|
||||
};
|
||||
|
||||
export default CalendarEventQueueDetails;
|
53
frontend/src/Calendar/Header/CalendarHeader.css
Normal file
53
frontend/src/Calendar/Header/CalendarHeader.css
Normal file
|
@ -0,0 +1,53 @@
|
|||
.header {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.navigationButtons {
|
||||
flex: 1 1 33%;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.todayButton {
|
||||
composes: button from 'Components/Link/Button.css';
|
||||
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.titleDesktop,
|
||||
.titleMobile {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.titleMobile {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.viewButtonsContainer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 1 1 33%;
|
||||
}
|
||||
|
||||
.viewMenu {
|
||||
composes: menu from 'Components/Menu/Menu.css';
|
||||
|
||||
line-height: 31px;
|
||||
}
|
||||
|
||||
.loading {
|
||||
composes: loading from 'Components/Loading/LoadingIndicator.css';
|
||||
|
||||
margin-top: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.navigationButtons {
|
||||
flex: 1 0 50%;
|
||||
}
|
||||
|
||||
.viewButtonsContainer {
|
||||
flex: 0 0 100px;
|
||||
}
|
||||
}
|
253
frontend/src/Calendar/Header/CalendarHeader.js
Normal file
253
frontend/src/Calendar/Header/CalendarHeader.js
Normal file
|
@ -0,0 +1,253 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Icon from 'Components/Icon';
|
||||
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 * as calendarViews from 'Calendar/calendarViews';
|
||||
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
||||
import styles from './CalendarHeader.css';
|
||||
|
||||
function getTitle(time, start, end, view, longDateFormat) {
|
||||
const timeMoment = moment(time);
|
||||
const startMoment = moment(start);
|
||||
const endMoment = moment(end);
|
||||
|
||||
if (view === 'day') {
|
||||
return timeMoment.format(longDateFormat);
|
||||
} else if (view === 'month') {
|
||||
return timeMoment.format('MMMM YYYY');
|
||||
} else if (view === 'agenda') {
|
||||
return 'Agenda';
|
||||
}
|
||||
|
||||
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,
|
||||
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}
|
||||
>
|
||||
Today
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{
|
||||
!isSmallScreen &&
|
||||
<div className={styles.titleDesktop}>
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.viewButtonsContainer}>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
isSmallScreen ?
|
||||
<Menu
|
||||
className={styles.viewMenu}
|
||||
alignMenu={align.RIGHT}
|
||||
>
|
||||
<MenuButton>
|
||||
<Icon
|
||||
name={icons.VIEW}
|
||||
size={22}
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
<ViewMenuItem
|
||||
name={calendarViews.WEEK}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
Week
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.FORECAST}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
Forecast
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.DAY}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
Day
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.AGENDA}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
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,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
onViewChange: PropTypes.func.isRequired,
|
||||
onTodayPress: PropTypes.func.isRequired,
|
||||
onPreviousPress: PropTypes.func.isRequired,
|
||||
onNextPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarHeader;
|
84
frontend/src/Calendar/Header/CalendarHeaderConnector.js
Normal file
84
frontend/src/Calendar/Header/CalendarHeaderConnector.js
Normal file
|
@ -0,0 +1,84 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { setCalendarView, gotoCalendarToday, gotoCalendarPreviousRange, gotoCalendarNextRange } from 'Store/Actions/calendarActions';
|
||||
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.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);
|
45
frontend/src/Calendar/Header/CalendarHeaderViewButton.js
Normal file
45
frontend/src/Calendar/Header/CalendarHeaderViewButton.js
Normal file
|
@ -0,0 +1,45 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import Button from 'Components/Link/Button';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
// 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;
|
6
frontend/src/Calendar/Legend/Legend.css
Normal file
6
frontend/src/Calendar/Legend/Legend.css
Normal file
|
@ -0,0 +1,6 @@
|
|||
.legend {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 10px;
|
||||
padding: 3px 0;
|
||||
}
|
68
frontend/src/Calendar/Legend/Legend.js
Normal file
68
frontend/src/Calendar/Legend/Legend.js
Normal file
|
@ -0,0 +1,68 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import LegendItem from './LegendItem';
|
||||
import styles from './Legend.css';
|
||||
|
||||
function Legend({ colorImpairedMode }) {
|
||||
return (
|
||||
<div className={styles.legend}>
|
||||
<div>
|
||||
<LegendItem
|
||||
name="Unaired Premiere"
|
||||
status="premiere"
|
||||
tooltip="Premiere episode hasn't aired yet"
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
status="unaired"
|
||||
tooltip="Episode hasn't aired yet"
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LegendItem
|
||||
status="downloading"
|
||||
tooltip="Episode is currently downloading"
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
status="downloaded"
|
||||
tooltip="Episode was downloaded and sorted"
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LegendItem
|
||||
name="On Air"
|
||||
status="onAir"
|
||||
tooltip="Episode is currently airing"
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
status="missing"
|
||||
tooltip="Episode file has not been found"
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LegendItem
|
||||
status="unmonitored"
|
||||
tooltip="Episode is unmonitored"
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Legend.propTypes = {
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default Legend;
|
41
frontend/src/Calendar/Legend/LegendItem.css
Normal file
41
frontend/src/Calendar/Legend/LegendItem.css
Normal file
|
@ -0,0 +1,41 @@
|
|||
.legendItem {
|
||||
margin: 3px 0;
|
||||
margin-right: 6px;
|
||||
padding-left: 5px;
|
||||
width: 150px;
|
||||
border-left-width: 4px;
|
||||
border-left-style: solid;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/*
|
||||
* Status
|
||||
*/
|
||||
|
||||
.downloaded {
|
||||
composes: downloaded from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.downloading {
|
||||
composes: downloading from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.unmonitored {
|
||||
composes: unmonitored from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.onAir {
|
||||
composes: onAir from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.missing {
|
||||
composes: missing from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.premiere {
|
||||
composes: premiere from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
||||
|
||||
.unaired {
|
||||
composes: unaired from 'Calendar/Events/CalendarEvent.css';
|
||||
}
|
36
frontend/src/Calendar/Legend/LegendItem.js
Normal file
36
frontend/src/Calendar/Legend/LegendItem.js
Normal file
|
@ -0,0 +1,36 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import styles from './LegendItem.css';
|
||||
|
||||
function LegendItem(props) {
|
||||
const {
|
||||
name,
|
||||
status,
|
||||
tooltip,
|
||||
colorImpairedMode
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.legendItem,
|
||||
styles[status],
|
||||
colorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
title={tooltip}
|
||||
>
|
||||
{name ? name : titleCase(status)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LegendItem.propTypes = {
|
||||
name: PropTypes.string,
|
||||
status: PropTypes.string.isRequired,
|
||||
tooltip: PropTypes.string.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default LegendItem;
|
7
frontend/src/Calendar/calendarViews.js
Normal file
7
frontend/src/Calendar/calendarViews.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
export const DAY = 'day';
|
||||
export const WEEK = 'week';
|
||||
export const MONTH = 'month';
|
||||
export const FORECAST = 'forecast';
|
||||
export const AGENDA = 'agenda';
|
||||
|
||||
export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
|
33
frontend/src/Calendar/getStatusStyle.js
Normal file
33
frontend/src/Calendar/getStatusStyle.js
Normal file
|
@ -0,0 +1,33 @@
|
|||
import moment from 'moment';
|
||||
|
||||
function getStatusStyle(episodeNumber, hasFile, downloading, startTime, endTime, isMonitored) {
|
||||
const currentTime = moment();
|
||||
|
||||
if (hasFile) {
|
||||
return 'downloaded';
|
||||
}
|
||||
|
||||
if (downloading) {
|
||||
return 'downloading';
|
||||
}
|
||||
|
||||
if (!isMonitored) {
|
||||
return 'unmonitored';
|
||||
}
|
||||
|
||||
if (currentTime.isAfter(startTime) && currentTime.isBefore(endTime)) {
|
||||
return 'onAir';
|
||||
}
|
||||
|
||||
if (endTime.isBefore(currentTime) && !hasFile) {
|
||||
return 'missing';
|
||||
}
|
||||
|
||||
if (episodeNumber === 1) {
|
||||
return 'premiere';
|
||||
}
|
||||
|
||||
return 'unaired';
|
||||
}
|
||||
|
||||
export default getStatusStyle;
|
29
frontend/src/Calendar/iCal/CalendarLinkModal.js
Normal file
29
frontend/src/Calendar/iCal/CalendarLinkModal.js
Normal file
|
@ -0,0 +1,29 @@
|
|||
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;
|
221
frontend/src/Calendar/iCal/CalendarLinkModalContent.js
Normal file
221
frontend/src/Calendar/iCal/CalendarLinkModalContent.js
Normal file
|
@ -0,0 +1,221 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
|
||||
function getUrls(state) {
|
||||
const {
|
||||
unmonitored,
|
||||
premieresOnly,
|
||||
asAllDay,
|
||||
tags
|
||||
} = state;
|
||||
|
||||
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/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=${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>
|
||||
Sonarr Calendar Feed
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>Include Unmonitored</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="unmonitored"
|
||||
value={unmonitored}
|
||||
helpText="Include unmonitored episodes in the iCal feed"
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Season Premieres Only</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="premieresOnly"
|
||||
value={premieresOnly}
|
||||
helpText="Only the first episode in a season will be in the feed"
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Show as All-Day Events</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="asAllDay"
|
||||
value={asAllDay}
|
||||
helpText="Events will appear as all-day events in your calendar"
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>Tags</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
value={tags}
|
||||
helpText="Feed will only contain series with at least one matching tag"
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<FormLabel>iCal Feed</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="iCalHttpUrl"
|
||||
value={iCalHttpUrl}
|
||||
readOnly={true}
|
||||
helpText="Copy this URL to your client(s) or click to subscribe if your browser supports webcal"
|
||||
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}>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarLinkModalContent.propTypes = {
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarLinkModalContent;
|
|
@ -0,0 +1,17 @@
|
|||
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);
|
Loading…
Add table
Add a link
Reference in a new issue