mirror of
https://github.com/Radarr/Radarr.git
synced 2026-04-16 21:15:33 -04:00
Compare commits
22 Commits
v5.13.0.93
...
v5.14.0.93
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
781e0c9d1c | ||
|
|
c946ed83f9 | ||
|
|
9aecf94e8e | ||
|
|
234e23eb47 | ||
|
|
748d888520 | ||
|
|
00d50a030c | ||
|
|
017fa5ad80 | ||
|
|
d5fb1c55c6 | ||
|
|
1be8385c41 | ||
|
|
6f26c55a1b | ||
|
|
2d2de7f76b | ||
|
|
2c9292c249 | ||
|
|
6747b74271 | ||
|
|
13f10906f1 | ||
|
|
25d08a67e4 | ||
|
|
aadefbe3b0 | ||
|
|
d99a7e9b8a | ||
|
|
dc29526961 | ||
|
|
f8e47fbdc7 | ||
|
|
56a7725e52 | ||
|
|
abf1b9d6cf | ||
|
|
dd90bf53dd |
@@ -9,7 +9,7 @@ variables:
|
||||
testsFolder: './_tests'
|
||||
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
|
||||
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
|
||||
majorVersion: '5.13.0'
|
||||
majorVersion: '5.14.0'
|
||||
minorVersion: $[counter('minorVersion', 2000)]
|
||||
radarrVersion: '$(majorVersion).$(minorVersion)'
|
||||
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
|
||||
|
||||
@@ -133,6 +133,12 @@ module.exports = (env) => {
|
||||
{
|
||||
source: 'frontend/src/Content/robots.txt',
|
||||
destination: path.join(distFolder, 'Content/robots.txt')
|
||||
},
|
||||
|
||||
// manifest.json and browserconfig.xml
|
||||
{
|
||||
source: 'frontend/src/Content/*.(json|xml)',
|
||||
destination: path.join(distFolder, 'Content')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,354 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
||||
import Link from 'Components/Link/Link';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HistoryDetails.css';
|
||||
|
||||
function HistoryDetails(props) {
|
||||
const {
|
||||
eventType,
|
||||
sourceTitle,
|
||||
data,
|
||||
downloadId,
|
||||
shortDateFormat,
|
||||
timeFormat
|
||||
} = props;
|
||||
|
||||
if (eventType === 'grabbed') {
|
||||
const {
|
||||
indexer,
|
||||
releaseGroup,
|
||||
movieMatchType,
|
||||
customFormatScore,
|
||||
nzbInfoUrl,
|
||||
downloadClient,
|
||||
downloadClientName,
|
||||
age,
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishedDate
|
||||
} = data;
|
||||
|
||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('Indexer')}
|
||||
data={indexer}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
releaseGroup ?
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('ReleaseGroup')}
|
||||
data={releaseGroup}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
customFormatScore && customFormatScore !== '0' ?
|
||||
<DescriptionListItem
|
||||
title={translate('CustomFormatScore')}
|
||||
data={formatCustomFormatScore(customFormatScore)}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
movieMatchType ?
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('MovieMatchType')}
|
||||
data={movieMatchType}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
nzbInfoUrl ?
|
||||
<span>
|
||||
<DescriptionListItemTitle>
|
||||
{translate('InfoUrl')}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription>
|
||||
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
|
||||
</DescriptionListItemDescription>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
downloadClientNameInfo ?
|
||||
<DescriptionListItem
|
||||
title={translate('DownloadClient')}
|
||||
data={downloadClientNameInfo}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
downloadId ?
|
||||
<DescriptionListItem
|
||||
title={translate('GrabId')}
|
||||
data={downloadId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
indexer ?
|
||||
<DescriptionListItem
|
||||
title={translate('AgeWhenGrabbed')}
|
||||
data={formatAge(age, ageHours, ageMinutes)}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
publishedDate ?
|
||||
<DescriptionListItem
|
||||
title={translate('PublishedDate')}
|
||||
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, { includeSeconds: true })}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const {
|
||||
message
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{
|
||||
downloadId ?
|
||||
<DescriptionListItem
|
||||
title={translate('GrabId')}
|
||||
data={downloadId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
message ?
|
||||
<DescriptionListItem
|
||||
title={translate('Message')}
|
||||
data={message}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFolderImported') {
|
||||
const {
|
||||
customFormatScore,
|
||||
droppedPath,
|
||||
importedPath
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{
|
||||
droppedPath ?
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Source')}
|
||||
data={droppedPath}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
importedPath ?
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('ImportedTo')}
|
||||
data={importedPath}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
customFormatScore && customFormatScore !== '0' ?
|
||||
<DescriptionListItem
|
||||
title={translate('CustomFormatScore')}
|
||||
data={formatCustomFormatScore(customFormatScore)}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'movieFileDeleted') {
|
||||
const {
|
||||
reason,
|
||||
customFormatScore
|
||||
} = data;
|
||||
|
||||
let reasonMessage = '';
|
||||
|
||||
switch (reason) {
|
||||
case 'Manual':
|
||||
reasonMessage = translate('DeletedReasonManual');
|
||||
break;
|
||||
case 'MissingFromDisk':
|
||||
reasonMessage = translate('DeletedReasonMissingFromDisk');
|
||||
break;
|
||||
case 'Upgrade':
|
||||
reasonMessage = translate('DeletedReasonUpgrade');
|
||||
break;
|
||||
default:
|
||||
reasonMessage = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('Reason')}
|
||||
data={reasonMessage}
|
||||
/>
|
||||
|
||||
{
|
||||
customFormatScore && customFormatScore !== '0' ?
|
||||
<DescriptionListItem
|
||||
title={translate('CustomFormatScore')}
|
||||
data={formatCustomFormatScore(customFormatScore)}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'movieFileRenamed') {
|
||||
const {
|
||||
sourcePath,
|
||||
sourceRelativePath,
|
||||
path,
|
||||
relativePath
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
title={translate('SourcePath')}
|
||||
data={sourcePath}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('SourceRelativePath')}
|
||||
data={sourceRelativePath}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('DestinationPath')}
|
||||
data={path}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('DestinationRelativePath')}
|
||||
data={relativePath}
|
||||
/>
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadIgnored') {
|
||||
const {
|
||||
message
|
||||
} = data;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{
|
||||
downloadId ?
|
||||
<DescriptionListItem
|
||||
title={translate('GrabId')}
|
||||
data={downloadId}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
message ?
|
||||
<DescriptionListItem
|
||||
title={translate('Message')}
|
||||
data={message}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
HistoryDetails.propTypes = {
|
||||
eventType: PropTypes.string.isRequired,
|
||||
sourceTitle: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
downloadId: PropTypes.string,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default HistoryDetails;
|
||||
287
frontend/src/Activity/History/Details/HistoryDetails.tsx
Normal file
287
frontend/src/Activity/History/Details/HistoryDetails.tsx
Normal file
@@ -0,0 +1,287 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import DescriptionListItemDescription from 'Components/DescriptionList/DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle from 'Components/DescriptionList/DescriptionListItemTitle';
|
||||
import Link from 'Components/Link/Link';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import {
|
||||
DownloadFailedHistory,
|
||||
DownloadFolderImportedHistory,
|
||||
DownloadIgnoredHistory,
|
||||
GrabbedHistoryData,
|
||||
HistoryData,
|
||||
HistoryEventType,
|
||||
MovieFileDeletedHistory,
|
||||
MovieFileRenamedHistory,
|
||||
} from 'typings/History';
|
||||
import formatDateTime from 'Utilities/Date/formatDateTime';
|
||||
import formatAge from 'Utilities/Number/formatAge';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HistoryDetails.css';
|
||||
|
||||
interface HistoryDetailsProps {
|
||||
eventType: HistoryEventType;
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
}
|
||||
|
||||
function HistoryDetails(props: HistoryDetailsProps) {
|
||||
const { eventType, sourceTitle, data, downloadId } = props;
|
||||
|
||||
const { shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
if (eventType === 'grabbed') {
|
||||
const {
|
||||
indexer,
|
||||
releaseGroup,
|
||||
movieMatchType,
|
||||
customFormatScore,
|
||||
nzbInfoUrl,
|
||||
downloadClient,
|
||||
downloadClientName,
|
||||
age,
|
||||
ageHours,
|
||||
ageMinutes,
|
||||
publishedDate,
|
||||
} = data as GrabbedHistoryData;
|
||||
|
||||
const downloadClientNameInfo = downloadClientName ?? downloadClient;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{indexer ? (
|
||||
<DescriptionListItem title={translate('Indexer')} data={indexer} />
|
||||
) : null}
|
||||
|
||||
{releaseGroup ? (
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('ReleaseGroup')}
|
||||
data={releaseGroup}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{customFormatScore && customFormatScore !== '0' ? (
|
||||
<DescriptionListItem
|
||||
title={translate('CustomFormatScore')}
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{movieMatchType ? (
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('MovieMatchType')}
|
||||
data={movieMatchType}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{nzbInfoUrl ? (
|
||||
<span>
|
||||
<DescriptionListItemTitle>
|
||||
{translate('InfoUrl')}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription>
|
||||
<Link to={nzbInfoUrl}>{nzbInfoUrl}</Link>
|
||||
</DescriptionListItemDescription>
|
||||
</span>
|
||||
) : null}
|
||||
|
||||
{downloadClientNameInfo ? (
|
||||
<DescriptionListItem
|
||||
title={translate('DownloadClient')}
|
||||
data={downloadClientNameInfo}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{downloadId ? (
|
||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||
) : null}
|
||||
|
||||
{age || ageHours || ageMinutes ? (
|
||||
<DescriptionListItem
|
||||
title={translate('AgeWhenGrabbed')}
|
||||
data={formatAge(age, ageHours, ageMinutes)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{publishedDate ? (
|
||||
<DescriptionListItem
|
||||
title={translate('PublishedDate')}
|
||||
data={formatDateTime(publishedDate, shortDateFormat, timeFormat, {
|
||||
includeSeconds: true,
|
||||
})}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFailed') {
|
||||
const { message } = data as DownloadFailedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{downloadId ? (
|
||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<DescriptionListItem title={translate('Message')} data={message} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadFolderImported') {
|
||||
const { customFormatScore, droppedPath, importedPath } =
|
||||
data as DownloadFolderImportedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{droppedPath ? (
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Source')}
|
||||
data={droppedPath}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{importedPath ? (
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('ImportedTo')}
|
||||
data={importedPath}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{customFormatScore && customFormatScore !== '0' ? (
|
||||
<DescriptionListItem
|
||||
title={translate('CustomFormatScore')}
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'movieFileDeleted') {
|
||||
const { reason, customFormatScore } = data as MovieFileDeletedHistory;
|
||||
|
||||
let reasonMessage = '';
|
||||
|
||||
switch (reason) {
|
||||
case 'Manual':
|
||||
reasonMessage = translate('DeletedReasonManual');
|
||||
break;
|
||||
case 'MissingFromDisk':
|
||||
reasonMessage = translate('DeletedReasonMovieMissingFromDisk');
|
||||
break;
|
||||
case 'Upgrade':
|
||||
reasonMessage = translate('DeletedReasonUpgrade');
|
||||
break;
|
||||
default:
|
||||
reasonMessage = '';
|
||||
}
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem title={translate('Name')} data={sourceTitle} />
|
||||
|
||||
<DescriptionListItem title={translate('Reason')} data={reasonMessage} />
|
||||
|
||||
{customFormatScore && customFormatScore !== '0' ? (
|
||||
<DescriptionListItem
|
||||
title={translate('CustomFormatScore')}
|
||||
data={formatCustomFormatScore(parseInt(customFormatScore))}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'movieFileRenamed') {
|
||||
const { sourcePath, sourceRelativePath, path, relativePath } =
|
||||
data as MovieFileRenamedHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
title={translate('SourcePath')}
|
||||
data={sourcePath}
|
||||
/>
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('SourceRelativePath')}
|
||||
data={sourceRelativePath}
|
||||
/>
|
||||
|
||||
<DescriptionListItem title={translate('DestinationPath')} data={path} />
|
||||
|
||||
<DescriptionListItem
|
||||
title={translate('DestinationRelativePath')}
|
||||
data={relativePath}
|
||||
/>
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
if (eventType === 'downloadIgnored') {
|
||||
const { message } = data as DownloadIgnoredHistory;
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
|
||||
{downloadId ? (
|
||||
<DescriptionListItem title={translate('GrabId')} data={downloadId} />
|
||||
) : null}
|
||||
|
||||
{message ? (
|
||||
<DescriptionListItem title={translate('Message')} data={message} />
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DescriptionList>
|
||||
<DescriptionListItem
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Name')}
|
||||
data={sourceTitle}
|
||||
/>
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryDetails;
|
||||
@@ -1,18 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import HistoryDetails from './HistoryDetails';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createUISettingsSelector(),
|
||||
(uiSettings) => {
|
||||
return {
|
||||
shortDateFormat: uiSettings.shortDateFormat,
|
||||
timeFormat: uiSettings.timeFormat
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(HistoryDetails);
|
||||
@@ -1,4 +1,3 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
@@ -8,11 +7,12 @@ import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryDetails from './HistoryDetails';
|
||||
import styles from './HistoryDetailsModal.css';
|
||||
|
||||
function getHeaderTitle(eventType) {
|
||||
function getHeaderTitle(eventType: HistoryEventType) {
|
||||
switch (eventType) {
|
||||
case 'grabbed':
|
||||
return translate('Grabbed');
|
||||
@@ -31,29 +31,33 @@ function getHeaderTitle(eventType) {
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryDetailsModal(props) {
|
||||
interface HistoryDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
eventType: HistoryEventType;
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed: boolean;
|
||||
onMarkAsFailedPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
const {
|
||||
isOpen,
|
||||
eventType,
|
||||
sourceTitle,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
isMarkingAsFailed = false,
|
||||
onMarkAsFailedPress,
|
||||
onModalClose
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{getHeaderTitle(eventType)}
|
||||
</ModalHeader>
|
||||
<ModalHeader>{getHeaderTitle(eventType)}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<HistoryDetails
|
||||
@@ -61,50 +65,26 @@ function HistoryDetailsModal(props) {
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
eventType === 'grabbed' &&
|
||||
<SpinnerButton
|
||||
className={styles.markAsFailedButton}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isMarkingAsFailed}
|
||||
onPress={onMarkAsFailedPress}
|
||||
>
|
||||
{translate('MarkAsFailed')}
|
||||
</SpinnerButton>
|
||||
}
|
||||
{eventType === 'grabbed' && (
|
||||
<SpinnerButton
|
||||
className={styles.markAsFailedButton}
|
||||
kind={kinds.DANGER}
|
||||
isSpinning={isMarkingAsFailed}
|
||||
onPress={onMarkAsFailedPress}
|
||||
>
|
||||
{translate('MarkAsFailed')}
|
||||
</SpinnerButton>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
HistoryDetailsModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
eventType: PropTypes.string.isRequired,
|
||||
sourceTitle: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
downloadId: PropTypes.string,
|
||||
isMarkingAsFailed: PropTypes.bool.isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
onMarkAsFailedPress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
HistoryDetailsModal.defaultProps = {
|
||||
isMarkingAsFailed: false
|
||||
};
|
||||
|
||||
export default HistoryDetailsModal;
|
||||
@@ -1,158 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
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 Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryFilterModal from './HistoryFilterModal';
|
||||
import HistoryRowConnector from './HistoryRowConnector';
|
||||
|
||||
class History extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
isMoviesFetching,
|
||||
isMoviesPopulated,
|
||||
moviesError,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
customFilters,
|
||||
totalRecords,
|
||||
onFilterSelect,
|
||||
onFirstPagePress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const isFetchingAny = isFetching || isMoviesFetching;
|
||||
const isAllPopulated = isPopulated && (isMoviesPopulated || !items.length);
|
||||
const hasError = error || moviesError;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('History')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('Refresh')}
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={isFetching}
|
||||
onPress={onFirstPagePress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
{...otherProps}
|
||||
columns={columns}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={HistoryFilterModal}
|
||||
onFilterSelect={onFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{
|
||||
isFetchingAny && !isAllPopulated &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
|
||||
{
|
||||
!isFetchingAny && hasError &&
|
||||
<Alert kind={kinds.DANGER}>
|
||||
{translate('HistoryLoadError')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
// If history isPopulated and it's empty show no history found and don't
|
||||
// wait for the episodes to populate because they are never coming.
|
||||
|
||||
isPopulated && !hasError && !items.length &&
|
||||
<Alert kind={kinds.INFO}>
|
||||
{translate('NoHistoryFound')}
|
||||
</Alert>
|
||||
}
|
||||
|
||||
{
|
||||
isAllPopulated && !hasError && !!items.length &&
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
{...otherProps}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
items.map((item) => {
|
||||
return (
|
||||
<HistoryRowConnector
|
||||
key={item.id}
|
||||
columns={columns}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePager
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={onFirstPagePress}
|
||||
{...otherProps}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
History.propTypes = {
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isMoviesFetching: PropTypes.bool.isRequired,
|
||||
isMoviesPopulated: PropTypes.bool.isRequired,
|
||||
moviesError: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
|
||||
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
totalRecords: PropTypes.number,
|
||||
onFilterSelect: PropTypes.func.isRequired,
|
||||
onFirstPagePress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default History;
|
||||
216
frontend/src/Activity/History/History.tsx
Normal file
216
frontend/src/Activity/History/History.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import Alert from 'Components/Alert';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
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 Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import TablePager from 'Components/Table/TablePager';
|
||||
import usePaging from 'Components/Table/usePaging';
|
||||
import useCurrentPage from 'Helpers/Hooks/useCurrentPage';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import createMoviesFetchingSelector from 'Movie/createMoviesFetchingSelector';
|
||||
import {
|
||||
clearHistory,
|
||||
fetchHistory,
|
||||
gotoHistoryPage,
|
||||
setHistoryFilter,
|
||||
setHistorySort,
|
||||
setHistoryTableOption,
|
||||
} from 'Store/Actions/historyActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import { TableOptionsChangePayload } from 'typings/Table';
|
||||
import {
|
||||
registerPagePopulator,
|
||||
unregisterPagePopulator,
|
||||
} from 'Utilities/pagePopulator';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import HistoryFilterModal from './HistoryFilterModal';
|
||||
import HistoryRow from './HistoryRow';
|
||||
|
||||
function History() {
|
||||
const requestCurrentPage = useCurrentPage();
|
||||
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
columns,
|
||||
selectedFilterKey,
|
||||
filters,
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
} = useSelector((state: AppState) => state.history);
|
||||
|
||||
const { isMoviesFetching, isMoviesPopulated, moviesError } = useSelector(
|
||||
createMoviesFetchingSelector()
|
||||
);
|
||||
const customFilters = useSelector(createCustomFiltersSelector('history'));
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const isFetchingAny = isFetching || isMoviesFetching;
|
||||
const isAllPopulated = isPopulated && (isMoviesPopulated || !items.length);
|
||||
const hasError = error || moviesError;
|
||||
|
||||
const {
|
||||
handleFirstPagePress,
|
||||
handlePreviousPagePress,
|
||||
handleNextPagePress,
|
||||
handleLastPagePress,
|
||||
handlePageSelect,
|
||||
} = usePaging({
|
||||
page,
|
||||
totalPages,
|
||||
gotoPage: gotoHistoryPage,
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleSortPress = useCallback(
|
||||
(sortKey: string) => {
|
||||
dispatch(setHistorySort({ sortKey }));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const handleTableOptionChange = useCallback(
|
||||
(payload: TableOptionsChangePayload) => {
|
||||
dispatch(setHistoryTableOption(payload));
|
||||
|
||||
if (payload.pageSize) {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (requestCurrentPage) {
|
||||
dispatch(fetchHistory());
|
||||
} else {
|
||||
dispatch(gotoHistoryPage({ page: 1 }));
|
||||
}
|
||||
|
||||
return () => {
|
||||
dispatch(clearHistory());
|
||||
};
|
||||
}, [requestCurrentPage, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
const repopulate = () => {
|
||||
dispatch(fetchHistory());
|
||||
};
|
||||
|
||||
registerPagePopulator(repopulate);
|
||||
|
||||
return () => {
|
||||
unregisterPagePopulator(repopulate);
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<PageContent title={translate('History')}>
|
||||
<PageToolbar>
|
||||
<PageToolbarSection>
|
||||
<PageToolbarButton
|
||||
label={translate('Refresh')}
|
||||
iconName={icons.REFRESH}
|
||||
isSpinning={isFetching}
|
||||
onPress={handleFirstPagePress}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
label={translate('Options')}
|
||||
iconName={icons.TABLE}
|
||||
/>
|
||||
</TableOptionsModalWrapper>
|
||||
|
||||
<FilterMenu
|
||||
alignMenu={align.RIGHT}
|
||||
selectedFilterKey={selectedFilterKey}
|
||||
filters={filters}
|
||||
customFilters={customFilters}
|
||||
filterModalConnectorComponent={HistoryFilterModal}
|
||||
onFilterSelect={handleFilterSelect}
|
||||
/>
|
||||
</PageToolbarSection>
|
||||
</PageToolbar>
|
||||
|
||||
<PageContentBody>
|
||||
{isFetchingAny && !isAllPopulated ? <LoadingIndicator /> : null}
|
||||
|
||||
{!isFetchingAny && hasError ? (
|
||||
<Alert kind={kinds.DANGER}>{translate('HistoryLoadError')}</Alert>
|
||||
) : null}
|
||||
|
||||
{
|
||||
// If history isPopulated and it's empty show no history found and don't
|
||||
// wait for the movies to populate because they are never coming.
|
||||
|
||||
isPopulated && !hasError && !items.length ? (
|
||||
<Alert kind={kinds.INFO}>{translate('NoHistoryFound')}</Alert>
|
||||
) : null
|
||||
}
|
||||
|
||||
{isAllPopulated && !hasError && items.length ? (
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
onSortPress={handleSortPress}
|
||||
>
|
||||
<TableBody>
|
||||
{items.map((item) => {
|
||||
return (
|
||||
<HistoryRow key={item.id} columns={columns} {...item} />
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<TablePager
|
||||
page={page}
|
||||
totalPages={totalPages}
|
||||
totalRecords={totalRecords}
|
||||
isFetching={isFetching}
|
||||
onFirstPagePress={handleFirstPagePress}
|
||||
onPreviousPagePress={handlePreviousPagePress}
|
||||
onNextPagePress={handleNextPagePress}
|
||||
onLastPagePress={handleLastPagePress}
|
||||
onPageSelect={handlePageSelect}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</PageContentBody>
|
||||
</PageContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default History;
|
||||
@@ -1,141 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import withCurrentPage from 'Components/withCurrentPage';
|
||||
import * as historyActions from 'Store/Actions/historyActions';
|
||||
import { createCustomFiltersSelector } from 'Store/Selectors/createClientSideCollectionSelector';
|
||||
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
|
||||
import History from './History';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.history,
|
||||
(state) => state.movies,
|
||||
createCustomFiltersSelector('history'),
|
||||
(history, movies, customFilters) => {
|
||||
return {
|
||||
isMoviesFetching: movies.isFetching,
|
||||
isMoviesPopulated: movies.isPopulated,
|
||||
moviesError: movies.error,
|
||||
customFilters,
|
||||
...history
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
...historyActions
|
||||
};
|
||||
|
||||
class HistoryConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
useCurrentPage,
|
||||
fetchHistory,
|
||||
gotoHistoryFirstPage
|
||||
} = this.props;
|
||||
|
||||
registerPagePopulator(this.repopulate);
|
||||
|
||||
if (useCurrentPage) {
|
||||
fetchHistory();
|
||||
} else {
|
||||
gotoHistoryFirstPage();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
unregisterPagePopulator(this.repopulate);
|
||||
this.props.clearHistory();
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
repopulate = () => {
|
||||
this.props.fetchHistory();
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFirstPagePress = () => {
|
||||
this.props.gotoHistoryFirstPage();
|
||||
};
|
||||
|
||||
onPreviousPagePress = () => {
|
||||
this.props.gotoHistoryPreviousPage();
|
||||
};
|
||||
|
||||
onNextPagePress = () => {
|
||||
this.props.gotoHistoryNextPage();
|
||||
};
|
||||
|
||||
onLastPagePress = () => {
|
||||
this.props.gotoHistoryLastPage();
|
||||
};
|
||||
|
||||
onPageSelect = (page) => {
|
||||
this.props.gotoHistoryPage({ page });
|
||||
};
|
||||
|
||||
onSortPress = (sortKey) => {
|
||||
this.props.setHistorySort({ sortKey });
|
||||
};
|
||||
|
||||
onFilterSelect = (selectedFilterKey) => {
|
||||
this.props.setHistoryFilter({ selectedFilterKey });
|
||||
};
|
||||
|
||||
onTableOptionChange = (payload) => {
|
||||
this.props.setHistoryTableOption(payload);
|
||||
|
||||
if (payload.pageSize) {
|
||||
this.props.gotoHistoryFirstPage();
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<History
|
||||
onFirstPagePress={this.onFirstPagePress}
|
||||
onPreviousPagePress={this.onPreviousPagePress}
|
||||
onNextPagePress={this.onNextPagePress}
|
||||
onLastPagePress={this.onLastPagePress}
|
||||
onPageSelect={this.onPageSelect}
|
||||
onSortPress={this.onSortPress}
|
||||
onFilterSelect={this.onFilterSelect}
|
||||
onTableOptionChange={this.onTableOptionChange}
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
HistoryConnector.propTypes = {
|
||||
useCurrentPage: PropTypes.bool.isRequired,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
fetchHistory: PropTypes.func.isRequired,
|
||||
gotoHistoryFirstPage: PropTypes.func.isRequired,
|
||||
gotoHistoryPreviousPage: PropTypes.func.isRequired,
|
||||
gotoHistoryNextPage: PropTypes.func.isRequired,
|
||||
gotoHistoryLastPage: PropTypes.func.isRequired,
|
||||
gotoHistoryPage: PropTypes.func.isRequired,
|
||||
setHistorySort: PropTypes.func.isRequired,
|
||||
setHistoryFilter: PropTypes.func.isRequired,
|
||||
setHistoryTableOption: PropTypes.func.isRequired,
|
||||
clearHistory: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default withCurrentPage(
|
||||
connect(createMapStateToProps, mapDispatchToProps)(HistoryConnector)
|
||||
);
|
||||
@@ -1,12 +1,17 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
GrabbedHistoryData,
|
||||
HistoryData,
|
||||
HistoryEventType,
|
||||
MovieFileDeletedHistory,
|
||||
} from 'typings/History';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './HistoryEventTypeCell.css';
|
||||
|
||||
function getIconName(eventType, data) {
|
||||
function getIconName(eventType: HistoryEventType, data: HistoryData) {
|
||||
switch (eventType) {
|
||||
case 'grabbed':
|
||||
return icons.DOWNLOADING;
|
||||
@@ -17,7 +22,9 @@ function getIconName(eventType, data) {
|
||||
case 'downloadFailed':
|
||||
return icons.DOWNLOADING;
|
||||
case 'movieFileDeleted':
|
||||
return data.reason === 'MissingFromDisk' ? icons.FILE_MISSING : icons.DELETE;
|
||||
return (data as MovieFileDeletedHistory).reason === 'MissingFromDisk'
|
||||
? icons.FILE_MISSING
|
||||
: icons.DELETE;
|
||||
case 'movieFileRenamed':
|
||||
return icons.ORGANIZE;
|
||||
case 'downloadIgnored':
|
||||
@@ -27,7 +34,7 @@ function getIconName(eventType, data) {
|
||||
}
|
||||
}
|
||||
|
||||
function getIconKind(eventType) {
|
||||
function getIconKind(eventType: HistoryEventType) {
|
||||
switch (eventType) {
|
||||
case 'downloadFailed':
|
||||
return kinds.DANGER;
|
||||
@@ -36,52 +43,47 @@ function getIconKind(eventType) {
|
||||
}
|
||||
}
|
||||
|
||||
function getTooltip(eventType, data) {
|
||||
function getTooltip(eventType: HistoryEventType, data: HistoryData) {
|
||||
switch (eventType) {
|
||||
case 'grabbed':
|
||||
return translate('MovieGrabbedHistoryTooltip', { indexer: data.indexer, downloadClient: data.downloadClient });
|
||||
return translate('MovieGrabbedTooltip', {
|
||||
indexer: (data as GrabbedHistoryData).indexer,
|
||||
downloadClient: (data as GrabbedHistoryData).downloadClient,
|
||||
});
|
||||
case 'movieFolderImported':
|
||||
return translate('MovieFolderImportedTooltip');
|
||||
case 'downloadFolderImported':
|
||||
return translate('MovieImportedTooltip');
|
||||
case 'downloadFailed':
|
||||
return translate('MovieDownloadFailedTooltip');
|
||||
return translate('DownloadFailedMovieTooltip');
|
||||
case 'movieFileDeleted':
|
||||
return data.reason === 'MissingFromDisk' ? translate('MovieFileMissingTooltip') : translate('MovieFileDeletedTooltip');
|
||||
return (data as MovieFileDeletedHistory).reason === 'MissingFromDisk'
|
||||
? translate('MovieFileMissingTooltip')
|
||||
: translate('MovieFileDeletedTooltip');
|
||||
case 'movieFileRenamed':
|
||||
return translate('MovieFileRenamedTooltip');
|
||||
case 'downloadIgnored':
|
||||
return translate('MovieDownloadIgnoredTooltip');
|
||||
return translate('DownloadIgnoredMovieTooltip');
|
||||
default:
|
||||
return translate('UnknownEventTooltip');
|
||||
}
|
||||
}
|
||||
|
||||
function HistoryEventTypeCell({ eventType, data }) {
|
||||
interface HistoryEventTypeCellProps {
|
||||
eventType: HistoryEventType;
|
||||
data: HistoryData;
|
||||
}
|
||||
|
||||
function HistoryEventTypeCell({ eventType, data }: HistoryEventTypeCellProps) {
|
||||
const iconName = getIconName(eventType, data);
|
||||
const iconKind = getIconKind(eventType);
|
||||
const tooltip = getTooltip(eventType, data);
|
||||
|
||||
return (
|
||||
<TableRowCell
|
||||
className={styles.cell}
|
||||
title={tooltip}
|
||||
>
|
||||
<Icon
|
||||
name={iconName}
|
||||
kind={iconKind}
|
||||
/>
|
||||
<TableRowCell className={styles.cell} title={tooltip}>
|
||||
<Icon name={iconName} kind={iconKind} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
HistoryEventTypeCell.propTypes = {
|
||||
eventType: PropTypes.string.isRequired,
|
||||
data: PropTypes.object
|
||||
};
|
||||
|
||||
HistoryEventTypeCell.defaultProps = {
|
||||
data: {}
|
||||
};
|
||||
|
||||
export default HistoryEventTypeCell;
|
||||
@@ -1,277 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import MovieFormats from 'Movie/MovieFormats';
|
||||
import MovieLanguages from 'Movie/MovieLanguages';
|
||||
import MovieQuality from 'Movie/MovieQuality';
|
||||
import MovieTitleLink from 'Movie/MovieTitleLink';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import HistoryDetailsModal from './Details/HistoryDetailsModal';
|
||||
import HistoryEventTypeCell from './HistoryEventTypeCell';
|
||||
import styles from './HistoryRow.css';
|
||||
|
||||
class HistoryRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.isMarkingAsFailed &&
|
||||
!this.props.isMarkingAsFailed &&
|
||||
!this.props.markAsFailedError
|
||||
) {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onDetailsPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
};
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
movie,
|
||||
quality,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
languages,
|
||||
qualityCutoffNotMet,
|
||||
eventType,
|
||||
sourceTitle,
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed,
|
||||
columns,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
onMarkAsFailedPress
|
||||
} = this.props;
|
||||
|
||||
if (!movie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{
|
||||
columns.map((column) => {
|
||||
const {
|
||||
name,
|
||||
isVisible
|
||||
} = column;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'eventType') {
|
||||
return (
|
||||
<HistoryEventTypeCell
|
||||
key={name}
|
||||
eventType={eventType}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.sortTitle') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieTitleLink
|
||||
titleSlug={movie.titleSlug}
|
||||
title={movie.title}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieLanguages
|
||||
languages={languages}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'quality') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieQuality
|
||||
quality={quality}
|
||||
isCutoffMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormats') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieFormats
|
||||
formats={customFormats}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'date') {
|
||||
return (
|
||||
<RelativeDateCell
|
||||
key={name}
|
||||
date={date}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'downloadClient') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.downloadClient}
|
||||
>
|
||||
{data.downloadClient}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'indexer') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.indexer}
|
||||
>
|
||||
{data.indexer}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormatScore') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.customFormatScore}
|
||||
>
|
||||
<Tooltip
|
||||
anchor={formatCustomFormatScore(
|
||||
customFormatScore,
|
||||
customFormats.length
|
||||
)}
|
||||
tooltip={<MovieFormats formats={customFormats} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'releaseGroup') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.releaseGroup}
|
||||
>
|
||||
{data.releaseGroup}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'sourceTitle') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
>
|
||||
{sourceTitle}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'details') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.details}
|
||||
>
|
||||
<div className={styles.actionContents}>
|
||||
<IconButton
|
||||
name={icons.INFO}
|
||||
onPress={this.onDetailsPress}
|
||||
/>
|
||||
</div>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
}
|
||||
|
||||
<HistoryDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
isMarkingAsFailed={isMarkingAsFailed}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
onMarkAsFailedPress={onMarkAsFailedPress}
|
||||
onModalClose={this.onDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
HistoryRow.propTypes = {
|
||||
movieId: PropTypes.number,
|
||||
movie: PropTypes.object.isRequired,
|
||||
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
quality: PropTypes.object.isRequired,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
||||
customFormatScore: PropTypes.number.isRequired,
|
||||
qualityCutoffNotMet: PropTypes.bool.isRequired,
|
||||
eventType: PropTypes.string.isRequired,
|
||||
sourceTitle: PropTypes.string.isRequired,
|
||||
date: PropTypes.string.isRequired,
|
||||
data: PropTypes.object.isRequired,
|
||||
downloadId: PropTypes.string,
|
||||
isMarkingAsFailed: PropTypes.bool,
|
||||
markAsFailedError: PropTypes.object,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired,
|
||||
onMarkAsFailedPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
HistoryRow.defaultProps = {
|
||||
customFormats: []
|
||||
};
|
||||
|
||||
export default HistoryRow;
|
||||
224
frontend/src/Activity/History/HistoryRow.tsx
Normal file
224
frontend/src/Activity/History/HistoryRow.tsx
Normal file
@@ -0,0 +1,224 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import Column from 'Components/Table/Column';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import MovieFormats from 'Movie/MovieFormats';
|
||||
import MovieLanguages from 'Movie/MovieLanguages';
|
||||
import MovieQuality from 'Movie/MovieQuality';
|
||||
import MovieTitleLink from 'Movie/MovieTitleLink';
|
||||
import useMovie from 'Movie/useMovie';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import HistoryDetailsModal from './Details/HistoryDetailsModal';
|
||||
import HistoryEventTypeCell from './HistoryEventTypeCell';
|
||||
import styles from './HistoryRow.css';
|
||||
|
||||
interface HistoryRowProps {
|
||||
id: number;
|
||||
movieId: number;
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
customFormats?: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
qualityCutoffNotMet: boolean;
|
||||
eventType: HistoryEventType;
|
||||
sourceTitle: string;
|
||||
date: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed?: boolean;
|
||||
markAsFailedError?: object;
|
||||
columns: Column[];
|
||||
}
|
||||
|
||||
function HistoryRow(props: HistoryRowProps) {
|
||||
const {
|
||||
id,
|
||||
movieId,
|
||||
languages,
|
||||
quality,
|
||||
customFormats = [],
|
||||
customFormatScore,
|
||||
qualityCutoffNotMet,
|
||||
eventType,
|
||||
sourceTitle,
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed = false,
|
||||
markAsFailedError,
|
||||
columns,
|
||||
} = props;
|
||||
|
||||
const wasMarkingAsFailed = usePrevious(isMarkingAsFailed);
|
||||
const dispatch = useDispatch();
|
||||
const movie = useMovie(movieId);
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleDetailsPress = useCallback(() => {
|
||||
setIsDetailsModalOpen(true);
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const handleDetailsModalClose = useCallback(() => {
|
||||
setIsDetailsModalOpen(false);
|
||||
}, [setIsDetailsModalOpen]);
|
||||
|
||||
const handleMarkAsFailedPress = useCallback(() => {
|
||||
dispatch(markAsFailed({ id }));
|
||||
}, [id, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wasMarkingAsFailed && !isMarkingAsFailed && !markAsFailedError) {
|
||||
setIsDetailsModalOpen(false);
|
||||
dispatch(fetchHistory());
|
||||
}
|
||||
}, [
|
||||
wasMarkingAsFailed,
|
||||
isMarkingAsFailed,
|
||||
markAsFailedError,
|
||||
setIsDetailsModalOpen,
|
||||
dispatch,
|
||||
]);
|
||||
|
||||
if (!movie) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{columns.map((column) => {
|
||||
const { name, isVisible } = column;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'eventType') {
|
||||
return (
|
||||
<HistoryEventTypeCell
|
||||
key={name}
|
||||
eventType={eventType}
|
||||
data={data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'movieMetadata.sortTitle') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieTitleLink titleSlug={movie.titleSlug} title={movie.title} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieLanguages languages={languages} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'quality') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieQuality
|
||||
quality={quality}
|
||||
isCutoffNotMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormats') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
<MovieFormats formats={customFormats} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'date') {
|
||||
return <RelativeDateCell key={name} date={date} />;
|
||||
}
|
||||
|
||||
if (name === 'downloadClient') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.downloadClient}>
|
||||
{'downloadClient' in data ? data.downloadClient : ''}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'indexer') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.indexer}>
|
||||
{'indexer' in data ? data.indexer : ''}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormatScore') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.customFormatScore}>
|
||||
<Tooltip
|
||||
anchor={formatCustomFormatScore(
|
||||
customFormatScore,
|
||||
customFormats.length
|
||||
)}
|
||||
tooltip={<MovieFormats formats={customFormats} />}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'releaseGroup') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.releaseGroup}>
|
||||
{'releaseGroup' in data ? data.releaseGroup : ''}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'sourceTitle') {
|
||||
return <TableRowCell key={name}>{sourceTitle}</TableRowCell>;
|
||||
}
|
||||
|
||||
if (name === 'details') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.details}>
|
||||
<IconButton name={icons.INFO} onPress={handleDetailsPress} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
<HistoryDetailsModal
|
||||
isOpen={isDetailsModalOpen}
|
||||
eventType={eventType}
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
isMarkingAsFailed={isMarkingAsFailed}
|
||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default HistoryRow;
|
||||
@@ -1,73 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||
import createMovieSelector from 'Store/Selectors/createMovieSelector';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import HistoryRow from './HistoryRow';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createMovieSelector(),
|
||||
createUISettingsSelector(),
|
||||
(movie, uiSettings) => {
|
||||
return {
|
||||
movie,
|
||||
shortDateFormat: uiSettings.shortDateFormat,
|
||||
timeFormat: uiSettings.timeFormat
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
fetchHistory,
|
||||
markAsFailed
|
||||
};
|
||||
|
||||
class HistoryRowConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (
|
||||
prevProps.isMarkingAsFailed &&
|
||||
!this.props.isMarkingAsFailed &&
|
||||
!this.props.markAsFailedError
|
||||
) {
|
||||
this.props.fetchHistory();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMarkAsFailedPress = () => {
|
||||
this.props.markAsFailed({ id: this.props.id });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<HistoryRow
|
||||
{...this.props}
|
||||
onMarkAsFailedPress={this.onMarkAsFailedPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
HistoryRowConnector.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
isMarkingAsFailed: PropTypes.bool,
|
||||
markAsFailedError: PropTypes.object,
|
||||
fetchHistory: PropTypes.func.isRequired,
|
||||
markAsFailed: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(HistoryRowConnector);
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import TooltipPosition from 'Helpers/Props/TooltipPosition';
|
||||
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||
import {
|
||||
QueueTrackedDownloadState,
|
||||
QueueTrackedDownloadStatus,
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import { ConnectedRouter } from 'connected-react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
|
||||
import React from 'react';
|
||||
import DocumentTitle from 'react-document-title';
|
||||
import { Provider } from 'react-redux';
|
||||
import { Store } from 'redux';
|
||||
import PageConnector from 'Components/Page/PageConnector';
|
||||
import ApplyTheme from './ApplyTheme';
|
||||
import AppRoutes from './AppRoutes';
|
||||
|
||||
function App({ store, history }) {
|
||||
interface AppProps {
|
||||
store: Store;
|
||||
history: ConnectedRouterProps['history'];
|
||||
}
|
||||
|
||||
function App({ store, history }: AppProps) {
|
||||
return (
|
||||
<DocumentTitle title={window.Radarr.instanceName}>
|
||||
<Provider store={store}>
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes app={App} />
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
@@ -22,9 +27,4 @@ function App({ store, history }) {
|
||||
);
|
||||
}
|
||||
|
||||
App.propTypes = {
|
||||
store: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
export default App;
|
||||
@@ -1,260 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import Blocklist from 'Activity/Blocklist/Blocklist';
|
||||
import HistoryConnector from 'Activity/History/HistoryConnector';
|
||||
import Queue from 'Activity/Queue/Queue';
|
||||
import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector';
|
||||
import ImportMovies from 'AddMovie/ImportMovie/ImportMovies';
|
||||
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
|
||||
import CollectionConnector from 'Collection/CollectionConnector';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector';
|
||||
import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
|
||||
import MovieIndex from 'Movie/Index/MovieIndex';
|
||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
|
||||
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
|
||||
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
|
||||
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
|
||||
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
|
||||
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
|
||||
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
|
||||
import Profiles from 'Settings/Profiles/Profiles';
|
||||
import QualityConnector from 'Settings/Quality/QualityConnector';
|
||||
import Settings from 'Settings/Settings';
|
||||
import TagSettings from 'Settings/Tags/TagSettings';
|
||||
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
|
||||
import BackupsConnector from 'System/Backup/BackupsConnector';
|
||||
import LogsTableConnector from 'System/Events/LogsTableConnector';
|
||||
import Logs from 'System/Logs/Logs';
|
||||
import Status from 'System/Status/Status';
|
||||
import Tasks from 'System/Tasks/Tasks';
|
||||
import Updates from 'System/Updates/Updates';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||
|
||||
function AppRoutes(props) {
|
||||
const {
|
||||
app
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
{/*
|
||||
Movies
|
||||
*/}
|
||||
|
||||
<Route
|
||||
exact={true}
|
||||
path="/"
|
||||
component={MovieIndex}
|
||||
/>
|
||||
|
||||
{
|
||||
window.Radarr.urlBase &&
|
||||
<Route
|
||||
exact={true}
|
||||
path="/"
|
||||
addUrlBase={false}
|
||||
render={() => {
|
||||
return (
|
||||
<Redirect
|
||||
to={getPathWithUrlBase('/')}
|
||||
component={app}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
}
|
||||
|
||||
<Route
|
||||
path="/add/new"
|
||||
component={AddNewMovieConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/collections"
|
||||
component={CollectionConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/add/import"
|
||||
component={ImportMovies}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/add/discover"
|
||||
component={DiscoverMovieConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/movie/:titleSlug"
|
||||
component={MovieDetailsPageConnector}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Calendar
|
||||
*/}
|
||||
|
||||
<Route
|
||||
path="/calendar"
|
||||
component={CalendarPageConnector}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Activity
|
||||
*/}
|
||||
|
||||
<Route
|
||||
path="/activity/history"
|
||||
component={HistoryConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/activity/queue"
|
||||
component={Queue}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/activity/blocklist"
|
||||
component={Blocklist}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Wanted
|
||||
*/}
|
||||
|
||||
<Route
|
||||
path="/wanted/missing"
|
||||
component={MissingConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/wanted/cutoffunmet"
|
||||
component={CutoffUnmetConnector}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Settings
|
||||
*/}
|
||||
|
||||
<Route
|
||||
exact={true}
|
||||
path="/settings"
|
||||
component={Settings}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/mediamanagement"
|
||||
component={MediaManagementConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/profiles"
|
||||
component={Profiles}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/quality"
|
||||
component={QualityConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/customformats"
|
||||
component={CustomFormatSettingsPage}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/indexers"
|
||||
component={IndexerSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/downloadclients"
|
||||
component={DownloadClientSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/importlists"
|
||||
component={ImportListSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/connect"
|
||||
component={NotificationSettings}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/metadata"
|
||||
component={MetadataSettings}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/tags"
|
||||
component={TagSettings}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/general"
|
||||
component={GeneralSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/ui"
|
||||
component={UISettingsConnector}
|
||||
/>
|
||||
|
||||
{/*
|
||||
System
|
||||
*/}
|
||||
|
||||
<Route
|
||||
path="/system/status"
|
||||
component={Status}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/system/tasks"
|
||||
component={Tasks}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/system/backup"
|
||||
component={BackupsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/system/updates"
|
||||
component={Updates}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/system/events"
|
||||
component={LogsTableConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/system/logs/files"
|
||||
component={Logs}
|
||||
/>
|
||||
|
||||
{/*
|
||||
Not Found
|
||||
*/}
|
||||
|
||||
<Route
|
||||
path="*"
|
||||
component={NotFound}
|
||||
/>
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
AppRoutes.propTypes = {
|
||||
app: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
163
frontend/src/App/AppRoutes.tsx
Normal file
163
frontend/src/App/AppRoutes.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import Blocklist from 'Activity/Blocklist/Blocklist';
|
||||
import History from 'Activity/History/History';
|
||||
import Queue from 'Activity/Queue/Queue';
|
||||
import AddNewMovieConnector from 'AddMovie/AddNewMovie/AddNewMovieConnector';
|
||||
import ImportMovies from 'AddMovie/ImportMovie/ImportMovies';
|
||||
import CalendarPageConnector from 'Calendar/CalendarPageConnector';
|
||||
import CollectionConnector from 'Collection/CollectionConnector';
|
||||
import NotFound from 'Components/NotFound';
|
||||
import Switch from 'Components/Router/Switch';
|
||||
import DiscoverMovieConnector from 'DiscoverMovie/DiscoverMovieConnector';
|
||||
import MovieDetailsPageConnector from 'Movie/Details/MovieDetailsPageConnector';
|
||||
import MovieIndex from 'Movie/Index/MovieIndex';
|
||||
import CustomFormatSettingsPage from 'Settings/CustomFormats/CustomFormatSettingsPage';
|
||||
import DownloadClientSettingsConnector from 'Settings/DownloadClients/DownloadClientSettingsConnector';
|
||||
import GeneralSettingsConnector from 'Settings/General/GeneralSettingsConnector';
|
||||
import ImportListSettingsConnector from 'Settings/ImportLists/ImportListSettingsConnector';
|
||||
import IndexerSettingsConnector from 'Settings/Indexers/IndexerSettingsConnector';
|
||||
import MediaManagementConnector from 'Settings/MediaManagement/MediaManagementConnector';
|
||||
import MetadataSettings from 'Settings/Metadata/MetadataSettings';
|
||||
import NotificationSettings from 'Settings/Notifications/NotificationSettings';
|
||||
import Profiles from 'Settings/Profiles/Profiles';
|
||||
import QualityConnector from 'Settings/Quality/QualityConnector';
|
||||
import Settings from 'Settings/Settings';
|
||||
import TagSettings from 'Settings/Tags/TagSettings';
|
||||
import UISettingsConnector from 'Settings/UI/UISettingsConnector';
|
||||
import BackupsConnector from 'System/Backup/BackupsConnector';
|
||||
import LogsTableConnector from 'System/Events/LogsTableConnector';
|
||||
import Logs from 'System/Logs/Logs';
|
||||
import Status from 'System/Status/Status';
|
||||
import Tasks from 'System/Tasks/Tasks';
|
||||
import Updates from 'System/Updates/Updates';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||
|
||||
function RedirectWithUrlBase() {
|
||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Switch>
|
||||
{/*
|
||||
Movies
|
||||
*/}
|
||||
|
||||
<Route exact={true} path="/" component={MovieIndex} />
|
||||
|
||||
{window.Radarr.urlBase && (
|
||||
<Route
|
||||
exact={true}
|
||||
path="/"
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
addUrlBase={false}
|
||||
render={RedirectWithUrlBase}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Route path="/add/new" component={AddNewMovieConnector} />
|
||||
|
||||
<Route path="/collections" component={CollectionConnector} />
|
||||
|
||||
<Route path="/add/import" component={ImportMovies} />
|
||||
|
||||
<Route path="/add/discover" component={DiscoverMovieConnector} />
|
||||
|
||||
<Route path="/movie/:titleSlug" component={MovieDetailsPageConnector} />
|
||||
|
||||
{/*
|
||||
Calendar
|
||||
*/}
|
||||
|
||||
<Route path="/calendar" component={CalendarPageConnector} />
|
||||
|
||||
{/*
|
||||
Activity
|
||||
*/}
|
||||
|
||||
<Route path="/activity/history" component={History} />
|
||||
|
||||
<Route path="/activity/queue" component={Queue} />
|
||||
|
||||
<Route path="/activity/blocklist" component={Blocklist} />
|
||||
|
||||
{/*
|
||||
Wanted
|
||||
*/}
|
||||
|
||||
<Route path="/wanted/missing" component={MissingConnector} />
|
||||
|
||||
<Route path="/wanted/cutoffunmet" component={CutoffUnmetConnector} />
|
||||
|
||||
{/*
|
||||
Settings
|
||||
*/}
|
||||
|
||||
<Route exact={true} path="/settings" component={Settings} />
|
||||
|
||||
<Route
|
||||
path="/settings/mediamanagement"
|
||||
component={MediaManagementConnector}
|
||||
/>
|
||||
|
||||
<Route path="/settings/profiles" component={Profiles} />
|
||||
|
||||
<Route path="/settings/quality" component={QualityConnector} />
|
||||
|
||||
<Route
|
||||
path="/settings/customformats"
|
||||
component={CustomFormatSettingsPage}
|
||||
/>
|
||||
|
||||
<Route path="/settings/indexers" component={IndexerSettingsConnector} />
|
||||
|
||||
<Route
|
||||
path="/settings/downloadclients"
|
||||
component={DownloadClientSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="/settings/importlists"
|
||||
component={ImportListSettingsConnector}
|
||||
/>
|
||||
|
||||
<Route path="/settings/connect" component={NotificationSettings} />
|
||||
|
||||
<Route path="/settings/metadata" component={MetadataSettings} />
|
||||
|
||||
<Route path="/settings/tags" component={TagSettings} />
|
||||
|
||||
<Route path="/settings/general" component={GeneralSettingsConnector} />
|
||||
|
||||
<Route path="/settings/ui" component={UISettingsConnector} />
|
||||
|
||||
{/*
|
||||
System
|
||||
*/}
|
||||
|
||||
<Route path="/system/status" component={Status} />
|
||||
|
||||
<Route path="/system/tasks" component={Tasks} />
|
||||
|
||||
<Route path="/system/backup" component={BackupsConnector} />
|
||||
|
||||
<Route path="/system/updates" component={Updates} />
|
||||
|
||||
<Route path="/system/events" component={LogsTableConnector} />
|
||||
|
||||
<Route path="/system/logs/files" component={Logs} />
|
||||
|
||||
{/*
|
||||
Not Found
|
||||
*/}
|
||||
|
||||
<Route path="*" component={NotFound} />
|
||||
</Switch>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppRoutes;
|
||||
@@ -1,30 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AppUpdatedModalContentConnector from './AppUpdatedModalContentConnector';
|
||||
|
||||
function AppUpdatedModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
closeOnBackgroundClick={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AppUpdatedModalContentConnector
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AppUpdatedModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AppUpdatedModal;
|
||||
28
frontend/src/App/AppUpdatedModal.tsx
Normal file
28
frontend/src/App/AppUpdatedModal.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import AppUpdatedModalContent from './AppUpdatedModalContent';
|
||||
|
||||
interface AppUpdatedModalProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function AppUpdatedModal(props: AppUpdatedModalProps) {
|
||||
const { isOpen, onModalClose } = props;
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
closeOnBackgroundClick={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AppUpdatedModalContent onModalClose={handleModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppUpdatedModal;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import AppUpdatedModal from './AppUpdatedModal';
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onModalClose() {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(null, createMapDispatchToProps)(AppUpdatedModal);
|
||||
@@ -1,139 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import UpdateChanges from 'System/Updates/UpdateChanges';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AppUpdatedModalContent.css';
|
||||
|
||||
function mergeUpdates(items, version, prevVersion) {
|
||||
let installedIndex = items.findIndex((u) => u.version === version);
|
||||
let installedPreviouslyIndex = items.findIndex((u) => u.version === prevVersion);
|
||||
|
||||
if (installedIndex === -1) {
|
||||
installedIndex = 0;
|
||||
}
|
||||
|
||||
if (installedPreviouslyIndex === -1) {
|
||||
installedPreviouslyIndex = items.length;
|
||||
} else if (installedPreviouslyIndex === installedIndex && items.length) {
|
||||
installedPreviouslyIndex += 1;
|
||||
}
|
||||
|
||||
const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
|
||||
|
||||
if (!appliedUpdates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appliedChanges = { new: [], fixed: [] };
|
||||
appliedUpdates.forEach((u) => {
|
||||
if (u.changes) {
|
||||
appliedChanges.new.push(... u.changes.new);
|
||||
appliedChanges.fixed.push(... u.changes.fixed);
|
||||
}
|
||||
});
|
||||
|
||||
const mergedUpdate = Object.assign({}, appliedUpdates[0], { changes: appliedChanges });
|
||||
|
||||
if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
|
||||
mergedUpdate.changes = null;
|
||||
}
|
||||
|
||||
return mergedUpdate;
|
||||
}
|
||||
|
||||
function AppUpdatedModalContent(props) {
|
||||
const {
|
||||
version,
|
||||
prevVersion,
|
||||
isPopulated,
|
||||
error,
|
||||
items,
|
||||
onSeeChangesPress,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
const update = mergeUpdates(items, version, prevVersion);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('AppUpdated')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div>
|
||||
<InlineMarkdown data={translate('AppUpdatedVersion', { version })} blockClassName={styles.version} />
|
||||
</div>
|
||||
|
||||
{
|
||||
isPopulated && !error && !!update &&
|
||||
<div>
|
||||
{
|
||||
!update.changes &&
|
||||
<div className={styles.maintenance}>{translate('MaintenanceRelease')}</div>
|
||||
}
|
||||
|
||||
{
|
||||
!!update.changes &&
|
||||
<div>
|
||||
<div className={styles.changes}>
|
||||
{translate('WhatsNew')}
|
||||
</div>
|
||||
|
||||
<UpdateChanges
|
||||
title={translate('New')}
|
||||
changes={update.changes.new}
|
||||
/>
|
||||
|
||||
<UpdateChanges
|
||||
title={translate('Fixed')}
|
||||
changes={update.changes.fixed}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!isPopulated && !error &&
|
||||
<LoadingIndicator />
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onSeeChangesPress}
|
||||
>
|
||||
{translate('RecentChanges')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Reload')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
AppUpdatedModalContent.propTypes = {
|
||||
version: PropTypes.string.isRequired,
|
||||
prevVersion: PropTypes.string,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
items: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onSeeChangesPress: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AppUpdatedModalContent;
|
||||
145
frontend/src/App/AppUpdatedModalContent.tsx
Normal file
145
frontend/src/App/AppUpdatedModalContent.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||
import UpdateChanges from 'System/Updates/UpdateChanges';
|
||||
import Update from 'typings/Update';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import AppState from './State/AppState';
|
||||
import styles from './AppUpdatedModalContent.css';
|
||||
|
||||
function mergeUpdates(items: Update[], version: string, prevVersion?: string) {
|
||||
let installedIndex = items.findIndex((u) => u.version === version);
|
||||
let installedPreviouslyIndex = items.findIndex(
|
||||
(u) => u.version === prevVersion
|
||||
);
|
||||
|
||||
if (installedIndex === -1) {
|
||||
installedIndex = 0;
|
||||
}
|
||||
|
||||
if (installedPreviouslyIndex === -1) {
|
||||
installedPreviouslyIndex = items.length;
|
||||
} else if (installedPreviouslyIndex === installedIndex && items.length) {
|
||||
installedPreviouslyIndex += 1;
|
||||
}
|
||||
|
||||
const appliedUpdates = items.slice(installedIndex, installedPreviouslyIndex);
|
||||
|
||||
if (!appliedUpdates.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const appliedChanges: Update['changes'] = { new: [], fixed: [] };
|
||||
|
||||
appliedUpdates.forEach((u: Update) => {
|
||||
if (u.changes) {
|
||||
appliedChanges.new.push(...u.changes.new);
|
||||
appliedChanges.fixed.push(...u.changes.fixed);
|
||||
}
|
||||
});
|
||||
|
||||
const mergedUpdate: Update = Object.assign({}, appliedUpdates[0], {
|
||||
changes: appliedChanges,
|
||||
});
|
||||
|
||||
if (!appliedChanges.new.length && !appliedChanges.fixed.length) {
|
||||
mergedUpdate.changes = null;
|
||||
}
|
||||
|
||||
return mergedUpdate;
|
||||
}
|
||||
|
||||
interface AppUpdatedModalContentProps {
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function AppUpdatedModalContent(props: AppUpdatedModalContentProps) {
|
||||
const dispatch = useDispatch();
|
||||
const { version, prevVersion } = useSelector((state: AppState) => state.app);
|
||||
const { isPopulated, error, items } = useSelector(
|
||||
(state: AppState) => state.system.updates
|
||||
);
|
||||
const previousVersion = usePrevious(version);
|
||||
|
||||
const { onModalClose } = props;
|
||||
|
||||
const update = mergeUpdates(items, version, prevVersion);
|
||||
|
||||
const handleSeeChangesPress = useCallback(() => {
|
||||
window.location.href = `${window.Radarr.urlBase}/system/updates`;
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchUpdates());
|
||||
}, [dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (version !== previousVersion) {
|
||||
dispatch(fetchUpdates());
|
||||
}
|
||||
}, [version, previousVersion, dispatch]);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('AppUpdated')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div>
|
||||
<InlineMarkdown
|
||||
data={translate('AppUpdatedVersion', { version })}
|
||||
blockClassName={styles.version}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{isPopulated && !error && !!update ? (
|
||||
<div>
|
||||
{update.changes ? (
|
||||
<div className={styles.maintenance}>
|
||||
{translate('MaintenanceRelease')}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{update.changes ? (
|
||||
<div>
|
||||
<div className={styles.changes}>{translate('WhatsNew')}</div>
|
||||
|
||||
<UpdateChanges
|
||||
title={translate('New')}
|
||||
changes={update.changes.new}
|
||||
/>
|
||||
|
||||
<UpdateChanges
|
||||
title={translate('Fixed')}
|
||||
changes={update.changes.fixed}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPopulated && !error ? <LoadingIndicator /> : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button onPress={handleSeeChangesPress}>
|
||||
{translate('RecentChanges')}
|
||||
</Button>
|
||||
|
||||
<Button kind={kinds.PRIMARY} onPress={onModalClose}>
|
||||
{translate('Reload')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppUpdatedModalContent;
|
||||
@@ -1,78 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { fetchUpdates } from 'Store/Actions/systemActions';
|
||||
import AppUpdatedModalContent from './AppUpdatedModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.app.version,
|
||||
(state) => state.app.prevVersion,
|
||||
(state) => state.system.updates,
|
||||
(version, prevVersion, updates) => {
|
||||
const {
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
} = updates;
|
||||
|
||||
return {
|
||||
version,
|
||||
prevVersion,
|
||||
isPopulated,
|
||||
error,
|
||||
items
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchFetchUpdates() {
|
||||
dispatch(fetchUpdates());
|
||||
},
|
||||
|
||||
onSeeChangesPress() {
|
||||
window.location = `${window.Radarr.urlBase}/system/updates`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class AppUpdatedModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatchFetchUpdates();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.version !== this.props.version) {
|
||||
this.props.dispatchFetchUpdates();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchFetchUpdates,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<AppUpdatedModalContent {...otherProps} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AppUpdatedModalContentConnector.propTypes = {
|
||||
version: PropTypes.string.isRequired,
|
||||
dispatchFetchUpdates: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(AppUpdatedModalContentConnector);
|
||||
@@ -1,5 +1,4 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
@@ -10,36 +9,31 @@ import { kinds } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './ConnectionLostModal.css';
|
||||
|
||||
function ConnectionLostModal(props) {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose
|
||||
} = props;
|
||||
interface ConnectionLostModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
function ConnectionLostModal(props: ConnectionLostModalProps) {
|
||||
const { isOpen } = props;
|
||||
|
||||
const handleModalClose = useCallback(() => {
|
||||
location.reload();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
{translate('ConnectionLost')}
|
||||
</ModalHeader>
|
||||
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
|
||||
<ModalContent onModalClose={handleModalClose}>
|
||||
<ModalHeader>{translate('ConnectionLost')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<div>
|
||||
{translate('ConnectionLostToBackend')}
|
||||
</div>
|
||||
<div>{translate('ConnectionLostToBackend')}</div>
|
||||
|
||||
<div className={styles.automatic}>
|
||||
{translate('ConnectionLostReconnect')}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
kind={kinds.PRIMARY}
|
||||
onPress={onModalClose}
|
||||
>
|
||||
<Button kind={kinds.PRIMARY} onPress={handleModalClose}>
|
||||
{translate('Reload')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
@@ -48,9 +42,4 @@ function ConnectionLostModal(props) {
|
||||
);
|
||||
}
|
||||
|
||||
ConnectionLostModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default ConnectionLostModal;
|
||||
@@ -1,12 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import ConnectionLostModal from './ConnectionLostModal';
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onModalClose() {
|
||||
location.reload();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(undefined, createMapDispatchToProps)(ConnectionLostModal);
|
||||
@@ -1,5 +1,5 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
|
||||
@@ -8,6 +8,7 @@ import MovieCreditAppState from './MovieCreditAppState';
|
||||
import MovieFilesAppState from './MovieFilesAppState';
|
||||
import MoviesAppState, { MovieIndexAppState } from './MoviesAppState';
|
||||
import ParseAppState from './ParseAppState';
|
||||
import PathsAppState from './PathsAppState';
|
||||
import QueueAppState from './QueueAppState';
|
||||
import RootFolderAppState from './RootFolderAppState';
|
||||
import SettingsAppState from './SettingsAppState';
|
||||
@@ -50,6 +51,7 @@ export interface AppSectionState {
|
||||
isConnected: boolean;
|
||||
isReconnecting: boolean;
|
||||
version: string;
|
||||
prevVersion?: string;
|
||||
dimensions: {
|
||||
isSmallScreen: boolean;
|
||||
width: number;
|
||||
@@ -70,6 +72,7 @@ interface AppState {
|
||||
movieIndex: MovieIndexAppState;
|
||||
movies: MoviesAppState;
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
queue: QueueAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
settings: SettingsAppState;
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import AppSectionState, {
|
||||
AppSectionFilterState,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import History from 'typings/History';
|
||||
|
||||
interface HistoryAppState
|
||||
extends AppSectionState<History>,
|
||||
AppSectionFilterState<History> {}
|
||||
AppSectionFilterState<History>,
|
||||
PagedAppSectionState,
|
||||
TableAppSectionState {}
|
||||
|
||||
export default HistoryAppState;
|
||||
|
||||
@@ -3,7 +3,7 @@ import AppSectionState, {
|
||||
AppSectionSaveState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import Movie from 'Movie/Movie';
|
||||
import { Filter, FilterBuilderProp } from './AppState';
|
||||
|
||||
|
||||
29
frontend/src/App/State/PathsAppState.ts
Normal file
29
frontend/src/App/State/PathsAppState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
interface BasePath {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
interface File extends BasePath {
|
||||
type: 'file';
|
||||
}
|
||||
|
||||
interface Folder extends BasePath {
|
||||
type: 'folder';
|
||||
}
|
||||
|
||||
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
|
||||
export type Path = File | Folder;
|
||||
|
||||
interface PathsAppState {
|
||||
currentPath: string;
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
directories: Folder[];
|
||||
files: File[];
|
||||
parent: string | null;
|
||||
}
|
||||
|
||||
export default PathsAppState;
|
||||
@@ -1,34 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './Alert.css';
|
||||
|
||||
function Alert(props) {
|
||||
const { className, kind, children, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Alert.propTypes = {
|
||||
className: PropTypes.string,
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
Alert.defaultProps = {
|
||||
className: styles.alert,
|
||||
kind: kinds.INFO
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
18
frontend/src/Components/Alert.tsx
Normal file
18
frontend/src/Components/Alert.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import styles from './Alert.css';
|
||||
|
||||
interface AlertProps {
|
||||
className?: string;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Alert(props: AlertProps) {
|
||||
const { className = styles.alert, kind = 'info', children } = props;
|
||||
|
||||
return <div className={classNames(className, styles[kind])}>{children}</div>;
|
||||
}
|
||||
|
||||
export default Alert;
|
||||
@@ -1,60 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './Card.css';
|
||||
|
||||
class Card extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
overlayClassName,
|
||||
overlayContent,
|
||||
children,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
if (overlayContent) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={onPress}
|
||||
/>
|
||||
|
||||
<div className={overlayClassName}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={className}
|
||||
onPress={onPress}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Card.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
overlayClassName: PropTypes.string.isRequired,
|
||||
overlayContent: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
Card.defaultProps = {
|
||||
className: styles.card,
|
||||
overlayClassName: styles.overlay,
|
||||
overlayContent: false
|
||||
};
|
||||
|
||||
export default Card;
|
||||
39
frontend/src/Components/Card.tsx
Normal file
39
frontend/src/Components/Card.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import Link, { LinkProps } from 'Components/Link/Link';
|
||||
import styles from './Card.css';
|
||||
|
||||
interface CardProps extends Pick<LinkProps, 'onPress'> {
|
||||
// TODO: Consider using different properties for classname depending if it's overlaying content or not
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
overlayContent?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Card(props: CardProps) {
|
||||
const {
|
||||
className = styles.card,
|
||||
overlayClassName = styles.overlay,
|
||||
overlayContent = false,
|
||||
children,
|
||||
onPress,
|
||||
} = props;
|
||||
|
||||
if (overlayContent) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link className={styles.underlay} onPress={onPress} />
|
||||
|
||||
<div className={overlayClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={className} onPress={onPress}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -1,33 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './DescriptionList.css';
|
||||
|
||||
class DescriptionList extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<dl className={className}>
|
||||
{children}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionList.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
DescriptionList.defaultProps = {
|
||||
className: styles.descriptionList
|
||||
};
|
||||
|
||||
export default DescriptionList;
|
||||
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import styles from './DescriptionList.css';
|
||||
|
||||
interface DescriptionListProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionList(props: DescriptionListProps) {
|
||||
const { className = styles.descriptionList, children } = props;
|
||||
|
||||
return <dl className={className}>{children}</dl>;
|
||||
}
|
||||
|
||||
export default DescriptionList;
|
||||
@@ -1,46 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionListItemDescription from './DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle from './DescriptionListItemTitle';
|
||||
|
||||
class DescriptionListItem extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
title,
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DescriptionListItemTitle
|
||||
className={titleClassName}
|
||||
>
|
||||
{title}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription
|
||||
className={descriptionClassName}
|
||||
>
|
||||
{data}
|
||||
</DescriptionListItemDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionListItem.propTypes = {
|
||||
className: PropTypes.string,
|
||||
titleClassName: PropTypes.string,
|
||||
descriptionClassName: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
export default DescriptionListItem;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import DescriptionListItemDescription, {
|
||||
DescriptionListItemDescriptionProps,
|
||||
} from './DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle, {
|
||||
DescriptionListItemTitleProps,
|
||||
} from './DescriptionListItemTitle';
|
||||
|
||||
interface DescriptionListItemProps {
|
||||
className?: string;
|
||||
titleClassName?: DescriptionListItemTitleProps['className'];
|
||||
descriptionClassName?: DescriptionListItemDescriptionProps['className'];
|
||||
title?: DescriptionListItemTitleProps['children'];
|
||||
data?: DescriptionListItemDescriptionProps['children'];
|
||||
}
|
||||
|
||||
function DescriptionListItem(props: DescriptionListItemProps) {
|
||||
const { className, titleClassName, descriptionClassName, title, data } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DescriptionListItemTitle className={titleClassName}>
|
||||
{title}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription className={descriptionClassName}>
|
||||
{data}
|
||||
</DescriptionListItemDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DescriptionListItem;
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemDescription.css';
|
||||
|
||||
function DescriptionListItemDescription(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dd className={className}>
|
||||
{children}
|
||||
</dd>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemDescription.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
DescriptionListItemDescription.defaultProps = {
|
||||
className: styles.description
|
||||
};
|
||||
|
||||
export default DescriptionListItemDescription;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './DescriptionListItemDescription.css';
|
||||
|
||||
export interface DescriptionListItemDescriptionProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionListItemDescription(
|
||||
props: DescriptionListItemDescriptionProps
|
||||
) {
|
||||
const { className = styles.description, children } = props;
|
||||
|
||||
return <dd className={className}>{children}</dd>;
|
||||
}
|
||||
|
||||
export default DescriptionListItemDescription;
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemTitle.css';
|
||||
|
||||
function DescriptionListItemTitle(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dt className={className}>
|
||||
{children}
|
||||
</dt>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemTitle.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.string
|
||||
};
|
||||
|
||||
DescriptionListItemTitle.defaultProps = {
|
||||
className: styles.title
|
||||
};
|
||||
|
||||
export default DescriptionListItemTitle;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './DescriptionListItemTitle.css';
|
||||
|
||||
export interface DescriptionListItemTitleProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionListItemTitle(props: DescriptionListItemTitleProps) {
|
||||
const { className = styles.title, children } = props;
|
||||
|
||||
return <dt className={className}>{children}</dt>;
|
||||
}
|
||||
|
||||
export default DescriptionListItemTitle;
|
||||
@@ -1,22 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
function DragPreviewLayer({ children, ...otherProps }) {
|
||||
return (
|
||||
<div {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DragPreviewLayer.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
DragPreviewLayer.defaultProps = {
|
||||
className: styles.dragLayer
|
||||
};
|
||||
|
||||
export default DragPreviewLayer;
|
||||
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
interface DragPreviewLayerProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DragPreviewLayer({
|
||||
className = styles.dragLayer,
|
||||
children,
|
||||
...otherProps
|
||||
}: DragPreviewLayerProps) {
|
||||
return (
|
||||
<div className={className} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragPreviewLayer;
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as sentry from '@sentry/browser';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
info: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
error,
|
||||
info
|
||||
});
|
||||
|
||||
sentry.captureException(error);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
errorComponent: ErrorComponent,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
error,
|
||||
info
|
||||
} = this.state;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorComponent
|
||||
error={error}
|
||||
info={info}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
errorComponent: PropTypes.elementType.isRequired
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as sentry from '@sentry/browser';
|
||||
import React, { Component, ErrorInfo } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
errorComponent: React.ElementType;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null;
|
||||
info: ErrorInfo | null;
|
||||
}
|
||||
|
||||
// Class component until componentDidCatch is supported in functional components
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
info: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
info,
|
||||
});
|
||||
|
||||
sentry.captureException(error);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, errorComponent: ErrorComponent } = this.props;
|
||||
const { error, info } = this.state;
|
||||
|
||||
if (error) {
|
||||
return <ErrorComponent error={error} info={info} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,41 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import styles from './FieldSet.css';
|
||||
|
||||
class FieldSet extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
size,
|
||||
legend,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<fieldset className={styles.fieldSet}>
|
||||
<legend className={classNames(styles.legend, (size === sizes.SMALL) && styles.small)}>
|
||||
{legend}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FieldSet.propTypes = {
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
FieldSet.defaultProps = {
|
||||
size: sizes.MEDIUM
|
||||
};
|
||||
|
||||
export default FieldSet;
|
||||
29
frontend/src/Components/FieldSet.tsx
Normal file
29
frontend/src/Components/FieldSet.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import styles from './FieldSet.css';
|
||||
|
||||
interface FieldSetProps {
|
||||
size?: Size;
|
||||
legend?: ComponentProps<'legend'>['children'];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) {
|
||||
return (
|
||||
<fieldset className={styles.fieldSet}>
|
||||
<legend
|
||||
className={classNames(
|
||||
styles.legend,
|
||||
size === sizes.SMALL && styles.small
|
||||
)}
|
||||
>
|
||||
{legend}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldSet;
|
||||
@@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
|
||||
import styles from './FileBrowserModal.css';
|
||||
|
||||
class FileBrowserModal extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={styles.modal}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<FileBrowserModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserModal;
|
||||
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import FileBrowserModalContent, {
|
||||
FileBrowserModalContentProps,
|
||||
} from './FileBrowserModalContent';
|
||||
import styles from './FileBrowserModal.css';
|
||||
|
||||
interface FileBrowserModalProps extends FileBrowserModalContentProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
const { isOpen, onModalClose, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<FileBrowserModalContent {...otherProps} onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserModal;
|
||||
@@ -1,250 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import PathInput from 'Components/Form/PathInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import Link from 'Components/Link/Link';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
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 Scroller from 'Components/Scroller/Scroller';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import { kinds, scrollDirections } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import FileBrowserRow from './FileBrowserRow';
|
||||
import styles from './FileBrowserModalContent.css';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'type',
|
||||
label: () => translate('Type'),
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
class FileBrowserModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scrollerRef = React.createRef();
|
||||
|
||||
this.state = {
|
||||
isFileBrowserModalOpen: false,
|
||||
currentPath: props.value
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
const {
|
||||
currentPath
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
currentPath !== this.state.currentPath &&
|
||||
currentPath !== prevState.currentPath
|
||||
) {
|
||||
this.setState({ currentPath });
|
||||
this._scrollerRef.current.scrollTop = 0;
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPathInputChange = ({ value }) => {
|
||||
this.setState({ currentPath: value });
|
||||
};
|
||||
|
||||
onRowPress = (path) => {
|
||||
this.props.onFetchPaths(path);
|
||||
};
|
||||
|
||||
onOkPress = () => {
|
||||
this.props.onChange({
|
||||
name: this.props.name,
|
||||
value: this.state.currentPath
|
||||
});
|
||||
|
||||
this.props.onClearPaths();
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
directories,
|
||||
files,
|
||||
isWindowsService,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const emptyParent = parent === '';
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
File Browser
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
{
|
||||
isWindowsService &&
|
||||
<Alert
|
||||
className={styles.mappedDrivesWarning}
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
<Link to="https://wiki.servarr.com/radarr/faq#why-cant-radarr-see-my-files-on-a-remote-server">
|
||||
{translate('MappedDrivesRunningAsService')}
|
||||
</Link> .
|
||||
</Alert>
|
||||
}
|
||||
|
||||
<PathInput
|
||||
className={styles.pathInput}
|
||||
placeholder={translate('StartTypingOrSelectAPathBelow')}
|
||||
hasFileBrowser={false}
|
||||
{...otherProps}
|
||||
value={this.state.currentPath}
|
||||
onChange={this.onPathInputChange}
|
||||
/>
|
||||
|
||||
<Scroller
|
||||
ref={this._scrollerRef}
|
||||
className={styles.scroller}
|
||||
scrollDirection={scrollDirections.BOTH}
|
||||
>
|
||||
{
|
||||
!!error &&
|
||||
<div>
|
||||
{translate('ErrorLoadingContents')}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
isPopulated && !error &&
|
||||
<Table
|
||||
horizontalScroll={false}
|
||||
columns={columns}
|
||||
>
|
||||
<TableBody>
|
||||
{
|
||||
emptyParent &&
|
||||
<FileBrowserRow
|
||||
type="computer"
|
||||
name="My Computer"
|
||||
path={parent}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!emptyParent && parent &&
|
||||
<FileBrowserRow
|
||||
type="parent"
|
||||
name="..."
|
||||
path={parent}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
directories.map((directory) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={directory.path}
|
||||
type={directory.type}
|
||||
name={directory.name}
|
||||
path={directory.path}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
files.map((file) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={file.path}
|
||||
type={file.type}
|
||||
name={file.name}
|
||||
path={file.path}
|
||||
onPress={this.onRowPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
isFetching &&
|
||||
<LoadingIndicator
|
||||
className={styles.loading}
|
||||
size={20}
|
||||
/>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Cancel')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
onPress={this.onOkPress}
|
||||
>
|
||||
{translate('Ok')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModalContent.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
value: PropTypes.string.isRequired,
|
||||
isFetching: PropTypes.bool.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
parent: PropTypes.string,
|
||||
currentPath: PropTypes.string.isRequired,
|
||||
directories: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
files: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
isWindowsService: PropTypes.bool.isRequired,
|
||||
onFetchPaths: PropTypes.func.isRequired,
|
||||
onClearPaths: PropTypes.func.isRequired,
|
||||
onChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserModalContent;
|
||||
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import PathInput from 'Components/Form/PathInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds, scrollDirections } from 'Helpers/Props';
|
||||
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createPathsSelector from './createPathsSelector';
|
||||
import FileBrowserRow from './FileBrowserRow';
|
||||
import styles from './FileBrowserModalContent.css';
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
name: 'type',
|
||||
label: () => translate('Type'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleClearPaths = () => {};
|
||||
|
||||
export interface FileBrowserModalContentProps {
|
||||
name: string;
|
||||
value: string;
|
||||
includeFiles?: boolean;
|
||||
onChange: (args: InputChanged<string>) => unknown;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function FileBrowserModalContent(props: FileBrowserModalContentProps) {
|
||||
const { name, value, includeFiles = true, onChange, onModalClose } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isWindows, mode } = useSelector(createSystemStatusSelector());
|
||||
const { isFetching, isPopulated, error, parent, directories, files, paths } =
|
||||
useSelector(createPathsSelector());
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(value);
|
||||
const scrollerRef = useRef(null);
|
||||
const previousValue = usePrevious(value);
|
||||
|
||||
const emptyParent = parent === '';
|
||||
const isWindowsService = isWindows && mode === 'service';
|
||||
|
||||
const handlePathInputChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
setCurrentPath(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRowPress = useCallback(
|
||||
(path: string) => {
|
||||
setCurrentPath(path);
|
||||
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
},
|
||||
[includeFiles, dispatch, setCurrentPath]
|
||||
);
|
||||
|
||||
const handleOkPress = useCallback(() => {
|
||||
onChange({
|
||||
name,
|
||||
value: currentPath,
|
||||
});
|
||||
|
||||
dispatch(clearPaths());
|
||||
onModalClose();
|
||||
}, [name, currentPath, dispatch, onChange, onModalClose]);
|
||||
|
||||
const handleFetchPaths = useCallback(
|
||||
(path: string) => {
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
},
|
||||
[includeFiles, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== previousValue && value !== currentPath) {
|
||||
setCurrentPath(value);
|
||||
}
|
||||
}, [value, previousValue, currentPath, setCurrentPath]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path: currentPath,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
dispatch(clearPaths());
|
||||
};
|
||||
},
|
||||
// This should only run once when the component mounts,
|
||||
// so we don't need to include the other dependencies.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('FileBrowser')}</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
{isWindowsService ? (
|
||||
<Alert className={styles.mappedDrivesWarning} kind={kinds.WARNING}>
|
||||
<InlineMarkdown
|
||||
data={translate('MappedNetworkDrivesWindowsService', {
|
||||
url: 'https://wiki.servarr.com/radarr/faq#why-cant-radarr-see-my-files-on-a-remote-server',
|
||||
})}
|
||||
/>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<PathInput
|
||||
className={styles.pathInput}
|
||||
placeholder={translate('FileBrowserPlaceholderText')}
|
||||
hasFileBrowser={false}
|
||||
includeFiles={includeFiles}
|
||||
paths={paths}
|
||||
name={name}
|
||||
value={currentPath}
|
||||
onChange={handlePathInputChange}
|
||||
onFetchPaths={handleFetchPaths}
|
||||
onClearPaths={handleClearPaths}
|
||||
/>
|
||||
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.scroller}
|
||||
scrollDirection="both"
|
||||
>
|
||||
{error ? <div>{translate('ErrorLoadingContents')}</div> : null}
|
||||
|
||||
{isPopulated && !error ? (
|
||||
<Table horizontalScroll={false} columns={columns}>
|
||||
<TableBody>
|
||||
{emptyParent ? (
|
||||
<FileBrowserRow
|
||||
type="computer"
|
||||
name={translate('MyComputer')}
|
||||
path={parent}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!emptyParent && parent ? (
|
||||
<FileBrowserRow
|
||||
type="parent"
|
||||
name="..."
|
||||
path={parent}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{directories.map((directory) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={directory.path}
|
||||
type={directory.type}
|
||||
name={directory.name}
|
||||
path={directory.path}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{files.map((file) => {
|
||||
return (
|
||||
<FileBrowserRow
|
||||
key={file.path}
|
||||
type={file.type}
|
||||
name={file.name}
|
||||
path={file.path}
|
||||
onPress={handleRowPress}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</Scroller>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{isFetching ? (
|
||||
<LoadingIndicator className={styles.loading} size={20} />
|
||||
) : null}
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Cancel')}</Button>
|
||||
|
||||
<Button onPress={handleOkPress}>{translate('Ok')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserModalContent;
|
||||
@@ -1,119 +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 { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import FileBrowserModalContent from './FileBrowserModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.paths,
|
||||
createSystemStatusSelector(),
|
||||
(paths, systemStatus) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files
|
||||
} = paths;
|
||||
|
||||
const filteredPaths = _.filter([...directories, ...files], ({ path }) => {
|
||||
return path.toLowerCase().startsWith(currentPath.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
paths: filteredPaths,
|
||||
isWindowsService: systemStatus.isWindows && systemStatus.mode === 'service'
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchFetchPaths: fetchPaths,
|
||||
dispatchClearPaths: clearPaths
|
||||
};
|
||||
|
||||
class FileBrowserModalContentConnector extends Component {
|
||||
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
value,
|
||||
includeFiles,
|
||||
dispatchFetchPaths
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchPaths({
|
||||
path: value,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onFetchPaths = (path) => {
|
||||
const {
|
||||
includeFiles,
|
||||
dispatchFetchPaths
|
||||
} = this.props;
|
||||
|
||||
dispatchFetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles
|
||||
});
|
||||
};
|
||||
|
||||
onClearPaths = () => {
|
||||
// this.props.dispatchClearPaths();
|
||||
};
|
||||
|
||||
onModalClose = () => {
|
||||
this.props.dispatchClearPaths();
|
||||
this.props.onModalClose();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<FileBrowserModalContent
|
||||
onFetchPaths={this.onFetchPaths}
|
||||
onClearPaths={this.onClearPaths}
|
||||
{...this.props}
|
||||
onModalClose={this.onModalClose}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModalContentConnector.propTypes = {
|
||||
value: PropTypes.string,
|
||||
includeFiles: PropTypes.bool.isRequired,
|
||||
dispatchFetchPaths: PropTypes.func.isRequired,
|
||||
dispatchClearPaths: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
FileBrowserModalContentConnector.defaultProps = {
|
||||
includeFiles: false
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(FileBrowserModalContentConnector);
|
||||
@@ -1,62 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './FileBrowserRow.css';
|
||||
|
||||
function getIconName(type) {
|
||||
switch (type) {
|
||||
case 'computer':
|
||||
return icons.COMPUTER;
|
||||
case 'drive':
|
||||
return icons.DRIVE;
|
||||
case 'file':
|
||||
return icons.FILE;
|
||||
case 'parent':
|
||||
return icons.PARENT;
|
||||
default:
|
||||
return icons.FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
class FileBrowserRow extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
this.props.onPress(this.props.path);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
type,
|
||||
name
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={this.onPress}>
|
||||
<TableRowCell className={styles.type}>
|
||||
<Icon name={getIconName(type)} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>{name}</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FileBrowserRow.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
path: PropTypes.string.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserRow;
|
||||
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { PathType } from 'App/State/PathsAppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './FileBrowserRow.css';
|
||||
|
||||
function getIconName(type: PathType) {
|
||||
switch (type) {
|
||||
case 'computer':
|
||||
return icons.COMPUTER;
|
||||
case 'drive':
|
||||
return icons.DRIVE;
|
||||
case 'file':
|
||||
return icons.FILE;
|
||||
case 'parent':
|
||||
return icons.PARENT;
|
||||
default:
|
||||
return icons.FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
interface FileBrowserRowProps {
|
||||
type: PathType;
|
||||
name: string;
|
||||
path: string;
|
||||
onPress: (path: string) => void;
|
||||
}
|
||||
|
||||
function FileBrowserRow(props: FileBrowserRowProps) {
|
||||
const { type, name, path, onPress } = props;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(path);
|
||||
}, [path, onPress]);
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={handlePress}>
|
||||
<TableRowCell className={styles.type}>
|
||||
<Icon name={getIconName(type)} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>{name}</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserRow;
|
||||
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createPathsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.paths,
|
||||
(paths) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
} = paths;
|
||||
|
||||
const filteredPaths = [...directories, ...files].filter(({ path }) => {
|
||||
return path.toLowerCase().startsWith(currentPath.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
paths: filteredPaths,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createPathsSelector;
|
||||
@@ -1,51 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './LoadingIndicator.css';
|
||||
|
||||
function LoadingIndicator({ className, rippleClassName, size }) {
|
||||
const sizeInPx = `${size}px`;
|
||||
const width = sizeInPx;
|
||||
const height = sizeInPx;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={className}
|
||||
style={{ height }}
|
||||
>
|
||||
<div
|
||||
className={classNames(styles.rippleContainer, 'followingBalls')}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div
|
||||
className={rippleClassName}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={rippleClassName}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={rippleClassName}
|
||||
style={{ width, height }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
LoadingIndicator.propTypes = {
|
||||
className: PropTypes.string,
|
||||
rippleClassName: PropTypes.string,
|
||||
size: PropTypes.number
|
||||
};
|
||||
|
||||
LoadingIndicator.defaultProps = {
|
||||
className: styles.loading,
|
||||
rippleClassName: styles.ripple,
|
||||
size: 50
|
||||
};
|
||||
|
||||
export default LoadingIndicator;
|
||||
36
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
36
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import styles from './LoadingIndicator.css';
|
||||
|
||||
interface LoadingIndicatorProps {
|
||||
className?: string;
|
||||
rippleClassName?: string;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
function LoadingIndicator({
|
||||
className = styles.loading,
|
||||
rippleClassName = styles.ripple,
|
||||
size = 50,
|
||||
}: LoadingIndicatorProps) {
|
||||
const sizeInPx = `${size}px`;
|
||||
const width = sizeInPx;
|
||||
const height = sizeInPx;
|
||||
|
||||
return (
|
||||
<div className={className} style={{ height }}>
|
||||
<div
|
||||
className={classNames(styles.rippleContainer, 'followingBalls')}
|
||||
style={{ width, height }}
|
||||
>
|
||||
<div className={rippleClassName} style={{ width, height }} />
|
||||
|
||||
<div className={rippleClassName} style={{ width, height }} />
|
||||
|
||||
<div className={rippleClassName} style={{ width, height }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingIndicator;
|
||||
@@ -8,21 +8,21 @@ const messages = [
|
||||
'Bleep Bloop.',
|
||||
'Locating the required gigapixels to render...',
|
||||
'Spinning up the hamster wheel...',
|
||||
'At least you\'re not on hold',
|
||||
"At least you're not on hold",
|
||||
'Hum something loud while others stare',
|
||||
'Loading humorous message... Please Wait',
|
||||
'I could\'ve been faster in Python',
|
||||
'Don\'t forget to rewind your movies',
|
||||
"I could've been faster in Python",
|
||||
"Don't forget to rewind your movies",
|
||||
'Congratulations! You are the 1000th visitor.',
|
||||
'HELP! I\'m being held hostage and forced to write these stupid lines!',
|
||||
"HELP! I'm being held hostage and forced to write these stupid lines!",
|
||||
'RE-calibrating the internet...',
|
||||
'I\'ll be here all week',
|
||||
'Don\'t forget to tip your waitress',
|
||||
"I'll be here all week",
|
||||
"Don't forget to tip your waitress",
|
||||
'Apply directly to the forehead',
|
||||
'Loading Battlestation'
|
||||
'Loading Battlestation',
|
||||
];
|
||||
|
||||
let message = null;
|
||||
let message: string | null = null;
|
||||
|
||||
function LoadingMessage() {
|
||||
if (!message) {
|
||||
@@ -30,11 +30,7 @@ function LoadingMessage() {
|
||||
message = messages[index];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.loadingMessage}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
return <div className={styles.loadingMessage}>{message}</div>;
|
||||
}
|
||||
|
||||
export default LoadingMessage;
|
||||
@@ -1,74 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
|
||||
class InlineMarkdown extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
data,
|
||||
blockClassName
|
||||
} = this.props;
|
||||
|
||||
// For now only replace links or code blocks (not both)
|
||||
const markdownBlocks = [];
|
||||
if (data) {
|
||||
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
|
||||
|
||||
let endIndex = 0;
|
||||
let match = null;
|
||||
|
||||
while ((match = linkRegex.exec(data)) !== null) {
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(<Link key={match.index} to={match[2]}>{match[1]}</Link>);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
|
||||
|
||||
endIndex = 0;
|
||||
match = null;
|
||||
let matchedCode = false;
|
||||
|
||||
while ((match = codeRegex.exec(data)) !== null) {
|
||||
matchedCode = true;
|
||||
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(<code key={`code-${match.index}`} className={blockClassName ?? null}>{match[0].substring(1, match[0].length - 1)}</code>);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
if (markdownBlocks.length === 0) {
|
||||
markdownBlocks.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
return <span className={className}>{markdownBlocks}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
InlineMarkdown.propTypes = {
|
||||
className: PropTypes.string,
|
||||
data: PropTypes.string,
|
||||
blockClassName: PropTypes.string
|
||||
};
|
||||
|
||||
export default InlineMarkdown;
|
||||
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
|
||||
interface InlineMarkdownProps {
|
||||
className?: string;
|
||||
data?: string;
|
||||
blockClassName?: string;
|
||||
}
|
||||
|
||||
function InlineMarkdown(props: InlineMarkdownProps) {
|
||||
const { className, data, blockClassName } = props;
|
||||
|
||||
// For now only replace links or code blocks (not both)
|
||||
const markdownBlocks: (ReactElement | string)[] = [];
|
||||
|
||||
if (data) {
|
||||
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
|
||||
|
||||
let endIndex = 0;
|
||||
let match = null;
|
||||
|
||||
while ((match = linkRegex.exec(data)) !== null) {
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(
|
||||
<Link key={match.index} to={match[2]}>
|
||||
{match[1]}
|
||||
</Link>
|
||||
);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
|
||||
|
||||
endIndex = 0;
|
||||
match = null;
|
||||
let matchedCode = false;
|
||||
|
||||
while ((match = codeRegex.exec(data)) !== null) {
|
||||
matchedCode = true;
|
||||
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(
|
||||
<code
|
||||
key={`code-${match.index}`}
|
||||
className={blockClassName ?? undefined}
|
||||
>
|
||||
{match[0].substring(1, match[0].length - 1)}
|
||||
</code>
|
||||
);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
if (markdownBlocks.length === 0) {
|
||||
markdownBlocks.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
return <span className={className}>{markdownBlocks}</span>;
|
||||
}
|
||||
|
||||
export default InlineMarkdown;
|
||||
@@ -1,79 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './MonitorToggleButton.css';
|
||||
|
||||
function getTooltip(monitored, isDisabled) {
|
||||
if (isDisabled) {
|
||||
return 'Cannot toggle monitored state when movie is unmonitored';
|
||||
}
|
||||
|
||||
if (monitored) {
|
||||
return 'Monitored, click to unmonitor';
|
||||
}
|
||||
|
||||
return 'Unmonitored, click to monitor';
|
||||
}
|
||||
|
||||
class MonitorToggleButton extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = (event) => {
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
|
||||
this.props.onPress(!this.props.monitored, { shiftKey });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
monitored,
|
||||
isDisabled,
|
||||
isSaving,
|
||||
size,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
|
||||
|
||||
return (
|
||||
<SpinnerIconButton
|
||||
className={classNames(
|
||||
className,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
name={iconName}
|
||||
size={size}
|
||||
title={getTooltip(monitored, isDisabled)}
|
||||
isDisabled={isDisabled}
|
||||
isSpinning={isSaving}
|
||||
{...otherProps}
|
||||
onPress={this.onPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MonitorToggleButton.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
size: PropTypes.number,
|
||||
isDisabled: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
MonitorToggleButton.defaultProps = {
|
||||
className: styles.toggleButton,
|
||||
isDisabled: false,
|
||||
isSaving: false
|
||||
};
|
||||
|
||||
export default MonitorToggleButton;
|
||||
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { SyntheticEvent, useCallback, useMemo } from 'react';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './MonitorToggleButton.css';
|
||||
|
||||
interface MonitorToggleButtonProps {
|
||||
className?: string;
|
||||
monitored: boolean;
|
||||
size?: number;
|
||||
isDisabled?: boolean;
|
||||
isSaving?: boolean;
|
||||
onPress: (value: boolean, options: { shiftKey: boolean }) => unknown;
|
||||
}
|
||||
|
||||
function MonitorToggleButton(props: MonitorToggleButtonProps) {
|
||||
const {
|
||||
className = styles.toggleButton,
|
||||
monitored,
|
||||
isDisabled = false,
|
||||
isSaving = false,
|
||||
size,
|
||||
onPress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (isDisabled) {
|
||||
return 'Cannot toggle monitored state when movie is unmonitored';
|
||||
}
|
||||
|
||||
if (monitored) {
|
||||
return translate('ToggleMonitoredToUnmonitored');
|
||||
}
|
||||
|
||||
return translate('ToggleUnmonitoredToMonitored');
|
||||
}, [monitored, isDisabled]);
|
||||
|
||||
const handlePress = useCallback(
|
||||
(event: SyntheticEvent<HTMLLinkElement, MouseEvent>) => {
|
||||
const shiftKey = event.nativeEvent.shiftKey;
|
||||
|
||||
onPress(!monitored, { shiftKey });
|
||||
},
|
||||
[monitored, onPress]
|
||||
);
|
||||
|
||||
return (
|
||||
<SpinnerIconButton
|
||||
className={classNames(className, isDisabled && styles.isDisabled)}
|
||||
name={iconName}
|
||||
size={size}
|
||||
title={title}
|
||||
isDisabled={isDisabled}
|
||||
isSpinning={isSaving}
|
||||
{...otherProps}
|
||||
onPress={handlePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default MonitorToggleButton;
|
||||
@@ -1,16 +1,19 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import PageContent from 'Components/Page/PageContent';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './NotFound.css';
|
||||
|
||||
function NotFound({ message }) {
|
||||
interface NotFoundProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function NotFound(props: NotFoundProps) {
|
||||
const { message = translate('DefaultNotFoundMessage') } = props;
|
||||
|
||||
return (
|
||||
<PageContent title={translate('MIA')}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.message}>
|
||||
{message}
|
||||
</div>
|
||||
<div className={styles.message}>{message}</div>
|
||||
|
||||
<img
|
||||
className={styles.image}
|
||||
@@ -21,12 +24,4 @@ function NotFound({ message }) {
|
||||
);
|
||||
}
|
||||
|
||||
NotFound.propTypes = {
|
||||
message: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
NotFound.defaultProps = {
|
||||
message: 'You must be lost, nothing to see here.'
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -1,8 +1,8 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
|
||||
import AppUpdatedModal from 'App/AppUpdatedModal';
|
||||
import ColorImpairedContext from 'App/ColorImpairedContext';
|
||||
import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
|
||||
import ConnectionLostModal from 'App/ConnectionLostModal';
|
||||
import SignalRConnector from 'Components/SignalRConnector';
|
||||
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
|
||||
import locationShape from 'Helpers/Props/Shapes/locationShape';
|
||||
@@ -102,12 +102,12 @@ class Page extends Component {
|
||||
{children}
|
||||
</div>
|
||||
|
||||
<AppUpdatedModalConnector
|
||||
<AppUpdatedModal
|
||||
isOpen={this.state.isUpdatedModalOpen}
|
||||
onModalClose={this.onUpdatedModalClose}
|
||||
/>
|
||||
|
||||
<ConnectionLostModalConnector
|
||||
<ConnectionLostModal
|
||||
isOpen={this.state.isConnectionLostModalOpen}
|
||||
onModalClose={this.onConnectionLostModalClose}
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
|
||||
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
|
||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
||||
import { isLocked } from 'Utilities/scrollLock';
|
||||
import styles from './PageContentBody.css';
|
||||
|
||||
@@ -36,7 +35,7 @@ const PageContentBody = forwardRef(
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
className={className}
|
||||
scrollDirection={ScrollDirection.Vertical}
|
||||
scrollDirection="vertical"
|
||||
onScroll={onScrollWrapper}
|
||||
>
|
||||
<div className={innerClassName}>{children}</div>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
function Portal(props) {
|
||||
const { children, target } = props;
|
||||
return ReactDOM.createPortal(children, target);
|
||||
}
|
||||
|
||||
Portal.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
target: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
Portal.defaultProps = {
|
||||
target: document.getElementById('portal-root')
|
||||
};
|
||||
|
||||
export default Portal;
|
||||
20
frontend/src/Components/Portal.tsx
Normal file
20
frontend/src/Components/Portal.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface PortalProps {
|
||||
children: Parameters<typeof ReactDOM.createPortal>[0];
|
||||
target?: Parameters<typeof ReactDOM.createPortal>[1];
|
||||
}
|
||||
|
||||
const defaultTarget = document.getElementById('portal-root');
|
||||
|
||||
function Portal(props: PortalProps) {
|
||||
const { children, target = defaultTarget } = props;
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(children, target);
|
||||
}
|
||||
|
||||
export default Portal;
|
||||
@@ -1,44 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Switch as RouterSwitch } from 'react-router-dom';
|
||||
import { map } from 'Helpers/elementChildren';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
|
||||
class Switch extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<RouterSwitch>
|
||||
{
|
||||
map(children, (child) => {
|
||||
const {
|
||||
path: childPath,
|
||||
addUrlBase = true
|
||||
} = child.props;
|
||||
|
||||
if (!childPath) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
|
||||
|
||||
return React.cloneElement(child, { path });
|
||||
})
|
||||
}
|
||||
</RouterSwitch>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Switch.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default Switch;
|
||||
38
frontend/src/Components/Router/Switch.tsx
Normal file
38
frontend/src/Components/Router/Switch.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { Children, ReactElement, ReactNode } from 'react';
|
||||
import { Switch as RouterSwitch } from 'react-router-dom';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
|
||||
interface ExtendedRoute {
|
||||
path: string;
|
||||
addUrlBase?: boolean;
|
||||
}
|
||||
|
||||
interface SwitchProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function Switch({ children }: SwitchProps) {
|
||||
return (
|
||||
<RouterSwitch>
|
||||
{Children.map(children, (child) => {
|
||||
if (!React.isValidElement<ExtendedRoute>(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const elementChild: ReactElement<ExtendedRoute> = child;
|
||||
|
||||
const { path: childPath, addUrlBase = true } = elementChild.props;
|
||||
|
||||
if (!childPath) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
|
||||
|
||||
return React.cloneElement(child, { path });
|
||||
})}
|
||||
</RouterSwitch>
|
||||
);
|
||||
}
|
||||
|
||||
export default Switch;
|
||||
@@ -1,179 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Scrollbars } from 'react-custom-scrollbars-2';
|
||||
import { scrollDirections } from 'Helpers/Props';
|
||||
import styles from './OverlayScroller.css';
|
||||
|
||||
const SCROLLBAR_SIZE = 10;
|
||||
|
||||
class OverlayScroller extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scroller = null;
|
||||
this._isScrolling = false;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
scrollTop
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
!this._isScrolling &&
|
||||
scrollTop != null &&
|
||||
scrollTop !== prevProps.scrollTop
|
||||
) {
|
||||
this._scroller.scrollTop(scrollTop);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_setScrollRef = (ref) => {
|
||||
this._scroller = ref;
|
||||
|
||||
if (ref) {
|
||||
this.props.registerScroller(ref.view);
|
||||
}
|
||||
};
|
||||
|
||||
_renderThumb = (props) => {
|
||||
return (
|
||||
<div
|
||||
className={this.props.trackClassName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderTrackHorizontal = ({ style, props }) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
borderRadius: 3,
|
||||
height: SCROLLBAR_SIZE
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.track}
|
||||
style={finalStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderTrackVertical = ({ style, props }) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
top: 2,
|
||||
borderRadius: 3,
|
||||
width: SCROLLBAR_SIZE
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.track}
|
||||
style={finalStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderView = (props) => {
|
||||
return (
|
||||
<div
|
||||
className={this.props.className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// Listers
|
||||
|
||||
onScrollStart = () => {
|
||||
this._isScrolling = true;
|
||||
};
|
||||
|
||||
onScrollStop = () => {
|
||||
this._isScrolling = false;
|
||||
};
|
||||
|
||||
onScroll = (event) => {
|
||||
const {
|
||||
scrollTop,
|
||||
scrollLeft
|
||||
} = event.currentTarget;
|
||||
|
||||
this._isScrolling = true;
|
||||
const onScroll = this.props.onScroll;
|
||||
|
||||
if (onScroll) {
|
||||
onScroll({ scrollTop, scrollLeft });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
autoHide,
|
||||
autoScroll,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
ref={this._setScrollRef}
|
||||
autoHide={autoHide}
|
||||
hideTracksWhenNotNeeded={autoScroll}
|
||||
renderTrackHorizontal={this._renderTrackHorizontal}
|
||||
renderTrackVertical={this._renderTrackVertical}
|
||||
renderThumbHorizontal={this._renderThumb}
|
||||
renderThumbVertical={this._renderThumb}
|
||||
renderView={this._renderView}
|
||||
onScrollStart={this.onScrollStart}
|
||||
onScrollStop={this.onScrollStop}
|
||||
onScroll={this.onScroll}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
OverlayScroller.propTypes = {
|
||||
className: PropTypes.string,
|
||||
trackClassName: PropTypes.string,
|
||||
scrollTop: PropTypes.number,
|
||||
scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
|
||||
autoHide: PropTypes.bool.isRequired,
|
||||
autoScroll: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node,
|
||||
onScroll: PropTypes.func,
|
||||
registerScroller: PropTypes.func
|
||||
};
|
||||
|
||||
OverlayScroller.defaultProps = {
|
||||
className: styles.scroller,
|
||||
trackClassName: styles.thumb,
|
||||
scrollDirection: scrollDirections.VERTICAL,
|
||||
autoHide: false,
|
||||
autoScroll: true,
|
||||
registerScroller: () => { /* no-op */ }
|
||||
};
|
||||
|
||||
export default OverlayScroller;
|
||||
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { ComponentPropsWithoutRef, useCallback, useRef } from 'react';
|
||||
import { Scrollbars } from 'react-custom-scrollbars-2';
|
||||
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
|
||||
import { OnScroll } from './Scroller';
|
||||
import styles from './OverlayScroller.css';
|
||||
|
||||
const SCROLLBAR_SIZE = 10;
|
||||
|
||||
interface OverlayScrollerProps {
|
||||
className?: string;
|
||||
trackClassName?: string;
|
||||
scrollTop?: number;
|
||||
scrollDirection: ScrollDirection;
|
||||
autoHide: boolean;
|
||||
autoScroll: boolean;
|
||||
children?: React.ReactNode;
|
||||
onScroll?: (payload: OnScroll) => void;
|
||||
}
|
||||
|
||||
interface ScrollbarTrackProps {
|
||||
style: React.CSSProperties;
|
||||
props: ComponentPropsWithoutRef<'div'>;
|
||||
}
|
||||
|
||||
function OverlayScroller(props: OverlayScrollerProps) {
|
||||
const {
|
||||
autoHide = false,
|
||||
autoScroll = true,
|
||||
className = styles.scroller,
|
||||
trackClassName = styles.thumb,
|
||||
children,
|
||||
onScroll,
|
||||
} = props;
|
||||
const scrollBarRef = useRef<Scrollbars>(null);
|
||||
const isScrolling = useRef(false);
|
||||
|
||||
const handleScrollStart = useCallback(() => {
|
||||
isScrolling.current = true;
|
||||
}, []);
|
||||
const handleScrollStop = useCallback(() => {
|
||||
isScrolling.current = false;
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollBarRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollLeft } = scrollBarRef.current.getValues();
|
||||
isScrolling.current = true;
|
||||
|
||||
if (onScroll) {
|
||||
onScroll({ scrollTop, scrollLeft });
|
||||
}
|
||||
}, [onScroll]);
|
||||
|
||||
const renderThumb = useCallback(
|
||||
(props: ComponentPropsWithoutRef<'div'>) => {
|
||||
return <div className={trackClassName} {...props} />;
|
||||
},
|
||||
[trackClassName]
|
||||
);
|
||||
|
||||
const renderTrackHorizontal = useCallback(
|
||||
({ style, props: trackProps }: ScrollbarTrackProps) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
borderRadius: 3,
|
||||
height: SCROLLBAR_SIZE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.track} style={finalStyle} {...trackProps} />
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderTrackVertical = useCallback(
|
||||
({ style, props: trackProps }: ScrollbarTrackProps) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
top: 2,
|
||||
borderRadius: 3,
|
||||
width: SCROLLBAR_SIZE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.track} style={finalStyle} {...trackProps} />
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderView = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(props: any) => {
|
||||
return <div className={className} {...props} />;
|
||||
},
|
||||
[className]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
ref={scrollBarRef}
|
||||
autoHide={autoHide}
|
||||
hideTracksWhenNotNeeded={autoScroll}
|
||||
renderTrackHorizontal={renderTrackHorizontal}
|
||||
renderTrackVertical={renderTrackVertical}
|
||||
renderThumbHorizontal={renderThumb}
|
||||
renderThumbVertical={renderThumb}
|
||||
renderView={renderView}
|
||||
onScrollStart={handleScrollStart}
|
||||
onScrollStop={handleScrollStop}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverlayScroller;
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
||||
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
|
||||
import styles from './Scroller.css';
|
||||
|
||||
export interface OnScroll {
|
||||
@@ -33,7 +33,7 @@ const Scroller = forwardRef(
|
||||
className,
|
||||
autoFocus = false,
|
||||
autoScroll = true,
|
||||
scrollDirection = ScrollDirection.Vertical,
|
||||
scrollDirection = 'vertical',
|
||||
children,
|
||||
scrollTop,
|
||||
initialScrollTop,
|
||||
@@ -59,7 +59,7 @@ const Scroller = forwardRef(
|
||||
currentRef.current.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
if (autoFocus && scrollDirection !== ScrollDirection.None) {
|
||||
if (autoFocus && scrollDirection !== 'none') {
|
||||
currentRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, [autoFocus, currentRef, scrollDirection, scrollTop]);
|
||||
|
||||
@@ -226,6 +226,14 @@ class SignalRConnector extends Component {
|
||||
}
|
||||
};
|
||||
|
||||
handleMetadata = ({ action, resource }) => {
|
||||
const section = 'settings.metadata';
|
||||
|
||||
if (action === 'updated') {
|
||||
this.props.dispatchUpdateItem({ section, ...resource });
|
||||
}
|
||||
};
|
||||
|
||||
handleNotification = ({ action, resource }) => {
|
||||
const section = 'settings.notifications';
|
||||
|
||||
|
||||
@@ -10,11 +10,13 @@ export interface SpinnerIconProps extends IconProps {
|
||||
export default function SpinnerIcon({
|
||||
name,
|
||||
spinningName = icons.SPINNER,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
}: SpinnerIconProps) {
|
||||
return (
|
||||
<Icon
|
||||
name={(otherProps.isSpinning && spinningName) || name}
|
||||
name={(isSpinning && spinningName) || name}
|
||||
isSpinning={isSpinning}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { tooltipPositions } from 'Helpers/Props';
|
||||
import Tooltip from './Tooltip';
|
||||
import styles from './Popover.css';
|
||||
|
||||
function Popover(props) {
|
||||
const {
|
||||
title,
|
||||
body,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
{...otherProps}
|
||||
bodyClassName={styles.tooltipBody}
|
||||
tooltip={
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Popover.propTypes = {
|
||||
className: PropTypes.string,
|
||||
bodyClassName: PropTypes.string,
|
||||
anchor: PropTypes.node.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
position: PropTypes.oneOf(tooltipPositions.all),
|
||||
canFlip: PropTypes.bool
|
||||
};
|
||||
|
||||
export default Popover;
|
||||
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import Tooltip, { TooltipProps } from './Tooltip';
|
||||
import styles from './Popover.css';
|
||||
|
||||
interface PopoverProps extends Omit<TooltipProps, 'tooltip' | 'bodyClassName'> {
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
}
|
||||
|
||||
function Popover({ title, body, ...otherProps }: PopoverProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
{...otherProps}
|
||||
bodyClassName={styles.tooltipBody}
|
||||
tooltip={
|
||||
<div>
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
<div className={styles.body}>{body}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Popover;
|
||||
@@ -1,235 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import { isMobile as isMobileUtil } from 'Utilities/browser';
|
||||
import styles from './Tooltip.css';
|
||||
|
||||
let maxWidth = null;
|
||||
|
||||
function getMaxWidth() {
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
|
||||
maxWidth = 800;
|
||||
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
|
||||
maxWidth = 650;
|
||||
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
|
||||
maxWidth = 500;
|
||||
} else {
|
||||
maxWidth = 450;
|
||||
}
|
||||
|
||||
return maxWidth;
|
||||
}
|
||||
|
||||
class Tooltip extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scheduleUpdate = null;
|
||||
this._closeTimeout = null;
|
||||
this._maxWidth = maxWidth || getMaxWidth();
|
||||
|
||||
this.state = {
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this._scheduleUpdate && this.state.isOpen) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._closeTimeout) {
|
||||
this._closeTimeout = clearTimeout(this._closeTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
computeMaxSize = (data) => {
|
||||
const {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^top/).test(data.placement)) {
|
||||
data.styles.maxHeight = top - 20;
|
||||
} else if ((/^bottom/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom - 20;
|
||||
} else if ((/^right/).test(data.placement)) {
|
||||
data.styles.maxWidth = Math.min(this._maxWidth, windowWidth - right - 20);
|
||||
data.styles.maxHeight = top - 20;
|
||||
} else {
|
||||
data.styles.maxWidth = Math.min(this._maxWidth, left - 20);
|
||||
data.styles.maxHeight = top - 20;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
if (isMobileUtil()) {
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
}
|
||||
};
|
||||
|
||||
onMouseEnter = () => {
|
||||
if (this._closeTimeout) {
|
||||
this._closeTimeout = clearTimeout(this._closeTimeout);
|
||||
}
|
||||
|
||||
this.setState({ isOpen: true });
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this._closeTimeout = setTimeout(() => {
|
||||
this.setState({ isOpen: false });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
bodyClassName,
|
||||
anchor,
|
||||
tooltip,
|
||||
kind,
|
||||
position,
|
||||
canFlip
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{anchor}
|
||||
</span>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
placement={position}
|
||||
// Disable events to improve performance when many tooltips
|
||||
// are shown (Quality Definitions for example).
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.computeMaxSize
|
||||
},
|
||||
preventOverflow: {
|
||||
// Fixes positioning for tooltips in the queue
|
||||
// and likely others.
|
||||
escapeWithReference: false
|
||||
},
|
||||
flip: {
|
||||
enabled: canFlip
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
const popperPlacement = placement ? placement.split('-')[0] : position;
|
||||
const vertical = popperPlacement === 'top' || popperPlacement === 'bottom';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.tooltipContainer,
|
||||
vertical ? styles.verticalContainer : styles.horizontalContainer
|
||||
)}
|
||||
style={style}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<div
|
||||
className={this.state.isOpen ? classNames(
|
||||
styles.arrow,
|
||||
styles[kind],
|
||||
styles[popperPlacement]
|
||||
) : styles.arrowDisabled}
|
||||
ref={arrowProps.ref}
|
||||
style={arrowProps.style}
|
||||
/>
|
||||
{
|
||||
this.state.isOpen ?
|
||||
<div
|
||||
className={classNames(
|
||||
styles.tooltip,
|
||||
styles[kind]
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={bodyClassName}
|
||||
>
|
||||
{tooltip}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Tooltip.propTypes = {
|
||||
className: PropTypes.string,
|
||||
bodyClassName: PropTypes.string.isRequired,
|
||||
anchor: PropTypes.node.isRequired,
|
||||
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
|
||||
position: PropTypes.oneOf(tooltipPositions.all),
|
||||
canFlip: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
bodyClassName: styles.body,
|
||||
kind: kinds.DEFAULT,
|
||||
position: tooltipPositions.TOP,
|
||||
canFlip: false
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
226
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
226
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
import classNames from 'classnames';
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import { isMobile as isMobileUtil } from 'Utilities/browser';
|
||||
import styles from './Tooltip.css';
|
||||
|
||||
export interface TooltipProps {
|
||||
className?: string;
|
||||
bodyClassName?: string;
|
||||
anchor: React.ReactNode;
|
||||
tooltip: string | React.ReactNode;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
position?: (typeof tooltipPositions.all)[number];
|
||||
canFlip?: boolean;
|
||||
}
|
||||
function Tooltip(props: TooltipProps) {
|
||||
const {
|
||||
className,
|
||||
bodyClassName = styles.body,
|
||||
anchor,
|
||||
tooltip,
|
||||
kind = kinds.DEFAULT,
|
||||
position = tooltipPositions.TOP,
|
||||
canFlip = false,
|
||||
} = props;
|
||||
|
||||
const closeTimeout = useRef<ReturnType<typeof setTimeout>>();
|
||||
const updater = useRef<(() => void) | null>(null);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!isMobileUtil()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setIsOpen((isOpen) => {
|
||||
return !isOpen;
|
||||
});
|
||||
}, [setIsOpen]);
|
||||
|
||||
const handleMouseEnterAnchor = useCallback(() => {
|
||||
// Mobile will fire mouse enter and click events rapidly,
|
||||
// this causes the tooltip not to open on the first press.
|
||||
// Ignore the mouse enter event on mobile.
|
||||
|
||||
if (isMobileUtil()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (closeTimeout.current) {
|
||||
clearTimeout(closeTimeout.current);
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const handleMouseEnterTooltip = useCallback(() => {
|
||||
if (closeTimeout.current) {
|
||||
clearTimeout(closeTimeout.current);
|
||||
}
|
||||
|
||||
setIsOpen(true);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const handleMouseLeave = useCallback(() => {
|
||||
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
|
||||
|
||||
clearTimeout(closeTimeout.current);
|
||||
closeTimeout.current = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
}, 100);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const maxWidth = useMemo(() => {
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
|
||||
return 800;
|
||||
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
|
||||
return 650;
|
||||
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
|
||||
return 500;
|
||||
}
|
||||
|
||||
return 450;
|
||||
}, []);
|
||||
|
||||
const computeMaxSize = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(data: any) => {
|
||||
const { top, right, bottom, left } = data.offsets.reference;
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if (/^top/.test(data.placement)) {
|
||||
data.styles.maxHeight = top - 20;
|
||||
} else if (/^bottom/.test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom - 20;
|
||||
} else if (/^right/.test(data.placement)) {
|
||||
data.styles.maxWidth = Math.min(maxWidth, windowWidth - right - 20);
|
||||
data.styles.maxHeight = top - 20;
|
||||
} else {
|
||||
data.styles.maxWidth = Math.min(maxWidth, left - 20);
|
||||
data.styles.maxHeight = top - 20;
|
||||
}
|
||||
|
||||
return data;
|
||||
},
|
||||
[maxWidth]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (updater.current && isOpen) {
|
||||
updater.current();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (closeTimeout.current) {
|
||||
clearTimeout(closeTimeout.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={handleClick}
|
||||
onMouseEnter={handleMouseEnterAnchor}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
{anchor}
|
||||
</span>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
// @ts-expect-error - PopperJS types are not in sync with our position types.
|
||||
placement={position}
|
||||
// Disable events to improve performance when many tooltips
|
||||
// are shown (Quality Definitions for example).
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: computeMaxSize,
|
||||
},
|
||||
preventOverflow: {
|
||||
// Fixes positioning for tooltips in the queue
|
||||
// and likely others.
|
||||
escapeWithReference: false,
|
||||
},
|
||||
flip: {
|
||||
enabled: canFlip,
|
||||
},
|
||||
}}
|
||||
>
|
||||
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
|
||||
updater.current = scheduleUpdate;
|
||||
|
||||
const popperPlacement = placement
|
||||
? placement.split('-')[0]
|
||||
: position;
|
||||
const vertical =
|
||||
popperPlacement === 'top' || popperPlacement === 'bottom';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.tooltipContainer,
|
||||
vertical
|
||||
? styles.verticalContainer
|
||||
: styles.horizontalContainer
|
||||
)}
|
||||
style={style}
|
||||
onMouseEnter={handleMouseEnterTooltip}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<div
|
||||
ref={arrowProps.ref}
|
||||
className={
|
||||
isOpen
|
||||
? classNames(
|
||||
styles.arrow,
|
||||
styles[kind],
|
||||
// @ts-expect-error - is a string that may not exist in styles
|
||||
styles[popperPlacement]
|
||||
)
|
||||
: styles.arrowDisabled
|
||||
}
|
||||
style={arrowProps.style}
|
||||
/>
|
||||
{isOpen ? (
|
||||
<div className={classNames(styles.tooltip, styles[kind])}>
|
||||
<div className={bodyClassName}>{tooltip}</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
|
||||
export default Tooltip;
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/Content/Images/Icons/mstile-150x150.png"/>
|
||||
<TileColor>#00ccff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "Radarr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "../../../../",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "standalone"
|
||||
}
|
||||
11
frontend/src/Content/browserconfig.xml
Normal file
11
frontend/src/Content/browserconfig.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="__URL_BASE__/Content/Images/Icons/mstile-150x150.png" />
|
||||
<TileColor>
|
||||
#00ccff
|
||||
</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
19
frontend/src/Content/manifest.json
Normal file
19
frontend/src/Content/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Radarr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "__URL_BASE__/",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
enum ScrollDirection {
|
||||
Horizontal = 'horizontal',
|
||||
Vertical = 'vertical',
|
||||
None = 'none',
|
||||
Both = 'both',
|
||||
}
|
||||
|
||||
export default ScrollDirection;
|
||||
@@ -1,6 +0,0 @@
|
||||
enum SortDirection {
|
||||
Ascending = 'ascending',
|
||||
Descending = 'descending',
|
||||
}
|
||||
|
||||
export default SortDirection;
|
||||
@@ -1,3 +0,0 @@
|
||||
type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
|
||||
|
||||
export default TooltipPosition;
|
||||
@@ -3,3 +3,5 @@ export const CENTER = 'center';
|
||||
export const RIGHT = 'right';
|
||||
|
||||
export const all = [LEFT, CENTER, RIGHT];
|
||||
|
||||
export type Align = 'left' | 'center' | 'right';
|
||||
|
||||
@@ -4,3 +4,5 @@ export const HORIZONTAL = 'horizontal';
|
||||
export const VERTICAL = 'vertical';
|
||||
|
||||
export const all = [NONE, HORIZONTAL, VERTICAL, BOTH];
|
||||
|
||||
export type ScrollDirection = 'none' | 'both' | 'horizontal' | 'vertical';
|
||||
@@ -2,3 +2,5 @@ export const ASCENDING = 'ascending';
|
||||
export const DESCENDING = 'descending';
|
||||
|
||||
export const all = [ASCENDING, DESCENDING];
|
||||
|
||||
export type SortDirection = 'ascending' | 'descending';
|
||||
@@ -3,9 +3,6 @@ export const RIGHT = 'right';
|
||||
export const BOTTOM = 'bottom';
|
||||
export const LEFT = 'left';
|
||||
|
||||
export const all = [
|
||||
TOP,
|
||||
RIGHT,
|
||||
BOTTOM,
|
||||
LEFT
|
||||
];
|
||||
export const all = [TOP, RIGHT, BOTTOM, LEFT];
|
||||
|
||||
export type TooltipPosition = 'top' | 'right' | 'bottom' | 'left';
|
||||
@@ -423,36 +423,41 @@ class MovieDetails extends Component {
|
||||
<div>
|
||||
{
|
||||
certification ?
|
||||
<span className={styles.certification}>
|
||||
<span className={styles.certification} title={translate('Certification')}>
|
||||
{certification}
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
year > 0 ?
|
||||
<span className={styles.year}>
|
||||
<Popover
|
||||
anchor={
|
||||
year
|
||||
}
|
||||
title={translate('ReleaseDates')}
|
||||
body={
|
||||
<MovieReleaseDates
|
||||
inCinemas={inCinemas}
|
||||
digitalRelease={digitalRelease}
|
||||
physicalRelease={physicalRelease}
|
||||
/>
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
<span className={styles.year}>
|
||||
<Popover
|
||||
anchor={
|
||||
year > 0 ? (
|
||||
year
|
||||
) : (
|
||||
<Icon
|
||||
name={icons.WARNING}
|
||||
kind={kinds.WARNING}
|
||||
size={20}
|
||||
/>
|
||||
)
|
||||
}
|
||||
title={translate('ReleaseDates')}
|
||||
body={
|
||||
<MovieReleaseDates
|
||||
tmdbId={tmdbId}
|
||||
inCinemas={inCinemas}
|
||||
digitalRelease={digitalRelease}
|
||||
physicalRelease={physicalRelease}
|
||||
/>
|
||||
</span> :
|
||||
null
|
||||
}
|
||||
}
|
||||
position={tooltipPositions.BOTTOM}
|
||||
/>
|
||||
</span>
|
||||
|
||||
{
|
||||
runtime ?
|
||||
<span className={styles.runtime}>
|
||||
<span className={styles.runtime} title={translate('Runtime')}>
|
||||
{formatRuntime(runtime, movieRuntimeFormat)}
|
||||
</span> :
|
||||
null
|
||||
|
||||
@@ -13,3 +13,10 @@
|
||||
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointExtraSmall) {
|
||||
.links {
|
||||
display: flex;
|
||||
flex-flow: column wrap;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Icon from 'Components/Icon';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Movie from 'Movie/Movie';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
@@ -11,10 +12,11 @@ import styles from './MovieReleaseDates.css';
|
||||
|
||||
type MovieReleaseDatesProps = Pick<
|
||||
Movie,
|
||||
'inCinemas' | 'digitalRelease' | 'physicalRelease'
|
||||
'tmdbId' | 'inCinemas' | 'digitalRelease' | 'physicalRelease'
|
||||
>;
|
||||
|
||||
function MovieReleaseDates({
|
||||
tmdbId,
|
||||
inCinemas,
|
||||
digitalRelease,
|
||||
physicalRelease,
|
||||
@@ -28,7 +30,12 @@ function MovieReleaseDates({
|
||||
<div className={styles.dateIcon}>
|
||||
<Icon name={icons.MISSING} />
|
||||
</div>
|
||||
{translate('NoMovieReleaseDatesAvailable')}
|
||||
|
||||
<InlineMarkdown
|
||||
data={translate('NoMovieReleaseDatesAvailable', {
|
||||
url: `https://www.themoviedb.org/movie/${tmdbId}`,
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import MenuContent from 'Components/Menu/MenuContent';
|
||||
import SortMenu from 'Components/Menu/SortMenu';
|
||||
import SortMenuItem from 'Components/Menu/SortMenuItem';
|
||||
import { align } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
interface MovieIndexSortMenuProps {
|
||||
|
||||
@@ -22,7 +22,7 @@ import PageToolbarSeparator from 'Components/Page/Toolbar/PageToolbarSeparator';
|
||||
import TableOptionsModalWrapper from 'Components/Table/TableOptions/TableOptionsModalWrapper';
|
||||
import withScrollPosition from 'Components/withScrollPosition';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { DESCENDING } from 'Helpers/Props/sortDirections';
|
||||
import InteractiveImportModal from 'InteractiveImport/InteractiveImportModal';
|
||||
import NoMovie from 'Movie/NoMovie';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
@@ -211,7 +211,7 @@ const MovieIndex = withScrollPosition((props: MovieIndexProps) => {
|
||||
const order = Object.keys(characters).sort();
|
||||
|
||||
// Reverse if sorting descending
|
||||
if (sortDirection === SortDirection.Descending) {
|
||||
if (sortDirection === DESCENDING) {
|
||||
order.reverse();
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ import { FixedSizeGrid as Grid, GridChildComponentProps } from 'react-window';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import MovieIndexPoster from 'Movie/Index/Posters/MovieIndexPoster';
|
||||
import Movie from 'Movie/Movie';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
|
||||
@@ -236,7 +236,7 @@ function MovieIndexRow(props: MovieIndexRowProps) {
|
||||
if (name === 'year') {
|
||||
return (
|
||||
<VirtualTableRowCell key={name} className={styles[name]}>
|
||||
{year}
|
||||
{year > 0 ? year : null}
|
||||
</VirtualTableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@ import AppState from 'App/State/AppState';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import Column from 'Components/Table/Column';
|
||||
import useMeasure from 'Helpers/Hooks/useMeasure';
|
||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import Movie from 'Movie/Movie';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import getIndexOfFirstCharacter from 'Utilities/Array/getIndexOfFirstCharacter';
|
||||
@@ -170,10 +169,7 @@ function MovieIndexTable(props: MovieIndexTableProps) {
|
||||
|
||||
return (
|
||||
<div ref={measureRef}>
|
||||
<Scroller
|
||||
className={styles.tableScroller}
|
||||
scrollDirection={ScrollDirection.Horizontal}
|
||||
>
|
||||
<Scroller className={styles.tableScroller} scrollDirection="horizontal">
|
||||
<MovieIndexTableHeader
|
||||
columns={columns}
|
||||
sortKey={sortKey}
|
||||
|
||||
@@ -9,7 +9,7 @@ import VirtualTableHeader from 'Components/Table/VirtualTableHeader';
|
||||
import VirtualTableHeaderCell from 'Components/Table/VirtualTableHeaderCell';
|
||||
import VirtualTableSelectAllHeaderCell from 'Components/Table/VirtualTableSelectAllHeaderCell';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import {
|
||||
setMovieSort,
|
||||
setMovieTableOption,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user