Initial Commit Rework

This commit is contained in:
Qstick 2017-09-03 22:20:56 -04:00
parent 74a4cc048c
commit 95051cbd63
2483 changed files with 101351 additions and 111396 deletions

View file

@ -0,0 +1,3 @@
.agenda {
margin-top: 10px;
}

View 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;

View 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);

View 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;
}
}

View 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;

View 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);

View file

@ -0,0 +1,8 @@
.calendar {
flex-grow: 1;
width: 100%;
}
.calendarContent {
width: 100%;
}

View 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;

View 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);

View 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%;
}

View 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;

View 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);

View 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;
}

View 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;

View 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);

View file

@ -0,0 +1,14 @@
.days {
display: flex;
border-right: 1px solid $borderColor;
}
.day,
.week,
.forecast {
flex-wrap: nowrap;
}
.month {
flex-wrap: wrap;
}

View 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;

View 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);

View file

@ -0,0 +1,13 @@
.dayOfWeek {
flex: 1 0 14.28%;
background-color: #e4eaec;
text-align: center;
}
.isSingleDay {
width: 100%;
}
.isToday {
background-color: $calendarTodayBackgroundColor;
}

View 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;

View file

@ -0,0 +1,4 @@
.daysOfWeek {
display: flex;
margin-top: 10px;
}

View 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;

View 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);

View 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);
}
}

View 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;

View 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);

View 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;

View 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;
}
}

View 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;

View 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);

View 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;

View file

@ -0,0 +1,6 @@
.legend {
display: flex;
flex-wrap: wrap;
margin-top: 10px;
padding: 3px 0;
}

View 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;

View 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';
}

View 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;

View 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];

View 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;

View 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;

View 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;

View file

@ -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);