mirror of
https://github.com/Sonarr/Sonarr
synced 2025-12-06 16:32:24 +01:00
Compare commits
138 commits
v4.0.10.26
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52972e7efc | ||
|
|
8c50919499 | ||
|
|
fdc07a47b1 | ||
|
|
36225c3709 | ||
|
|
bc037ae356 | ||
|
|
77a335de30 | ||
|
|
88d56361c4 | ||
|
|
d10107739b | ||
|
|
7db7567c8e | ||
|
|
2b2b973b30 | ||
|
|
bb954a7424 | ||
|
|
640e3e5d44 | ||
|
|
1260d3c800 | ||
|
|
feeed9a7cf | ||
|
|
c8cb74a976 | ||
|
|
7193acb5ee | ||
|
|
6f1fc1686f | ||
|
|
b7407837b7 | ||
|
|
4e65669c48 | ||
|
|
fa38498db0 | ||
|
|
3b024443c5 | ||
|
|
4ba9b21bb7 | ||
|
|
e37684e045 | ||
|
|
103ccd74f3 | ||
|
|
ba22992265 | ||
|
|
963395b969 | ||
|
|
970df1a1d8 | ||
|
|
2ac139ab4d | ||
|
|
c69db1ff92 | ||
|
|
6dae2f0d84 | ||
|
|
87934c7761 | ||
|
|
fe8478f42a | ||
|
|
a840bb5423 | ||
|
|
8f5d628c55 | ||
|
|
acebe87dba | ||
|
|
7d77500667 | ||
|
|
ec73a13396 | ||
|
|
fa0f77659c | ||
|
|
1609f0c964 | ||
|
|
1fea0b3d10 | ||
|
|
3c8268c428 | ||
|
|
c589c4f85e | ||
|
|
f843107c25 | ||
|
|
035c474f10 | ||
|
|
4dcc015fb1 | ||
|
|
1969e0107f | ||
|
|
ac7c05c050 | ||
|
|
8aad79fd3e | ||
|
|
f05e552e8e | ||
|
|
8cd5cd603a | ||
|
|
ef358e6f24 | ||
|
|
fae24e98fb | ||
|
|
c885fb81f9 | ||
|
|
514c04935f | ||
|
|
4b14368736 | ||
|
|
1c30ecd66d | ||
|
|
f7b54f9d6b | ||
|
|
ce7d8a175e | ||
|
|
ab49268bac | ||
|
|
608f67a074 | ||
|
|
9a69222c9a | ||
|
|
82c526e15c | ||
|
|
983b079c82 | ||
|
|
edfc12e27a | ||
|
|
ed10b63fa0 | ||
|
|
016b571838 | ||
|
|
bfcd017012 | ||
|
|
2e83d59f61 | ||
|
|
c39fb4fe6f | ||
|
|
220b4bc257 | ||
|
|
99e25cec0f | ||
|
|
5d1d44e09e | ||
|
|
3b00112447 | ||
|
|
cb7489ce8f | ||
|
|
b552d4e9f7 | ||
|
|
c0e264cfc5 | ||
|
|
811eb36c7b | ||
|
|
1484809099 | ||
|
|
024462c52d | ||
|
|
e70aef9690 | ||
|
|
36633b5d08 | ||
|
|
1374240321 | ||
|
|
f1d54d2a9a | ||
|
|
03b8c4c28e | ||
|
|
4e4bf3507f | ||
|
|
34ae65c087 | ||
|
|
ebe23104d4 | ||
|
|
e8c3aa20bd | ||
|
|
6c231cbe6a | ||
|
|
8ce688186e | ||
|
|
04ebf03fb5 | ||
|
|
c38debab1b | ||
|
|
32f66922e7 | ||
|
|
ed536a85ad | ||
|
|
c62fc9d05b | ||
|
|
fb9a5efe05 | ||
|
|
8cb58a63d8 | ||
|
|
4c41a4f368 | ||
|
|
e039dc45e2 | ||
|
|
776143cc81 | ||
|
|
8c67a3bdee | ||
|
|
160151c6e0 | ||
|
|
efd48710e4 | ||
|
|
00c16cd06b | ||
|
|
65d07fa99e | ||
|
|
bd656ae7f6 | ||
|
|
62bcf397dd | ||
|
|
f9606518ee | ||
|
|
40f4ef27b2 | ||
|
|
93c3f6d1d6 | ||
|
|
417af2b915 | ||
|
|
4491df3ae7 | ||
|
|
a90866a73e | ||
|
|
2f62494adc | ||
|
|
e361f18837 | ||
|
|
183b8b574a | ||
|
|
12c1eb86f2 | ||
|
|
5034d83062 | ||
|
|
dba3a82439 | ||
|
|
b51a490979 | ||
|
|
8b38ccfb63 | ||
|
|
91c5e6f122 | ||
|
|
dcbef6b7b7 | ||
|
|
ca0bb14027 | ||
|
|
3e99917e9d | ||
|
|
936cf699ff | ||
|
|
202190d032 | ||
|
|
f739fd0900 | ||
|
|
88f4016fe0 | ||
|
|
78fb20282d | ||
|
|
6677fd1116 | ||
|
|
e28b7c3df6 | ||
|
|
67a1ecb0fe | ||
|
|
5bc943583c | ||
|
|
ceeec091f8 | ||
|
|
675e3cd38a | ||
|
|
45a62a2e59 | ||
|
|
ae7c07e02f |
436 changed files with 22011 additions and 8436 deletions
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
|
|
@ -22,7 +22,7 @@ env:
|
|||
FRAMEWORK: net6.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.10
|
||||
VERSION: 4.0.16
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# <img width="24px" src="./Logo/256.png" alt="Sonarr"></img> Sonarr
|
||||
|
||||
[](https://translate.servarr.com/engage/servarr/)
|
||||
[](https://translate.servarr.com/engage/servarr/)
|
||||
[](#backers)
|
||||
[](#sponsors)
|
||||
[](#mega-sponsors)
|
||||
|
|
|
|||
|
|
@ -363,7 +363,11 @@ module.exports = {
|
|||
{
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
destructuredArrayIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true
|
||||
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ module.exports = (env) => {
|
|||
const config = {
|
||||
mode: isProduction ? 'production' : 'development',
|
||||
devtool: isProduction ? 'source-map' : 'eval-source-map',
|
||||
target: 'web',
|
||||
|
||||
stats: {
|
||||
children: false
|
||||
|
|
@ -51,8 +52,7 @@ module.exports = (env) => {
|
|||
'node_modules'
|
||||
],
|
||||
alias: {
|
||||
jquery: 'jquery/dist/jquery.min',
|
||||
'react-middle-truncate': 'react-middle-truncate/lib/react-middle-truncate'
|
||||
jquery: 'jquery/dist/jquery.min'
|
||||
},
|
||||
fallback: {
|
||||
buffer: false,
|
||||
|
|
@ -187,7 +187,7 @@ module.exports = (env) => {
|
|||
loose: true,
|
||||
debug: false,
|
||||
useBuiltIns: 'entry',
|
||||
corejs: 3
|
||||
corejs: '3.39'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||
indexer,
|
||||
releaseGroup,
|
||||
seriesMatchType,
|
||||
releaseSource,
|
||||
customFormatScore,
|
||||
nzbInfoUrl,
|
||||
downloadClient,
|
||||
|
|
@ -53,6 +54,31 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||
|
||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||
|
||||
let releaseSourceMessage = '';
|
||||
|
||||
switch (releaseSource) {
|
||||
case 'Unknown':
|
||||
releaseSourceMessage = translate('Unknown');
|
||||
break;
|
||||
case 'Rss':
|
||||
releaseSourceMessage = translate('Rss');
|
||||
break;
|
||||
case 'Search':
|
||||
releaseSourceMessage = translate('Search');
|
||||
break;
|
||||
case 'UserInvokedSearch':
|
||||
releaseSourceMessage = translate('UserInvokedSearch');
|
||||
break;
|
||||
case 'InteractiveSearch':
|
||||
releaseSourceMessage = translate('InteractiveSearch');
|
||||
break;
|
||||
case 'ReleasePush':
|
||||
releaseSourceMessage = translate('ReleasePush');
|
||||
break;
|
||||
default:
|
||||
releaseSourceMessage = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
|
|
@ -88,6 +114,14 @@ function HistoryDetails(props: HistoryDetailsProps) {
|
|||
/>
|
||||
) : null}
|
||||
|
||||
{releaseSource ? (
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('ReleaseSource')}
|
||||
data={releaseSourceMessage}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{nzbInfoUrl ? (
|
||||
<span>
|
||||
<DescriptionListItemTitle>
|
||||
|
|
|
|||
|
|
@ -70,10 +70,15 @@
|
|||
}
|
||||
|
||||
.originalLanguageName,
|
||||
.network {
|
||||
.network,
|
||||
.genres {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.genres {
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.tvdbLink {
|
||||
composes: link from '~Components/Link/Link.css';
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
interface CssExports {
|
||||
'alreadyExistsIcon': string;
|
||||
'content': string;
|
||||
'genres': string;
|
||||
'icons': string;
|
||||
'network': string;
|
||||
'originalLanguageName': string;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Label from 'Components/Label';
|
|||
import Link from 'Components/Link/Link';
|
||||
import MetadataAttribution from 'Components/MetadataAttribution';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import SeriesGenres from 'Series/SeriesGenres';
|
||||
import SeriesPoster from 'Series/SeriesPoster';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AddNewSeriesModal from './AddNewSeriesModal';
|
||||
|
|
@ -56,6 +57,7 @@ class AddNewSeriesSearchResult extends Component {
|
|||
year,
|
||||
network,
|
||||
originalLanguage,
|
||||
genres,
|
||||
status,
|
||||
overview,
|
||||
statistics,
|
||||
|
|
@ -181,6 +183,18 @@ class AddNewSeriesSearchResult extends Component {
|
|||
null
|
||||
}
|
||||
|
||||
{
|
||||
genres.length > 0 ?
|
||||
<Label size={sizes.LARGE}>
|
||||
<Icon
|
||||
name={icons.GENRE}
|
||||
size={13}
|
||||
/>
|
||||
<SeriesGenres className={styles.genres} genres={genres} />
|
||||
</Label> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
seasonCount ?
|
||||
<Label size={sizes.LARGE}>
|
||||
|
|
@ -243,6 +257,7 @@ AddNewSeriesSearchResult.propTypes = {
|
|||
year: PropTypes.number.isRequired,
|
||||
network: PropTypes.string,
|
||||
originalLanguage: PropTypes.object,
|
||||
genres: PropTypes.arrayOf(PropTypes.string),
|
||||
status: PropTypes.string.isRequired,
|
||||
overview: PropTypes.string,
|
||||
statistics: PropTypes.object.isRequired,
|
||||
|
|
@ -254,4 +269,8 @@ AddNewSeriesSearchResult.propTypes = {
|
|||
isSmallScreen: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
AddNewSeriesSearchResult.defaultProps = {
|
||||
genres: []
|
||||
};
|
||||
|
||||
export default AddNewSeriesSearchResult;
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
.inputContainer {
|
||||
margin-right: 20px;
|
||||
min-width: 150px;
|
||||
|
||||
div {
|
||||
margin-top: 10px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.label {
|
||||
margin-bottom: 3px;
|
||||
margin-bottom: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
|
||||
import React from 'react';
|
||||
import DocumentTitle from 'react-document-title';
|
||||
|
|
@ -12,17 +13,21 @@ interface AppProps {
|
|||
history: ConnectedRouterProps['history'];
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
function App({ store, history }: AppProps) {
|
||||
return (
|
||||
<DocumentTitle title={window.Sonarr.instanceName}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
</QueryClientProvider>
|
||||
</DocumentTitle>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import History from 'Activity/History/History';
|
|||
import Queue from 'Activity/Queue/Queue';
|
||||
import AddNewSeriesConnector from 'AddSeries/AddNewSeries/AddNewSeriesConnector';
|
||||
import ImportSeries from 'AddSeries/ImportSeries/ImportSeries';
|
||||
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
|
||||
import CalendarPage from 'Calendar/CalendarPage';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import SeriesDetailsPageConnector from 'Series/Details/SeriesDetailsPageConnector';
|
||||
|
|
@ -72,7 +72,7 @@ function AppRoutes() {
|
|||
Calendar
|
||||
*/}
|
||||
|
||||
<Route path="/calendar" component={CalendarPageConnector} />
|
||||
<Route path="/calendar" component={CalendarPage} />
|
||||
|
||||
{/*
|
||||
Activity
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import Column from 'Components/Table/Column';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { ValidationFailure } from 'typings/pending';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
responseJSON: {
|
||||
message: string;
|
||||
};
|
||||
status?: number;
|
||||
responseJSON:
|
||||
| {
|
||||
message: string | undefined;
|
||||
}
|
||||
| ValidationFailure[]
|
||||
| undefined;
|
||||
}
|
||||
|
||||
export interface AppSectionDeleteState {
|
||||
|
|
@ -58,6 +63,16 @@ export interface AppSectionItemState<T> {
|
|||
item: T;
|
||||
}
|
||||
|
||||
export interface AppSectionProviderState<T>
|
||||
extends AppSectionDeleteState,
|
||||
AppSectionSaveState {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
items: T[];
|
||||
pendingChanges: Partial<T>;
|
||||
}
|
||||
|
||||
interface AppSectionState<T> {
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
|
|
|
|||
|
|
@ -54,10 +54,12 @@ export interface CustomFilter {
|
|||
export interface AppSectionState {
|
||||
isConnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
isSidebarVisible: boolean;
|
||||
version: string;
|
||||
prevVersion?: string;
|
||||
dimensions: {
|
||||
isSmallScreen: boolean;
|
||||
isLargeScreen: boolean;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
|
@ -70,6 +72,7 @@ interface AppState {
|
|||
captcha: CaptchaAppState;
|
||||
commands: CommandAppState;
|
||||
episodeFiles: EpisodeFilesAppState;
|
||||
episodeHistory: HistoryAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodesSelection: EpisodesAppState;
|
||||
history: HistoryAppState;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,29 @@
|
|||
import moment from 'moment';
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Episode from 'Episode/Episode';
|
||||
import { CalendarView } from 'Calendar/calendarViews';
|
||||
import { CalendarItem } from 'typings/Calendar';
|
||||
|
||||
interface CalendarOptions {
|
||||
showEpisodeInformation: boolean;
|
||||
showFinaleIcon: boolean;
|
||||
showSpecialIcon: boolean;
|
||||
showCutoffUnmetIcon: boolean;
|
||||
collapseMultipleEpisodes: boolean;
|
||||
fullColorEvents: boolean;
|
||||
}
|
||||
|
||||
interface CalendarAppState
|
||||
extends AppSectionState<Episode>,
|
||||
AppSectionFilterState<Episode> {}
|
||||
extends AppSectionState<CalendarItem>,
|
||||
AppSectionFilterState<CalendarItem> {
|
||||
searchMissingCommandId: number | null;
|
||||
start: moment.Moment;
|
||||
end: moment.Moment;
|
||||
dates: string[];
|
||||
time: string;
|
||||
view: CalendarView;
|
||||
options: CalendarOptions;
|
||||
}
|
||||
|
||||
export default CalendarAppState;
|
||||
|
|
|
|||
6
frontend/src/App/State/MetadataAppState.ts
Normal file
6
frontend/src/App/State/MetadataAppState.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { AppSectionProviderState } from 'App/State/AppSectionState';
|
||||
import Metadata from 'typings/Metadata';
|
||||
|
||||
type MetadataAppState = AppSectionProviderState<Metadata>;
|
||||
|
||||
export default MetadataAppState;
|
||||
|
|
@ -59,6 +59,8 @@ interface SeriesAppState
|
|||
deleteOptions: {
|
||||
addImportListExclusion: boolean;
|
||||
};
|
||||
|
||||
pendingChanges: Partial<Series>;
|
||||
}
|
||||
|
||||
export default SeriesAppState;
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import NamingConfig from 'typings/Settings/NamingConfig';
|
|||
import NamingExample from 'typings/Settings/NamingExample';
|
||||
import ReleaseProfile from 'typings/Settings/ReleaseProfile';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import MetadataAppState from './MetadataAppState';
|
||||
|
||||
export interface DownloadClientAppState
|
||||
extends AppSectionState<DownloadClient>,
|
||||
|
|
@ -36,8 +37,7 @@ export interface NamingAppState
|
|||
extends AppSectionItemState<NamingConfig>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface NamingExamplesAppState
|
||||
extends AppSectionItemState<NamingExample> {}
|
||||
export type NamingExamplesAppState = AppSectionItemState<NamingExample>;
|
||||
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
|
|
@ -97,6 +97,7 @@ interface SettingsAppState {
|
|||
indexerFlags: IndexerFlagSettingsAppState;
|
||||
indexers: IndexerAppState;
|
||||
languages: LanguageSettingsAppState;
|
||||
metadata: MetadataAppState;
|
||||
naming: NamingAppState;
|
||||
namingExamples: NamingExamplesAppState;
|
||||
notifications: NotificationAppState;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import AppSectionState from 'App/State/AppSectionState';
|
||||
import Episode from 'Episode/Episode';
|
||||
|
||||
interface WantedCutoffUnmetAppState extends AppSectionState<Episode> {}
|
||||
type WantedCutoffUnmetAppState = AppSectionState<Episode>;
|
||||
|
||||
interface WantedMissingAppState extends AppSectionState<Episode> {}
|
||||
type WantedMissingAppState = AppSectionState<Episode>;
|
||||
|
||||
interface WantedAppState {
|
||||
cutoffUnmet: WantedCutoffUnmetAppState;
|
||||
|
|
|
|||
|
|
@ -1,38 +0,0 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import AgendaEventConnector from './AgendaEventConnector';
|
||||
import styles from './Agenda.css';
|
||||
|
||||
function Agenda(props) {
|
||||
const {
|
||||
items
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={styles.agenda}>
|
||||
{
|
||||
items.map((item, index) => {
|
||||
const momentDate = moment(item.airDateUtc);
|
||||
const showDate = index === 0 ||
|
||||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
|
||||
|
||||
return (
|
||||
<AgendaEventConnector
|
||||
key={item.id}
|
||||
episodeId={item.id}
|
||||
showDate={showDate}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Agenda.propTypes = {
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default Agenda;
|
||||
25
frontend/src/Calendar/Agenda/Agenda.tsx
Normal file
25
frontend/src/Calendar/Agenda/Agenda.tsx
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import AgendaEvent from './AgendaEvent';
|
||||
import styles from './Agenda.css';
|
||||
|
||||
function Agenda() {
|
||||
const { items } = useSelector((state: AppState) => state.calendar);
|
||||
|
||||
return (
|
||||
<div className={styles.agenda}>
|
||||
{items.map((item, index) => {
|
||||
const momentDate = moment(item.airDateUtc);
|
||||
const showDate =
|
||||
index === 0 ||
|
||||
!moment(items[index - 1].airDateUtc).isSame(momentDate, 'day');
|
||||
|
||||
return <AgendaEvent key={item.id} showDate={showDate} {...item} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Agenda;
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import Agenda from './Agenda';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(calendar) => {
|
||||
return calendar;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(Agenda);
|
||||
|
|
@ -1,254 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AgendaEvent.css';
|
||||
|
||||
class AgendaEvent extends Component {
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
};
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
series,
|
||||
episodeFile,
|
||||
title,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
monitored,
|
||||
unverifiedSceneNumbering,
|
||||
finaleType,
|
||||
hasFile,
|
||||
grabbed,
|
||||
queueItem,
|
||||
showDate,
|
||||
showEpisodeInformation,
|
||||
showFinaleIcon,
|
||||
showSpecialIcon,
|
||||
showCutoffUnmetIcon,
|
||||
timeFormat,
|
||||
longDateFormat,
|
||||
colorImpairedMode
|
||||
} = this.props;
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
||||
const downloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored);
|
||||
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
||||
|
||||
return (
|
||||
<div className={styles.event}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={this.onPress}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.date}>
|
||||
{
|
||||
showDate &&
|
||||
startTime.format(longDateFormat)
|
||||
}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.eventWrapper,
|
||||
styles[statusStyle],
|
||||
colorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
>
|
||||
<div className={styles.time}>
|
||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
||||
</div>
|
||||
|
||||
<div className={styles.seriesTitle}>
|
||||
{series.title}
|
||||
</div>
|
||||
|
||||
{
|
||||
showEpisodeInformation &&
|
||||
<div className={styles.seasonEpisodeNumber}>
|
||||
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
||||
|
||||
{
|
||||
series.seriesType === 'anime' && absoluteEpisodeNumber &&
|
||||
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span>
|
||||
}
|
||||
|
||||
<div className={styles.episodeSeparator}> - </div>
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.episodeTitle}>
|
||||
{
|
||||
showEpisodeInformation &&
|
||||
title
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
missingAbsoluteNumber &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
unverifiedSceneNumbering && !missingAbsoluteNumber ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('SceneNumberNotVerified')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!!queueItem &&
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails
|
||||
seriesType={series.seriesType}
|
||||
seasonNumber={seasonNumber}
|
||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||
{...queueItem}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
{
|
||||
!queueItem && grabbed &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('EpisodeIsDownloading')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
showCutoffUnmetIcon &&
|
||||
!!episodeFile &&
|
||||
episodeFile.qualityCutoffNotMet &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.EPISODE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
episodeNumber === 1 && seasonNumber > 0 &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.INFO}
|
||||
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
showFinaleIcon &&
|
||||
finaleType ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.WARNING}
|
||||
title={getFinaleTypeName(finaleType)}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
showSpecialIcon &&
|
||||
(episodeNumber === 0 || seasonNumber === 0) &&
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.PINK}
|
||||
title={translate('Special')}
|
||||
/>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
episodeId={id}
|
||||
episodeEntity={episodeEntities.CALENDAR}
|
||||
seriesId={series.id}
|
||||
episodeTitle={title}
|
||||
showOpenSeriesButton={true}
|
||||
onModalClose={this.onDetailsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AgendaEvent.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
series: PropTypes.object.isRequired,
|
||||
episodeFile: PropTypes.object,
|
||||
title: PropTypes.string.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
unverifiedSceneNumbering: PropTypes.bool,
|
||||
finaleType: PropTypes.string,
|
||||
hasFile: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
showDate: PropTypes.bool.isRequired,
|
||||
showEpisodeInformation: PropTypes.bool.isRequired,
|
||||
showFinaleIcon: PropTypes.bool.isRequired,
|
||||
showSpecialIcon: PropTypes.bool.isRequired,
|
||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default AgendaEvent;
|
||||
227
frontend/src/Calendar/Agenda/AgendaEvent.tsx
Normal file
227
frontend/src/Calendar/Agenda/AgendaEvent.tsx
Normal file
|
|
@ -0,0 +1,227 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import CalendarEventQueueDetails from 'Calendar/Events/CalendarEventQueueDetails';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AgendaEvent.css';
|
||||
|
||||
interface AgendaEventProps {
|
||||
id: number;
|
||||
seriesId: number;
|
||||
episodeFileId: number;
|
||||
title: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
absoluteEpisodeNumber?: number;
|
||||
airDateUtc: string;
|
||||
monitored: boolean;
|
||||
unverifiedSceneNumbering?: boolean;
|
||||
finaleType?: string;
|
||||
hasFile: boolean;
|
||||
grabbed?: boolean;
|
||||
showDate: boolean;
|
||||
}
|
||||
|
||||
function AgendaEvent(props: AgendaEventProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
episodeFileId,
|
||||
title,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
monitored,
|
||||
unverifiedSceneNumbering,
|
||||
finaleType,
|
||||
hasFile,
|
||||
grabbed,
|
||||
showDate,
|
||||
} = props;
|
||||
|
||||
const series = useSeries(seriesId)!;
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
const { timeFormat, longDateFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
const {
|
||||
showEpisodeInformation,
|
||||
showFinaleIcon,
|
||||
showSpecialIcon,
|
||||
showCutoffUnmetIcon,
|
||||
} = useSelector((state: AppState) => state.calendar.options);
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
||||
const downloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(
|
||||
hasFile,
|
||||
downloading,
|
||||
startTime,
|
||||
endTime,
|
||||
isMonitored
|
||||
);
|
||||
const missingAbsoluteNumber =
|
||||
series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
setIsDetailsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDetailsModalClose = useCallback(() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={styles.event}>
|
||||
<Link className={styles.underlay} onPress={handlePress} />
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.date}>
|
||||
{showDate && startTime.format(longDateFormat)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.eventWrapper,
|
||||
styles[statusStyle],
|
||||
enableColorImpairedMode && 'colorImpaired'
|
||||
)}
|
||||
>
|
||||
<div className={styles.time}>
|
||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||
{formatTime(endTime.toISOString(), timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className={styles.seriesTitle}>{series.title}</div>
|
||||
|
||||
{showEpisodeInformation ? (
|
||||
<div className={styles.seasonEpisodeNumber}>
|
||||
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
||||
{series.seriesType === 'anime' && absoluteEpisodeNumber && (
|
||||
<span className={styles.absoluteEpisodeNumber}>
|
||||
({absoluteEpisodeNumber})
|
||||
</span>
|
||||
)}
|
||||
<div className={styles.episodeSeparator}> - </div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.episodeTitle}>
|
||||
{showEpisodeInformation ? title : null}
|
||||
</div>
|
||||
|
||||
{missingAbsoluteNumber ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('SceneNumberNotVerified')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{queueItem ? (
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails
|
||||
seasonNumber={seasonNumber}
|
||||
{...queueItem}
|
||||
/>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{!queueItem && grabbed ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('EpisodeIsDownloading')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCutoffUnmetIcon &&
|
||||
episodeFile &&
|
||||
episodeFile.qualityCutoffNotMet ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.EPISODE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{episodeNumber === 1 && seasonNumber > 0 && (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.INFO}
|
||||
title={
|
||||
seasonNumber === 1
|
||||
? translate('SeriesPremiere')
|
||||
: translate('SeasonPremiere')
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showFinaleIcon && finaleType ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.WARNING}
|
||||
title={getFinaleTypeName(finaleType)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.PINK}
|
||||
title={translate('Special')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={isDetailsModalOpen}
|
||||
episodeId={id}
|
||||
episodeEntity={episodeEntities.CALENDAR}
|
||||
seriesId={series.id}
|
||||
episodeTitle={title}
|
||||
showOpenSeriesButton={true}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default AgendaEvent;
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import AgendaEvent from './AgendaEvent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
createSeriesSelector(),
|
||||
createEpisodeFileSelector(),
|
||||
createQueueItemSelector(),
|
||||
createUISettingsSelector(),
|
||||
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
|
||||
return {
|
||||
series,
|
||||
episodeFile,
|
||||
queueItem,
|
||||
...calendarOptions,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
longDateFormat: uiSettings.longDateFormat,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(AgendaEvent);
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AgendaConnector from './Agenda/AgendaConnector';
|
||||
import * as calendarViews from './calendarViews';
|
||||
import CalendarDaysConnector from './Day/CalendarDaysConnector';
|
||||
import DaysOfWeekConnector from './Day/DaysOfWeekConnector';
|
||||
import CalendarHeaderConnector from './Header/CalendarHeaderConnector';
|
||||
import styles from './Calendar.css';
|
||||
|
||||
class Calendar extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{
|
||||
isFetching && !isPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetching && !!error &&
|
||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
!error && isPopulated && view === calendarViews.AGENDA &&
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeaderConnector />
|
||||
<AgendaConnector />
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!error && isPopulated && view !== calendarViews.AGENDA &&
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeaderConnector />
|
||||
<DaysOfWeekConnector />
|
||||
<CalendarDaysConnector />
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Calendar.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
view: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Calendar;
|
||||
170
frontend/src/Calendar/Calendar.tsx
Normal file
170
frontend/src/Calendar/Calendar.tsx
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import React, { useCallback, useEffect, useRef } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Episode from 'Episode/Episode';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import {
|
||||
clearCalendar,
|
||||
fetchCalendar,
|
||||
gotoCalendarToday,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import {
|
||||
clearEpisodeFiles,
|
||||
fetchEpisodeFiles,
|
||||
} from 'Store/Actions/episodeFileActions';
|
||||
import {
|
||||
clearQueueDetails,
|
||||
fetchQueueDetails,
|
||||
} from 'Store/Actions/queueActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import {
|
||||
registerPagePopulator,
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Agenda from './Agenda/Agenda';
|
||||
import CalendarDays from './Day/CalendarDays';
|
||||
import DaysOfWeek from './Day/DaysOfWeek';
|
||||
import CalendarHeader from './Header/CalendarHeader';
|
||||
import styles from './Calendar.css';
|
||||
|
||||
const UPDATE_DELAY = 3600000; // 1 hour
|
||||
|
||||
function Calendar() {
|
||||
const dispatch = useDispatch();
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
|
||||
const { isFetching, isPopulated, error, items, time, view } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
|
||||
const isRefreshingSeries = useSelector(
|
||||
createCommandExecutingSelector(commandNames.REFRESH_SERIES)
|
||||
);
|
||||
|
||||
const firstDayOfWeek = useSelector(
|
||||
(state: AppState) => state.settings.ui.item.firstDayOfWeek
|
||||
);
|
||||
|
||||
const wasRefreshingSeries = usePrevious(isRefreshingSeries);
|
||||
const previousFirstDayOfWeek = usePrevious(firstDayOfWeek);
|
||||
const previousItems = usePrevious(items);
|
||||
|
||||
const handleScheduleUpdate = useCallback(() => {
|
||||
clearTimeout(updateTimeout.current);
|
||||
|
||||
function updateCalendar() {
|
||||
dispatch(gotoCalendarToday());
|
||||
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
||||
}
|
||||
|
||||
updateTimeout.current = setTimeout(updateCalendar, UPDATE_DELAY);
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScheduleUpdate();
|
||||
|
||||
return () => {
|
||||
dispatch(clearCalendar());
|
||||
dispatch(clearQueueDetails());
|
||||
dispatch(clearEpisodeFiles());
|
||||
clearTimeout(updateTimeout.current);
|
||||
};
|
||||
}, [dispatch, handleScheduleUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchCalendar());
|
||||
} else {
|
||||
dispatch(gotoCalendarToday());
|
||||
}
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchQueueDetails({ time, view }));
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate, [
|
||||
'episodeFileUpdated',
|
||||
'episodeFileDeleted',
|
||||
]);
|
||||
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [time, view, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
handleScheduleUpdate();
|
||||
}, [time, handleScheduleUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
previousFirstDayOfWeek != null &&
|
||||
firstDayOfWeek !== previousFirstDayOfWeek
|
||||
) {
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
}
|
||||
}, [time, view, firstDayOfWeek, previousFirstDayOfWeek, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasRefreshingSeries && !isRefreshingSeries) {
|
||||
dispatch(fetchCalendar({ time, view }));
|
||||
}
|
||||
}, [time, view, isRefreshingSeries, wasRefreshingSeries, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previousItems || hasDifferentItems(items, previousItems)) {
|
||||
const episodeIds = selectUniqueIds<Episode, number>(items, 'id');
|
||||
const episodeFileIds = selectUniqueIds<Episode, number>(
|
||||
items,
|
||||
'episodeFileId'
|
||||
);
|
||||
|
||||
if (items.length) {
|
||||
dispatch(fetchQueueDetails({ episodeIds }));
|
||||
}
|
||||
|
||||
if (episodeFileIds.length) {
|
||||
dispatch(fetchEpisodeFiles({ episodeFileIds }));
|
||||
}
|
||||
}
|
||||
}, [items, previousItems, dispatch]);
|
||||
|
||||
return (
|
||||
<div className={styles.calendar}>
|
||||
{isFetching && !isPopulated ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetching && error ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('CalendarLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view === 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
<Agenda />
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!error && isPopulated && view !== 'agenda' ? (
|
||||
<div className={styles.calendarContent}>
|
||||
<CalendarHeader />
|
||||
<DaysOfWeek />
|
||||
<CalendarDays />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Calendar;
|
||||
|
|
@ -1,196 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import * as calendarActions from 'Store/Actions/calendarActions';
|
||||
import { clearEpisodeFiles, fetchEpisodeFiles } from 'Store/Actions/episodeFileActions';
|
||||
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import hasDifferentItems from 'Utilities/Object/hasDifferentItems';
|
||||
import selectUniqueIds from 'Utilities/Object/selectUniqueIds';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import Calendar from './Calendar';
|
||||
|
||||
const UPDATE_DELAY = 3600000; // 1 hour
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(state) => state.settings.ui.item.firstDayOfWeek,
|
||||
createCommandExecutingSelector(commandNames.REFRESH_SERIES),
|
||||
(calendar, firstDayOfWeek, isRefreshingSeries) => {
|
||||
return {
|
||||
...calendar,
|
||||
isRefreshingSeries,
|
||||
firstDayOfWeek
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
...calendarActions,
|
||||
fetchEpisodeFiles,
|
||||
clearEpisodeFiles,
|
||||
fetchQueueDetails,
|
||||
clearQueueDetails
|
||||
};
|
||||
|
||||
class CalendarConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
useCurrentPage,
|
||||
fetchCalendar,
|
||||
gotoCalendarToday
|
||||
} = this.props;
|
||||
|
||||
registerPagePopulator(this.repopulate, ['episodeFileUpdated', 'episodeFileDeleted']);
|
||||
|
||||
if (useCurrentPage) {
|
||||
fetchCalendar();
|
||||
} else {
|
||||
gotoCalendarToday();
|
||||
}
|
||||
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
items,
|
||||
time,
|
||||
view,
|
||||
isRefreshingSeries,
|
||||
firstDayOfWeek
|
||||
} = this.props;
|
||||
|
||||
if (hasDifferentItems(prevProps.items, items)) {
|
||||
const episodeIds = selectUniqueIds(items, 'id');
|
||||
const episodeFileIds = selectUniqueIds(items, 'episodeFileId');
|
||||
|
||||
if (items.length) {
|
||||
this.props.fetchQueueDetails({ episodeIds });
|
||||
}
|
||||
|
||||
if (episodeFileIds.length) {
|
||||
this.props.fetchEpisodeFiles({ episodeFileIds });
|
||||
}
|
||||
}
|
||||
|
||||
if (prevProps.time !== time) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
if (prevProps.firstDayOfWeek !== firstDayOfWeek) {
|
||||
this.props.fetchCalendar({ time, view });
|
||||
}
|
||||
|
||||
if (prevProps.isRefreshingSeries && !isRefreshingSeries) {
|
||||
this.props.fetchCalendar({ time, view });
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.repopulate);
|
||||
this.props.clearCalendar();
|
||||
this.props.clearQueueDetails();
|
||||
this.props.clearEpisodeFiles();
|
||||
this.clearUpdateTimeout();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
repopulate = () => {
|
||||
const {
|
||||
time,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
this.props.fetchQueueDetails({ time, view });
|
||||
this.props.fetchCalendar({ time, view });
|
||||
};
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.updateCalendar, UPDATE_DELAY);
|
||||
};
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
updateCalendar = () => {
|
||||
this.props.gotoCalendarToday();
|
||||
this.scheduleUpdate();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onCalendarViewChange = (view) => {
|
||||
this.props.setCalendarView({ view });
|
||||
};
|
||||
|
||||
onTodayPress = () => {
|
||||
this.props.gotoCalendarToday();
|
||||
};
|
||||
|
||||
onPreviousPress = () => {
|
||||
this.props.gotoCalendarPreviousRange();
|
||||
};
|
||||
|
||||
onNextPress = () => {
|
||||
this.props.gotoCalendarNextRange();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Calendar
|
||||
{...this.props}
|
||||
onCalendarViewChange={this.onCalendarViewChange}
|
||||
onTodayPress={this.onTodayPress}
|
||||
onPreviousPress={this.onPreviousPress}
|
||||
onNextPress={this.onNextPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarConnector.propTypes = {
|
||||
useCurrentPage: PropTypes.bool.isRequired,
|
||||
time: PropTypes.string,
|
||||
view: PropTypes.string.isRequired,
|
||||
firstDayOfWeek: PropTypes.number.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isRefreshingSeries: PropTypes.bool.isRequired,
|
||||
setCalendarView: PropTypes.func.isRequired,
|
||||
gotoCalendarToday: PropTypes.func.isRequired,
|
||||
gotoCalendarPreviousRange: PropTypes.func.isRequired,
|
||||
gotoCalendarNextRange: PropTypes.func.isRequired,
|
||||
clearCalendar: PropTypes.func.isRequired,
|
||||
fetchCalendar: PropTypes.func.isRequired,
|
||||
fetchEpisodeFiles: PropTypes.func.isRequired,
|
||||
clearEpisodeFiles: PropTypes.func.isRequired,
|
||||
fetchQueueDetails: PropTypes.func.isRequired,
|
||||
clearQueueDetails: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarConnector);
|
||||
|
|
@ -1,197 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Measure from 'Components/Measure';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarConnector from './CalendarConnector';
|
||||
import CalendarFilterModal from './CalendarFilterModal';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import LegendConnector from './Legend/LegendConnector';
|
||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||
import styles from './CalendarPage.css';
|
||||
|
||||
const MINIMUM_DAY_WIDTH = 120;
|
||||
|
||||
class CalendarPage extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isCalendarLinkModalOpen: false,
|
||||
isOptionsModalOpen: false,
|
||||
width: 0
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
const days = Math.max(3, Math.min(7, Math.floor(width / MINIMUM_DAY_WIDTH)));
|
||||
|
||||
this.props.onDaysCountChange(days);
|
||||
};
|
||||
|
||||
onGetCalendarLinkPress = () => {
|
||||
this.setState({ isCalendarLinkModalOpen: true });
|
||||
};
|
||||
|
||||
onGetCalendarLinkModalClose = () => {
|
||||
this.setState({ isCalendarLinkModalOpen: false });
|
||||
};
|
||||
|
||||
onOptionsPress = () => {
|
||||
this.setState({ isOptionsModalOpen: true });
|
||||
};
|
||||
|
||||
onOptionsModalClose = () => {
|
||||
this.setState({ isOptionsModalOpen: false });
|
||||
};
|
||||
|
||||
onSearchMissingPress = () => {
|
||||
const {
|
||||
missingEpisodeIds,
|
||||
onSearchMissingPress
|
||||
} = this.props;
|
||||
|
||||
onSearchMissingPress(missingEpisodeIds);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
customFilters,
|
||||
hasSeries,
|
||||
missingEpisodeIds,
|
||||
isRssSyncExecuting,
|
||||
isSearchingForMissing,
|
||||
useCurrentPage,
|
||||
onRssSyncPress,
|
||||
onFilterSelect
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isCalendarLinkModalOpen,
|
||||
isOptionsModalOpen
|
||||
} = this.state;
|
||||
|
||||
const isMeasured = this.state.width > 0;
|
||||
const PageComponent = hasSeries ? CalendarConnector : NoSeries;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={this.onGetCalendarLinkPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={onRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingEpisodeIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={this.onSearchMissingPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={this.onOptionsPress}
|
||||
/>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
<Measure
|
||||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
{
|
||||
isMeasured ?
|
||||
<PageComponent
|
||||
useCurrentPage={useCurrentPage}
|
||||
/> :
|
||||
<div />
|
||||
}
|
||||
</Measure>
|
||||
|
||||
{
|
||||
hasSeries &&
|
||||
<LegendConnector />
|
||||
}
|
||||
</PageContentBody>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={this.onGetCalendarLinkModalClose}
|
||||
/>
|
||||
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={this.onOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarPage.propTypes = {
|
||||
selectedFilterKey: PropTypes.string.isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
hasSeries: PropTypes.bool.isRequired,
|
||||
missingEpisodeIds: PropTypes.arrayOf(PropTypes.number).isRequired,
|
||||
isRssSyncExecuting: PropTypes.bool.isRequired,
|
||||
isSearchingForMissing: PropTypes.bool.isRequired,
|
||||
useCurrentPage: PropTypes.bool.isRequired,
|
||||
onSearchMissingPress: PropTypes.func.isRequired,
|
||||
onDaysCountChange: PropTypes.func.isRequired,
|
||||
onRssSyncPress: PropTypes.func.isRequired,
|
||||
onFilterSelect: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarPage;
|
||||
226
frontend/src/Calendar/CalendarPage.tsx
Normal file
226
frontend/src/Calendar/CalendarPage.tsx
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import Measure from 'Components/Measure';
|
||||
import FilterMenu from 'Components/Menu/FilterMenu';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import PageContentBody from 'Components/Page/PageContentBody';
|
||||
import PageToolbar from 'Components/Page/Toolbar/PageToolbar';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import PageToolbarSection from 'Components/Page/Toolbar/PageToolbarSection';
|
||||
import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import NoSeries from 'Series/NoSeries';
|
||||
import {
|
||||
searchMissing,
|
||||
setCalendarDaysCount,
|
||||
setCalendarFilter,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import Calendar from './Calendar';
|
||||
import CalendarFilterModal from './CalendarFilterModal';
|
||||
import CalendarLinkModal from './iCal/CalendarLinkModal';
|
||||
import Legend from './Legend/Legend';
|
||||
import CalendarOptionsModal from './Options/CalendarOptionsModal';
|
||||
import styles from './CalendarPage.css';
|
||||
|
||||
const MINIMUM_DAY_WIDTH = 120;
|
||||
|
||||
function createMissingEpisodeIdsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.start,
|
||||
(state: AppState) => state.calendar.end,
|
||||
(state: AppState) => state.calendar.items,
|
||||
(state: AppState) => state.queue.details.items,
|
||||
(start, end, episodes, queueDetails) => {
|
||||
return episodes.reduce<number[]>((acc, episode) => {
|
||||
const airDateUtc = episode.airDateUtc;
|
||||
|
||||
if (
|
||||
!episode.episodeFileId &&
|
||||
moment(airDateUtc).isAfter(start) &&
|
||||
moment(airDateUtc).isBefore(end) &&
|
||||
isBefore(episode.airDateUtc) &&
|
||||
!queueDetails.some(
|
||||
(details) => !!details.episode && details.episode.id === episode.id
|
||||
)
|
||||
) {
|
||||
acc.push(episode.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(
|
||||
commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
})
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function CalendarPage() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { selectedFilterKey, filters } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
const missingEpisodeIds = useSelector(createMissingEpisodeIdsSelector());
|
||||
const isSearchingForMissing = useSelector(createIsSearchingSelector());
|
||||
const isRssSyncExecuting = useSelector(
|
||||
createCommandExecutingSelector(commandNames.RSS_SYNC)
|
||||
);
|
||||
const customFilters = useSelector(createCustomFiltersSelector('calendar'));
|
||||
const hasSeries = !!useSelector(createSeriesCountSelector());
|
||||
|
||||
const [isCalendarLinkModalOpen, setIsCalendarLinkModalOpen] = useState(false);
|
||||
const [isOptionsModalOpen, setIsOptionsModalOpen] = useState(false);
|
||||
const [width, setWidth] = useState(0);
|
||||
|
||||
const isMeasured = width > 0;
|
||||
const PageComponent = hasSeries ? Calendar : NoSeries;
|
||||
|
||||
const handleMeasure = useCallback(
|
||||
({ width: newWidth }: { width: number }) => {
|
||||
setWidth(newWidth);
|
||||
|
||||
const dayCount = Math.max(
|
||||
3,
|
||||
Math.min(7, Math.floor(newWidth / MINIMUM_DAY_WIDTH))
|
||||
);
|
||||
|
||||
dispatch(setCalendarDaysCount({ dayCount }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleGetCalendarLinkPress = useCallback(() => {
|
||||
setIsCalendarLinkModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGetCalendarLinkModalClose = useCallback(() => {
|
||||
setIsCalendarLinkModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleOptionsPress = useCallback(() => {
|
||||
setIsOptionsModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOptionsModalClose = useCallback(() => {
|
||||
setIsOptionsModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const handleRssSyncPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: commandNames.RSS_SYNC,
|
||||
})
|
||||
);
|
||||
}, [dispatch]);
|
||||
|
||||
const handleSearchMissingPress = useCallback(() => {
|
||||
dispatch(searchMissing({ episodeIds: missingEpisodeIds }));
|
||||
}, [missingEpisodeIds, dispatch]);
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(key: string) => {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey: key }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('Calendar')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('ICalLink')}
|
||||
iconName={icons.CALENDAR}
|
||||
onPress={handleGetCalendarLinkPress}
|
||||
/>
|
||||
|
||||
<PageToolbarSeparator />
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('RssSync')}
|
||||
iconName={icons.RSS}
|
||||
isSpinning={isRssSyncExecuting}
|
||||
onPress={handleRssSyncPress}
|
||||
/>
|
||||
|
||||
<PageToolbarButton
|
||||
label={translate('SearchForMissing')}
|
||||
iconName={icons.SEARCH}
|
||||
isDisabled={!missingEpisodeIds.length}
|
||||
isSpinning={isSearchingForMissing}
|
||||
onPress={handleSearchMissingPress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.POSTER}
|
||||
onPress={handleOptionsPress}
|
||||
/>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
isDisabled={!hasSeries}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={CalendarFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody
|
||||
className={styles.calendarPageBody}
|
||||
innerClassName={styles.calendarInnerPageBody}
|
||||
>
|
||||
<Measure whitelist={['width']} onMeasure={handleMeasure}>
|
||||
{isMeasured ? <PageComponent totalItems={0} /> : <div />}
|
||||
</Measure>
|
||||
|
||||
{hasSeries && <Legend />}
|
||||
</PageContentBody>
|
||||
|
||||
<CalendarLinkModal
|
||||
isOpen={isCalendarLinkModalOpen}
|
||||
onModalClose={handleGetCalendarLinkModalClose}
|
||||
/>
|
||||
|
||||
<CalendarOptionsModal
|
||||
isOpen={isOptionsModalOpen}
|
||||
onModalClose={handleOptionsModalClose}
|
||||
/>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarPage;
|
||||
|
|
@ -1,117 +0,0 @@
|
|||
import moment from 'moment';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import withCurrentPage from 'Components/withCurrentPage';
|
||||
import { searchMissing, setCalendarDaysCount, setCalendarFilter } from 'Store/Actions/calendarActions';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createSeriesCountSelector from 'Store/Selectors/createSeriesCountSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import CalendarPage from './CalendarPage';
|
||||
|
||||
function createMissingEpisodeIdsSelector() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.start,
|
||||
(state) => state.calendar.end,
|
||||
(state) => state.calendar.items,
|
||||
(state) => state.queue.details.items,
|
||||
(start, end, episodes, queueDetails) => {
|
||||
return episodes.reduce((acc, episode) => {
|
||||
const airDateUtc = episode.airDateUtc;
|
||||
|
||||
if (
|
||||
!episode.episodeFileId &&
|
||||
moment(airDateUtc).isAfter(start) &&
|
||||
moment(airDateUtc).isBefore(end) &&
|
||||
isBefore(episode.airDateUtc) &&
|
||||
!queueDetails.some((details) => !!details.episode && details.episode.id === episode.id)
|
||||
) {
|
||||
acc.push(episode.id);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createIsSearchingSelector() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.searchMissingCommandId,
|
||||
createCommandsSelector(),
|
||||
(searchMissingCommandId, commands) => {
|
||||
if (searchMissingCommandId == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return isCommandExecuting(commands.find((command) => {
|
||||
return command.id === searchMissingCommandId;
|
||||
}));
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.selectedFilterKey,
|
||||
(state) => state.calendar.filters,
|
||||
createCustomFiltersSelector('calendar'),
|
||||
createSeriesCountSelector(),
|
||||
createUISettingsSelector(),
|
||||
createMissingEpisodeIdsSelector(),
|
||||
createCommandExecutingSelector(commandNames.RSS_SYNC),
|
||||
createIsSearchingSelector(),
|
||||
(
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
customFilters,
|
||||
seriesCount,
|
||||
uiSettings,
|
||||
missingEpisodeIds,
|
||||
isRssSyncExecuting,
|
||||
isSearchingForMissing
|
||||
) => {
|
||||
return {
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
customFilters,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||
hasSeries: !!seriesCount,
|
||||
missingEpisodeIds,
|
||||
isRssSyncExecuting,
|
||||
isSearchingForMissing
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onRssSyncPress() {
|
||||
dispatch(executeCommand({
|
||||
name: commandNames.RSS_SYNC
|
||||
}));
|
||||
},
|
||||
|
||||
onSearchMissingPress(episodeIds) {
|
||||
dispatch(searchMissing({ episodeIds }));
|
||||
},
|
||||
|
||||
onDaysCountChange(dayCount) {
|
||||
dispatch(setCalendarDaysCount({ dayCount }));
|
||||
},
|
||||
|
||||
onFilterSelect(selectedFilterKey) {
|
||||
dispatch(setCalendarFilter({ selectedFilterKey }));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default withCurrentPage(
|
||||
connect(createMapStateToProps, createMapDispatchToProps)(CalendarPage)
|
||||
);
|
||||
|
|
@ -1,25 +1,104 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
|
||||
import CalendarEventGroupConnector from 'Calendar/Events/CalendarEventGroupConnector';
|
||||
import Series from 'Series/Series';
|
||||
import CalendarEventGroup, { CalendarEvent } from 'typings/CalendarEventGroup';
|
||||
import CalendarEvent from 'Calendar/Events/CalendarEvent';
|
||||
import CalendarEventGroup from 'Calendar/Events/CalendarEventGroup';
|
||||
import {
|
||||
CalendarEvent as CalendarEventModel,
|
||||
CalendarEventGroup as CalendarEventGroupModel,
|
||||
CalendarItem,
|
||||
} from 'typings/Calendar';
|
||||
import styles from './CalendarDay.css';
|
||||
|
||||
function sort(items: (CalendarEventModel | CalendarEventGroupModel)[]) {
|
||||
return items.sort((a, b) => {
|
||||
const aDate = a.isGroup
|
||||
? moment(a.events[0].airDateUtc).unix()
|
||||
: moment(a.airDateUtc).unix();
|
||||
|
||||
const bDate = b.isGroup
|
||||
? moment(b.events[0].airDateUtc).unix()
|
||||
: moment(b.airDateUtc).unix();
|
||||
|
||||
return aDate - bDate;
|
||||
});
|
||||
}
|
||||
|
||||
function createCalendarEventsConnector(date: string) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.items,
|
||||
(state: AppState) => state.calendar.options.collapseMultipleEpisodes,
|
||||
(items, collapseMultipleEpisodes) => {
|
||||
const momentDate = moment(date);
|
||||
|
||||
const filtered = items.filter((item) => {
|
||||
return momentDate.isSame(moment(item.airDateUtc), 'day');
|
||||
});
|
||||
|
||||
if (!collapseMultipleEpisodes) {
|
||||
return sort(
|
||||
filtered.map((item) => ({
|
||||
isGroup: false,
|
||||
...item,
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
const groupedObject = Object.groupBy(
|
||||
filtered,
|
||||
(item: CalendarItem) => `${item.seriesId}-${item.seasonNumber}`
|
||||
);
|
||||
|
||||
const grouped = Object.entries(groupedObject).reduce<
|
||||
(CalendarEventModel | CalendarEventGroupModel)[]
|
||||
>((acc, [, events]) => {
|
||||
if (!events) {
|
||||
return acc;
|
||||
}
|
||||
|
||||
if (events.length === 1) {
|
||||
acc.push({
|
||||
isGroup: false,
|
||||
...events[0],
|
||||
});
|
||||
} else {
|
||||
acc.push({
|
||||
isGroup: true,
|
||||
seriesId: events[0].seriesId,
|
||||
seasonNumber: events[0].seasonNumber,
|
||||
episodeIds: events.map((event) => event.id),
|
||||
events: events.sort(
|
||||
(a, b) =>
|
||||
moment(a.airDateUtc).unix() - moment(b.airDateUtc).unix()
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
return sort(grouped);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarDayProps {
|
||||
date: string;
|
||||
time: string;
|
||||
isTodaysDate: boolean;
|
||||
events: (CalendarEvent | CalendarEventGroup)[];
|
||||
view: string;
|
||||
onEventModalOpenToggle(...args: unknown[]): unknown;
|
||||
onEventModalOpenToggle(isOpen: boolean): unknown;
|
||||
}
|
||||
|
||||
function CalendarDay(props: CalendarDayProps) {
|
||||
const { date, time, isTodaysDate, events, view, onEventModalOpenToggle } =
|
||||
props;
|
||||
function CalendarDay({
|
||||
date,
|
||||
isTodaysDate,
|
||||
onEventModalOpenToggle,
|
||||
}: CalendarDayProps) {
|
||||
const { time, view } = useSelector((state: AppState) => state.calendar);
|
||||
const events = useSelector(createCalendarEventsConnector(date));
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
|
|
@ -53,7 +132,7 @@ function CalendarDay(props: CalendarDayProps) {
|
|||
{events.map((event) => {
|
||||
if (event.isGroup) {
|
||||
return (
|
||||
<CalendarEventGroupConnector
|
||||
<CalendarEventGroup
|
||||
key={event.seriesId}
|
||||
{...event}
|
||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||
|
|
@ -62,11 +141,11 @@ function CalendarDay(props: CalendarDayProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<CalendarEventConnector
|
||||
<CalendarEvent
|
||||
key={event.id}
|
||||
{...event}
|
||||
episodeId={event.id}
|
||||
series={event.series as Series}
|
||||
seriesId={event.seriesId}
|
||||
airDateUtc={event.airDateUtc as string}
|
||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,91 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import CalendarDay from './CalendarDay';
|
||||
|
||||
function sort(items) {
|
||||
return _.sortBy(items, (item) => {
|
||||
if (item.isGroup) {
|
||||
return moment(item.events[0].airDateUtc).unix();
|
||||
}
|
||||
|
||||
return moment(item.airDateUtc).unix();
|
||||
});
|
||||
}
|
||||
|
||||
function createCalendarEventsConnector() {
|
||||
return createSelector(
|
||||
(state, { date }) => date,
|
||||
(state) => state.calendar.items,
|
||||
(state) => state.calendar.options.collapseMultipleEpisodes,
|
||||
(date, items, collapseMultipleEpisodes) => {
|
||||
const filtered = _.filter(items, (item) => {
|
||||
return moment(date).isSame(moment(item.airDateUtc), 'day');
|
||||
});
|
||||
|
||||
if (!collapseMultipleEpisodes) {
|
||||
return sort(filtered);
|
||||
}
|
||||
|
||||
const groupedObject = _.groupBy(filtered, (item) => `${item.seriesId}-${item.seasonNumber}`);
|
||||
const grouped = [];
|
||||
|
||||
Object.keys(groupedObject).forEach((key) => {
|
||||
const events = groupedObject[key];
|
||||
|
||||
if (events.length === 1) {
|
||||
grouped.push(events[0]);
|
||||
} else {
|
||||
grouped.push({
|
||||
isGroup: true,
|
||||
seriesId: events[0].seriesId,
|
||||
seasonNumber: events[0].seasonNumber,
|
||||
episodeIds: events.map((event) => event.id),
|
||||
events: _.sortBy(events, (item) => moment(item.airDateUtc).unix())
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const sorted = sort(grouped);
|
||||
|
||||
return sorted;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
createCalendarEventsConnector(),
|
||||
(calendar, events) => {
|
||||
return {
|
||||
time: calendar.time,
|
||||
view: calendar.view,
|
||||
events
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
class CalendarDayConnector extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CalendarDay
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarDayConnector.propTypes = {
|
||||
date: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarDayConnector);
|
||||
|
|
@ -1,164 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import isToday from 'Utilities/Date/isToday';
|
||||
import CalendarDayConnector from './CalendarDayConnector';
|
||||
import styles from './CalendarDays.css';
|
||||
|
||||
class CalendarDays extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._touchStart = null;
|
||||
|
||||
this.state = {
|
||||
todaysDate: moment().startOf('day').toISOString(),
|
||||
isEventModalOpen: false
|
||||
};
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const view = this.props.view;
|
||||
|
||||
if (view === calendarViews.MONTH) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
|
||||
window.addEventListener('touchstart', this.onTouchStart);
|
||||
window.addEventListener('touchend', this.onTouchEnd);
|
||||
window.addEventListener('touchcancel', this.onTouchCancel);
|
||||
window.addEventListener('touchmove', this.onTouchMove);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearUpdateTimeout();
|
||||
|
||||
window.removeEventListener('touchstart', this.onTouchStart);
|
||||
window.removeEventListener('touchend', this.onTouchEnd);
|
||||
window.removeEventListener('touchcancel', this.onTouchCancel);
|
||||
window.removeEventListener('touchmove', this.onTouchMove);
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
||||
|
||||
this.setState({ todaysDate: todaysDate.toISOString() });
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
||||
};
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onEventModalOpenToggle = (isEventModalOpen) => {
|
||||
this.setState({ isEventModalOpen });
|
||||
};
|
||||
|
||||
onTouchStart = (event) => {
|
||||
const touches = event.touches;
|
||||
const touchStart = touches[0].pageX;
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
touchStart < 50 ||
|
||||
this.props.isSidebarVisible ||
|
||||
this.state.isEventModalOpen
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchStart = touchStart;
|
||||
};
|
||||
|
||||
onTouchEnd = (event) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch > this._touchStart && currentTouch - this._touchStart > 100) {
|
||||
this.props.onNavigatePrevious();
|
||||
} else if (currentTouch < this._touchStart && this._touchStart - currentTouch > 100) {
|
||||
this.props.onNavigateNext();
|
||||
}
|
||||
|
||||
this._touchStart = null;
|
||||
};
|
||||
|
||||
onTouchCancel = (event) => {
|
||||
this._touchStart = null;
|
||||
};
|
||||
|
||||
onTouchMove = (event) => {
|
||||
if (!this._touchStart) {
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dates,
|
||||
view
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.days,
|
||||
styles[view]
|
||||
)}
|
||||
>
|
||||
{
|
||||
dates.map((date) => {
|
||||
return (
|
||||
<CalendarDayConnector
|
||||
key={date}
|
||||
date={date}
|
||||
isTodaysDate={isToday(date)}
|
||||
onEventModalOpenToggle={this.onEventModalOpenToggle}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarDays.propTypes = {
|
||||
dates: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
view: PropTypes.string.isRequired,
|
||||
isSidebarVisible: PropTypes.bool.isRequired,
|
||||
onNavigatePrevious: PropTypes.func.isRequired,
|
||||
onNavigateNext: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarDays;
|
||||
135
frontend/src/Calendar/Day/CalendarDays.tsx
Normal file
135
frontend/src/Calendar/Day/CalendarDays.tsx
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import {
|
||||
gotoCalendarNextRange,
|
||||
gotoCalendarPreviousRange,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import CalendarDay from './CalendarDay';
|
||||
import styles from './CalendarDays.css';
|
||||
|
||||
function CalendarDays() {
|
||||
const dispatch = useDispatch();
|
||||
const { dates, view } = useSelector((state: AppState) => state.calendar);
|
||||
const isSidebarVisible = useSelector(
|
||||
(state: AppState) => state.app.isSidebarVisible
|
||||
);
|
||||
|
||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const touchStart = useRef<number | null>(null);
|
||||
const isEventModalOpen = useRef(false);
|
||||
const [todaysDate, setTodaysDate] = useState(
|
||||
moment().startOf('day').toISOString()
|
||||
);
|
||||
|
||||
const handleEventModalOpenToggle = useCallback((isOpen: boolean) => {
|
||||
isEventModalOpen.current = isOpen;
|
||||
}, []);
|
||||
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
clearTimeout(updateTimeout.current);
|
||||
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
||||
|
||||
setTodaysDate(todaysDate.toISOString());
|
||||
|
||||
updateTimeout.current = setTimeout(scheduleUpdate, diff);
|
||||
}, []);
|
||||
|
||||
const handleTouchStart = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.touches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch < 50 || isSidebarVisible || isEventModalOpen.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
touchStart.current = currentTouch;
|
||||
},
|
||||
[isSidebarVisible]
|
||||
);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(event: TouchEvent) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!touchStart.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
currentTouch > touchStart.current &&
|
||||
currentTouch - touchStart.current > 100
|
||||
) {
|
||||
dispatch(gotoCalendarPreviousRange());
|
||||
} else if (
|
||||
currentTouch < touchStart.current &&
|
||||
touchStart.current - currentTouch > 100
|
||||
) {
|
||||
dispatch(gotoCalendarNextRange());
|
||||
}
|
||||
|
||||
touchStart.current = null;
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
touchStart.current = null;
|
||||
}, []);
|
||||
|
||||
const handleTouchMove = useCallback(() => {
|
||||
if (!touchStart.current) {
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view === calendarViews.MONTH) {
|
||||
scheduleUpdate();
|
||||
}
|
||||
}, [view, scheduleUpdate]);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('touchstart', handleTouchStart);
|
||||
window.addEventListener('touchend', handleTouchEnd);
|
||||
window.addEventListener('touchcancel', handleTouchCancel);
|
||||
window.addEventListener('touchmove', handleTouchMove);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('touchstart', handleTouchStart);
|
||||
window.removeEventListener('touchend', handleTouchEnd);
|
||||
window.removeEventListener('touchcancel', handleTouchCancel);
|
||||
window.removeEventListener('touchmove', handleTouchMove);
|
||||
};
|
||||
}, [handleTouchStart, handleTouchEnd, handleTouchCancel, handleTouchMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.days, styles[view as keyof typeof styles])}
|
||||
>
|
||||
{dates.map((date) => {
|
||||
return (
|
||||
<CalendarDay
|
||||
key={date}
|
||||
date={date}
|
||||
isTodaysDate={date === todaysDate}
|
||||
onEventModalOpenToggle={handleEventModalOpenToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarDays;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { gotoCalendarNextRange, gotoCalendarPreviousRange } from 'Store/Actions/calendarActions';
|
||||
import CalendarDays from './CalendarDays';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
(state) => state.app.isSidebarVisible,
|
||||
(calendar, isSidebarVisible) => {
|
||||
return {
|
||||
dates: calendar.dates,
|
||||
view: calendar.view,
|
||||
isSidebarVisible
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
onNavigatePrevious: gotoCalendarPreviousRange,
|
||||
onNavigateNext: gotoCalendarNextRange
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarDays);
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import styles from './DayOfWeek.css';
|
||||
|
||||
class DayOfWeek extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
date,
|
||||
view,
|
||||
isTodaysDate,
|
||||
calendarWeekColumnHeader,
|
||||
shortDateFormat,
|
||||
showRelativeDates
|
||||
} = this.props;
|
||||
|
||||
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
|
||||
const momentDate = moment(date);
|
||||
let formatedDate = momentDate.format('dddd');
|
||||
|
||||
if (view === calendarViews.WEEK) {
|
||||
formatedDate = momentDate.format(calendarWeekColumnHeader);
|
||||
} else if (view === calendarViews.FORECAST) {
|
||||
formatedDate = getRelativeDate({ date, shortDateFormat, showRelativeDates });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.dayOfWeek,
|
||||
view === calendarViews.DAY && styles.isSingleDay,
|
||||
highlightToday && styles.isToday
|
||||
)}
|
||||
>
|
||||
{formatedDate}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DayOfWeek.propTypes = {
|
||||
date: PropTypes.string.isRequired,
|
||||
view: PropTypes.string.isRequired,
|
||||
isTodaysDate: PropTypes.bool.isRequired,
|
||||
calendarWeekColumnHeader: PropTypes.string.isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
showRelativeDates: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default DayOfWeek;
|
||||
54
frontend/src/Calendar/Day/DayOfWeek.tsx
Normal file
54
frontend/src/Calendar/Day/DayOfWeek.tsx
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import getRelativeDate from 'Utilities/Date/getRelativeDate';
|
||||
import styles from './DayOfWeek.css';
|
||||
|
||||
interface DayOfWeekProps {
|
||||
date: string;
|
||||
view: string;
|
||||
isTodaysDate: boolean;
|
||||
calendarWeekColumnHeader: string;
|
||||
shortDateFormat: string;
|
||||
showRelativeDates: boolean;
|
||||
}
|
||||
|
||||
function DayOfWeek(props: DayOfWeekProps) {
|
||||
const {
|
||||
date,
|
||||
view,
|
||||
isTodaysDate,
|
||||
calendarWeekColumnHeader,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
} = props;
|
||||
|
||||
const highlightToday = view !== calendarViews.MONTH && isTodaysDate;
|
||||
const momentDate = moment(date);
|
||||
let formatedDate = momentDate.format('dddd');
|
||||
|
||||
if (view === calendarViews.WEEK) {
|
||||
formatedDate = momentDate.format(calendarWeekColumnHeader);
|
||||
} else if (view === calendarViews.FORECAST) {
|
||||
formatedDate = getRelativeDate({
|
||||
date,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.dayOfWeek,
|
||||
view === calendarViews.DAY && styles.isSingleDay,
|
||||
highlightToday && styles.isToday
|
||||
)}
|
||||
>
|
||||
{formatedDate}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DayOfWeek;
|
||||
|
|
@ -1,97 +0,0 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import DayOfWeek from './DayOfWeek';
|
||||
import styles from './DaysOfWeek.css';
|
||||
|
||||
class DaysOfWeek extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
todaysDate: moment().startOf('day').toISOString()
|
||||
};
|
||||
|
||||
this.updateTimeoutId = null;
|
||||
}
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const view = this.props.view;
|
||||
|
||||
if (view !== calendarViews.AGENDA || view !== calendarViews.MONTH) {
|
||||
this.scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.clearUpdateTimeout();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleUpdate = () => {
|
||||
this.clearUpdateTimeout();
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = todaysDate.clone().add(1, 'day').diff(moment());
|
||||
|
||||
this.setState({
|
||||
todaysDate: todaysDate.toISOString()
|
||||
});
|
||||
|
||||
this.updateTimeoutId = setTimeout(this.scheduleUpdate, diff);
|
||||
};
|
||||
|
||||
clearUpdateTimeout = () => {
|
||||
if (this.updateTimeoutId) {
|
||||
clearTimeout(this.updateTimeoutId);
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dates,
|
||||
view,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
if (view === calendarViews.AGENDA) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.daysOfWeek}>
|
||||
{
|
||||
dates.map((date) => {
|
||||
return (
|
||||
<DayOfWeek
|
||||
key={date}
|
||||
date={date}
|
||||
view={view}
|
||||
isTodaysDate={date === this.state.todaysDate}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DaysOfWeek.propTypes = {
|
||||
dates: PropTypes.arrayOf(PropTypes.string),
|
||||
view: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default DaysOfWeek;
|
||||
60
frontend/src/Calendar/Day/DaysOfWeek.tsx
Normal file
60
frontend/src/Calendar/Day/DaysOfWeek.tsx
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import moment from 'moment';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import DayOfWeek from './DayOfWeek';
|
||||
import styles from './DaysOfWeek.css';
|
||||
|
||||
function DaysOfWeek() {
|
||||
const { dates, view } = useSelector((state: AppState) => state.calendar);
|
||||
const { calendarWeekColumnHeader, shortDateFormat, showRelativeDates } =
|
||||
useSelector(createUISettingsSelector());
|
||||
|
||||
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const [todaysDate, setTodaysDate] = useState(
|
||||
moment().startOf('day').toISOString()
|
||||
);
|
||||
|
||||
const scheduleUpdate = useCallback(() => {
|
||||
clearTimeout(updateTimeout.current);
|
||||
|
||||
const todaysDate = moment().startOf('day');
|
||||
const diff = moment().diff(todaysDate.clone().add(1, 'day'));
|
||||
|
||||
setTodaysDate(todaysDate.toISOString());
|
||||
|
||||
updateTimeout.current = setTimeout(scheduleUpdate, diff);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (view !== calendarViews.AGENDA && view !== calendarViews.MONTH) {
|
||||
scheduleUpdate();
|
||||
}
|
||||
}, [view, scheduleUpdate]);
|
||||
|
||||
if (view === calendarViews.AGENDA) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.daysOfWeek}>
|
||||
{dates.map((date) => {
|
||||
return (
|
||||
<DayOfWeek
|
||||
key={date}
|
||||
date={date}
|
||||
view={view}
|
||||
isTodaysDate={date === todaysDate}
|
||||
calendarWeekColumnHeader={calendarWeekColumnHeader}
|
||||
shortDateFormat={shortDateFormat}
|
||||
showRelativeDates={showRelativeDates}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DaysOfWeek;
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import DaysOfWeek from './DaysOfWeek';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
createUISettingsSelector(),
|
||||
(calendar, UiSettings) => {
|
||||
return {
|
||||
dates: calendar.dates.slice(0, 7),
|
||||
view: calendar.view,
|
||||
calendarWeekColumnHeader: UiSettings.calendarWeekColumnHeader,
|
||||
shortDateFormat: UiSettings.shortDateFormat,
|
||||
showRelativeDates: UiSettings.showRelativeDates
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(DaysOfWeek);
|
||||
|
|
@ -1,267 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
|
||||
import styles from './CalendarEvent.css';
|
||||
|
||||
class CalendarEvent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true }, () => {
|
||||
this.props.onEventModalOpenToggle(true);
|
||||
});
|
||||
};
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false }, () => {
|
||||
this.props.onEventModalOpenToggle(false);
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
id,
|
||||
series,
|
||||
episodeFile,
|
||||
title,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
monitored,
|
||||
unverifiedSceneNumbering,
|
||||
finaleType,
|
||||
hasFile,
|
||||
grabbed,
|
||||
queueItem,
|
||||
showEpisodeInformation,
|
||||
showFinaleIcon,
|
||||
showSpecialIcon,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
timeFormat,
|
||||
colorImpairedMode
|
||||
} = this.props;
|
||||
|
||||
if (!series) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
||||
const isDownloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(hasFile, isDownloading, startTime, endTime, isMonitored);
|
||||
const missingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.event,
|
||||
styles[statusStyle],
|
||||
colorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={this.onPress}
|
||||
/>
|
||||
|
||||
<div className={styles.overlay} >
|
||||
<div className={styles.info}>
|
||||
<div className={styles.seriesTitle}>
|
||||
{series.title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.statusContainer,
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{
|
||||
missingAbsoluteNumber ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
unverifiedSceneNumbering && !missingAbsoluteNumber ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('SceneNumberNotVerified')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
queueItem ?
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails
|
||||
{...queueItem}
|
||||
fullColorEvents={fullColorEvents}
|
||||
/>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!queueItem && grabbed ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('EpisodeIsDownloading')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
showCutoffUnmetIcon &&
|
||||
!!episodeFile &&
|
||||
episodeFile.qualityCutoffNotMet ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.EPISODE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
episodeNumber === 1 && seasonNumber > 0 ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.PREMIERE}
|
||||
kind={kinds.INFO}
|
||||
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
showFinaleIcon &&
|
||||
finaleType ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
|
||||
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
|
||||
title={getFinaleTypeName(finaleType)}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
showSpecialIcon &&
|
||||
(episodeNumber === 0 || seasonNumber === 0) ?
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.PINK}
|
||||
title={translate('Special')}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{
|
||||
showEpisodeInformation ?
|
||||
<div className={styles.episodeInfo}>
|
||||
<div className={styles.episodeTitle}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
||||
|
||||
{
|
||||
series.seriesType === 'anime' && absoluteEpisodeNumber ?
|
||||
<span className={styles.absoluteEpisodeNumber}>({absoluteEpisodeNumber})</span> : null
|
||||
}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
<div className={styles.airTime}>
|
||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
episodeId={id}
|
||||
episodeEntity={episodeEntities.CALENDAR}
|
||||
seriesId={series.id}
|
||||
episodeTitle={title}
|
||||
showOpenSeriesButton={true}
|
||||
onModalClose={this.onDetailsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarEvent.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
series: PropTypes.object.isRequired,
|
||||
episodeFile: PropTypes.object,
|
||||
title: PropTypes.string.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
unverifiedSceneNumbering: PropTypes.bool,
|
||||
finaleType: PropTypes.string,
|
||||
hasFile: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
// These props come from the connector, not marked as required to appease TS for now.
|
||||
showEpisodeInformation: PropTypes.bool,
|
||||
showFinaleIcon: PropTypes.bool,
|
||||
showSpecialIcon: PropTypes.bool,
|
||||
showCutoffUnmetIcon: PropTypes.bool,
|
||||
fullColorEvents: PropTypes.bool,
|
||||
timeFormat: PropTypes.string,
|
||||
colorImpairedMode: PropTypes.bool,
|
||||
onEventModalOpenToggle: PropTypes.func
|
||||
};
|
||||
|
||||
export default CalendarEvent;
|
||||
240
frontend/src/Calendar/Events/CalendarEvent.tsx
Normal file
240
frontend/src/Calendar/Events/CalendarEvent.tsx
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarEventQueueDetails from './CalendarEventQueueDetails';
|
||||
import styles from './CalendarEvent.css';
|
||||
|
||||
interface CalendarEventProps {
|
||||
id: number;
|
||||
episodeId: number;
|
||||
seriesId: number;
|
||||
episodeFileId?: number;
|
||||
title: string;
|
||||
seasonNumber: number;
|
||||
episodeNumber: number;
|
||||
absoluteEpisodeNumber?: number;
|
||||
airDateUtc: string;
|
||||
monitored: boolean;
|
||||
unverifiedSceneNumbering?: boolean;
|
||||
finaleType?: string;
|
||||
hasFile: boolean;
|
||||
grabbed?: boolean;
|
||||
onEventModalOpenToggle: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function CalendarEvent(props: CalendarEventProps) {
|
||||
const {
|
||||
id,
|
||||
seriesId,
|
||||
episodeFileId,
|
||||
title,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
airDateUtc,
|
||||
monitored,
|
||||
unverifiedSceneNumbering,
|
||||
finaleType,
|
||||
hasFile,
|
||||
grabbed,
|
||||
onEventModalOpenToggle,
|
||||
} = props;
|
||||
|
||||
const series = useSeries(seriesId);
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(id));
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
const {
|
||||
showEpisodeInformation,
|
||||
showFinaleIcon,
|
||||
showSpecialIcon,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
} = useSelector((state: AppState) => state.calendar.options);
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
setIsDetailsModalOpen(true);
|
||||
onEventModalOpenToggle(true);
|
||||
}, [onEventModalOpenToggle]);
|
||||
|
||||
const handleDetailsModalClose = useCallback(() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
onEventModalOpenToggle(false);
|
||||
}, [onEventModalOpenToggle]);
|
||||
|
||||
if (!series) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(airDateUtc).add(series.runtime, 'minutes');
|
||||
const isDownloading = !!(queueItem || grabbed);
|
||||
const isMonitored = series.monitored && monitored;
|
||||
const statusStyle = getStatusStyle(
|
||||
hasFile,
|
||||
isDownloading,
|
||||
startTime,
|
||||
endTime,
|
||||
isMonitored
|
||||
);
|
||||
const missingAbsoluteNumber =
|
||||
series.seriesType === 'anime' && seasonNumber > 0 && !absoluteEpisodeNumber;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.event,
|
||||
styles[statusStyle],
|
||||
enableColorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
<Link className={styles.underlay} onPress={handlePress} />
|
||||
|
||||
<div className={styles.overlay}>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.seriesTitle}>{series.title}</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.statusContainer,
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{missingAbsoluteNumber ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{unverifiedSceneNumbering && !missingAbsoluteNumber ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('SceneNumberNotVerified')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{queueItem ? (
|
||||
<span className={styles.statusIcon}>
|
||||
<CalendarEventQueueDetails {...queueItem} />
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{!queueItem && grabbed ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('EpisodeIsDownloading')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCutoffUnmetIcon &&
|
||||
!!episodeFile &&
|
||||
episodeFile.qualityCutoffNotMet ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.EPISODE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
title={translate('QualityCutoffNotMet')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{episodeNumber === 1 && seasonNumber > 0 ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.PREMIERE}
|
||||
kind={kinds.INFO}
|
||||
title={
|
||||
seasonNumber === 1
|
||||
? translate('SeriesPremiere')
|
||||
: translate('SeasonPremiere')
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showFinaleIcon && finaleType ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={
|
||||
finaleType === 'series'
|
||||
? icons.FINALE_SERIES
|
||||
: icons.FINALE_SEASON
|
||||
}
|
||||
kind={finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
|
||||
title={getFinaleTypeName(finaleType)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showSpecialIcon && (episodeNumber === 0 || seasonNumber === 0) ? (
|
||||
<Icon
|
||||
className={styles.statusIcon}
|
||||
name={icons.INFO}
|
||||
kind={kinds.PINK}
|
||||
title={translate('Special')}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showEpisodeInformation ? (
|
||||
<div className={styles.episodeInfo}>
|
||||
<div className={styles.episodeTitle}>{title}</div>
|
||||
|
||||
<div>
|
||||
{seasonNumber}x{padNumber(episodeNumber, 2)}
|
||||
{series.seriesType === 'anime' && absoluteEpisodeNumber ? (
|
||||
<span className={styles.absoluteEpisodeNumber}>
|
||||
({absoluteEpisodeNumber})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={styles.airTime}>
|
||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||
{formatTime(endTime.toISOString(), timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={isDetailsModalOpen}
|
||||
episodeId={id}
|
||||
episodeEntity={episodeEntities.CALENDAR}
|
||||
seriesId={series.id}
|
||||
episodeTitle={title}
|
||||
showOpenSeriesButton={true}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarEvent;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CalendarEvent from './CalendarEvent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
createSeriesSelector(),
|
||||
createEpisodeFileSelector(),
|
||||
createQueueItemSelector(),
|
||||
createUISettingsSelector(),
|
||||
(calendarOptions, series, episodeFile, queueItem, uiSettings) => {
|
||||
return {
|
||||
series,
|
||||
episodeFile,
|
||||
queueItem,
|
||||
...calendarOptions,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarEvent);
|
||||
|
|
@ -1,259 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import CalendarEventConnector from 'Calendar/Events/CalendarEventConnector';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './CalendarEventGroup.css';
|
||||
|
||||
function getEventsInfo(series, events) {
|
||||
let files = 0;
|
||||
let queued = 0;
|
||||
let monitored = 0;
|
||||
let absoluteEpisodeNumbers = 0;
|
||||
|
||||
events.forEach((event) => {
|
||||
if (event.episodeFileId) {
|
||||
files++;
|
||||
}
|
||||
|
||||
if (event.queued) {
|
||||
queued++;
|
||||
}
|
||||
|
||||
if (series.monitored && event.monitored) {
|
||||
monitored++;
|
||||
}
|
||||
|
||||
if (event.absoluteEpisodeNumber) {
|
||||
absoluteEpisodeNumbers++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
allDownloaded: files === events.length,
|
||||
anyQueued: queued > 0,
|
||||
anyMonitored: monitored > 0,
|
||||
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length
|
||||
};
|
||||
}
|
||||
|
||||
class CalendarEventGroup extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isExpanded: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onExpandPress = () => {
|
||||
this.setState({ isExpanded: !this.state.isExpanded });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
series,
|
||||
events,
|
||||
isDownloading,
|
||||
showEpisodeInformation,
|
||||
showFinaleIcon,
|
||||
timeFormat,
|
||||
fullColorEvents,
|
||||
colorImpairedMode,
|
||||
onEventModalOpenToggle
|
||||
} = this.props;
|
||||
|
||||
const { isExpanded } = this.state;
|
||||
const {
|
||||
allDownloaded,
|
||||
anyQueued,
|
||||
anyMonitored,
|
||||
allAbsoluteEpisodeNumbers
|
||||
} = getEventsInfo(series, events);
|
||||
const anyDownloading = isDownloading || anyQueued;
|
||||
const firstEpisode = events[0];
|
||||
const lastEpisode = events[events.length -1];
|
||||
const airDateUtc = firstEpisode.airDateUtc;
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
|
||||
const seasonNumber = firstEpisode.seasonNumber;
|
||||
const statusStyle = getStatusStyle(allDownloaded, anyDownloading, startTime, endTime, anyMonitored);
|
||||
const isMissingAbsoluteNumber = series.seriesType === 'anime' && seasonNumber > 0 && !allAbsoluteEpisodeNumbers;
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
events.map((event) => {
|
||||
if (event.isGroup) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CalendarEventConnector
|
||||
key={event.id}
|
||||
episodeId={event.id}
|
||||
{...event}
|
||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
<Link
|
||||
className={styles.collapseContainer}
|
||||
component="div"
|
||||
onPress={this.onExpandPress}
|
||||
>
|
||||
<Icon
|
||||
name={icons.COLLAPSE}
|
||||
/>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.eventGroup,
|
||||
styles[statusStyle],
|
||||
colorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.seriesTitle}>
|
||||
{series.title}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.statusContainer,
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{
|
||||
isMissingAbsoluteNumber &&
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
anyDownloading &&
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('AnEpisodeIsDownloading')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
firstEpisode.episodeNumber === 1 && seasonNumber > 0 &&
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={icons.PREMIERE}
|
||||
kind={kinds.INFO}
|
||||
title={seasonNumber === 1 ? translate('SeriesPremiere') : translate('SeasonPremiere')}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
showFinaleIcon &&
|
||||
lastEpisode.finaleType ?
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={lastEpisode.finaleType === 'series' ? icons.FINALE_SERIES : icons.FINALE_SEASON}
|
||||
kind={lastEpisode.finaleType === 'series' ? kinds.DANGER : kinds.WARNING}
|
||||
title={getFinaleTypeName(lastEpisode.finaleType)}
|
||||
/> : null
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.airingInfo}>
|
||||
<div className={styles.airTime}>
|
||||
{formatTime(airDateUtc, timeFormat)} - {formatTime(endTime.toISOString(), timeFormat, { includeMinuteZero: true })}
|
||||
</div>
|
||||
|
||||
{
|
||||
showEpisodeInformation ?
|
||||
<div className={styles.episodeInfo}>
|
||||
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-{padNumber(lastEpisode.episodeNumber, 2)}
|
||||
|
||||
{
|
||||
series.seriesType === 'anime' &&
|
||||
firstEpisode.absoluteEpisodeNumber &&
|
||||
lastEpisode.absoluteEpisodeNumber &&
|
||||
<span className={styles.absoluteEpisodeNumber}>
|
||||
({firstEpisode.absoluteEpisodeNumber}-{lastEpisode.absoluteEpisodeNumber})
|
||||
</span>
|
||||
}
|
||||
</div> :
|
||||
<Link
|
||||
className={styles.expandContainerInline}
|
||||
component="div"
|
||||
onPress={this.onExpandPress}
|
||||
>
|
||||
<Icon
|
||||
name={icons.EXPAND}
|
||||
/>
|
||||
</Link>
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
showEpisodeInformation ?
|
||||
<Link
|
||||
className={styles.expandContainer}
|
||||
component="div"
|
||||
onPress={this.onExpandPress}
|
||||
>
|
||||
|
||||
<Icon
|
||||
name={icons.EXPAND}
|
||||
/>
|
||||
|
||||
</Link> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarEventGroup.propTypes = {
|
||||
// Most of these props come from the connector and are required, but TS is confused.
|
||||
series: PropTypes.object,
|
||||
events: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isDownloading: PropTypes.bool,
|
||||
showEpisodeInformation: PropTypes.bool,
|
||||
showFinaleIcon: PropTypes.bool,
|
||||
fullColorEvents: PropTypes.bool,
|
||||
timeFormat: PropTypes.string,
|
||||
colorImpairedMode: PropTypes.bool,
|
||||
onEventModalOpenToggle: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarEventGroup;
|
||||
253
frontend/src/Calendar/Events/CalendarEventGroup.tsx
Normal file
253
frontend/src/Calendar/Events/CalendarEventGroup.tsx
Normal file
|
|
@ -0,0 +1,253 @@
|
|||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import getStatusStyle from 'Calendar/getStatusStyle';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import getFinaleTypeName from 'Episode/getFinaleTypeName';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { CalendarItem } from 'typings/Calendar';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarEvent from './CalendarEvent';
|
||||
import styles from './CalendarEventGroup.css';
|
||||
|
||||
function createIsDownloadingSelector(episodeIds: number[]) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.queue.details,
|
||||
(details) => {
|
||||
return details.items.some((item) => {
|
||||
return !!(item.episodeId && episodeIds.includes(item.episodeId));
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
interface CalendarEventGroupProps {
|
||||
episodeIds: number[];
|
||||
seriesId: number;
|
||||
events: CalendarItem[];
|
||||
onEventModalOpenToggle: (isOpen: boolean) => void;
|
||||
}
|
||||
|
||||
function CalendarEventGroup({
|
||||
episodeIds,
|
||||
seriesId,
|
||||
events,
|
||||
onEventModalOpenToggle,
|
||||
}: CalendarEventGroupProps) {
|
||||
const isDownloading = useSelector(createIsDownloadingSelector(episodeIds));
|
||||
const series = useSeries(seriesId)!;
|
||||
|
||||
const { timeFormat, enableColorImpairedMode } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
const { showEpisodeInformation, showFinaleIcon, fullColorEvents } =
|
||||
useSelector((state: AppState) => state.calendar.options);
|
||||
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const firstEpisode = events[0];
|
||||
const lastEpisode = events[events.length - 1];
|
||||
const airDateUtc = firstEpisode.airDateUtc;
|
||||
const startTime = moment(airDateUtc);
|
||||
const endTime = moment(lastEpisode.airDateUtc).add(series.runtime, 'minutes');
|
||||
const seasonNumber = firstEpisode.seasonNumber;
|
||||
|
||||
const { allDownloaded, anyQueued, anyMonitored, allAbsoluteEpisodeNumbers } =
|
||||
useMemo(() => {
|
||||
let files = 0;
|
||||
let queued = 0;
|
||||
let monitored = 0;
|
||||
let absoluteEpisodeNumbers = 0;
|
||||
|
||||
events.forEach((event) => {
|
||||
if (event.episodeFileId) {
|
||||
files++;
|
||||
}
|
||||
|
||||
if (event.queued) {
|
||||
queued++;
|
||||
}
|
||||
|
||||
if (series.monitored && event.monitored) {
|
||||
monitored++;
|
||||
}
|
||||
|
||||
if (event.absoluteEpisodeNumber) {
|
||||
absoluteEpisodeNumbers++;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
allDownloaded: files === events.length,
|
||||
anyQueued: queued > 0,
|
||||
anyMonitored: monitored > 0,
|
||||
allAbsoluteEpisodeNumbers: absoluteEpisodeNumbers === events.length,
|
||||
};
|
||||
}, [series, events]);
|
||||
|
||||
const anyDownloading = isDownloading || anyQueued;
|
||||
|
||||
const statusStyle = getStatusStyle(
|
||||
allDownloaded,
|
||||
anyDownloading,
|
||||
startTime,
|
||||
endTime,
|
||||
anyMonitored
|
||||
);
|
||||
const isMissingAbsoluteNumber =
|
||||
series.seriesType === 'anime' &&
|
||||
seasonNumber > 0 &&
|
||||
!allAbsoluteEpisodeNumbers;
|
||||
|
||||
const handleExpandPress = useCallback(() => {
|
||||
setIsExpanded((state) => !state);
|
||||
}, []);
|
||||
|
||||
if (isExpanded) {
|
||||
return (
|
||||
<div>
|
||||
{events.map((event) => {
|
||||
return (
|
||||
<CalendarEvent
|
||||
key={event.id}
|
||||
episodeId={event.id}
|
||||
{...event}
|
||||
onEventModalOpenToggle={onEventModalOpenToggle}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
<Link
|
||||
className={styles.collapseContainer}
|
||||
component="div"
|
||||
onPress={handleExpandPress}
|
||||
>
|
||||
<Icon name={icons.COLLAPSE} />
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.eventGroup,
|
||||
styles[statusStyle],
|
||||
enableColorImpairedMode && 'colorImpaired',
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.seriesTitle}>{series.title}</div>
|
||||
|
||||
<div
|
||||
className={classNames(
|
||||
styles.statusContainer,
|
||||
fullColorEvents && 'fullColor'
|
||||
)}
|
||||
>
|
||||
{isMissingAbsoluteNumber ? (
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={icons.WARNING}
|
||||
title={translate('EpisodeMissingAbsoluteNumber')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{anyDownloading ? (
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={icons.DOWNLOADING}
|
||||
title={translate('AnEpisodeIsDownloading')}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{firstEpisode.episodeNumber === 1 && seasonNumber > 0 ? (
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={icons.PREMIERE}
|
||||
kind={kinds.INFO}
|
||||
title={
|
||||
seasonNumber === 1
|
||||
? translate('SeriesPremiere')
|
||||
: translate('SeasonPremiere')
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showFinaleIcon && lastEpisode.finaleType ? (
|
||||
<Icon
|
||||
containerClassName={styles.statusIcon}
|
||||
name={
|
||||
lastEpisode.finaleType === 'series'
|
||||
? icons.FINALE_SERIES
|
||||
: icons.FINALE_SEASON
|
||||
}
|
||||
kind={
|
||||
lastEpisode.finaleType === 'series'
|
||||
? kinds.DANGER
|
||||
: kinds.WARNING
|
||||
}
|
||||
title={getFinaleTypeName(lastEpisode.finaleType)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.airingInfo}>
|
||||
<div className={styles.airTime}>
|
||||
{formatTime(airDateUtc, timeFormat)} -{' '}
|
||||
{formatTime(endTime.toISOString(), timeFormat, {
|
||||
includeMinuteZero: true,
|
||||
})}
|
||||
</div>
|
||||
|
||||
{showEpisodeInformation ? (
|
||||
<div className={styles.episodeInfo}>
|
||||
{seasonNumber}x{padNumber(firstEpisode.episodeNumber, 2)}-
|
||||
{padNumber(lastEpisode.episodeNumber, 2)}
|
||||
{series.seriesType === 'anime' &&
|
||||
firstEpisode.absoluteEpisodeNumber &&
|
||||
lastEpisode.absoluteEpisodeNumber ? (
|
||||
<span className={styles.absoluteEpisodeNumber}>
|
||||
({firstEpisode.absoluteEpisodeNumber}-
|
||||
{lastEpisode.absoluteEpisodeNumber})
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
className={styles.expandContainerInline}
|
||||
component="div"
|
||||
onPress={handleExpandPress}
|
||||
>
|
||||
<Icon name={icons.EXPAND} />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showEpisodeInformation ? (
|
||||
<Link
|
||||
className={styles.expandContainer}
|
||||
component="div"
|
||||
onPress={handleExpandPress}
|
||||
>
|
||||
|
||||
<Icon name={icons.EXPAND} />
|
||||
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarEventGroup;
|
||||
|
|
@ -1,37 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CalendarEventGroup from './CalendarEventGroup';
|
||||
|
||||
function createIsDownloadingSelector() {
|
||||
return createSelector(
|
||||
(state, { episodeIds }) => episodeIds,
|
||||
(state) => state.queue.details,
|
||||
(episodeIds, details) => {
|
||||
return details.items.some((item) => {
|
||||
return !!(item.episodeId && episodeIds.includes(item.episodeId));
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
createSeriesSelector(),
|
||||
createIsDownloadingSelector(),
|
||||
createUISettingsSelector(),
|
||||
(calendarOptions, series, isDownloading, uiSettings) => {
|
||||
return {
|
||||
series,
|
||||
isDownloading,
|
||||
...calendarOptions,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarEventGroup);
|
||||
|
|
@ -1,56 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import CircularProgressBar from 'Components/CircularProgressBar';
|
||||
|
||||
function CalendarEventQueueDetails(props) {
|
||||
const {
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
trackedDownloadState,
|
||||
trackedDownloadStatus,
|
||||
statusMessages,
|
||||
errorMessage
|
||||
} = props;
|
||||
|
||||
const progress = size ? (100 - sizeleft / size * 100) : 0;
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
trackedDownloadState={trackedDownloadState}
|
||||
trackedDownloadStatus={trackedDownloadStatus}
|
||||
statusMessages={statusMessages}
|
||||
errorMessage={errorMessage}
|
||||
progressBar={
|
||||
<CircularProgressBar
|
||||
progress={progress}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
strokeColor={'#7a43b6'}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarEventQueueDetails.propTypes = {
|
||||
title: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
sizeleft: PropTypes.number.isRequired,
|
||||
estimatedCompletionTime: PropTypes.string,
|
||||
status: PropTypes.string.isRequired,
|
||||
trackedDownloadState: PropTypes.string.isRequired,
|
||||
trackedDownloadStatus: PropTypes.string.isRequired,
|
||||
statusMessages: PropTypes.arrayOf(PropTypes.object),
|
||||
errorMessage: PropTypes.string
|
||||
};
|
||||
|
||||
export default CalendarEventQueueDetails;
|
||||
58
frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
Normal file
58
frontend/src/Calendar/Events/CalendarEventQueueDetails.tsx
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import React from 'react';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import CircularProgressBar from 'Components/CircularProgressBar';
|
||||
import {
|
||||
QueueTrackedDownloadState,
|
||||
QueueTrackedDownloadStatus,
|
||||
StatusMessage,
|
||||
} from 'typings/Queue';
|
||||
|
||||
interface CalendarEventQueueDetailsProps {
|
||||
title: string;
|
||||
size: number;
|
||||
sizeleft: number;
|
||||
estimatedCompletionTime?: string;
|
||||
status: string;
|
||||
trackedDownloadState: QueueTrackedDownloadState;
|
||||
trackedDownloadStatus: QueueTrackedDownloadStatus;
|
||||
statusMessages?: StatusMessage[];
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
function CalendarEventQueueDetails({
|
||||
title,
|
||||
size,
|
||||
sizeleft,
|
||||
estimatedCompletionTime,
|
||||
status,
|
||||
trackedDownloadState,
|
||||
trackedDownloadStatus,
|
||||
statusMessages,
|
||||
errorMessage,
|
||||
}: CalendarEventQueueDetailsProps) {
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<QueueDetails
|
||||
title={title}
|
||||
size={size}
|
||||
sizeleft={sizeleft}
|
||||
estimatedCompletionTime={estimatedCompletionTime}
|
||||
status={status}
|
||||
trackedDownloadState={trackedDownloadState}
|
||||
trackedDownloadStatus={trackedDownloadStatus}
|
||||
statusMessages={statusMessages}
|
||||
errorMessage={errorMessage}
|
||||
progressBar={
|
||||
<CircularProgressBar
|
||||
progress={progress}
|
||||
size={20}
|
||||
strokeWidth={2}
|
||||
strokeColor="#7a43b6"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarEventQueueDetails;
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
||||
import styles from './CalendarHeader.css';
|
||||
|
||||
function getTitle(time, start, end, view, longDateFormat) {
|
||||
const timeMoment = moment(time);
|
||||
const startMoment = moment(start);
|
||||
const endMoment = moment(end);
|
||||
|
||||
if (view === 'day') {
|
||||
return timeMoment.format(longDateFormat);
|
||||
} else if (view === 'month') {
|
||||
return timeMoment.format('MMMM YYYY');
|
||||
} else if (view === 'agenda') {
|
||||
return translate('Agenda');
|
||||
}
|
||||
|
||||
let startFormat = 'MMM D YYYY';
|
||||
let endFormat = 'MMM D YYYY';
|
||||
|
||||
if (startMoment.isSame(endMoment, 'month')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'D YYYY';
|
||||
} else if (startMoment.isSame(endMoment, 'year')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'MMM D YYYY';
|
||||
}
|
||||
|
||||
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(endFormat)}`;
|
||||
}
|
||||
|
||||
// TODO Convert to a stateful Component so we can track view internally when changed
|
||||
|
||||
class CalendarHeader extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
view: props.view
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const view = this.props.view;
|
||||
|
||||
if (prevProps.view !== view) {
|
||||
this.setState({ view });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onViewChange = (view) => {
|
||||
this.setState({ view }, () => {
|
||||
this.props.onViewChange(view);
|
||||
});
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
time,
|
||||
start,
|
||||
end,
|
||||
longDateFormat,
|
||||
isSmallScreen,
|
||||
collapseViewButtons,
|
||||
onTodayPress,
|
||||
onPreviousPress,
|
||||
onNextPress
|
||||
} = this.props;
|
||||
|
||||
const view = this.state.view;
|
||||
|
||||
const title = getTitle(time, start, end, view, longDateFormat);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
isSmallScreen &&
|
||||
<div className={styles.titleMobile}>
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.navigationButtons}>
|
||||
<Button
|
||||
buttonGroupPosition={align.LEFT}
|
||||
isDisabled={view === calendarViews.AGENDA}
|
||||
onPress={onPreviousPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_PREVIOUS} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
buttonGroupPosition={align.RIGHT}
|
||||
isDisabled={view === calendarViews.AGENDA}
|
||||
onPress={onNextPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_NEXT} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className={styles.todayButton}
|
||||
isDisabled={view === calendarViews.AGENDA}
|
||||
onPress={onTodayPress}
|
||||
>
|
||||
{translate('Today')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{
|
||||
!isSmallScreen &&
|
||||
<div className={styles.titleDesktop}>
|
||||
{title}
|
||||
</div>
|
||||
}
|
||||
|
||||
<div className={styles.viewButtonsContainer}>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
collapseViewButtons ?
|
||||
<Menu
|
||||
className={styles.viewMenu}
|
||||
alignMenu={align.RIGHT}
|
||||
>
|
||||
<MenuButton>
|
||||
<Icon
|
||||
name={icons.VIEW}
|
||||
size={22}
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
{
|
||||
isSmallScreen ?
|
||||
null :
|
||||
<ViewMenuItem
|
||||
name={calendarViews.MONTH}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Month')}
|
||||
</ViewMenuItem>
|
||||
}
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.WEEK}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Week')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.FORECAST}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Forecast')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.DAY}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Day')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name={calendarViews.AGENDA}
|
||||
selectedView={view}
|
||||
onPress={this.onViewChange}
|
||||
>
|
||||
{translate('Agenda')}
|
||||
</ViewMenuItem>
|
||||
</MenuContent>
|
||||
</Menu> :
|
||||
|
||||
<div className={styles.viewButtons}>
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.MONTH}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.LEFT}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.WEEK}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.CENTER}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.FORECAST}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.CENTER}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.DAY}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.CENTER}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view={calendarViews.AGENDA}
|
||||
selectedView={view}
|
||||
buttonGroupPosition={align.RIGHT}
|
||||
onPress={this.onViewChange}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarHeader.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
time: PropTypes.string.isRequired,
|
||||
start: PropTypes.string.isRequired,
|
||||
end: PropTypes.string.isRequired,
|
||||
view: PropTypes.oneOf(calendarViews.all).isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
collapseViewButtons: PropTypes.bool.isRequired,
|
||||
longDateFormat: PropTypes.string.isRequired,
|
||||
onViewChange: PropTypes.func.isRequired,
|
||||
onTodayPress: PropTypes.func.isRequired,
|
||||
onPreviousPress: PropTypes.func.isRequired,
|
||||
onNextPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarHeader;
|
||||
221
frontend/src/Calendar/Header/CalendarHeader.tsx
Normal file
221
frontend/src/Calendar/Header/CalendarHeader.tsx
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
import moment from 'moment';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { CalendarView } from 'Calendar/calendarViews';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import ViewMenuItem from 'Components/Menu/ViewMenuItem';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import {
|
||||
gotoCalendarNextRange,
|
||||
gotoCalendarPreviousRange,
|
||||
gotoCalendarToday,
|
||||
setCalendarView,
|
||||
} from 'Store/Actions/calendarActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import CalendarHeaderViewButton from './CalendarHeaderViewButton';
|
||||
import styles from './CalendarHeader.css';
|
||||
|
||||
function CalendarHeader() {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isFetching, view, time, start, end } = useSelector(
|
||||
(state: AppState) => state.calendar
|
||||
);
|
||||
|
||||
const { isSmallScreen, isLargeScreen } = useSelector(
|
||||
createDimensionsSelector()
|
||||
);
|
||||
|
||||
const { longDateFormat } = useSelector(createUISettingsSelector());
|
||||
|
||||
const handleViewChange = useCallback(
|
||||
(newView: CalendarView) => {
|
||||
dispatch(setCalendarView({ view: newView }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTodayPress = useCallback(() => {
|
||||
dispatch(gotoCalendarToday());
|
||||
}, [dispatch]);
|
||||
|
||||
const handlePreviousPress = useCallback(() => {
|
||||
dispatch(gotoCalendarPreviousRange());
|
||||
}, [dispatch]);
|
||||
|
||||
const handleNextPress = useCallback(() => {
|
||||
dispatch(gotoCalendarNextRange());
|
||||
}, [dispatch]);
|
||||
|
||||
const title = useMemo(() => {
|
||||
const timeMoment = moment(time);
|
||||
const startMoment = moment(start);
|
||||
const endMoment = moment(end);
|
||||
|
||||
if (view === 'day') {
|
||||
return timeMoment.format(longDateFormat);
|
||||
} else if (view === 'month') {
|
||||
return timeMoment.format('MMMM YYYY');
|
||||
} else if (view === 'agenda') {
|
||||
return translate('Agenda');
|
||||
}
|
||||
|
||||
let startFormat = 'MMM D YYYY';
|
||||
let endFormat = 'MMM D YYYY';
|
||||
|
||||
if (startMoment.isSame(endMoment, 'month')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'D YYYY';
|
||||
} else if (startMoment.isSame(endMoment, 'year')) {
|
||||
startFormat = 'MMM D';
|
||||
endFormat = 'MMM D YYYY';
|
||||
}
|
||||
|
||||
return `${startMoment.format(startFormat)} \u2014 ${endMoment.format(
|
||||
endFormat
|
||||
)}`;
|
||||
}, [time, start, end, view, longDateFormat]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isSmallScreen ? <div className={styles.titleMobile}>{title}</div> : null}
|
||||
|
||||
<div className={styles.header}>
|
||||
<div className={styles.navigationButtons}>
|
||||
<Button
|
||||
buttonGroupPosition="left"
|
||||
isDisabled={view === 'agenda'}
|
||||
onPress={handlePreviousPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_PREVIOUS} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
buttonGroupPosition="right"
|
||||
isDisabled={view === 'agenda'}
|
||||
onPress={handleNextPress}
|
||||
>
|
||||
<Icon name={icons.PAGE_NEXT} />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
className={styles.todayButton}
|
||||
isDisabled={view === 'agenda'}
|
||||
onPress={handleTodayPress}
|
||||
>
|
||||
{translate('Today')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isSmallScreen ? null : (
|
||||
<div className={styles.titleDesktop}>{title}</div>
|
||||
)}
|
||||
|
||||
<div className={styles.viewButtonsContainer}>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
{isLargeScreen ? (
|
||||
<Menu className={styles.viewMenu} alignMenu={align.RIGHT}>
|
||||
<MenuButton>
|
||||
<Icon name={icons.VIEW} size={22} />
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
{isSmallScreen ? null : (
|
||||
<ViewMenuItem
|
||||
name="month"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Month')}
|
||||
</ViewMenuItem>
|
||||
)}
|
||||
|
||||
<ViewMenuItem
|
||||
name="week"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Week')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name="forecast"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Forecast')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name="day"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Day')}
|
||||
</ViewMenuItem>
|
||||
|
||||
<ViewMenuItem
|
||||
name="agenda"
|
||||
selectedView={view}
|
||||
onPress={handleViewChange}
|
||||
>
|
||||
{translate('Agenda')}
|
||||
</ViewMenuItem>
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
) : (
|
||||
<>
|
||||
<CalendarHeaderViewButton
|
||||
view="month"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="left"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="week"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="center"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="forecast"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="center"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="day"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="center"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
|
||||
<CalendarHeaderViewButton
|
||||
view="agenda"
|
||||
selectedView={view}
|
||||
buttonGroupPosition="right"
|
||||
onPress={handleViewChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarHeader;
|
||||
|
|
@ -1,85 +0,0 @@
|
|||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { gotoCalendarNextRange, gotoCalendarPreviousRange, gotoCalendarToday, setCalendarView } from 'Store/Actions/calendarActions';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CalendarHeader from './CalendarHeader';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar,
|
||||
createDimensionsSelector(),
|
||||
createUISettingsSelector(),
|
||||
(calendar, dimensions, uiSettings) => {
|
||||
const result = _.pick(calendar, [
|
||||
'isFetching',
|
||||
'view',
|
||||
'time',
|
||||
'start',
|
||||
'end'
|
||||
]);
|
||||
|
||||
result.isSmallScreen = dimensions.isSmallScreen;
|
||||
result.collapseViewButtons = dimensions.isLargeScreen;
|
||||
result.longDateFormat = uiSettings.longDateFormat;
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
setCalendarView,
|
||||
gotoCalendarToday,
|
||||
gotoCalendarPreviousRange,
|
||||
gotoCalendarNextRange
|
||||
};
|
||||
|
||||
class CalendarHeaderConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onViewChange = (view) => {
|
||||
this.props.setCalendarView({ view });
|
||||
};
|
||||
|
||||
onTodayPress = () => {
|
||||
this.props.gotoCalendarToday();
|
||||
};
|
||||
|
||||
onPreviousPress = () => {
|
||||
this.props.gotoCalendarPreviousRange();
|
||||
};
|
||||
|
||||
onNextPress = () => {
|
||||
this.props.gotoCalendarNextRange();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<CalendarHeader
|
||||
{...this.props}
|
||||
onViewChange={this.onViewChange}
|
||||
onTodayPress={this.onTodayPress}
|
||||
onPreviousPress={this.onPreviousPress}
|
||||
onNextPress={this.onNextPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarHeaderConnector.propTypes = {
|
||||
setCalendarView: PropTypes.func.isRequired,
|
||||
gotoCalendarToday: PropTypes.func.isRequired,
|
||||
gotoCalendarPreviousRange: PropTypes.func.isRequired,
|
||||
gotoCalendarNextRange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarHeaderConnector);
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import * as calendarViews from 'Calendar/calendarViews';
|
||||
import Button from 'Components/Link/Button';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
// import styles from './CalendarHeaderViewButton.css';
|
||||
|
||||
class CalendarHeaderViewButton extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.props.onPress(this.props.view);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
view,
|
||||
selectedView,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Button
|
||||
isDisabled={selectedView === view}
|
||||
{...otherProps}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{titleCase(view)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarHeaderViewButton.propTypes = {
|
||||
view: PropTypes.oneOf(calendarViews.all).isRequired,
|
||||
selectedView: PropTypes.oneOf(calendarViews.all).isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarHeaderViewButton;
|
||||
34
frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
Normal file
34
frontend/src/Calendar/Header/CalendarHeaderViewButton.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import { CalendarView } from 'Calendar/calendarViews';
|
||||
import Button, { ButtonProps } from 'Components/Link/Button';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
|
||||
interface CalendarHeaderViewButtonProps
|
||||
extends Omit<ButtonProps, 'children' | 'onPress'> {
|
||||
view: CalendarView;
|
||||
selectedView: CalendarView;
|
||||
onPress: (view: CalendarView) => void;
|
||||
}
|
||||
|
||||
function CalendarHeaderViewButton({
|
||||
view,
|
||||
selectedView,
|
||||
onPress,
|
||||
...otherProps
|
||||
}: CalendarHeaderViewButtonProps) {
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(view);
|
||||
}, [view, onPress]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
isDisabled={selectedView === view}
|
||||
{...otherProps}
|
||||
onPress={handlePress}
|
||||
>
|
||||
{titleCase(view)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarHeaderViewButton;
|
||||
|
|
@ -1,20 +1,22 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import LegendIconItem from './LegendIconItem';
|
||||
import LegendItem from './LegendItem';
|
||||
import styles from './Legend.css';
|
||||
|
||||
function Legend(props) {
|
||||
function Legend() {
|
||||
const view = useSelector((state: AppState) => state.calendar.view);
|
||||
const {
|
||||
view,
|
||||
showFinaleIcon,
|
||||
showSpecialIcon,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
colorImpairedMode
|
||||
} = props;
|
||||
} = useSelector((state: AppState) => state.calendar.options);
|
||||
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
|
||||
|
||||
const iconsToShow = [];
|
||||
const isAgendaView = view === 'agenda';
|
||||
|
|
@ -56,7 +58,7 @@ function Legend(props) {
|
|||
if (showCutoffUnmetIcon) {
|
||||
iconsToShow.push(
|
||||
<LegendIconItem
|
||||
name={translate('Cutoff Not Met')}
|
||||
name={translate('CutoffNotMet')}
|
||||
icon={icons.EPISODE_FILE}
|
||||
kind={kinds.WARNING}
|
||||
fullColorEvents={fullColorEvents}
|
||||
|
|
@ -73,7 +75,7 @@ function Legend(props) {
|
|||
tooltip={translate('CalendarLegendEpisodeUnairedTooltip')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
|
|
@ -81,7 +83,7 @@ function Legend(props) {
|
|||
tooltip={translate('CalendarLegendEpisodeUnmonitoredTooltip')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -92,7 +94,7 @@ function Legend(props) {
|
|||
tooltip={translate('CalendarLegendEpisodeOnAirTooltip')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
|
|
@ -100,7 +102,7 @@ function Legend(props) {
|
|||
tooltip={translate('CalendarLegendEpisodeMissingTooltip')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -110,7 +112,7 @@ function Legend(props) {
|
|||
tooltip={translate('CalendarLegendEpisodeDownloadingTooltip')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
|
||||
<LegendItem
|
||||
|
|
@ -118,7 +120,7 @@ function Legend(props) {
|
|||
tooltip={translate('CalendarLegendEpisodeDownloadedTooltip')}
|
||||
isAgendaView={isAgendaView}
|
||||
fullColorEvents={fullColorEvents}
|
||||
colorImpairedMode={colorImpairedMode}
|
||||
colorImpairedMode={enableColorImpairedMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -134,30 +136,15 @@ function Legend(props) {
|
|||
{iconsToShow[0]}
|
||||
</div>
|
||||
|
||||
{
|
||||
iconsToShow.length > 1 &&
|
||||
<div>
|
||||
{iconsToShow[1]}
|
||||
{iconsToShow[2]}
|
||||
</div>
|
||||
}
|
||||
{
|
||||
iconsToShow.length > 3 &&
|
||||
<div>
|
||||
{iconsToShow[3]}
|
||||
</div>
|
||||
}
|
||||
{iconsToShow.length > 1 ? (
|
||||
<div>
|
||||
{iconsToShow[1]}
|
||||
{iconsToShow[2]}
|
||||
</div>
|
||||
) : null}
|
||||
{iconsToShow.length > 3 ? <div>{iconsToShow[3]}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Legend.propTypes = {
|
||||
view: PropTypes.string.isRequired,
|
||||
showFinaleIcon: PropTypes.bool.isRequired,
|
||||
showSpecialIcon: PropTypes.bool.isRequired,
|
||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default Legend;
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import Legend from './Legend';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
(state) => state.calendar.view,
|
||||
createUISettingsSelector(),
|
||||
(calendarOptions, view, uiSettings) => {
|
||||
return {
|
||||
...calendarOptions,
|
||||
view,
|
||||
colorImpairedMode: uiSettings.enableColorImpairedMode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(Legend);
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import styles from './LegendIconItem.css';
|
||||
|
||||
function LegendIconItem(props) {
|
||||
const {
|
||||
name,
|
||||
fullColorEvents,
|
||||
icon,
|
||||
kind,
|
||||
tooltip
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.legendIconItem}
|
||||
title={tooltip}
|
||||
>
|
||||
<Icon
|
||||
className={classNames(
|
||||
styles.icon,
|
||||
fullColorEvents && 'fullColorEvents'
|
||||
)}
|
||||
name={icon}
|
||||
kind={kind}
|
||||
/>
|
||||
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LegendIconItem.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
icon: PropTypes.object.isRequired,
|
||||
kind: PropTypes.string.isRequired,
|
||||
tooltip: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default LegendIconItem;
|
||||
33
frontend/src/Calendar/Legend/LegendIconItem.tsx
Normal file
33
frontend/src/Calendar/Legend/LegendIconItem.tsx
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { FontAwesomeIconProps } from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import styles from './LegendIconItem.css';
|
||||
|
||||
interface LegendIconItemProps extends Pick<IconProps, 'kind'> {
|
||||
name: string;
|
||||
fullColorEvents: boolean;
|
||||
icon: FontAwesomeIconProps['icon'];
|
||||
tooltip: string;
|
||||
}
|
||||
|
||||
function LegendIconItem(props: LegendIconItemProps) {
|
||||
const { name, fullColorEvents, icon, kind, tooltip } = props;
|
||||
|
||||
return (
|
||||
<div className={styles.legendIconItem} title={tooltip}>
|
||||
<Icon
|
||||
className={classNames(
|
||||
styles.icon,
|
||||
fullColorEvents && 'fullColorEvents'
|
||||
)}
|
||||
name={icon}
|
||||
kind={kind}
|
||||
/>
|
||||
|
||||
{name}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendIconItem;
|
||||
|
|
@ -1,17 +1,26 @@
|
|||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { CalendarStatus } from 'typings/Calendar';
|
||||
import titleCase from 'Utilities/String/titleCase';
|
||||
import styles from './LegendItem.css';
|
||||
|
||||
function LegendItem(props) {
|
||||
interface LegendItemProps {
|
||||
name?: string;
|
||||
status: CalendarStatus;
|
||||
tooltip: string;
|
||||
isAgendaView: boolean;
|
||||
fullColorEvents: boolean;
|
||||
colorImpairedMode: boolean;
|
||||
}
|
||||
|
||||
function LegendItem(props: LegendItemProps) {
|
||||
const {
|
||||
name,
|
||||
status,
|
||||
tooltip,
|
||||
isAgendaView,
|
||||
fullColorEvents,
|
||||
colorImpairedMode
|
||||
colorImpairedMode,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
|
|
@ -29,13 +38,4 @@ function LegendItem(props) {
|
|||
);
|
||||
}
|
||||
|
||||
LegendItem.propTypes = {
|
||||
name: PropTypes.string,
|
||||
status: PropTypes.string.isRequired,
|
||||
tooltip: PropTypes.string.isRequired,
|
||||
isAgendaView: PropTypes.bool.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
colorImpairedMode: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default LegendItem;
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarOptionsModalContentConnector from './CalendarOptionsModalContentConnector';
|
||||
|
||||
function CalendarOptionsModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<CalendarOptionsModalContentConnector
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarOptionsModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarOptionsModal;
|
||||
21
frontend/src/Calendar/Options/CalendarOptionsModal.tsx
Normal file
21
frontend/src/Calendar/Options/CalendarOptionsModal.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
|
||||
|
||||
interface CalendarOptionsModalProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarOptionsModal({
|
||||
isOpen,
|
||||
onModalClose,
|
||||
}: CalendarOptionsModalProps) {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<CalendarOptionsModalContent onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarOptionsModal;
|
||||
|
|
@ -1,276 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { firstDayOfWeekOptions, timeFormatOptions, weekColumnOptions } from 'Settings/UI/UISettings';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
class CalendarOptionsModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode,
|
||||
fullColorEvents
|
||||
} = props;
|
||||
|
||||
this.state = {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode,
|
||||
fullColorEvents
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
prevProps.firstDayOfWeek !== firstDayOfWeek ||
|
||||
prevProps.calendarWeekColumnHeader !== calendarWeekColumnHeader ||
|
||||
prevProps.timeFormat !== timeFormat ||
|
||||
prevProps.enableColorImpairedMode !== enableColorImpairedMode
|
||||
) {
|
||||
this.setState({
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onOptionInputChange = ({ name, value }) => {
|
||||
const {
|
||||
dispatchSetCalendarOption
|
||||
} = this.props;
|
||||
|
||||
dispatchSetCalendarOption({ [name]: value });
|
||||
};
|
||||
|
||||
onGlobalInputChange = ({ name, value }) => {
|
||||
const {
|
||||
dispatchSaveUISettings
|
||||
} = this.props;
|
||||
|
||||
const setting = { [name]: value };
|
||||
|
||||
this.setState(setting, () => {
|
||||
dispatchSaveUISettings(setting);
|
||||
});
|
||||
};
|
||||
|
||||
onLinkFocus = (event) => {
|
||||
event.target.select();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
collapseMultipleEpisodes,
|
||||
showEpisodeInformation,
|
||||
showFinaleIcon,
|
||||
showSpecialIcon,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('CalendarOptions')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FieldSet legend={translate('Local')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="collapseMultipleEpisodes"
|
||||
value={collapseMultipleEpisodes}
|
||||
helpText={translate('CollapseMultipleEpisodesHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showEpisodeInformation"
|
||||
value={showEpisodeInformation}
|
||||
helpText={translate('ShowEpisodeInformationHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForFinales')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showFinaleIcon"
|
||||
value={showFinaleIcon}
|
||||
helpText={translate('IconForFinalesHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForSpecials')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showSpecialIcon"
|
||||
value={showSpecialIcon}
|
||||
helpText={translate('IconForSpecialsHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showCutoffUnmetIcon"
|
||||
value={showCutoffUnmetIcon}
|
||||
helpText={translate('IconForCutoffUnmetHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FullColorEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="fullColorEvents"
|
||||
value={fullColorEvents}
|
||||
helpText={translate('FullColorEventsHelpText')}
|
||||
onChange={this.onOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Global')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="firstDayOfWeek"
|
||||
values={firstDayOfWeekOptions}
|
||||
value={firstDayOfWeek}
|
||||
onChange={this.onGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="calendarWeekColumnHeader"
|
||||
values={weekColumnOptions}
|
||||
value={calendarWeekColumnHeader}
|
||||
onChange={this.onGlobalInputChange}
|
||||
helpText={translate('WeekColumnHeaderHelpText')}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TimeFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="timeFormat"
|
||||
values={timeFormatOptions}
|
||||
value={timeFormat}
|
||||
onChange={this.onGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableColorImpairedMode"
|
||||
value={enableColorImpairedMode}
|
||||
helpText={translate('EnableColorImpairedModeHelpText')}
|
||||
onChange={this.onGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarOptionsModalContent.propTypes = {
|
||||
collapseMultipleEpisodes: PropTypes.bool.isRequired,
|
||||
showEpisodeInformation: PropTypes.bool.isRequired,
|
||||
showFinaleIcon: PropTypes.bool.isRequired,
|
||||
showSpecialIcon: PropTypes.bool.isRequired,
|
||||
showCutoffUnmetIcon: PropTypes.bool.isRequired,
|
||||
firstDayOfWeek: PropTypes.number.isRequired,
|
||||
calendarWeekColumnHeader: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
enableColorImpairedMode: PropTypes.bool.isRequired,
|
||||
fullColorEvents: PropTypes.bool.isRequired,
|
||||
dispatchSetCalendarOption: PropTypes.func.isRequired,
|
||||
dispatchSaveUISettings: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarOptionsModalContent;
|
||||
228
frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
Normal file
228
frontend/src/Calendar/Options/CalendarOptionsModalContent.tsx
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FieldSet from 'Components/FieldSet';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import {
|
||||
firstDayOfWeekOptions,
|
||||
timeFormatOptions,
|
||||
weekColumnOptions,
|
||||
} from 'Settings/UI/UISettings';
|
||||
import { setCalendarOption } from 'Store/Actions/calendarActions';
|
||||
import { saveUISettings } from 'Store/Actions/settingsActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import UiSettings from 'typings/Settings/UiSettings';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface CalendarOptionsModalContentProps {
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarOptionsModalContent({
|
||||
onModalClose,
|
||||
}: CalendarOptionsModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
collapseMultipleEpisodes,
|
||||
showEpisodeInformation,
|
||||
showFinaleIcon,
|
||||
showSpecialIcon,
|
||||
showCutoffUnmetIcon,
|
||||
fullColorEvents,
|
||||
} = useSelector((state: AppState) => state.calendar.options);
|
||||
|
||||
const uiSettings = useSelector(createUISettingsSelector());
|
||||
|
||||
const [state, setState] = useState<Partial<UiSettings>>({
|
||||
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
||||
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||
});
|
||||
|
||||
const {
|
||||
firstDayOfWeek,
|
||||
calendarWeekColumnHeader,
|
||||
timeFormat,
|
||||
enableColorImpairedMode,
|
||||
} = state;
|
||||
|
||||
const handleOptionInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
dispatch(setCalendarOption({ [name]: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleGlobalInputChange = useCallback(
|
||||
({ name, value }: InputChanged) => {
|
||||
setState((prevState) => ({ ...prevState, [name]: value }));
|
||||
|
||||
dispatch(saveUISettings({ [name]: value }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setState({
|
||||
firstDayOfWeek: uiSettings.firstDayOfWeek,
|
||||
calendarWeekColumnHeader: uiSettings.calendarWeekColumnHeader,
|
||||
timeFormat: uiSettings.timeFormat,
|
||||
enableColorImpairedMode: uiSettings.enableColorImpairedMode,
|
||||
});
|
||||
}, [uiSettings]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('CalendarOptions')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<FieldSet legend={translate('Local')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('CollapseMultipleEpisodes')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="collapseMultipleEpisodes"
|
||||
value={collapseMultipleEpisodes}
|
||||
helpText={translate('CollapseMultipleEpisodesHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowEpisodeInformation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showEpisodeInformation"
|
||||
value={showEpisodeInformation}
|
||||
helpText={translate('ShowEpisodeInformationHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForFinales')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showFinaleIcon"
|
||||
value={showFinaleIcon}
|
||||
helpText={translate('IconForFinalesHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForSpecials')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showSpecialIcon"
|
||||
value={showSpecialIcon}
|
||||
helpText={translate('IconForSpecialsHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IconForCutoffUnmet')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="showCutoffUnmetIcon"
|
||||
value={showCutoffUnmetIcon}
|
||||
helpText={translate('IconForCutoffUnmetHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FullColorEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="fullColorEvents"
|
||||
value={fullColorEvents}
|
||||
helpText={translate('FullColorEventsHelpText')}
|
||||
onChange={handleOptionInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
|
||||
<FieldSet legend={translate('Global')}>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('FirstDayOfWeek')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="firstDayOfWeek"
|
||||
values={firstDayOfWeekOptions}
|
||||
value={firstDayOfWeek}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('WeekColumnHeader')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="calendarWeekColumnHeader"
|
||||
values={weekColumnOptions}
|
||||
value={calendarWeekColumnHeader}
|
||||
helpText={translate('WeekColumnHeaderHelpText')}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('TimeFormat')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="timeFormat"
|
||||
values={timeFormatOptions}
|
||||
value={timeFormat}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('EnableColorImpairedMode')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="enableColorImpairedMode"
|
||||
value={enableColorImpairedMode}
|
||||
helpText={translate('EnableColorImpairedModeHelpText')}
|
||||
onChange={handleGlobalInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</FieldSet>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarOptionsModalContent;
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { setCalendarOption } from 'Store/Actions/calendarActions';
|
||||
import { saveUISettings } from 'Store/Actions/settingsActions';
|
||||
import CalendarOptionsModalContent from './CalendarOptionsModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.calendar.options,
|
||||
(state) => state.settings.ui.item,
|
||||
(options, uiSettings) => {
|
||||
return {
|
||||
...options,
|
||||
...uiSettings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchSetCalendarOption: setCalendarOption,
|
||||
dispatchSaveUISettings: saveUISettings
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(CalendarOptionsModalContent);
|
||||
|
|
@ -5,3 +5,5 @@ export const FORECAST = 'forecast';
|
|||
export const AGENDA = 'agenda';
|
||||
|
||||
export const all = [DAY, WEEK, MONTH, FORECAST, AGENDA];
|
||||
|
||||
export type CalendarView = 'agenda' | 'day' | 'forecast' | 'month' | 'week';
|
||||
|
|
@ -1,7 +1,13 @@
|
|||
/* eslint max-params: 0 */
|
||||
import moment from 'moment';
|
||||
import { CalendarStatus } from 'typings/Calendar';
|
||||
|
||||
function getStatusStyle(hasFile, downloading, startTime, endTime, isMonitored) {
|
||||
function getStatusStyle(
|
||||
hasFile: boolean,
|
||||
downloading: boolean,
|
||||
startTime: moment.Moment,
|
||||
endTime: moment.Moment,
|
||||
isMonitored: boolean
|
||||
): CalendarStatus {
|
||||
const currentTime = moment();
|
||||
|
||||
if (hasFile) {
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarLinkModalContentConnector from './CalendarLinkModalContentConnector';
|
||||
|
||||
function CalendarLinkModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<CalendarLinkModalContentConnector
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
CalendarLinkModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarLinkModal;
|
||||
20
frontend/src/Calendar/iCal/CalendarLinkModal.tsx
Normal file
20
frontend/src/Calendar/iCal/CalendarLinkModal.tsx
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import CalendarLinkModalContent from './CalendarLinkModalContent';
|
||||
|
||||
interface CalendarLinkModalProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarLinkModal(props: CalendarLinkModalProps) {
|
||||
const { isOpen, onModalClose } = props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<CalendarLinkModalContent onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarLinkModal;
|
||||
|
|
@ -1,222 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function getUrls(state) {
|
||||
const {
|
||||
unmonitored,
|
||||
premieresOnly,
|
||||
asAllDay,
|
||||
tags
|
||||
} = state;
|
||||
|
||||
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
|
||||
|
||||
if (unmonitored) {
|
||||
icalUrl += 'unmonitored=true&';
|
||||
}
|
||||
|
||||
if (premieresOnly) {
|
||||
icalUrl += 'premieresOnly=true&';
|
||||
}
|
||||
|
||||
if (asAllDay) {
|
||||
icalUrl += 'asAllDay=true&';
|
||||
}
|
||||
|
||||
if (tags.length) {
|
||||
icalUrl += `tags=${tags.toString()}&`;
|
||||
}
|
||||
|
||||
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
|
||||
|
||||
const iCalHttpUrl = `${window.location.protocol}//${icalUrl}`;
|
||||
const iCalWebCalUrl = `webcal://${icalUrl}`;
|
||||
|
||||
return {
|
||||
iCalHttpUrl,
|
||||
iCalWebCalUrl
|
||||
};
|
||||
}
|
||||
|
||||
class CalendarLinkModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
const defaultState = {
|
||||
unmonitored: false,
|
||||
premieresOnly: false,
|
||||
asAllDay: false,
|
||||
tags: []
|
||||
};
|
||||
|
||||
const urls = getUrls(defaultState);
|
||||
|
||||
this.state = {
|
||||
...defaultState,
|
||||
...urls
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
const state = {
|
||||
...this.state,
|
||||
[name]: value
|
||||
};
|
||||
|
||||
const urls = getUrls(state);
|
||||
|
||||
this.setState({
|
||||
[name]: value,
|
||||
...urls
|
||||
});
|
||||
};
|
||||
|
||||
onLinkFocus = (event) => {
|
||||
event.target.select();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
unmonitored,
|
||||
premieresOnly,
|
||||
asAllDay,
|
||||
tags,
|
||||
iCalHttpUrl,
|
||||
iCalWebCalUrl
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('CalendarFeed')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="unmonitored"
|
||||
value={unmonitored}
|
||||
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="premieresOnly"
|
||||
value={premieresOnly}
|
||||
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="asAllDay"
|
||||
value={asAllDay}
|
||||
helpText={translate('ICalShowAsAllDayEventsHelpText')}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TAG}
|
||||
name="tags"
|
||||
value={tags}
|
||||
helpText={translate('ICalTagsSeriesHelpText')}
|
||||
onChange={this.onInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup
|
||||
size={sizes.LARGE}
|
||||
>
|
||||
<FormLabel>{translate('ICalFeed')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="iCalHttpUrl"
|
||||
value={iCalHttpUrl}
|
||||
readOnly={true}
|
||||
helpText={translate('ICalFeedHelpText')}
|
||||
buttons={[
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
value={iCalHttpUrl}
|
||||
kind={kinds.DEFAULT}
|
||||
/>,
|
||||
|
||||
<FormInputButton
|
||||
key="webcal"
|
||||
kind={kinds.DEFAULT}
|
||||
to={iCalWebCalUrl}
|
||||
target="_blank"
|
||||
noRouter={true}
|
||||
>
|
||||
<Icon name={icons.CALENDAR_O} />
|
||||
</FormInputButton>
|
||||
]}
|
||||
onChange={this.onInputChange}
|
||||
onFocus={this.onLinkFocus}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
CalendarLinkModalContent.propTypes = {
|
||||
tagList: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default CalendarLinkModalContent;
|
||||
166
frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
Normal file
166
frontend/src/Calendar/iCal/CalendarLinkModalContent.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import React, { FocusEvent, useCallback, useMemo, useState } from 'react';
|
||||
import Form from 'Components/Form/Form';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputButton from 'Components/Form/FormInputButton';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import Icon from 'Components/Icon';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ClipboardButton from 'Components/Link/ClipboardButton';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { icons, inputTypes, kinds, sizes } from 'Helpers/Props';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface CalendarLinkModalContentProps {
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function CalendarLinkModalContent({
|
||||
onModalClose,
|
||||
}: CalendarLinkModalContentProps) {
|
||||
const [state, setState] = useState({
|
||||
unmonitored: false,
|
||||
premieresOnly: false,
|
||||
asAllDay: false,
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const { unmonitored, premieresOnly, asAllDay, tags } = state;
|
||||
|
||||
const handleInputChange = useCallback(({ name, value }: InputChanged) => {
|
||||
setState((prevState) => ({ ...prevState, [name]: value }));
|
||||
}, []);
|
||||
|
||||
const handleLinkFocus = useCallback(
|
||||
(event: FocusEvent<HTMLInputElement, Element>) => {
|
||||
event.target.select();
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const { iCalHttpUrl, iCalWebCalUrl } = useMemo(() => {
|
||||
let icalUrl = `${window.location.host}${window.Sonarr.urlBase}/feed/v3/calendar/Sonarr.ics?`;
|
||||
|
||||
if (unmonitored) {
|
||||
icalUrl += 'unmonitored=true&';
|
||||
}
|
||||
|
||||
if (premieresOnly) {
|
||||
icalUrl += 'premieresOnly=true&';
|
||||
}
|
||||
|
||||
if (asAllDay) {
|
||||
icalUrl += 'asAllDay=true&';
|
||||
}
|
||||
|
||||
if (tags.length) {
|
||||
icalUrl += `tags=${tags.toString()}&`;
|
||||
}
|
||||
|
||||
icalUrl += `apikey=${encodeURIComponent(window.Sonarr.apiKey)}`;
|
||||
|
||||
return {
|
||||
iCalHttpUrl: `${window.location.protocol}//${icalUrl}`,
|
||||
iCalWebCalUrl: `webcal://${icalUrl}`,
|
||||
};
|
||||
}, [unmonitored, premieresOnly, asAllDay, tags]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('CalendarFeed')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Form>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('IncludeUnmonitored')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="unmonitored"
|
||||
value={unmonitored}
|
||||
helpText={translate('ICalIncludeUnmonitoredEpisodesHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('SeasonPremieresOnly')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="premieresOnly"
|
||||
value={premieresOnly}
|
||||
helpText={translate('ICalSeasonPremieresOnlyHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ICalShowAsAllDayEvents')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="asAllDay"
|
||||
value={asAllDay}
|
||||
helpText={translate('ICalShowAsAllDayEventsHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Tags')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SERIES_TAG}
|
||||
name="tags"
|
||||
value={tags}
|
||||
helpText={translate('ICalTagsSeriesHelpText')}
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup size={sizes.LARGE}>
|
||||
<FormLabel>{translate('ICalFeed')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="iCalHttpUrl"
|
||||
value={iCalHttpUrl}
|
||||
readOnly={true}
|
||||
helpText={translate('ICalFeedHelpText')}
|
||||
buttons={[
|
||||
<ClipboardButton
|
||||
key="copy"
|
||||
value={iCalHttpUrl}
|
||||
kind={kinds.DEFAULT}
|
||||
/>,
|
||||
|
||||
<FormInputButton
|
||||
key="webcal"
|
||||
kind={kinds.DEFAULT}
|
||||
to={iCalWebCalUrl}
|
||||
target="_blank"
|
||||
noRouter={true}
|
||||
>
|
||||
<Icon name={icons.CALENDAR_O} />
|
||||
</FormInputButton>,
|
||||
]}
|
||||
onChange={handleInputChange}
|
||||
onFocus={handleLinkFocus}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Form>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default CalendarLinkModalContent;
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import CalendarLinkModalContent from './CalendarLinkModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createTagsSelector(),
|
||||
(tagList) => {
|
||||
return {
|
||||
tagList
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(CalendarLinkModalContent);
|
||||
|
|
@ -14,13 +14,14 @@ function FormInputButton({
|
|||
className = styles.button,
|
||||
canSpin = false,
|
||||
isLastButton = true,
|
||||
kind = kinds.PRIMARY,
|
||||
...otherProps
|
||||
}: FormInputButtonProps) {
|
||||
if (canSpin) {
|
||||
return (
|
||||
<SpinnerButton
|
||||
className={classNames(className, !isLastButton && styles.middleButton)}
|
||||
kind={kinds.PRIMARY}
|
||||
kind={kind}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
|
@ -29,7 +30,7 @@ function FormInputButton({
|
|||
return (
|
||||
<Button
|
||||
className={classNames(className, !isLastButton && styles.middleButton)}
|
||||
kind={kinds.PRIMARY}
|
||||
kind={kind}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { ReactNode } from 'react';
|
||||
import React, { FocusEvent, ReactNode } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { InputType } from 'Helpers/Props/inputTypes';
|
||||
|
|
@ -10,6 +10,7 @@ import CaptchaInput from './CaptchaInput';
|
|||
import CheckInput from './CheckInput';
|
||||
import { FormInputButtonProps } from './FormInputButton';
|
||||
import FormInputHelpText from './FormInputHelpText';
|
||||
import KeyValueListInput from './KeyValueListInput';
|
||||
import NumberInput from './NumberInput';
|
||||
import OAuthInput from './OAuthInput';
|
||||
import PasswordInput from './PasswordInput';
|
||||
|
|
@ -18,6 +19,7 @@ import DownloadClientSelectInput from './Select/DownloadClientSelectInput';
|
|||
import EnhancedSelectInput from './Select/EnhancedSelectInput';
|
||||
import IndexerFlagsSelectInput from './Select/IndexerFlagsSelectInput';
|
||||
import IndexerSelectInput from './Select/IndexerSelectInput';
|
||||
import LanguageSelectInput from './Select/LanguageSelectInput';
|
||||
import MonitorEpisodesSelectInput from './Select/MonitorEpisodesSelectInput';
|
||||
import MonitorNewItemsSelectInput from './Select/MonitorNewItemsSelectInput';
|
||||
import ProviderDataSelectInput from './Select/ProviderOptionSelectInput';
|
||||
|
|
@ -47,6 +49,12 @@ function getComponent(type: InputType) {
|
|||
case inputTypes.DEVICE:
|
||||
return DeviceInput;
|
||||
|
||||
case inputTypes.KEY_VALUE_LIST:
|
||||
return KeyValueListInput;
|
||||
|
||||
case inputTypes.LANGUAGE_SELECT:
|
||||
return LanguageSelectInput;
|
||||
|
||||
case inputTypes.MONITOR_EPISODES_SELECT:
|
||||
return MonitorEpisodesSelectInput;
|
||||
|
||||
|
|
@ -137,15 +145,18 @@ interface FormInputGroupProps<T> {
|
|||
autoFocus?: boolean;
|
||||
includeNoChange?: boolean;
|
||||
includeNoChangeDisabled?: boolean;
|
||||
valueOptions?: object;
|
||||
selectedValueOptions?: object;
|
||||
indexerFlags?: number;
|
||||
pending?: boolean;
|
||||
canEdit?: boolean;
|
||||
includeAny?: boolean;
|
||||
delimiters?: string[];
|
||||
readOnly?: boolean;
|
||||
errors?: (ValidationMessage | ValidationError)[];
|
||||
warnings?: (ValidationMessage | ValidationWarning)[];
|
||||
onChange: (args: T) => void;
|
||||
onFocus?: (event: FocusEvent<HTMLInputElement>) => void;
|
||||
}
|
||||
|
||||
function FormInputGroup<T>(props: FormInputGroupProps<T>) {
|
||||
|
|
@ -247,14 +258,7 @@ function FormInputGroup<T>(props: FormInputGroupProps<T>) {
|
|||
{helpLink ? <Link to={helpLink}>{translate('MoreInfo')}</Link> : null}
|
||||
|
||||
{errors.map((error, index) => {
|
||||
return 'message' in error ? (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={error.message}
|
||||
isError={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
) : (
|
||||
return 'errorMessage' in error ? (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={error.errorMessage}
|
||||
|
|
@ -263,23 +267,30 @@ function FormInputGroup<T>(props: FormInputGroupProps<T>) {
|
|||
isError={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
) : (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={error.message}
|
||||
isError={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{warnings.map((warning, index) => {
|
||||
return 'message' in warning ? (
|
||||
return 'errorMessage' in warning ? (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={warning.message}
|
||||
text={warning.errorMessage}
|
||||
link={warning.infoLink}
|
||||
tooltip={warning.detailedDescription}
|
||||
isWarning={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
) : (
|
||||
<FormInputHelpText
|
||||
key={index}
|
||||
text={warning.errorMessage}
|
||||
link={warning.infoLink}
|
||||
tooltip={warning.detailedDescription}
|
||||
text={warning.message}
|
||||
isWarning={true}
|
||||
isCheckInput={checkInput}
|
||||
/>
|
||||
|
|
|
|||
21
frontend/src/Components/Form/KeyValueListInput.css
Normal file
21
frontend/src/Components/Form/KeyValueListInput.css
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
.inputContainer {
|
||||
composes: input from '~Components/Form/Input.css';
|
||||
|
||||
position: relative;
|
||||
min-height: 35px;
|
||||
height: auto;
|
||||
|
||||
&.isFocused {
|
||||
outline: 0;
|
||||
border-color: var(--inputFocusBorderColor);
|
||||
box-shadow: inset 0 1px 1px var(--inputBoxShadowColor), 0 0 8px var(--inputFocusBoxShadowColor);
|
||||
}
|
||||
}
|
||||
|
||||
.hasError {
|
||||
composes: hasError from '~Components/Form/Input.css';
|
||||
}
|
||||
|
||||
.hasWarning {
|
||||
composes: hasWarning from '~Components/Form/Input.css';
|
||||
}
|
||||
10
frontend/src/Components/Form/KeyValueListInput.css.d.ts
vendored
Normal file
10
frontend/src/Components/Form/KeyValueListInput.css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'hasError': string;
|
||||
'hasWarning': string;
|
||||
'inputContainer': string;
|
||||
'isFocused': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
104
frontend/src/Components/Form/KeyValueListInput.tsx
Normal file
104
frontend/src/Components/Form/KeyValueListInput.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import classNames from 'classnames';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { InputOnChange } from 'typings/inputs';
|
||||
import KeyValueListInputItem from './KeyValueListInputItem';
|
||||
import styles from './KeyValueListInput.css';
|
||||
|
||||
interface KeyValue {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
|
||||
export interface KeyValueListInputProps {
|
||||
className?: string;
|
||||
name: string;
|
||||
value: KeyValue[];
|
||||
hasError?: boolean;
|
||||
hasWarning?: boolean;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
onChange: InputOnChange<KeyValue[]>;
|
||||
}
|
||||
|
||||
function KeyValueListInput({
|
||||
className = styles.inputContainer,
|
||||
name,
|
||||
value = [],
|
||||
hasError = false,
|
||||
hasWarning = false,
|
||||
keyPlaceholder,
|
||||
valuePlaceholder,
|
||||
onChange,
|
||||
}: KeyValueListInputProps): JSX.Element {
|
||||
const [isFocused, setIsFocused] = useState(false);
|
||||
|
||||
const handleItemChange = useCallback(
|
||||
(index: number | null, itemValue: KeyValue) => {
|
||||
const newValue = [...value];
|
||||
|
||||
if (index === null) {
|
||||
newValue.push(itemValue);
|
||||
} else {
|
||||
newValue.splice(index, 1, itemValue);
|
||||
}
|
||||
|
||||
onChange({ name, value: newValue });
|
||||
},
|
||||
[value, name, onChange]
|
||||
);
|
||||
|
||||
const handleRemoveItem = useCallback(
|
||||
(index: number) => {
|
||||
const newValue = [...value];
|
||||
newValue.splice(index, 1);
|
||||
onChange({ name, value: newValue });
|
||||
},
|
||||
[value, name, onChange]
|
||||
);
|
||||
|
||||
const onFocus = useCallback(() => setIsFocused(true), []);
|
||||
|
||||
const onBlur = useCallback(() => {
|
||||
setIsFocused(false);
|
||||
|
||||
const newValue = value.reduce((acc: KeyValue[], v) => {
|
||||
if (v.key || v.value) {
|
||||
acc.push(v);
|
||||
}
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
if (newValue.length !== value.length) {
|
||||
onChange({ name, value: newValue });
|
||||
}
|
||||
}, [value, name, onChange]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
isFocused && styles.isFocused,
|
||||
hasError && styles.hasError,
|
||||
hasWarning && styles.hasWarning
|
||||
)}
|
||||
>
|
||||
{[...value, { key: '', value: '' }].map((v, index) => (
|
||||
<KeyValueListInputItem
|
||||
key={index}
|
||||
index={index}
|
||||
keyValue={v.key}
|
||||
value={v.value}
|
||||
keyPlaceholder={keyPlaceholder}
|
||||
valuePlaceholder={valuePlaceholder}
|
||||
isNew={index === value.length}
|
||||
onChange={handleItemChange}
|
||||
onRemove={handleRemoveItem}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyValueListInput;
|
||||
35
frontend/src/Components/Form/KeyValueListInputItem.css
Normal file
35
frontend/src/Components/Form/KeyValueListInputItem.css
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
.itemContainer {
|
||||
display: flex;
|
||||
margin-bottom: 3px;
|
||||
border-bottom: 1px solid var(--inputBorderColor);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.keyInputWrapper {
|
||||
flex: 1 0 0;
|
||||
}
|
||||
|
||||
.valueInputWrapper {
|
||||
flex: 1 0 0;
|
||||
min-width: 40px;
|
||||
}
|
||||
|
||||
.buttonWrapper {
|
||||
flex: 0 0 22px;
|
||||
}
|
||||
|
||||
.keyInput,
|
||||
.valueInput {
|
||||
width: 100%;
|
||||
border: none;
|
||||
background-color: transparent;
|
||||
color: var(--textColor);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--helpTextColor);
|
||||
}
|
||||
}
|
||||
12
frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
vendored
Normal file
12
frontend/src/Components/Form/KeyValueListInputItem.css.d.ts
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// This file is automatically generated.
|
||||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'buttonWrapper': string;
|
||||
'itemContainer': string;
|
||||
'keyInput': string;
|
||||
'keyInputWrapper': string;
|
||||
'valueInput': string;
|
||||
'valueInputWrapper': string;
|
||||
}
|
||||
export const cssExports: CssExports;
|
||||
export default cssExports;
|
||||
89
frontend/src/Components/Form/KeyValueListInputItem.tsx
Normal file
89
frontend/src/Components/Form/KeyValueListInputItem.tsx
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import TextInput from './TextInput';
|
||||
import styles from './KeyValueListInputItem.css';
|
||||
|
||||
interface KeyValueListInputItemProps {
|
||||
index: number;
|
||||
keyValue: string;
|
||||
value: string;
|
||||
keyPlaceholder?: string;
|
||||
valuePlaceholder?: string;
|
||||
isNew: boolean;
|
||||
onChange: (index: number, itemValue: { key: string; value: string }) => void;
|
||||
onRemove: (index: number) => void;
|
||||
onFocus: () => void;
|
||||
onBlur: () => void;
|
||||
}
|
||||
|
||||
function KeyValueListInputItem({
|
||||
index,
|
||||
keyValue,
|
||||
value,
|
||||
keyPlaceholder = 'Key',
|
||||
valuePlaceholder = 'Value',
|
||||
isNew,
|
||||
onChange,
|
||||
onRemove,
|
||||
onFocus,
|
||||
onBlur,
|
||||
}: KeyValueListInputItemProps): JSX.Element {
|
||||
const handleKeyChange = useCallback(
|
||||
({ value: keyValue }: { value: string }) => {
|
||||
onChange(index, { key: keyValue, value });
|
||||
},
|
||||
[index, value, onChange]
|
||||
);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
({ value }: { value: string }) => {
|
||||
onChange(index, { key: keyValue, value });
|
||||
},
|
||||
[index, keyValue, onChange]
|
||||
);
|
||||
|
||||
const handleRemovePress = useCallback(() => {
|
||||
onRemove(index);
|
||||
}, [index, onRemove]);
|
||||
|
||||
return (
|
||||
<div className={styles.itemContainer}>
|
||||
<div className={styles.keyInputWrapper}>
|
||||
<TextInput
|
||||
className={styles.keyInput}
|
||||
name="key"
|
||||
value={keyValue}
|
||||
placeholder={keyPlaceholder}
|
||||
onChange={handleKeyChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.valueInputWrapper}>
|
||||
<TextInput
|
||||
className={styles.valueInput}
|
||||
name="value"
|
||||
value={value}
|
||||
placeholder={valuePlaceholder}
|
||||
onChange={handleValueChange}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.buttonWrapper}>
|
||||
{isNew ? null : (
|
||||
<IconButton
|
||||
name={icons.REMOVE}
|
||||
tabIndex={-1}
|
||||
onPress={handleRemovePress}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default KeyValueListInputItem;
|
||||
|
|
@ -16,3 +16,7 @@
|
|||
|
||||
height: 35px;
|
||||
}
|
||||
|
||||
.fileBrowserMiddleButton {
|
||||
composes: middleButton from '~./FormInputButton.css';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// Please do not change this file!
|
||||
interface CssExports {
|
||||
'fileBrowserButton': string;
|
||||
'fileBrowserMiddleButton': string;
|
||||
'hasFileBrowser': string;
|
||||
'inputWrapper': string;
|
||||
'pathMatch': string;
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import classNames from 'classnames';
|
||||
import React, {
|
||||
KeyboardEvent,
|
||||
SyntheticEvent,
|
||||
|
|
@ -29,6 +30,7 @@ interface PathInputProps {
|
|||
value?: string;
|
||||
placeholder?: string;
|
||||
includeFiles: boolean;
|
||||
hasButton?: boolean;
|
||||
hasFileBrowser?: boolean;
|
||||
onChange: (change: InputChanged<string>) => void;
|
||||
}
|
||||
|
|
@ -96,6 +98,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
|
|||
value: inputValue = '',
|
||||
paths,
|
||||
includeFiles,
|
||||
hasButton,
|
||||
hasFileBrowser = true,
|
||||
onChange,
|
||||
onFetchPaths,
|
||||
|
|
@ -229,9 +232,12 @@ export function PathInputInternal(props: PathInputInternalProps) {
|
|||
/>
|
||||
|
||||
{hasFileBrowser ? (
|
||||
<div>
|
||||
<>
|
||||
<FormInputButton
|
||||
className={styles.fileBrowserButton}
|
||||
className={classNames(
|
||||
styles.fileBrowserButton,
|
||||
hasButton && styles.fileBrowserMiddleButton
|
||||
)}
|
||||
onPress={handleFileBrowserOpenPress}
|
||||
>
|
||||
<Icon name={icons.FOLDER_OPEN} />
|
||||
|
|
@ -245,7 +251,7 @@ export function PathInputInternal(props: PathInputInternalProps) {
|
|||
onChange={onChange}
|
||||
onModalClose={handleFileBrowserModalClose}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ function getType({ type, selectOptionsProviderAction }) {
|
|||
return inputTypes.CHECK;
|
||||
case 'device':
|
||||
return inputTypes.DEVICE;
|
||||
case 'keyValueList':
|
||||
return inputTypes.KEY_VALUE_LIST;
|
||||
case 'password':
|
||||
return inputTypes.PASSWORD;
|
||||
case 'number':
|
||||
|
|
@ -136,6 +138,8 @@ ProviderFieldFormGroup.propTypes = {
|
|||
type: PropTypes.string.isRequired,
|
||||
advanced: PropTypes.bool.isRequired,
|
||||
hidden: PropTypes.string,
|
||||
isDisabled: PropTypes.bool,
|
||||
provider: PropTypes.string,
|
||||
pending: PropTypes.bool.isRequired,
|
||||
errors: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
warnings: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import HintedSelectInputOption from './HintedSelectInputOption';
|
|||
import HintedSelectInputSelectedValue from './HintedSelectInputSelectedValue';
|
||||
import styles from './EnhancedSelectInput.css';
|
||||
|
||||
const MINIMUM_DISTANCE_FROM_EDGE = 10;
|
||||
|
||||
function isArrowKey(keyCode: number) {
|
||||
return keyCode === keyCodes.UP_ARROW || keyCode === keyCodes.DOWN_ARROW;
|
||||
}
|
||||
|
|
@ -189,14 +191,9 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
|||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const handleComputeMaxHeight = useCallback((data: any) => {
|
||||
const { top, bottom } = data.offsets.reference;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (/^botton/.test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom;
|
||||
} else {
|
||||
data.styles.maxHeight = top;
|
||||
}
|
||||
data.styles.maxHeight = windowHeight - MINIMUM_DISTANCE_FROM_EDGE;
|
||||
|
||||
return data;
|
||||
}, []);
|
||||
|
|
@ -233,18 +230,12 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
|||
}, [handleWindowClick]);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (isOpen) {
|
||||
removeListener();
|
||||
} else {
|
||||
addListener();
|
||||
}
|
||||
|
||||
if (!isOpen && onOpen) {
|
||||
onOpen();
|
||||
}
|
||||
|
||||
setIsOpen(!isOpen);
|
||||
}, [isOpen, setIsOpen, addListener, removeListener, onOpen]);
|
||||
}, [isOpen, setIsOpen, onOpen]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(newValue: ArrayElement<V>) => {
|
||||
|
|
@ -411,6 +402,16 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
|||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
addListener();
|
||||
} else {
|
||||
removeListener();
|
||||
}
|
||||
|
||||
return removeListener;
|
||||
}, [isOpen, addListener, removeListener]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Manager>
|
||||
|
|
@ -504,6 +505,10 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
|
|||
enabled: true,
|
||||
fn: handleComputeMaxHeight,
|
||||
},
|
||||
preventOverflow: {
|
||||
enabled: true,
|
||||
boundariesElement: 'viewport',
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ ref, style, scheduleUpdate }) => {
|
||||
|
|
|
|||
|
|
@ -1,43 +1,95 @@
|
|||
import React, { useMemo } from 'react';
|
||||
import { EnhancedSelectInputChanged } from 'typings/inputs';
|
||||
import React, { useCallback, useMemo } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Language from 'Language/Language';
|
||||
import createFilteredLanguagesSelector from 'Store/Selectors/createFilteredLanguagesSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EnhancedSelectInput, {
|
||||
EnhancedSelectInputValue,
|
||||
} from './EnhancedSelectInput';
|
||||
|
||||
interface LanguageSelectInputProps {
|
||||
interface LanguageSelectInputOnChangeProps {
|
||||
name: string;
|
||||
value: number;
|
||||
values: EnhancedSelectInputValue<number>[];
|
||||
onChange: (change: EnhancedSelectInputChanged<number>) => void;
|
||||
value: number | string | Language;
|
||||
}
|
||||
|
||||
function LanguageSelectInput({
|
||||
values,
|
||||
interface LanguageSelectInputProps {
|
||||
name: string;
|
||||
value: number | string | Language;
|
||||
includeNoChange: boolean;
|
||||
includeNoChangeDisabled?: boolean;
|
||||
includeMixed: boolean;
|
||||
onChange: (payload: LanguageSelectInputOnChangeProps) => void;
|
||||
}
|
||||
|
||||
export default function LanguageSelectInput({
|
||||
value,
|
||||
includeNoChange,
|
||||
includeNoChangeDisabled,
|
||||
includeMixed,
|
||||
onChange,
|
||||
...otherProps
|
||||
}: LanguageSelectInputProps) {
|
||||
const mappedValues = useMemo(() => {
|
||||
const minId = values.reduce(
|
||||
(min: number, v) => (v.key < 1 ? v.key : min),
|
||||
values[0].key
|
||||
const { items } = useSelector(createFilteredLanguagesSelector(true));
|
||||
|
||||
const values = useMemo(() => {
|
||||
const result: EnhancedSelectInputValue<number | string>[] = items.map(
|
||||
(item) => {
|
||||
return {
|
||||
key: item.id,
|
||||
value: item.name,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return values.map(({ key, value }) => {
|
||||
return {
|
||||
key,
|
||||
value,
|
||||
dividerAfter: minId < 1 ? key === minId : false,
|
||||
};
|
||||
});
|
||||
}, [values]);
|
||||
if (includeNoChange) {
|
||||
result.unshift({
|
||||
key: 'noChange',
|
||||
value: translate('NoChange'),
|
||||
isDisabled: includeNoChangeDisabled,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeMixed) {
|
||||
result.unshift({
|
||||
key: 'mixed',
|
||||
value: `(${translate('Mixed')})`,
|
||||
isDisabled: true,
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [includeNoChange, includeNoChangeDisabled, includeMixed, items]);
|
||||
|
||||
const selectValue =
|
||||
typeof value === 'number' || typeof value === 'string' ? value : value.id;
|
||||
|
||||
const handleChange = useCallback(
|
||||
(payload: LanguageSelectInputOnChangeProps) => {
|
||||
if (typeof value === 'number') {
|
||||
onChange(payload);
|
||||
} else {
|
||||
const language = items.find((i) => i.id === payload.value);
|
||||
|
||||
onChange({
|
||||
...payload,
|
||||
value: language
|
||||
? {
|
||||
id: language.id,
|
||||
name: language.name,
|
||||
}
|
||||
: ({ id: payload.value } as Language),
|
||||
});
|
||||
}
|
||||
},
|
||||
[value, items, onChange]
|
||||
);
|
||||
|
||||
return (
|
||||
<EnhancedSelectInput
|
||||
{...otherProps}
|
||||
values={mappedValues}
|
||||
onChange={onChange}
|
||||
value={selectValue}
|
||||
values={values}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default LanguageSelectInput;
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@ import { useDispatch, useSelector } from 'react-redux';
|
|||
import { createSelector } from 'reselect';
|
||||
import FileBrowserModal from 'Components/FileBrowser/FileBrowserModal';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { addRootFolder } from 'Store/Actions/rootFolderActions';
|
||||
import {
|
||||
addRootFolder,
|
||||
fetchRootFolders,
|
||||
} from 'Store/Actions/rootFolderActions';
|
||||
import createRootFoldersSelector from 'Store/Selectors/createRootFoldersSelector';
|
||||
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
|
@ -131,7 +134,7 @@ function RootFolderSelectInput({
|
|||
const handleNewRootFolderSelect = useCallback(
|
||||
({ value: newValue }: InputChanged<string>) => {
|
||||
setNewRootFolderPath(newValue);
|
||||
dispatch(addRootFolder(newValue));
|
||||
dispatch(addRootFolder({ path: newValue }));
|
||||
},
|
||||
[setNewRootFolderPath, dispatch]
|
||||
);
|
||||
|
|
@ -189,6 +192,10 @@ function RootFolderSelectInput({
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchRootFolders());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<EnhancedSelectInput
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ const VALID_TAG_REGEX = new RegExp('[^-_a-z0-9]', 'i');
|
|||
function isValidTag(tagName: string) {
|
||||
try {
|
||||
return !VALID_TAG_REGEX.test(tagName);
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import React, { useCallback } from 'react';
|
||||
import MiddleTruncate from 'react-middle-truncate';
|
||||
import Label, { LabelProps } from 'Components/Label';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import MiddleTruncate from 'Components/MiddleTruncate';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { TagBase } from './TagInput';
|
||||
import styles from './TagInputTag.css';
|
||||
|
|
@ -58,7 +58,7 @@ function TagInputTag<T extends TagBase>({
|
|||
tabIndex={-1}
|
||||
onPress={handleDelete}
|
||||
>
|
||||
<MiddleTruncate text={String(tag.name)} start={10} end={10} />
|
||||
<MiddleTruncate text={String(tag.name)} />
|
||||
</Link>
|
||||
|
||||
{canEdit ? (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import classNames from 'classnames';
|
||||
import React, {
|
||||
ChangeEvent,
|
||||
FocusEvent,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
|
|
@ -25,7 +26,7 @@ export interface TextInputProps<T> {
|
|||
min?: number;
|
||||
max?: number;
|
||||
onChange: (change: InputChanged<T> | FileInputChanged) => void;
|
||||
onFocus?: (event: SyntheticEvent) => void;
|
||||
onFocus?: (event: FocusEvent) => void;
|
||||
onBlur?: (event: SyntheticEvent) => void;
|
||||
onCopy?: (event: SyntheticEvent) => void;
|
||||
onSelectionChange?: (start: number | null, end: number | null) => void;
|
||||
|
|
@ -94,7 +95,7 @@ function TextInput<T>({
|
|||
);
|
||||
|
||||
const handleFocus = useCallback(
|
||||
(event: SyntheticEvent) => {
|
||||
(event: FocusEvent) => {
|
||||
onFocus?.(event);
|
||||
|
||||
selectionChanged();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ export interface IconProps
|
|||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
size?: number;
|
||||
isSpinning?: FontAwesomeIconProps['spin'];
|
||||
title?: string | (() => string);
|
||||
title?: string | (() => string) | null;
|
||||
}
|
||||
|
||||
export default function Icon({
|
||||
|
|
|
|||
|
|
@ -1,16 +1,14 @@
|
|||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { align, kinds, sizes } from 'Helpers/Props';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import { Align } from 'Helpers/Props/align';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import Link, { LinkProps } from './Link';
|
||||
import styles from './Button.css';
|
||||
|
||||
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
|
||||
buttonGroupPosition?: Extract<
|
||||
(typeof align.all)[number],
|
||||
keyof typeof styles
|
||||
>;
|
||||
buttonGroupPosition?: Extract<Align, keyof typeof styles>;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
children: Required<LinkProps['children']>;
|
||||
|
|
|
|||
63
frontend/src/Components/MiddleTruncate.tsx
Normal file
63
frontend/src/Components/MiddleTruncate.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
|
||||
interface MiddleTruncateProps {
|
||||
text: string;
|
||||
}
|
||||
|
||||
function getTruncatedText(text: string, length: number) {
|
||||
return `${text.slice(0, length)}...${text.slice(text.length - length)}`;
|
||||
}
|
||||
|
||||
function MiddleTruncate({ text }: MiddleTruncateProps) {
|
||||
const [containerRef, { width: containerWidth }] = useMeasure();
|
||||
const [textRef, { width: textWidth }] = useMeasure();
|
||||
const [truncatedText, setTruncatedText] = useState(text);
|
||||
const truncatedTextRef = useRef(text);
|
||||
|
||||
useEffect(() => {
|
||||
setTruncatedText(text);
|
||||
}, [text]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerWidth || !textWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (textWidth <= containerWidth) {
|
||||
return;
|
||||
}
|
||||
|
||||
const characterLength = textWidth / text.length;
|
||||
const charactersToRemove =
|
||||
Math.ceil(text.length - containerWidth / characterLength) + 3;
|
||||
let length = Math.ceil(text.length / 2 - charactersToRemove / 2);
|
||||
|
||||
let updatedText = getTruncatedText(text, length);
|
||||
|
||||
// Make sure if the text is still too long, we keep reducing the length
|
||||
// each time we re-run this.
|
||||
while (
|
||||
updatedText.length >= truncatedTextRef.current.length &&
|
||||
length > 10
|
||||
) {
|
||||
length--;
|
||||
updatedText = getTruncatedText(text, length);
|
||||
}
|
||||
|
||||
// Store the value in the ref so we can compare it in the next render,
|
||||
// without triggering this effect every time we change the text.
|
||||
truncatedTextRef.current = updatedText;
|
||||
setTruncatedText(updatedText);
|
||||
}, [text, truncatedTextRef, containerWidth, textWidth]);
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ whiteSpace: 'nowrap' }}>
|
||||
<div ref={textRef} style={{ display: 'inline-block' }}>
|
||||
{truncatedText}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MiddleTruncate;
|
||||
|
|
@ -19,6 +19,7 @@
|
|||
.modal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
border-radius: 6px;
|
||||
opacity: 1;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import React, { ComponentPropsWithoutRef } from 'react';
|
||||
import styles from './TableRowCell.css';
|
||||
|
||||
export interface TableRowCellProps extends ComponentPropsWithoutRef<'td'> {}
|
||||
export type TableRowCellProps = ComponentPropsWithoutRef<'td'>;
|
||||
|
||||
export default function TableRowCell({
|
||||
className = styles.cell,
|
||||
|
|
|
|||
|
|
@ -25,7 +25,9 @@ interface Episode extends ModelBase {
|
|||
endTime?: string;
|
||||
grabDate?: string;
|
||||
seriesTitle?: string;
|
||||
queued?: boolean;
|
||||
series?: Series;
|
||||
finaleType?: string;
|
||||
}
|
||||
|
||||
export default Episode;
|
||||
|
|
|
|||
|
|
@ -19,8 +19,8 @@ import {
|
|||
clearReleases,
|
||||
} from 'Store/Actions/releaseActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
|
||||
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
|
||||
import EpisodeHistory from './History/EpisodeHistory';
|
||||
import EpisodeSearch from './Search/EpisodeSearch';
|
||||
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
|
||||
import EpisodeSummary from './Summary/EpisodeSummary';
|
||||
import styles from './EpisodeDetailsModalContent.css';
|
||||
|
|
@ -168,13 +168,13 @@ function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
|
|||
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<EpisodeHistoryConnector episodeId={episodeId} />
|
||||
<EpisodeHistory episodeId={episodeId} />
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{/* Don't wrap in tabContent so we not have a top margin */}
|
||||
<EpisodeSearchConnector
|
||||
<EpisodeSearch
|
||||
episodeId={episodeId}
|
||||
startInteractiveSearch={startInteractiveSearch}
|
||||
onModalClose={onModalClose}
|
||||
|
|
|
|||
|
|
@ -1,130 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import Icon from 'Components/Icon';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeHistoryRow from './EpisodeHistoryRow';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'eventType',
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: () => translate('SourceTitle'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('CustomFormats'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore')
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
class EpisodeHistory extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
onMarkAsFailedPress
|
||||
} = this.props;
|
||||
|
||||
const hasItems = !!items.length;
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<LoadingIndicator />
|
||||
);
|
||||
}
|
||||
|
||||
if (!isFetching && !!error) {
|
||||
return (
|
||||
<Alert kind={kinds.DANGER}>{translate('EpisodeHistoryLoadError')}</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPopulated && !hasItems && !error) {
|
||||
return (
|
||||
<Alert kind={kinds.INFO}>{translate('NoEpisodeHistory')}</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPopulated && hasItems && !error) {
|
||||
return (
|
||||
<Table
|
||||
columns={columns}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<EpisodeHistoryRow
|
||||
key={item.id}
|
||||
{...item}
|
||||
onMarkAsFailedPress={onMarkAsFailedPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeHistory.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onMarkAsFailedPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EpisodeHistory.defaultProps = {
|
||||
selectedTab: 'details'
|
||||
};
|
||||
|
||||
export default EpisodeHistory;
|
||||
129
frontend/src/Episode/History/EpisodeHistory.tsx
Normal file
129
frontend/src/Episode/History/EpisodeHistory.tsx
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import Icon from 'Components/Icon';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
clearEpisodeHistory,
|
||||
episodeHistoryMarkAsFailed,
|
||||
fetchEpisodeHistory,
|
||||
} from 'Store/Actions/episodeHistoryActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeHistoryRow from './EpisodeHistoryRow';
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
name: 'eventType',
|
||||
label: '',
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'sourceTitle',
|
||||
label: () => translate('SourceTitle'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('CustomFormats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore'),
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'date',
|
||||
label: () => translate('Date'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
interface EpisodeHistoryProps {
|
||||
episodeId: number;
|
||||
}
|
||||
|
||||
function EpisodeHistory({ episodeId }: EpisodeHistoryProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { items, isFetching, isPopulated, error } = useSelector(
|
||||
(state: AppState) => state.episodeHistory
|
||||
);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(
|
||||
(historyId: number) => {
|
||||
dispatch(episodeHistoryMarkAsFailed({ historyId, episodeId }));
|
||||
},
|
||||
[episodeId, dispatch]
|
||||
);
|
||||
|
||||
const hasItems = !!items.length;
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchEpisodeHistory({ episodeId }));
|
||||
|
||||
return () => {
|
||||
dispatch(clearEpisodeHistory());
|
||||
};
|
||||
}, [episodeId, dispatch]);
|
||||
|
||||
if (isFetching) {
|
||||
return <LoadingIndicator />;
|
||||
}
|
||||
|
||||
if (!isFetching && !!error) {
|
||||
return (
|
||||
<Alert kind={kinds.DANGER}>{translate('EpisodeHistoryLoadError')}</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
if (isPopulated && !hasItems && !error) {
|
||||
return <Alert kind={kinds.INFO}>{translate('NoEpisodeHistory')}</Alert>;
|
||||
}
|
||||
|
||||
if (isPopulated && hasItems && !error) {
|
||||
return (
|
||||
<Table columns={columns}>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<EpisodeHistoryRow
|
||||
key={item.id}
|
||||
{...item}
|
||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export default EpisodeHistory;
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearEpisodeHistory, episodeHistoryMarkAsFailed, fetchEpisodeHistory } from 'Store/Actions/episodeHistoryActions';
|
||||
import EpisodeHistory from './EpisodeHistory';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.episodeHistory,
|
||||
(episodeHistory) => {
|
||||
return episodeHistory;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchEpisodeHistory,
|
||||
clearEpisodeHistory,
|
||||
episodeHistoryMarkAsFailed
|
||||
};
|
||||
|
||||
class EpisodeHistoryConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.fetchEpisodeHistory({ episodeId: this.props.episodeId });
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.clearEpisodeHistory();
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMarkAsFailedPress = (historyId) => {
|
||||
this.props.episodeHistoryMarkAsFailed({ historyId, episodeId: this.props.episodeId });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EpisodeHistory
|
||||
{...this.props}
|
||||
onMarkAsFailedPress={this.onMarkAsFailedPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeHistoryConnector.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
fetchEpisodeHistory: PropTypes.func.isRequired,
|
||||
clearEpisodeHistory: PropTypes.func.isRequired,
|
||||
episodeHistoryMarkAsFailed: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeHistoryConnector);
|
||||
|
|
@ -1,177 +0,0 @@
|
|||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import HistoryDetails from 'Activity/History/Details/HistoryDetails';
|
||||
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeHistoryRow.css';
|
||||
|
||||
function getTitle(eventType) {
|
||||
switch (eventType) {
|
||||
case 'grabbed': return 'Grabbed';
|
||||
case 'seriesFolderImported': return 'Series Folder Imported';
|
||||
case 'downloadFolderImported': return 'Download Folder Imported';
|
||||
case 'downloadFailed': return 'Download Failed';
|
||||
case 'episodeFileDeleted': return 'Episode File Deleted';
|
||||
case 'episodeFileRenamed': return 'Episode File Renamed';
|
||||
default: return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
class EpisodeHistoryRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isMarkAsFailedModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMarkAsFailedPress = () => {
|
||||
this.setState({ isMarkAsFailedModalOpen: true });
|
||||
};
|
||||
|
||||
onConfirmMarkAsFailed = () => {
|
||||
this.props.onMarkAsFailedPress(this.props.id);
|
||||
this.setState({ isMarkAsFailedModalOpen: false });
|
||||
};
|
||||
|
||||
onMarkAsFailedModalClose = () => {
|
||||
this.setState({ isMarkAsFailedModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
eventType,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
qualityCutoffNotMet,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
date,
|
||||
data,
|
||||
downloadId
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isMarkAsFailedModalOpen
|
||||
} = this.state;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<HistoryEventTypeCell
|
||||
eventType={eventType}
|
||||
data={data}
|
||||
/>
|
||||
|
||||
<TableRowCell>
|
||||
{sourceTitle}
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeLanguages languages={languages} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeQuality
|
||||
quality={quality}
|
||||
isCutoffNotMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeFormats formats={customFormats} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
{formatCustomFormatScore(customFormatScore, customFormats.length)}
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCell
|
||||
date={date}
|
||||
includeSeconds={true}
|
||||
includeTime={true}
|
||||
/>
|
||||
|
||||
<TableRowCell className={styles.actions}>
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.INFO}
|
||||
/>
|
||||
}
|
||||
title={getTitle(eventType)}
|
||||
body={
|
||||
<HistoryDetails
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
|
||||
{
|
||||
eventType === 'grabbed' &&
|
||||
<IconButton
|
||||
title={translate('MarkAsFailed')}
|
||||
name={icons.REMOVE}
|
||||
size={14}
|
||||
onPress={this.onMarkAsFailedPress}
|
||||
/>
|
||||
}
|
||||
</TableRowCell>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isMarkAsFailedModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('MarkAsFailed')}
|
||||
message={translate('MarkAsFailedConfirmation', { sourceTitle })}
|
||||
confirmLabel={translate('MarkAsFailed')}
|
||||
onConfirm={this.onConfirmMarkAsFailed}
|
||||
onCancel={this.onMarkAsFailedModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeHistoryRow.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
eventType: PropTypes.string.isRequired,
|
||||
sourceTitle: PropTypes.string.isRequired,
|
||||
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
quality: PropTypes.object.isRequired,
|
||||
qualityCutoffNotMet: PropTypes.bool.isRequired,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
||||
customFormatScore: PropTypes.number.isRequired,
|
||||
date: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
downloadId: PropTypes.string,
|
||||
onMarkAsFailedPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeHistoryRow;
|
||||
151
frontend/src/Episode/History/EpisodeHistoryRow.tsx
Normal file
151
frontend/src/Episode/History/EpisodeHistoryRow.tsx
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import HistoryDetails from 'Activity/History/Details/HistoryDetails';
|
||||
import HistoryEventTypeCell from 'Activity/History/HistoryEventTypeCell';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './EpisodeHistoryRow.css';
|
||||
|
||||
function getTitle(eventType: HistoryEventType) {
|
||||
switch (eventType) {
|
||||
case 'grabbed':
|
||||
return 'Grabbed';
|
||||
case 'seriesFolderImported':
|
||||
return 'Series Folder Imported';
|
||||
case 'downloadFolderImported':
|
||||
return 'Download Folder Imported';
|
||||
case 'downloadFailed':
|
||||
return 'Download Failed';
|
||||
case 'episodeFileDeleted':
|
||||
return 'Episode File Deleted';
|
||||
case 'episodeFileRenamed':
|
||||
return 'Episode File Renamed';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
interface EpisodeHistoryRowProps {
|
||||
id: number;
|
||||
eventType: HistoryEventType;
|
||||
sourceTitle: string;
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
qualityCutoffNotMet: boolean;
|
||||
customFormats: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
date: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
onMarkAsFailedPress: (id: number) => void;
|
||||
}
|
||||
|
||||
function EpisodeHistoryRow({
|
||||
id,
|
||||
eventType,
|
||||
sourceTitle,
|
||||
languages,
|
||||
quality,
|
||||
qualityCutoffNotMet,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
onMarkAsFailedPress,
|
||||
}: EpisodeHistoryRowProps) {
|
||||
const [isMarkAsFailedModalOpen, setIsMarkAsFailedModalOpen] = useState(false);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(() => {
|
||||
setIsMarkAsFailedModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmMarkAsFailed = useCallback(() => {
|
||||
onMarkAsFailedPress(id);
|
||||
setIsMarkAsFailedModalOpen(false);
|
||||
}, [id, onMarkAsFailedPress]);
|
||||
|
||||
const handleMarkAsFailedModalClose = useCallback(() => {
|
||||
setIsMarkAsFailedModalOpen(false);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
<HistoryEventTypeCell eventType={eventType} data={data} />
|
||||
|
||||
<TableRowCell>{sourceTitle}</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeLanguages languages={languages} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeQuality
|
||||
quality={quality}
|
||||
isCutoffNotMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
<EpisodeFormats formats={customFormats} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>
|
||||
{formatCustomFormatScore(customFormatScore, customFormats.length)}
|
||||
</TableRowCell>
|
||||
|
||||
<RelativeDateCell date={date} includeSeconds={true} includeTime={true} />
|
||||
|
||||
<TableRowCell className={styles.actions}>
|
||||
<Popover
|
||||
anchor={<Icon name={icons.INFO} />}
|
||||
title={getTitle(eventType)}
|
||||
body={
|
||||
<HistoryDetails
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
|
||||
{eventType === 'grabbed' && (
|
||||
<IconButton
|
||||
title={translate('MarkAsFailed')}
|
||||
name={icons.REMOVE}
|
||||
size={14}
|
||||
onPress={handleMarkAsFailedPress}
|
||||
/>
|
||||
)}
|
||||
</TableRowCell>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isMarkAsFailedModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('MarkAsFailed')}
|
||||
message={translate('MarkAsFailedConfirmation', { sourceTitle })}
|
||||
confirmLabel={translate('MarkAsFailed')}
|
||||
onConfirm={handleConfirmMarkAsFailed}
|
||||
onCancel={handleMarkAsFailedModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeHistoryRow;
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue