mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-18 21:35:27 -04:00
Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 974c4a601b | |||
| 9549038121 | |||
| 2f193ac58a | |||
| e893ca4f1c | |||
| 039d7775ed | |||
| 87bd5e62f2 | |||
| a9072ac460 | |||
| 55aaaa5c40 | |||
| ee99c3895d | |||
| e1e10e195c | |||
| 0b9a212f33 | |||
| 0e384ee3aa | |||
| d903529389 | |||
| 6f51e72d00 | |||
| 66cead6b48 | |||
| 7f0696c574 | |||
| 1584311914 | |||
| 278c7891a3 | |||
| 0a0e03dca0 | |||
| 546e9fd1d0 | |||
| c80bd81bb9 | |||
| e1cbc4a782 | |||
| 53d8c9ba8d | |||
| 9136ee4ad9 | |||
| 44fab9a96c | |||
| 66e4b7c819 | |||
| 98c4cbdd13 | |||
| 25d9f09a43 | |||
| 7ea1301221 | |||
| f033799d7a | |||
| cfa2f4d4c6 | |||
| 882b54be61 | |||
| 041fdd3929 | |||
| 4548dcdf97 | |||
| 4e14ce022c | |||
| a9b93dd9c6 | |||
| 50d7e8fed4 | |||
| 402db9128c | |||
| 846333ddf0 | |||
| dde28cbd7e | |||
| 8ceb306bf1 | |||
| 8af4246ff9 | |||
| a2e06e9e65 | |||
| ae7b187e41 | |||
| 63b4998c8e | |||
| 45665886d6 | |||
| 860424ac22 |
@@ -22,7 +22,7 @@ env:
|
|||||||
FRAMEWORK: net6.0
|
FRAMEWORK: net6.0
|
||||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||||
SONARR_MAJOR_VERSION: 4
|
SONARR_MAJOR_VERSION: 4
|
||||||
VERSION: 4.0.8
|
VERSION: 4.0.9
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
backend:
|
backend:
|
||||||
|
|||||||
@@ -162,3 +162,6 @@ src/.idea/
|
|||||||
|
|
||||||
# API doc generation
|
# API doc generation
|
||||||
.config/
|
.config/
|
||||||
|
|
||||||
|
# Ignore Jetbrains IntelliJ Workspace Directories
|
||||||
|
.idea/
|
||||||
|
|||||||
Vendored
+1
-1
@@ -9,7 +9,7 @@
|
|||||||
|
|
||||||
"editor.formatOnSave": false,
|
"editor.formatOnSave": false,
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll": true
|
"source.fixAll": "explicit"
|
||||||
},
|
},
|
||||||
|
|
||||||
"typescript.preferences.quoteStyle": "single",
|
"typescript.preferences.quoteStyle": "single",
|
||||||
|
|||||||
@@ -27,8 +27,6 @@ interface HistoryDetailsProps {
|
|||||||
sourceTitle: string;
|
sourceTitle: string;
|
||||||
data: HistoryData;
|
data: HistoryData;
|
||||||
downloadId?: string;
|
downloadId?: string;
|
||||||
shortDateFormat: string;
|
|
||||||
timeFormat: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryDetails(props: HistoryDetailsProps) {
|
function HistoryDetails(props: HistoryDetailsProps) {
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ interface HistoryDetailsModalProps {
|
|||||||
data: HistoryData;
|
data: HistoryData;
|
||||||
downloadId?: string;
|
downloadId?: string;
|
||||||
isMarkingAsFailed: boolean;
|
isMarkingAsFailed: boolean;
|
||||||
shortDateFormat: string;
|
|
||||||
timeFormat: string;
|
|
||||||
onMarkAsFailedPress: () => void;
|
onMarkAsFailedPress: () => void;
|
||||||
onModalClose: () => void;
|
onModalClose: () => void;
|
||||||
}
|
}
|
||||||
@@ -52,8 +50,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
|||||||
data,
|
data,
|
||||||
downloadId,
|
downloadId,
|
||||||
isMarkingAsFailed = false,
|
isMarkingAsFailed = false,
|
||||||
shortDateFormat,
|
|
||||||
timeFormat,
|
|
||||||
onMarkAsFailedPress,
|
onMarkAsFailedPress,
|
||||||
onModalClose,
|
onModalClose,
|
||||||
} = props;
|
} = props;
|
||||||
@@ -69,8 +65,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
|||||||
sourceTitle={sourceTitle}
|
sourceTitle={sourceTitle}
|
||||||
data={data}
|
data={data}
|
||||||
downloadId={downloadId}
|
downloadId={downloadId}
|
||||||
shortDateFormat={shortDateFormat}
|
|
||||||
timeFormat={timeFormat}
|
|
||||||
/>
|
/>
|
||||||
</ModalBody>
|
</ModalBody>
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
import { useDispatch, useSelector } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import IconButton from 'Components/Link/IconButton';
|
import IconButton from 'Components/Link/IconButton';
|
||||||
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
import RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
@@ -20,7 +20,6 @@ import { QualityModel } from 'Quality/Quality';
|
|||||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||||
import useSeries from 'Series/useSeries';
|
import useSeries from 'Series/useSeries';
|
||||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
|
||||||
import CustomFormat from 'typings/CustomFormat';
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||||
@@ -72,10 +71,6 @@ function HistoryRow(props: HistoryRowProps) {
|
|||||||
const series = useSeries(seriesId);
|
const series = useSeries(seriesId);
|
||||||
const episode = useEpisode(episodeId, 'episodes');
|
const episode = useEpisode(episodeId, 'episodes');
|
||||||
|
|
||||||
const { shortDateFormat, timeFormat } = useSelector(
|
|
||||||
createUISettingsSelector()
|
|
||||||
);
|
|
||||||
|
|
||||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||||
|
|
||||||
const handleDetailsPress = useCallback(() => {
|
const handleDetailsPress = useCallback(() => {
|
||||||
@@ -260,8 +255,6 @@ function HistoryRow(props: HistoryRowProps) {
|
|||||||
data={data}
|
data={data}
|
||||||
downloadId={downloadId}
|
downloadId={downloadId}
|
||||||
isMarkingAsFailed={isMarkingAsFailed}
|
isMarkingAsFailed={isMarkingAsFailed}
|
||||||
shortDateFormat={shortDateFormat}
|
|
||||||
timeFormat={timeFormat}
|
|
||||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||||
onModalClose={handleDetailsModalClose}
|
onModalClose={handleDetailsModalClose}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon from 'Components/Icon';
|
import Icon, { IconProps } from 'Components/Icon';
|
||||||
import Popover from 'Components/Tooltip/Popover';
|
import Popover from 'Components/Tooltip/Popover';
|
||||||
import { icons, kinds } from 'Helpers/Props';
|
import { icons, kinds } from 'Helpers/Props';
|
||||||
import TooltipPosition from 'Helpers/Props/TooltipPosition';
|
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||||
import {
|
import {
|
||||||
QueueTrackedDownloadState,
|
QueueTrackedDownloadState,
|
||||||
QueueTrackedDownloadStatus,
|
QueueTrackedDownloadStatus,
|
||||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
|||||||
|
|
||||||
// status === 'downloading'
|
// status === 'downloading'
|
||||||
let iconName = icons.DOWNLOADING;
|
let iconName = icons.DOWNLOADING;
|
||||||
let iconKind = kinds.DEFAULT;
|
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||||
let title = translate('Downloading');
|
let title = translate('Downloading');
|
||||||
|
|
||||||
if (status === 'paused') {
|
if (status === 'paused') {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import Column from 'Components/Table/Column';
|
import Column from 'Components/Table/Column';
|
||||||
import SortDirection from 'Helpers/Props/SortDirection';
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||||
|
|
||||||
export interface Error {
|
export interface Error {
|
||||||
|
|||||||
@@ -1,17 +1,20 @@
|
|||||||
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
|
|
||||||
import BlocklistAppState from './BlocklistAppState';
|
import BlocklistAppState from './BlocklistAppState';
|
||||||
import CalendarAppState from './CalendarAppState';
|
import CalendarAppState from './CalendarAppState';
|
||||||
import CommandAppState from './CommandAppState';
|
import CommandAppState from './CommandAppState';
|
||||||
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
||||||
import EpisodesAppState from './EpisodesAppState';
|
import EpisodesAppState from './EpisodesAppState';
|
||||||
import HistoryAppState from './HistoryAppState';
|
import HistoryAppState from './HistoryAppState';
|
||||||
|
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||||
import ParseAppState from './ParseAppState';
|
import ParseAppState from './ParseAppState';
|
||||||
|
import PathsAppState from './PathsAppState';
|
||||||
import QueueAppState from './QueueAppState';
|
import QueueAppState from './QueueAppState';
|
||||||
|
import ReleasesAppState from './ReleasesAppState';
|
||||||
import RootFolderAppState from './RootFolderAppState';
|
import RootFolderAppState from './RootFolderAppState';
|
||||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||||
import SettingsAppState from './SettingsAppState';
|
import SettingsAppState from './SettingsAppState';
|
||||||
import SystemAppState from './SystemAppState';
|
import SystemAppState from './SystemAppState';
|
||||||
import TagsAppState from './TagsAppState';
|
import TagsAppState from './TagsAppState';
|
||||||
|
import WantedAppState from './WantedAppState';
|
||||||
|
|
||||||
interface FilterBuilderPropOption {
|
interface FilterBuilderPropOption {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -62,19 +65,22 @@ interface AppState {
|
|||||||
blocklist: BlocklistAppState;
|
blocklist: BlocklistAppState;
|
||||||
calendar: CalendarAppState;
|
calendar: CalendarAppState;
|
||||||
commands: CommandAppState;
|
commands: CommandAppState;
|
||||||
episodes: EpisodesAppState;
|
|
||||||
episodeFiles: EpisodeFilesAppState;
|
episodeFiles: EpisodeFilesAppState;
|
||||||
|
episodes: EpisodesAppState;
|
||||||
episodesSelection: EpisodesAppState;
|
episodesSelection: EpisodesAppState;
|
||||||
history: HistoryAppState;
|
history: HistoryAppState;
|
||||||
interactiveImport: InteractiveImportAppState;
|
interactiveImport: InteractiveImportAppState;
|
||||||
parse: ParseAppState;
|
parse: ParseAppState;
|
||||||
|
paths: PathsAppState;
|
||||||
queue: QueueAppState;
|
queue: QueueAppState;
|
||||||
|
releases: ReleasesAppState;
|
||||||
rootFolders: RootFolderAppState;
|
rootFolders: RootFolderAppState;
|
||||||
series: SeriesAppState;
|
series: SeriesAppState;
|
||||||
seriesIndex: SeriesIndexAppState;
|
seriesIndex: SeriesIndexAppState;
|
||||||
settings: SettingsAppState;
|
settings: SettingsAppState;
|
||||||
system: SystemAppState;
|
system: SystemAppState;
|
||||||
tags: TagsAppState;
|
tags: TagsAppState;
|
||||||
|
wanted: WantedAppState;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default AppState;
|
export default AppState;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import AppSectionState, {
|
||||||
|
AppSectionFilterState,
|
||||||
|
} from 'App/State/AppSectionState';
|
||||||
|
import Release from 'typings/Release';
|
||||||
|
|
||||||
|
interface ReleasesAppState
|
||||||
|
extends AppSectionState<Release>,
|
||||||
|
AppSectionFilterState<Release> {}
|
||||||
|
|
||||||
|
export default ReleasesAppState;
|
||||||
@@ -3,7 +3,7 @@ import AppSectionState, {
|
|||||||
AppSectionSaveState,
|
AppSectionSaveState,
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
import Column from 'Components/Table/Column';
|
import Column from 'Components/Table/Column';
|
||||||
import SortDirection from 'Helpers/Props/SortDirection';
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
import Series from 'Series/Series';
|
import Series from 'Series/Series';
|
||||||
import { Filter, FilterBuilderProp } from './AppState';
|
import { Filter, FilterBuilderProp } from './AppState';
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import AppSectionState, {
|
|||||||
PagedAppSectionState,
|
PagedAppSectionState,
|
||||||
} from 'App/State/AppSectionState';
|
} from 'App/State/AppSectionState';
|
||||||
import Language from 'Language/Language';
|
import Language from 'Language/Language';
|
||||||
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
import DownloadClient from 'typings/DownloadClient';
|
import DownloadClient from 'typings/DownloadClient';
|
||||||
import ImportList from 'typings/ImportList';
|
import ImportList from 'typings/ImportList';
|
||||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||||
@@ -48,6 +49,11 @@ export interface QualityProfilesAppState
|
|||||||
extends AppSectionState<QualityProfile>,
|
extends AppSectionState<QualityProfile>,
|
||||||
AppSectionItemSchemaState<QualityProfile> {}
|
AppSectionItemSchemaState<QualityProfile> {}
|
||||||
|
|
||||||
|
export interface CustomFormatAppState
|
||||||
|
extends AppSectionState<CustomFormat>,
|
||||||
|
AppSectionDeleteState,
|
||||||
|
AppSectionSaveState {}
|
||||||
|
|
||||||
export interface ImportListOptionsSettingsAppState
|
export interface ImportListOptionsSettingsAppState
|
||||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||||
AppSectionSaveState {}
|
AppSectionSaveState {}
|
||||||
@@ -66,6 +72,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
|||||||
|
|
||||||
interface SettingsAppState {
|
interface SettingsAppState {
|
||||||
advancedSettings: boolean;
|
advancedSettings: boolean;
|
||||||
|
customFormats: CustomFormatAppState;
|
||||||
downloadClients: DownloadClientAppState;
|
downloadClients: DownloadClientAppState;
|
||||||
general: GeneralAppState;
|
general: GeneralAppState;
|
||||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import AppSectionState from 'App/State/AppSectionState';
|
||||||
|
import Episode from 'Episode/Episode';
|
||||||
|
|
||||||
|
interface WantedCutoffUnmetAppState extends AppSectionState<Episode> {}
|
||||||
|
|
||||||
|
interface WantedMissingAppState extends AppSectionState<Episode> {}
|
||||||
|
|
||||||
|
interface WantedAppState {
|
||||||
|
cutoffUnmet: WantedCutoffUnmetAppState;
|
||||||
|
missing: WantedMissingAppState;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default WantedAppState;
|
||||||
@@ -26,6 +26,7 @@ export interface CommandBody {
|
|||||||
seriesId?: number;
|
seriesId?: number;
|
||||||
seriesIds?: number[];
|
seriesIds?: number[];
|
||||||
seasonNumber?: number;
|
seasonNumber?: number;
|
||||||
|
episodeIds?: number[];
|
||||||
[key: string]: string | number | boolean | number[] | undefined;
|
[key: string]: string | number | boolean | number[] | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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,138 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import styles from './CircularProgressBar.css';
|
|
||||||
|
|
||||||
class CircularProgressBar extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
progress: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidMount() {
|
|
||||||
this._progressStep();
|
|
||||||
}
|
|
||||||
|
|
||||||
componentDidUpdate(prevProps) {
|
|
||||||
const progress = this.props.progress;
|
|
||||||
|
|
||||||
if (prevProps.progress !== progress) {
|
|
||||||
this._cancelProgressStep();
|
|
||||||
this._progressStep();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
this._cancelProgressStep();
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Control
|
|
||||||
|
|
||||||
_progressStep() {
|
|
||||||
this.requestAnimationFrame = window.requestAnimationFrame(() => {
|
|
||||||
this.setState({
|
|
||||||
progress: this.state.progress + 1
|
|
||||||
}, () => {
|
|
||||||
if (this.state.progress < this.props.progress) {
|
|
||||||
this._progressStep();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
_cancelProgressStep() {
|
|
||||||
if (this.requestAnimationFrame) {
|
|
||||||
window.cancelAnimationFrame(this.requestAnimationFrame);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
size,
|
|
||||||
strokeWidth,
|
|
||||||
strokeColor,
|
|
||||||
showProgressText
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const progress = this.state.progress;
|
|
||||||
|
|
||||||
const center = size / 2;
|
|
||||||
const radius = center - strokeWidth;
|
|
||||||
const circumference = Math.PI * (radius * 2);
|
|
||||||
const sizeInPixels = `${size}px`;
|
|
||||||
const strokeDashoffset = ((100 - progress) / 100) * circumference;
|
|
||||||
const progressText = `${Math.round(progress)}%`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={containerClassName}
|
|
||||||
style={{
|
|
||||||
width: sizeInPixels,
|
|
||||||
height: sizeInPixels,
|
|
||||||
lineHeight: sizeInPixels
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className={className}
|
|
||||||
version="1.1"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width={size}
|
|
||||||
height={size}
|
|
||||||
>
|
|
||||||
<circle
|
|
||||||
fill="transparent"
|
|
||||||
r={radius}
|
|
||||||
cx={center}
|
|
||||||
cy={center}
|
|
||||||
strokeDasharray={circumference}
|
|
||||||
style={{
|
|
||||||
stroke: strokeColor,
|
|
||||||
strokeWidth,
|
|
||||||
strokeDashoffset
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
{
|
|
||||||
showProgressText &&
|
|
||||||
<div className={styles.circularProgressBarText}>
|
|
||||||
{progressText}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
CircularProgressBar.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
containerClassName: PropTypes.string,
|
|
||||||
size: PropTypes.number,
|
|
||||||
progress: PropTypes.number.isRequired,
|
|
||||||
strokeWidth: PropTypes.number,
|
|
||||||
strokeColor: PropTypes.string,
|
|
||||||
showProgressText: PropTypes.bool
|
|
||||||
};
|
|
||||||
|
|
||||||
CircularProgressBar.defaultProps = {
|
|
||||||
className: styles.circularProgressBar,
|
|
||||||
containerClassName: styles.circularProgressBarContainer,
|
|
||||||
size: 60,
|
|
||||||
strokeWidth: 5,
|
|
||||||
strokeColor: '#35c5f4',
|
|
||||||
showProgressText: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default CircularProgressBar;
|
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import styles from './CircularProgressBar.css';
|
||||||
|
|
||||||
|
interface CircularProgressBarProps {
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
size?: number;
|
||||||
|
progress: number;
|
||||||
|
strokeWidth?: number;
|
||||||
|
strokeColor?: string;
|
||||||
|
showProgressText?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CircularProgressBar({
|
||||||
|
className = styles.circularProgressBar,
|
||||||
|
containerClassName = styles.circularProgressBarContainer,
|
||||||
|
size = 60,
|
||||||
|
strokeWidth = 5,
|
||||||
|
strokeColor = '#35c5f4',
|
||||||
|
showProgressText = false,
|
||||||
|
progress,
|
||||||
|
}: CircularProgressBarProps) {
|
||||||
|
const [currentProgress, setCurrentProgress] = useState(0);
|
||||||
|
const raf = React.useRef<number>(0);
|
||||||
|
const center = size / 2;
|
||||||
|
const radius = center - strokeWidth;
|
||||||
|
const circumference = Math.PI * (radius * 2);
|
||||||
|
const sizeInPixels = `${size}px`;
|
||||||
|
const strokeDashoffset = ((100 - currentProgress) / 100) * circumference;
|
||||||
|
const progressText = `${Math.round(currentProgress)}%`;
|
||||||
|
|
||||||
|
const handleAnimation = useCallback(
|
||||||
|
(p: number) => {
|
||||||
|
setCurrentProgress((prevProgress) => {
|
||||||
|
if (prevProgress < p) {
|
||||||
|
return prevProgress + Math.min(1, p - prevProgress);
|
||||||
|
}
|
||||||
|
|
||||||
|
return prevProgress;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[setCurrentProgress]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (progress > currentProgress) {
|
||||||
|
cancelAnimationFrame(raf.current);
|
||||||
|
|
||||||
|
raf.current = requestAnimationFrame(() => handleAnimation(progress));
|
||||||
|
}
|
||||||
|
}, [progress, currentProgress, handleAnimation]);
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => {
|
||||||
|
return () => cancelAnimationFrame(raf.current);
|
||||||
|
},
|
||||||
|
// We only want to run this effect once
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={containerClassName}
|
||||||
|
style={{
|
||||||
|
width: sizeInPixels,
|
||||||
|
height: sizeInPixels,
|
||||||
|
lineHeight: sizeInPixels,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
className={className}
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={size}
|
||||||
|
height={size}
|
||||||
|
>
|
||||||
|
<circle
|
||||||
|
fill="transparent"
|
||||||
|
r={radius}
|
||||||
|
cx={center}
|
||||||
|
cy={center}
|
||||||
|
strokeDasharray={circumference}
|
||||||
|
style={{
|
||||||
|
stroke: strokeColor,
|
||||||
|
strokeWidth,
|
||||||
|
strokeDashoffset,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{showProgressText && (
|
||||||
|
<div className={styles.circularProgressBarText}>{progressText}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default CircularProgressBar;
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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,246 +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 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 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>
|
|
||||||
{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/sonarr/faq#why-cant-sonarr-see-my-files-on-a-remote-server' })} />
|
|
||||||
</Alert>
|
|
||||||
}
|
|
||||||
|
|
||||||
<PathInput
|
|
||||||
className={styles.pathInput}
|
|
||||||
placeholder={translate('FileBrowserPlaceholderText')}
|
|
||||||
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={translate('MyComputer')}
|
|
||||||
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;
|
|
||||||
@@ -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/sonarr/faq#why-cant-sonarr-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;
|
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -49,7 +49,11 @@ class TextTagInputConnector extends Component {
|
|||||||
const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name);
|
const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name);
|
||||||
|
|
||||||
newTags.forEach((newTag) => {
|
newTags.forEach((newTag) => {
|
||||||
newValue.push(newTag.trim());
|
const newTagValue = newTag.trim();
|
||||||
|
|
||||||
|
if (newTagValue) {
|
||||||
|
newValue.push(newTagValue);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
onChange({ name, value: newValue });
|
onChange({ name, value: newValue });
|
||||||
@@ -80,7 +84,12 @@ class TextTagInputConnector extends Component {
|
|||||||
|
|
||||||
const newValue = [...valueArray];
|
const newValue = [...valueArray];
|
||||||
newValue.splice(tagToReplace.index, 1);
|
newValue.splice(tagToReplace.index, 1);
|
||||||
newValue.push(newTag.name.trim());
|
|
||||||
|
const newTagValue = newTag.name.trim();
|
||||||
|
|
||||||
|
if (newTagValue) {
|
||||||
|
newValue.push(newTagValue);
|
||||||
|
}
|
||||||
|
|
||||||
onChange({ name, value: newValue });
|
onChange({ name, value: newValue });
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import Icon from 'Components/Icon';
|
import Icon, { IconProps } from 'Components/Icon';
|
||||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './HeartRating.css';
|
import styles from './HeartRating.css';
|
||||||
|
|
||||||
function HeartRating({ rating, votes, iconSize }) {
|
interface HeartRatingProps {
|
||||||
|
rating: number;
|
||||||
|
votes?: number;
|
||||||
|
iconSize?: IconProps['size'];
|
||||||
|
}
|
||||||
|
|
||||||
|
function HeartRating({ rating, votes = 0, iconSize = 14 }: HeartRatingProps) {
|
||||||
return (
|
return (
|
||||||
<Tooltip
|
<Tooltip
|
||||||
anchor={
|
anchor={
|
||||||
<span className={styles.rating}>
|
<span className={styles.rating}>
|
||||||
<Icon
|
<Icon className={styles.heart} name={icons.HEART} size={iconSize} />
|
||||||
className={styles.heart}
|
|
||||||
name={icons.HEART}
|
|
||||||
size={iconSize}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{rating * 10}%
|
{rating * 10}%
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
@@ -27,15 +27,4 @@ function HeartRating({ rating, votes, iconSize }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
HeartRating.propTypes = {
|
|
||||||
rating: PropTypes.number.isRequired,
|
|
||||||
votes: PropTypes.number.isRequired,
|
|
||||||
iconSize: PropTypes.number.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
HeartRating.defaultProps = {
|
|
||||||
votes: 0,
|
|
||||||
iconSize: 14
|
|
||||||
};
|
|
||||||
|
|
||||||
export default HeartRating;
|
export default HeartRating;
|
||||||
@@ -1,73 +0,0 @@
|
|||||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { PureComponent } from 'react';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
import styles from './Icon.css';
|
|
||||||
|
|
||||||
class Icon extends PureComponent {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
containerClassName,
|
|
||||||
className,
|
|
||||||
name,
|
|
||||||
kind,
|
|
||||||
size,
|
|
||||||
title,
|
|
||||||
isSpinning,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const icon = (
|
|
||||||
<FontAwesomeIcon
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
styles[kind]
|
|
||||||
)}
|
|
||||||
icon={name}
|
|
||||||
spin={isSpinning}
|
|
||||||
style={{
|
|
||||||
fontSize: `${size}px`
|
|
||||||
}}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={containerClassName}
|
|
||||||
title={typeof title === 'function' ? title() : title}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return icon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Icon.propTypes = {
|
|
||||||
containerClassName: PropTypes.string,
|
|
||||||
className: PropTypes.string,
|
|
||||||
name: PropTypes.object.isRequired,
|
|
||||||
kind: PropTypes.string.isRequired,
|
|
||||||
size: PropTypes.number.isRequired,
|
|
||||||
title: PropTypes.oneOfType([PropTypes.string, PropTypes.func]),
|
|
||||||
isSpinning: PropTypes.bool.isRequired,
|
|
||||||
fixedWidth: PropTypes.bool.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
Icon.defaultProps = {
|
|
||||||
kind: kinds.DEFAULT,
|
|
||||||
size: 14,
|
|
||||||
isSpinning: false,
|
|
||||||
fixedWidth: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Icon;
|
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import {
|
||||||
|
FontAwesomeIcon,
|
||||||
|
FontAwesomeIconProps,
|
||||||
|
} from '@fortawesome/react-fontawesome';
|
||||||
|
import classNames from 'classnames';
|
||||||
|
import React, { ComponentProps } from 'react';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
import styles from './Icon.css';
|
||||||
|
|
||||||
|
export interface IconProps
|
||||||
|
extends Omit<
|
||||||
|
FontAwesomeIconProps,
|
||||||
|
'icon' | 'spin' | 'name' | 'title' | 'size'
|
||||||
|
> {
|
||||||
|
containerClassName?: ComponentProps<'span'>['className'];
|
||||||
|
name: FontAwesomeIconProps['icon'];
|
||||||
|
kind?: Extract<Kind, keyof typeof styles>;
|
||||||
|
size?: number;
|
||||||
|
isSpinning?: FontAwesomeIconProps['spin'];
|
||||||
|
title?: string | (() => string);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Icon({
|
||||||
|
containerClassName,
|
||||||
|
className,
|
||||||
|
name,
|
||||||
|
kind = kinds.DEFAULT,
|
||||||
|
size = 14,
|
||||||
|
title,
|
||||||
|
isSpinning = false,
|
||||||
|
fixedWidth = false,
|
||||||
|
...otherProps
|
||||||
|
}: IconProps) {
|
||||||
|
const icon = (
|
||||||
|
<FontAwesomeIcon
|
||||||
|
className={classNames(className, styles[kind])}
|
||||||
|
icon={name}
|
||||||
|
spin={isSpinning}
|
||||||
|
fixedWidth={fixedWidth}
|
||||||
|
style={{
|
||||||
|
fontSize: `${size}px`,
|
||||||
|
}}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (title) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={containerClassName}
|
||||||
|
title={typeof title === 'function' ? title() : title}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return icon;
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, { ComponentProps, ReactNode } from 'react';
|
import React, { ComponentProps, ReactNode } from 'react';
|
||||||
import { kinds, sizes } from 'Helpers/Props';
|
import { kinds, sizes } from 'Helpers/Props';
|
||||||
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
import { Size } from 'Helpers/Props/sizes';
|
||||||
import styles from './Label.css';
|
import styles from './Label.css';
|
||||||
|
|
||||||
export interface LabelProps extends ComponentProps<'span'> {
|
export interface LabelProps extends ComponentProps<'span'> {
|
||||||
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
|
kind?: Extract<Kind, keyof typeof styles>;
|
||||||
size?: Extract<(typeof sizes.all)[number], keyof typeof styles>;
|
size?: Extract<Size, keyof typeof styles>;
|
||||||
outline?: boolean;
|
outline?: boolean;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { align, kinds, sizes } from 'Helpers/Props';
|
|
||||||
import Link from './Link';
|
|
||||||
import styles from './Button.css';
|
|
||||||
|
|
||||||
class Button extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
buttonGroupPosition,
|
|
||||||
kind,
|
|
||||||
size,
|
|
||||||
children,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
styles[kind],
|
|
||||||
styles[size],
|
|
||||||
buttonGroupPosition && styles[buttonGroupPosition]
|
|
||||||
)}
|
|
||||||
{...otherProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
Button.propTypes = {
|
|
||||||
className: PropTypes.string.isRequired,
|
|
||||||
buttonGroupPosition: PropTypes.oneOf(align.all),
|
|
||||||
kind: PropTypes.oneOf(kinds.all),
|
|
||||||
size: PropTypes.oneOf(sizes.all),
|
|
||||||
children: PropTypes.node
|
|
||||||
};
|
|
||||||
|
|
||||||
Button.defaultProps = {
|
|
||||||
className: styles.button,
|
|
||||||
kind: kinds.DEFAULT,
|
|
||||||
size: sizes.MEDIUM
|
|
||||||
};
|
|
||||||
|
|
||||||
export default Button;
|
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import { align, kinds, sizes } from 'Helpers/Props';
|
||||||
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
import { Size } from 'Helpers/Props/sizes';
|
||||||
|
import Link, { LinkProps } from './Link';
|
||||||
|
import styles from './Button.css';
|
||||||
|
|
||||||
|
export interface ButtonProps extends Omit<LinkProps, 'children' | 'size'> {
|
||||||
|
buttonGroupPosition?: Extract<
|
||||||
|
(typeof align.all)[number],
|
||||||
|
keyof typeof styles
|
||||||
|
>;
|
||||||
|
kind?: Extract<Kind, keyof typeof styles>;
|
||||||
|
size?: Extract<Size, keyof typeof styles>;
|
||||||
|
children: Required<LinkProps['children']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Button({
|
||||||
|
className = styles.button,
|
||||||
|
buttonGroupPosition,
|
||||||
|
kind = kinds.DEFAULT,
|
||||||
|
size = sizes.MEDIUM,
|
||||||
|
...otherProps
|
||||||
|
}: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
styles[kind],
|
||||||
|
styles[size],
|
||||||
|
buttonGroupPosition && styles[buttonGroupPosition]
|
||||||
|
)}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import Link from './Link';
|
|
||||||
import styles from './IconButton.css';
|
|
||||||
|
|
||||||
function IconButton(props) {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
iconClassName,
|
|
||||||
name,
|
|
||||||
kind,
|
|
||||||
size,
|
|
||||||
isSpinning,
|
|
||||||
isDisabled,
|
|
||||||
...otherProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
isDisabled && styles.isDisabled
|
|
||||||
)}
|
|
||||||
aria-label={translate('TableOptionsButton')}
|
|
||||||
isDisabled={isDisabled}
|
|
||||||
{...otherProps}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={iconClassName}
|
|
||||||
name={name}
|
|
||||||
kind={kind}
|
|
||||||
size={size}
|
|
||||||
isSpinning={isSpinning}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton.propTypes = {
|
|
||||||
...Link.propTypes,
|
|
||||||
className: PropTypes.string.isRequired,
|
|
||||||
iconClassName: PropTypes.string,
|
|
||||||
kind: PropTypes.string,
|
|
||||||
name: PropTypes.object.isRequired,
|
|
||||||
size: PropTypes.number,
|
|
||||||
title: PropTypes.string,
|
|
||||||
isSpinning: PropTypes.bool,
|
|
||||||
isDisabled: PropTypes.bool
|
|
||||||
};
|
|
||||||
|
|
||||||
IconButton.defaultProps = {
|
|
||||||
className: styles.button,
|
|
||||||
size: 12
|
|
||||||
};
|
|
||||||
|
|
||||||
export default IconButton;
|
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import Icon, { IconProps } from 'Components/Icon';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import Link, { LinkProps } from './Link';
|
||||||
|
import styles from './IconButton.css';
|
||||||
|
|
||||||
|
export interface IconButtonProps
|
||||||
|
extends Omit<LinkProps, 'name' | 'kind'>,
|
||||||
|
Pick<IconProps, 'name' | 'kind' | 'size' | 'isSpinning'> {
|
||||||
|
iconClassName?: IconProps['className'];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function IconButton({
|
||||||
|
className = styles.button,
|
||||||
|
iconClassName,
|
||||||
|
name,
|
||||||
|
kind,
|
||||||
|
size = 12,
|
||||||
|
isSpinning,
|
||||||
|
...otherProps
|
||||||
|
}: IconButtonProps) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
otherProps.isDisabled && styles.isDisabled
|
||||||
|
)}
|
||||||
|
aria-label={translate('TableOptionsButton')}
|
||||||
|
{...otherProps}
|
||||||
|
>
|
||||||
|
<Icon
|
||||||
|
className={iconClassName}
|
||||||
|
name={name}
|
||||||
|
kind={kind}
|
||||||
|
size={size}
|
||||||
|
isSpinning={isSpinning}
|
||||||
|
/>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,96 +1,93 @@
|
|||||||
import classNames from 'classnames';
|
import classNames from 'classnames';
|
||||||
import React, {
|
import React, {
|
||||||
ComponentClass,
|
ComponentPropsWithoutRef,
|
||||||
FunctionComponent,
|
ElementType,
|
||||||
SyntheticEvent,
|
SyntheticEvent,
|
||||||
useCallback,
|
useCallback,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import { Link as RouterLink } from 'react-router-dom';
|
import { Link as RouterLink } from 'react-router-dom';
|
||||||
import styles from './Link.css';
|
import styles from './Link.css';
|
||||||
|
|
||||||
interface ReactRouterLinkProps {
|
export type LinkProps<C extends ElementType = 'button'> =
|
||||||
to?: string;
|
ComponentPropsWithoutRef<C> & {
|
||||||
}
|
component?: C;
|
||||||
|
to?: string;
|
||||||
|
target?: string;
|
||||||
|
isDisabled?: LinkProps<C>['disabled'];
|
||||||
|
noRouter?: boolean;
|
||||||
|
onPress?(event: SyntheticEvent): void;
|
||||||
|
};
|
||||||
|
|
||||||
export interface LinkProps extends React.HTMLProps<HTMLAnchorElement> {
|
export default function Link<C extends ElementType = 'button'>({
|
||||||
className?: string;
|
className,
|
||||||
component?:
|
component,
|
||||||
| string
|
to,
|
||||||
| FunctionComponent<LinkProps>
|
target,
|
||||||
| ComponentClass<LinkProps, unknown>;
|
type,
|
||||||
to?: string;
|
isDisabled,
|
||||||
target?: string;
|
noRouter,
|
||||||
isDisabled?: boolean;
|
onPress,
|
||||||
noRouter?: boolean;
|
...otherProps
|
||||||
onPress?(event: SyntheticEvent): void;
|
}: LinkProps<C>) {
|
||||||
}
|
const Component = component || 'button';
|
||||||
function Link(props: LinkProps) {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
component = 'button',
|
|
||||||
to,
|
|
||||||
target,
|
|
||||||
type,
|
|
||||||
isDisabled,
|
|
||||||
noRouter = false,
|
|
||||||
onPress,
|
|
||||||
...otherProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const onClick = useCallback(
|
const onClick = useCallback(
|
||||||
(event: SyntheticEvent) => {
|
(event: SyntheticEvent) => {
|
||||||
if (!isDisabled && onPress) {
|
if (isDisabled) {
|
||||||
onPress(event);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPress?.(event);
|
||||||
},
|
},
|
||||||
[isDisabled, onPress]
|
[isDisabled, onPress]
|
||||||
);
|
);
|
||||||
|
|
||||||
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
|
const linkClass = classNames(
|
||||||
target,
|
|
||||||
};
|
|
||||||
let el = component;
|
|
||||||
|
|
||||||
if (to) {
|
|
||||||
if (/\w+?:\/\//.test(to)) {
|
|
||||||
el = 'a';
|
|
||||||
linkProps.href = to;
|
|
||||||
linkProps.target = target || '_blank';
|
|
||||||
linkProps.rel = 'noreferrer';
|
|
||||||
} else if (noRouter) {
|
|
||||||
el = 'a';
|
|
||||||
linkProps.href = to;
|
|
||||||
linkProps.target = target || '_self';
|
|
||||||
} else {
|
|
||||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
|
||||||
// @ts-ignore
|
|
||||||
el = RouterLink;
|
|
||||||
linkProps.to = `${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`;
|
|
||||||
linkProps.target = target;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (el === 'button' || el === 'input') {
|
|
||||||
linkProps.type = type || 'button';
|
|
||||||
linkProps.disabled = isDisabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
linkProps.className = classNames(
|
|
||||||
className,
|
className,
|
||||||
styles.link,
|
styles.link,
|
||||||
to && styles.to,
|
to && styles.to,
|
||||||
isDisabled && 'isDisabled'
|
isDisabled && 'isDisabled'
|
||||||
);
|
);
|
||||||
|
|
||||||
const elementProps = {
|
if (to) {
|
||||||
...otherProps,
|
const toLink = /\w+?:\/\//.test(to);
|
||||||
type,
|
|
||||||
...linkProps,
|
|
||||||
};
|
|
||||||
|
|
||||||
elementProps.onClick = onClick;
|
if (toLink || noRouter) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={to}
|
||||||
|
target={target || (toLink ? '_blank' : '_self')}
|
||||||
|
rel={toLink ? 'noreferrer' : undefined}
|
||||||
|
className={linkClass}
|
||||||
|
onClick={onClick}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return React.createElement(el, elementProps);
|
return (
|
||||||
|
<RouterLink
|
||||||
|
to={`${window.Sonarr.urlBase}/${to.replace(/^\//, '')}`}
|
||||||
|
target={target}
|
||||||
|
className={linkClass}
|
||||||
|
onClick={onClick}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Component
|
||||||
|
type={
|
||||||
|
component === 'button' || component === 'input'
|
||||||
|
? type || 'button'
|
||||||
|
: type
|
||||||
|
}
|
||||||
|
target={target}
|
||||||
|
className={linkClass}
|
||||||
|
disabled={isDisabled}
|
||||||
|
onClick={onClick}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default Link;
|
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
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={styles.rippleContainer}
|
|
||||||
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;
|
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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={styles.rippleContainer} 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;
|
||||||
+9
-13
@@ -8,21 +8,21 @@ const messages = [
|
|||||||
'Bleep Bloop.',
|
'Bleep Bloop.',
|
||||||
'Locating the required gigapixels to render...',
|
'Locating the required gigapixels to render...',
|
||||||
'Spinning up the hamster wheel...',
|
'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',
|
'Hum something loud while others stare',
|
||||||
'Loading humorous message... Please Wait',
|
'Loading humorous message... Please Wait',
|
||||||
'I could\'ve been faster in Python',
|
"I could've been faster in Python",
|
||||||
'Don\'t forget to rewind your episodes',
|
"Don't forget to rewind your episodes",
|
||||||
'Congratulations! You are the 1000th visitor.',
|
'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...',
|
'RE-calibrating the internet...',
|
||||||
'I\'ll be here all week',
|
"I'll be here all week",
|
||||||
'Don\'t forget to tip your waitress',
|
"Don't forget to tip your waitress",
|
||||||
'Apply directly to the forehead',
|
'Apply directly to the forehead',
|
||||||
'Loading Battlestation'
|
'Loading Battlestation',
|
||||||
];
|
];
|
||||||
|
|
||||||
let message = null;
|
let message: string | null = null;
|
||||||
|
|
||||||
function LoadingMessage() {
|
function LoadingMessage() {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@@ -30,11 +30,7 @@ function LoadingMessage() {
|
|||||||
message = messages[index];
|
message = messages[index];
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return <div className={styles.loadingMessage}>{message}</div>;
|
||||||
<div className={styles.loadingMessage}>
|
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default LoadingMessage;
|
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;
|
|
||||||
@@ -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
-4
@@ -6,10 +6,7 @@ import styles from './MetadataAttribution.css';
|
|||||||
export default function MetadataAttribution() {
|
export default function MetadataAttribution() {
|
||||||
return (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<Link
|
<Link className={styles.attribution} to="/settings/metadatasource">
|
||||||
className={styles.attribution}
|
|
||||||
to="/settings/metadatasource"
|
|
||||||
>
|
|
||||||
{translate('MetadataProvidedBy', { provider: 'TheTVDB' })}
|
{translate('MetadataProvidedBy', { provider: 'TheTVDB' })}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
@@ -3,9 +3,9 @@
|
|||||||
|
|
||||||
padding: 0;
|
padding: 0;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
}
|
|
||||||
|
|
||||||
.isDisabled {
|
&.isDisabled {
|
||||||
color: var(--disabledColor);
|
color: var(--disabledColor);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,80 +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 translate from 'Utilities/String/translate';
|
|
||||||
import styles from './MonitorToggleButton.css';
|
|
||||||
|
|
||||||
function getTooltip(monitored, isDisabled) {
|
|
||||||
if (isDisabled) {
|
|
||||||
return translate('ToggleMonitoredSeriesUnmonitored ');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (monitored) {
|
|
||||||
return translate('ToggleMonitoredToUnmonitored');
|
|
||||||
}
|
|
||||||
|
|
||||||
return translate('ToggleUnmonitoredToMonitored');
|
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
@@ -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 translate('ToggleMonitoredSeriesUnmonitored');
|
||||||
|
}
|
||||||
|
|
||||||
|
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,18 +1,19 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import PageContent from 'Components/Page/PageContent';
|
import PageContent from 'Components/Page/PageContent';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import styles from './NotFound.css';
|
import styles from './NotFound.css';
|
||||||
|
|
||||||
function NotFound(props) {
|
interface NotFoundProps {
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotFound(props: NotFoundProps) {
|
||||||
const { message = translate('DefaultNotFoundMessage') } = props;
|
const { message = translate('DefaultNotFoundMessage') } = props;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContent title="MIA">
|
<PageContent title="MIA">
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.message}>
|
<div className={styles.message}>{message}</div>
|
||||||
{message}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<img
|
<img
|
||||||
className={styles.image}
|
className={styles.image}
|
||||||
@@ -23,8 +24,4 @@ function NotFound(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
NotFound.propTypes = {
|
|
||||||
message: PropTypes.string
|
|
||||||
};
|
|
||||||
|
|
||||||
export default NotFound;
|
export default NotFound;
|
||||||
@@ -6,7 +6,7 @@ import Link from 'Components/Link/Link';
|
|||||||
import { icons } from 'Helpers/Props';
|
import { icons } from 'Helpers/Props';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
||||||
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
|
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||||
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
|
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
|
||||||
import styles from './PageHeader.css';
|
import styles from './PageHeader.css';
|
||||||
|
|
||||||
@@ -83,7 +83,8 @@ class PageHeader extends Component {
|
|||||||
size={14}
|
size={14}
|
||||||
title={translate('Donate')}
|
title={translate('Donate')}
|
||||||
/>
|
/>
|
||||||
<PageHeaderActionsMenuConnector
|
|
||||||
|
<PageHeaderActionsMenu
|
||||||
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
|
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,90 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Icon from 'Components/Icon';
|
|
||||||
import Menu from 'Components/Menu/Menu';
|
|
||||||
import MenuButton from 'Components/Menu/MenuButton';
|
|
||||||
import MenuContent from 'Components/Menu/MenuContent';
|
|
||||||
import MenuItem from 'Components/Menu/MenuItem';
|
|
||||||
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
|
|
||||||
import { align, icons, kinds } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import styles from './PageHeaderActionsMenu.css';
|
|
||||||
|
|
||||||
function PageHeaderActionsMenu(props) {
|
|
||||||
const {
|
|
||||||
formsAuth,
|
|
||||||
onKeyboardShortcutsPress,
|
|
||||||
onRestartPress,
|
|
||||||
onShutdownPress
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<Menu alignMenu={align.RIGHT}>
|
|
||||||
<MenuButton className={styles.menuButton} aria-label="Menu Button">
|
|
||||||
<Icon
|
|
||||||
name={icons.INTERACTIVE}
|
|
||||||
title={translate('Menu')}
|
|
||||||
/>
|
|
||||||
</MenuButton>
|
|
||||||
|
|
||||||
<MenuContent>
|
|
||||||
<MenuItem onPress={onKeyboardShortcutsPress}>
|
|
||||||
<Icon
|
|
||||||
className={styles.itemIcon}
|
|
||||||
name={icons.KEYBOARD}
|
|
||||||
/>
|
|
||||||
{translate('KeyboardShortcuts')}
|
|
||||||
</MenuItem>
|
|
||||||
|
|
||||||
<MenuItemSeparator />
|
|
||||||
|
|
||||||
<MenuItem onPress={onRestartPress}>
|
|
||||||
<Icon
|
|
||||||
className={styles.itemIcon}
|
|
||||||
name={icons.RESTART}
|
|
||||||
/>
|
|
||||||
{translate('Restart')}
|
|
||||||
</MenuItem>
|
|
||||||
|
|
||||||
<MenuItem onPress={onShutdownPress}>
|
|
||||||
<Icon
|
|
||||||
className={styles.itemIcon}
|
|
||||||
name={icons.SHUTDOWN}
|
|
||||||
kind={kinds.DANGER}
|
|
||||||
/>
|
|
||||||
{translate('Shutdown')}
|
|
||||||
</MenuItem>
|
|
||||||
|
|
||||||
{
|
|
||||||
formsAuth &&
|
|
||||||
<div className={styles.separator} />
|
|
||||||
}
|
|
||||||
|
|
||||||
{
|
|
||||||
formsAuth &&
|
|
||||||
<MenuItem
|
|
||||||
to={`${window.Sonarr.urlBase}/logout`}
|
|
||||||
noRouter={true}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className={styles.itemIcon}
|
|
||||||
name={icons.LOGOUT}
|
|
||||||
/>
|
|
||||||
{translate('Logout')}
|
|
||||||
</MenuItem>
|
|
||||||
}
|
|
||||||
</MenuContent>
|
|
||||||
</Menu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
PageHeaderActionsMenu.propTypes = {
|
|
||||||
formsAuth: PropTypes.bool.isRequired,
|
|
||||||
onKeyboardShortcutsPress: PropTypes.func.isRequired,
|
|
||||||
onRestartPress: PropTypes.func.isRequired,
|
|
||||||
onShutdownPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default PageHeaderActionsMenu;
|
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import AppState from 'App/State/AppState';
|
||||||
|
import Icon from 'Components/Icon';
|
||||||
|
import Menu from 'Components/Menu/Menu';
|
||||||
|
import MenuButton from 'Components/Menu/MenuButton';
|
||||||
|
import MenuContent from 'Components/Menu/MenuContent';
|
||||||
|
import MenuItem from 'Components/Menu/MenuItem';
|
||||||
|
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
|
||||||
|
import { align, icons, kinds } from 'Helpers/Props';
|
||||||
|
import { restart, shutdown } from 'Store/Actions/systemActions';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './PageHeaderActionsMenu.css';
|
||||||
|
|
||||||
|
interface PageHeaderActionsMenuProps {
|
||||||
|
onKeyboardShortcutsPress(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PageHeaderActionsMenu(props: PageHeaderActionsMenuProps) {
|
||||||
|
const { onKeyboardShortcutsPress } = props;
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const { authentication, isDocker } = useSelector(
|
||||||
|
(state: AppState) => state.system.status.item
|
||||||
|
);
|
||||||
|
|
||||||
|
const formsAuth = authentication === 'forms';
|
||||||
|
|
||||||
|
const handleRestartPress = useCallback(() => {
|
||||||
|
dispatch(restart());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const handleShutdownPress = useCallback(() => {
|
||||||
|
dispatch(shutdown());
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Menu alignMenu={align.RIGHT}>
|
||||||
|
<MenuButton className={styles.menuButton} aria-label="Menu Button">
|
||||||
|
<Icon name={icons.INTERACTIVE} title={translate('Menu')} />
|
||||||
|
</MenuButton>
|
||||||
|
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem onPress={onKeyboardShortcutsPress}>
|
||||||
|
<Icon className={styles.itemIcon} name={icons.KEYBOARD} />
|
||||||
|
{translate('KeyboardShortcuts')}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
{isDocker ? null : (
|
||||||
|
<>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
|
||||||
|
<MenuItem onPress={handleRestartPress}>
|
||||||
|
<Icon className={styles.itemIcon} name={icons.RESTART} />
|
||||||
|
{translate('Restart')}
|
||||||
|
</MenuItem>
|
||||||
|
|
||||||
|
<MenuItem onPress={handleShutdownPress}>
|
||||||
|
<Icon
|
||||||
|
className={styles.itemIcon}
|
||||||
|
name={icons.SHUTDOWN}
|
||||||
|
kind={kinds.DANGER}
|
||||||
|
/>
|
||||||
|
{translate('Shutdown')}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{formsAuth ? (
|
||||||
|
<>
|
||||||
|
<MenuItemSeparator />
|
||||||
|
|
||||||
|
<MenuItem to={`${window.Sonarr.urlBase}/logout`} noRouter={true}>
|
||||||
|
<Icon className={styles.itemIcon} name={icons.LOGOUT} />
|
||||||
|
{translate('Logout')}
|
||||||
|
</MenuItem>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default PageHeaderActionsMenu;
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import { restart, shutdown } from 'Store/Actions/systemActions';
|
|
||||||
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state) => state.system.status,
|
|
||||||
(status) => {
|
|
||||||
return {
|
|
||||||
formsAuth: status.item.authentication === 'forms'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
restart,
|
|
||||||
shutdown
|
|
||||||
};
|
|
||||||
|
|
||||||
class PageHeaderActionsMenuConnector extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onRestartPress = () => {
|
|
||||||
this.props.restart();
|
|
||||||
};
|
|
||||||
|
|
||||||
onShutdownPress = () => {
|
|
||||||
this.props.shutdown();
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<PageHeaderActionsMenu
|
|
||||||
{...this.props}
|
|
||||||
onRestartPress={this.onRestartPress}
|
|
||||||
onShutdownPress={this.onShutdownPress}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
PageHeaderActionsMenuConnector.propTypes = {
|
|
||||||
restart: PropTypes.func.isRequired,
|
|
||||||
shutdown: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
|
import React, { ForwardedRef, forwardRef, ReactNode, useCallback } from 'react';
|
||||||
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
|
import Scroller, { OnScroll } from 'Components/Scroller/Scroller';
|
||||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
|
||||||
import { isLocked } from 'Utilities/scrollLock';
|
import { isLocked } from 'Utilities/scrollLock';
|
||||||
import styles from './PageContentBody.css';
|
import styles from './PageContentBody.css';
|
||||||
|
|
||||||
@@ -36,7 +35,7 @@ const PageContentBody = forwardRef(
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
{...otherProps}
|
{...otherProps}
|
||||||
className={className}
|
className={className}
|
||||||
scrollDirection={ScrollDirection.Vertical}
|
scrollDirection="vertical"
|
||||||
onScroll={onScrollWrapper}
|
onScroll={onScrollWrapper}
|
||||||
>
|
>
|
||||||
<div className={innerClassName}>{children}</div>
|
<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;
|
|
||||||
@@ -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,114 +0,0 @@
|
|||||||
import classNames from 'classnames';
|
|
||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
|
|
||||||
import { kinds, sizes } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import styles from './ProgressBar.css';
|
|
||||||
|
|
||||||
function ProgressBar(props) {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
containerClassName,
|
|
||||||
title,
|
|
||||||
progress,
|
|
||||||
precision,
|
|
||||||
showText,
|
|
||||||
text,
|
|
||||||
kind,
|
|
||||||
size,
|
|
||||||
width
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
const progressPercent = `${progress.toFixed(precision)}%`;
|
|
||||||
const progressText = text || progressPercent;
|
|
||||||
const actualWidth = width ? `${width}px` : '100%';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ColorImpairedConsumer>
|
|
||||||
{(enableColorImpairedMode) => {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
containerClassName,
|
|
||||||
styles[size]
|
|
||||||
)}
|
|
||||||
title={title}
|
|
||||||
style={{ width: actualWidth }}
|
|
||||||
>
|
|
||||||
{
|
|
||||||
showText && width ?
|
|
||||||
<div
|
|
||||||
className={classNames(styles.backTextContainer, styles[kind])}
|
|
||||||
style={{ width: actualWidth }}
|
|
||||||
>
|
|
||||||
<div className={styles.backText}>
|
|
||||||
<div>
|
|
||||||
{progressText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
className,
|
|
||||||
styles[kind],
|
|
||||||
enableColorImpairedMode && 'colorImpaired'
|
|
||||||
)}
|
|
||||||
role="meter"
|
|
||||||
aria-label={translate('ProgressBarProgress', { progress: progress.toFixed(0) })}
|
|
||||||
aria-valuenow={progress.toFixed(0)}
|
|
||||||
aria-valuemin="0"
|
|
||||||
aria-valuemax="100"
|
|
||||||
style={{ width: progressPercent }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{
|
|
||||||
showText ?
|
|
||||||
<div
|
|
||||||
className={classNames(styles.frontTextContainer, styles[kind])}
|
|
||||||
style={{ width: progressPercent }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={styles.frontText}
|
|
||||||
style={{ width: actualWidth }}
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
{progressText}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div> :
|
|
||||||
null
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
</ColorImpairedConsumer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
ProgressBar.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
containerClassName: PropTypes.string,
|
|
||||||
title: PropTypes.string,
|
|
||||||
progress: PropTypes.number.isRequired,
|
|
||||||
precision: PropTypes.number.isRequired,
|
|
||||||
showText: PropTypes.bool.isRequired,
|
|
||||||
text: PropTypes.string,
|
|
||||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
|
||||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
|
||||||
width: PropTypes.number
|
|
||||||
};
|
|
||||||
|
|
||||||
ProgressBar.defaultProps = {
|
|
||||||
className: styles.progressBar,
|
|
||||||
containerClassName: styles.container,
|
|
||||||
precision: 1,
|
|
||||||
showText: false,
|
|
||||||
kind: kinds.PRIMARY,
|
|
||||||
size: sizes.MEDIUM
|
|
||||||
};
|
|
||||||
|
|
||||||
export default ProgressBar;
|
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import classNames from 'classnames';
|
||||||
|
import React from 'react';
|
||||||
|
import { ColorImpairedConsumer } from 'App/ColorImpairedContext';
|
||||||
|
import { Kind } from 'Helpers/Props/kinds';
|
||||||
|
import { Size } from 'Helpers/Props/sizes';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import styles from './ProgressBar.css';
|
||||||
|
|
||||||
|
interface ProgressBarProps {
|
||||||
|
className?: string;
|
||||||
|
containerClassName?: string;
|
||||||
|
title?: string;
|
||||||
|
progress: number;
|
||||||
|
precision?: number;
|
||||||
|
showText?: boolean;
|
||||||
|
text?: string;
|
||||||
|
kind?: Extract<Kind, keyof typeof styles>;
|
||||||
|
size?: Extract<Size, keyof typeof styles>;
|
||||||
|
width?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProgressBar({
|
||||||
|
className = styles.progressBar,
|
||||||
|
containerClassName = styles.container,
|
||||||
|
title,
|
||||||
|
progress,
|
||||||
|
precision = 1,
|
||||||
|
showText = false,
|
||||||
|
text,
|
||||||
|
kind = 'primary',
|
||||||
|
size = 'medium',
|
||||||
|
width,
|
||||||
|
}: ProgressBarProps) {
|
||||||
|
const progressPercent = `${progress.toFixed(precision)}%`;
|
||||||
|
const progressText = text || progressPercent;
|
||||||
|
const actualWidth = width ? `${width}px` : '100%';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ColorImpairedConsumer>
|
||||||
|
{(enableColorImpairedMode) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={classNames(containerClassName, styles[size])}
|
||||||
|
title={title}
|
||||||
|
style={{ width: actualWidth }}
|
||||||
|
>
|
||||||
|
{showText && width ? (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.backTextContainer, styles[kind])}
|
||||||
|
style={{ width: actualWidth }}
|
||||||
|
>
|
||||||
|
<div className={styles.backText}>
|
||||||
|
<div>{progressText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={classNames(
|
||||||
|
className,
|
||||||
|
styles[kind],
|
||||||
|
enableColorImpairedMode && 'colorImpaired'
|
||||||
|
)}
|
||||||
|
role="meter"
|
||||||
|
aria-label={translate('ProgressBarProgress', {
|
||||||
|
progress: progress.toFixed(0),
|
||||||
|
})}
|
||||||
|
aria-valuenow={Math.floor(progress)}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
|
style={{ width: progressPercent }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{showText ? (
|
||||||
|
<div
|
||||||
|
className={classNames(styles.frontTextContainer, styles[kind])}
|
||||||
|
style={{ width: progressPercent }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={styles.frontText}
|
||||||
|
style={{ width: actualWidth }}
|
||||||
|
>
|
||||||
|
<div>{progressText}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
</ColorImpairedConsumer>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ProgressBar;
|
||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -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,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
|
||||||
import styles from './Scroller.css';
|
import styles from './Scroller.css';
|
||||||
|
|
||||||
export interface OnScroll {
|
export interface OnScroll {
|
||||||
@@ -33,7 +33,7 @@ const Scroller = forwardRef(
|
|||||||
className,
|
className,
|
||||||
autoFocus = false,
|
autoFocus = false,
|
||||||
autoScroll = true,
|
autoScroll = true,
|
||||||
scrollDirection = ScrollDirection.Vertical,
|
scrollDirection = 'vertical',
|
||||||
children,
|
children,
|
||||||
scrollTop,
|
scrollTop,
|
||||||
initialScrollTop,
|
initialScrollTop,
|
||||||
@@ -59,7 +59,7 @@ const Scroller = forwardRef(
|
|||||||
currentRef.current.scrollTop = scrollTop;
|
currentRef.current.scrollTop = scrollTop;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (autoFocus && scrollDirection !== ScrollDirection.None) {
|
if (autoFocus && scrollDirection !== 'none') {
|
||||||
currentRef.current.focus({ preventScroll: true });
|
currentRef.current.focus({ preventScroll: true });
|
||||||
}
|
}
|
||||||
}, [autoFocus, currentRef, scrollDirection, scrollTop]);
|
}, [autoFocus, currentRef, scrollDirection, scrollTop]);
|
||||||
|
|||||||
@@ -1,34 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import { icons } from 'Helpers/Props';
|
|
||||||
import Icon from './Icon';
|
|
||||||
|
|
||||||
function SpinnerIcon(props) {
|
|
||||||
const {
|
|
||||||
name,
|
|
||||||
spinningName,
|
|
||||||
isSpinning,
|
|
||||||
...otherProps
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Icon
|
|
||||||
name={isSpinning ? (spinningName || name) : name}
|
|
||||||
isSpinning={isSpinning}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
SpinnerIcon.propTypes = {
|
|
||||||
className: PropTypes.string,
|
|
||||||
name: PropTypes.object.isRequired,
|
|
||||||
spinningName: PropTypes.object.isRequired,
|
|
||||||
isSpinning: PropTypes.bool.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
SpinnerIcon.defaultProps = {
|
|
||||||
spinningName: icons.SPINNER
|
|
||||||
};
|
|
||||||
|
|
||||||
export default SpinnerIcon;
|
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import Icon, { IconProps } from './Icon';
|
||||||
|
|
||||||
|
export interface SpinnerIconProps extends IconProps {
|
||||||
|
spinningName?: IconProps['name'];
|
||||||
|
isSpinning: Required<IconProps['isSpinning']>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SpinnerIcon({
|
||||||
|
name,
|
||||||
|
spinningName = icons.SPINNER,
|
||||||
|
isSpinning,
|
||||||
|
...otherProps
|
||||||
|
}: SpinnerIconProps) {
|
||||||
|
return (
|
||||||
|
<Icon
|
||||||
|
name={(isSpinning && spinningName) || name}
|
||||||
|
isSpinning={isSpinning}
|
||||||
|
{...otherProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import styles from './TableRowCell.css';
|
|
||||||
|
|
||||||
class TableRowCell extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
className={className}
|
|
||||||
{...otherProps}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
TableRowCell.propTypes = {
|
|
||||||
className: PropTypes.string.isRequired,
|
|
||||||
children: PropTypes.oneOfType([PropTypes.string, PropTypes.node])
|
|
||||||
};
|
|
||||||
|
|
||||||
TableRowCell.defaultProps = {
|
|
||||||
className: styles.cell
|
|
||||||
};
|
|
||||||
|
|
||||||
export default TableRowCell;
|
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import React, { ComponentPropsWithoutRef } from 'react';
|
||||||
|
import styles from './TableRowCell.css';
|
||||||
|
|
||||||
|
export interface TableRowCellProps extends ComponentPropsWithoutRef<'td'> {}
|
||||||
|
|
||||||
|
export default function TableRowCell({
|
||||||
|
className = styles.cell,
|
||||||
|
...tdProps
|
||||||
|
}: TableRowCellProps) {
|
||||||
|
return <td className={className} {...tdProps} />;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||||
|
|
||||||
type PropertyFunction<T> = () => T;
|
type PropertyFunction<T> = () => T;
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ interface Column {
|
|||||||
className?: string;
|
className?: string;
|
||||||
columnLabel?: string;
|
columnLabel?: string;
|
||||||
isSortable?: boolean;
|
isSortable?: boolean;
|
||||||
|
fixedSortDirection?: SortDirection;
|
||||||
isVisible: boolean;
|
isVisible: boolean;
|
||||||
isModifiable?: boolean;
|
isModifiable?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -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;
|
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
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(0);
|
||||||
|
const updater = useRef<(() => void) | null>(null);
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
const handleClick = useCallback(() => {
|
||||||
|
if (!isMobileUtil()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen((isOpen) => {
|
||||||
|
return !isOpen;
|
||||||
|
});
|
||||||
|
}, [setIsOpen]);
|
||||||
|
|
||||||
|
const handleMouseEnter = 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) {
|
||||||
|
window.clearTimeout(closeTimeout.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsOpen(true);
|
||||||
|
}, [setIsOpen]);
|
||||||
|
|
||||||
|
const handleMouseLeave = useCallback(() => {
|
||||||
|
// Still listen for mouse leave on mobile to allow clicks outside to close the tooltip.
|
||||||
|
closeTimeout.current = window.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) {
|
||||||
|
window.clearTimeout(closeTimeout.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Manager>
|
||||||
|
<Reference>
|
||||||
|
{({ ref }) => (
|
||||||
|
<span
|
||||||
|
ref={ref}
|
||||||
|
className={className}
|
||||||
|
onClick={handleClick}
|
||||||
|
onMouseEnter={handleMouseEnter}
|
||||||
|
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={handleMouseEnter}
|
||||||
|
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;
|
||||||
@@ -9,6 +9,7 @@ interface Episode extends ModelBase {
|
|||||||
episodeNumber: number;
|
episodeNumber: number;
|
||||||
airDate: string;
|
airDate: string;
|
||||||
airDateUtc?: string;
|
airDateUtc?: string;
|
||||||
|
lastSearchTime?: string;
|
||||||
runtime: number;
|
runtime: number;
|
||||||
absoluteEpisodeNumber?: number;
|
absoluteEpisodeNumber?: number;
|
||||||
sceneSeasonNumber?: number;
|
sceneSeasonNumber?: number;
|
||||||
@@ -19,6 +20,7 @@ interface Episode extends ModelBase {
|
|||||||
episodeFile?: object;
|
episodeFile?: object;
|
||||||
hasFile: boolean;
|
hasFile: boolean;
|
||||||
monitored: boolean;
|
monitored: boolean;
|
||||||
|
grabbed?: boolean;
|
||||||
unverifiedSceneNumbering: boolean;
|
unverifiedSceneNumbering: boolean;
|
||||||
endTime?: string;
|
endTime?: string;
|
||||||
grabDate?: string;
|
grabDate?: string;
|
||||||
|
|||||||
@@ -1,60 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import Modal from 'Components/Modal/Modal';
|
|
||||||
import { sizes } from 'Helpers/Props';
|
|
||||||
import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
|
|
||||||
|
|
||||||
class EpisodeDetailsModal extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
closeOnBackgroundClick: props.selectedTab !== 'search'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onTabChange = (isSearch) => {
|
|
||||||
this.setState({ closeOnBackgroundClick: !isSearch });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
isOpen,
|
|
||||||
onModalClose,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Modal
|
|
||||||
isOpen={isOpen}
|
|
||||||
size={sizes.EXTRA_EXTRA_LARGE}
|
|
||||||
closeOnBackgroundClick={this.state.closeOnBackgroundClick}
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
>
|
|
||||||
<EpisodeDetailsModalContentConnector
|
|
||||||
{...otherProps}
|
|
||||||
onTabChange={this.onTabChange}
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
/>
|
|
||||||
</Modal>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EpisodeDetailsModal.propTypes = {
|
|
||||||
selectedTab: PropTypes.string,
|
|
||||||
isOpen: PropTypes.bool.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EpisodeDetailsModal;
|
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import React, { useCallback, useState } from 'react';
|
||||||
|
import Modal from 'Components/Modal/Modal';
|
||||||
|
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
|
||||||
|
import { EpisodeEntities } from 'Episode/useEpisode';
|
||||||
|
import { sizes } from 'Helpers/Props';
|
||||||
|
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
|
||||||
|
|
||||||
|
interface EpisodeDetailsModalProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
episodeId: number;
|
||||||
|
episodeEntity: EpisodeEntities;
|
||||||
|
seriesId: number;
|
||||||
|
episodeTitle: string;
|
||||||
|
isSaving?: boolean;
|
||||||
|
showOpenSeriesButton?: boolean;
|
||||||
|
selectedTab?: EpisodeDetailsTab;
|
||||||
|
startInteractiveSearch?: boolean;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeDetailsModal(props: EpisodeDetailsModalProps) {
|
||||||
|
const { selectedTab, isOpen, onModalClose, ...otherProps } = props;
|
||||||
|
|
||||||
|
const [closeOnBackgroundClick, setCloseOnBackgroundClick] = useState(
|
||||||
|
selectedTab !== 'search'
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(isSearch: boolean) => {
|
||||||
|
setCloseOnBackgroundClick(!isSearch);
|
||||||
|
},
|
||||||
|
[setCloseOnBackgroundClick]
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
size={sizes.EXTRA_EXTRA_LARGE}
|
||||||
|
closeOnBackgroundClick={closeOnBackgroundClick}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
>
|
||||||
|
<EpisodeDetailsModalContent
|
||||||
|
{...otherProps}
|
||||||
|
selectedTab={selectedTab}
|
||||||
|
onTabChange={handleTabChange}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EpisodeDetailsModal;
|
||||||
@@ -1,222 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
|
||||||
import Button from 'Components/Link/Button';
|
|
||||||
import ModalBody from 'Components/Modal/ModalBody';
|
|
||||||
import ModalContent from 'Components/Modal/ModalContent';
|
|
||||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
|
||||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
|
||||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
|
||||||
import episodeEntities from 'Episode/episodeEntities';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
|
|
||||||
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
|
|
||||||
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
|
|
||||||
import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector';
|
|
||||||
import styles from './EpisodeDetailsModalContent.css';
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
'details',
|
|
||||||
'history',
|
|
||||||
'search'
|
|
||||||
];
|
|
||||||
|
|
||||||
class EpisodeDetailsModalContent extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
selectedTab: props.selectedTab
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onTabSelect = (index, lastIndex) => {
|
|
||||||
const selectedTab = tabs[index];
|
|
||||||
this.props.onTabChange(selectedTab === 'search');
|
|
||||||
this.setState({ selectedTab });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
episodeId,
|
|
||||||
episodeEntity,
|
|
||||||
episodeFileId,
|
|
||||||
seriesId,
|
|
||||||
seriesTitle,
|
|
||||||
titleSlug,
|
|
||||||
seriesMonitored,
|
|
||||||
seriesType,
|
|
||||||
seasonNumber,
|
|
||||||
episodeNumber,
|
|
||||||
absoluteEpisodeNumber,
|
|
||||||
episodeTitle,
|
|
||||||
airDate,
|
|
||||||
monitored,
|
|
||||||
isSaving,
|
|
||||||
showOpenSeriesButton,
|
|
||||||
startInteractiveSearch,
|
|
||||||
onMonitorEpisodePress,
|
|
||||||
onModalClose
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
const seriesLink = `/series/${titleSlug}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ModalContent
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
>
|
|
||||||
<ModalHeader>
|
|
||||||
<MonitorToggleButton
|
|
||||||
className={styles.toggleButton}
|
|
||||||
id={episodeId}
|
|
||||||
monitored={monitored}
|
|
||||||
size={18}
|
|
||||||
isDisabled={!seriesMonitored}
|
|
||||||
isSaving={isSaving}
|
|
||||||
onPress={onMonitorEpisodePress}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className={styles.seriesTitle}>
|
|
||||||
{seriesTitle}
|
|
||||||
</span>
|
|
||||||
|
|
||||||
<span className={styles.separator}>-</span>
|
|
||||||
|
|
||||||
<SeasonEpisodeNumber
|
|
||||||
seasonNumber={seasonNumber}
|
|
||||||
episodeNumber={episodeNumber}
|
|
||||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
|
||||||
airDate={airDate}
|
|
||||||
seriesType={seriesType}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<span className={styles.separator}>-</span>
|
|
||||||
|
|
||||||
{episodeTitle}
|
|
||||||
</ModalHeader>
|
|
||||||
|
|
||||||
<ModalBody>
|
|
||||||
<Tabs
|
|
||||||
className={styles.tabs}
|
|
||||||
selectedIndex={tabs.indexOf(this.state.selectedTab)}
|
|
||||||
onSelect={this.onTabSelect}
|
|
||||||
>
|
|
||||||
<TabList
|
|
||||||
className={styles.tabList}
|
|
||||||
>
|
|
||||||
<Tab
|
|
||||||
className={styles.tab}
|
|
||||||
selectedClassName={styles.selectedTab}
|
|
||||||
>
|
|
||||||
{translate('Details')}
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab
|
|
||||||
className={styles.tab}
|
|
||||||
selectedClassName={styles.selectedTab}
|
|
||||||
>
|
|
||||||
{translate('History')}
|
|
||||||
</Tab>
|
|
||||||
|
|
||||||
<Tab
|
|
||||||
className={styles.tab}
|
|
||||||
selectedClassName={styles.selectedTab}
|
|
||||||
>
|
|
||||||
{translate('Search')}
|
|
||||||
</Tab>
|
|
||||||
</TabList>
|
|
||||||
|
|
||||||
<TabPanel>
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<EpisodeSummaryConnector
|
|
||||||
episodeId={episodeId}
|
|
||||||
episodeEntity={episodeEntity}
|
|
||||||
episodeFileId={episodeFileId}
|
|
||||||
seriesId={seriesId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel>
|
|
||||||
<div className={styles.tabContent}>
|
|
||||||
<EpisodeHistoryConnector
|
|
||||||
episodeId={episodeId}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</TabPanel>
|
|
||||||
|
|
||||||
<TabPanel>
|
|
||||||
{/* Don't wrap in tabContent so we not have a top margin */}
|
|
||||||
<EpisodeSearchConnector
|
|
||||||
episodeId={episodeId}
|
|
||||||
startInteractiveSearch={startInteractiveSearch}
|
|
||||||
onModalClose={onModalClose}
|
|
||||||
/>
|
|
||||||
</TabPanel>
|
|
||||||
</Tabs>
|
|
||||||
</ModalBody>
|
|
||||||
|
|
||||||
<ModalFooter>
|
|
||||||
{
|
|
||||||
showOpenSeriesButton &&
|
|
||||||
<Button
|
|
||||||
className={styles.openSeriesButton}
|
|
||||||
to={seriesLink}
|
|
||||||
onPress={onModalClose}
|
|
||||||
>
|
|
||||||
{translate('OpenSeries')}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
onPress={onModalClose}
|
|
||||||
>
|
|
||||||
{translate('Close')}
|
|
||||||
</Button>
|
|
||||||
</ModalFooter>
|
|
||||||
</ModalContent>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EpisodeDetailsModalContent.propTypes = {
|
|
||||||
episodeId: PropTypes.number.isRequired,
|
|
||||||
episodeEntity: PropTypes.string.isRequired,
|
|
||||||
episodeFileId: PropTypes.number,
|
|
||||||
seriesId: PropTypes.number.isRequired,
|
|
||||||
seriesTitle: PropTypes.string.isRequired,
|
|
||||||
titleSlug: PropTypes.string.isRequired,
|
|
||||||
seriesMonitored: PropTypes.bool.isRequired,
|
|
||||||
seriesType: PropTypes.string.isRequired,
|
|
||||||
seasonNumber: PropTypes.number.isRequired,
|
|
||||||
episodeNumber: PropTypes.number.isRequired,
|
|
||||||
absoluteEpisodeNumber: PropTypes.number,
|
|
||||||
airDate: PropTypes.string.isRequired,
|
|
||||||
episodeTitle: PropTypes.string.isRequired,
|
|
||||||
monitored: PropTypes.bool.isRequired,
|
|
||||||
isSaving: PropTypes.bool,
|
|
||||||
showOpenSeriesButton: PropTypes.bool,
|
|
||||||
selectedTab: PropTypes.string.isRequired,
|
|
||||||
startInteractiveSearch: PropTypes.bool.isRequired,
|
|
||||||
onMonitorEpisodePress: PropTypes.func.isRequired,
|
|
||||||
onTabChange: PropTypes.func.isRequired,
|
|
||||||
onModalClose: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
EpisodeDetailsModalContent.defaultProps = {
|
|
||||||
selectedTab: 'details',
|
|
||||||
episodeEntity: episodeEntities.EPISODES,
|
|
||||||
startInteractiveSearch: false
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EpisodeDetailsModalContent;
|
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { useDispatch } from 'react-redux';
|
||||||
|
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||||
|
import Button from 'Components/Link/Button';
|
||||||
|
import ModalBody from 'Components/Modal/ModalBody';
|
||||||
|
import ModalContent from 'Components/Modal/ModalContent';
|
||||||
|
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||||
|
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||||
|
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||||
|
import Episode from 'Episode/Episode';
|
||||||
|
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
|
||||||
|
import episodeEntities from 'Episode/episodeEntities';
|
||||||
|
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
|
||||||
|
import Series from 'Series/Series';
|
||||||
|
import useSeries from 'Series/useSeries';
|
||||||
|
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
|
||||||
|
import {
|
||||||
|
cancelFetchReleases,
|
||||||
|
clearReleases,
|
||||||
|
} from 'Store/Actions/releaseActions';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
|
||||||
|
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
|
||||||
|
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
|
||||||
|
import EpisodeSummary from './Summary/EpisodeSummary';
|
||||||
|
import styles from './EpisodeDetailsModalContent.css';
|
||||||
|
|
||||||
|
const TABS: EpisodeDetailsTab[] = ['details', 'history', 'search'];
|
||||||
|
|
||||||
|
export interface EpisodeDetailsModalContentProps {
|
||||||
|
episodeId: number;
|
||||||
|
episodeEntity: EpisodeEntities;
|
||||||
|
seriesId: number;
|
||||||
|
episodeTitle: string;
|
||||||
|
isSaving?: boolean;
|
||||||
|
showOpenSeriesButton?: boolean;
|
||||||
|
selectedTab?: EpisodeDetailsTab;
|
||||||
|
startInteractiveSearch?: boolean;
|
||||||
|
onTabChange(isSearch: boolean): void;
|
||||||
|
onModalClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
|
||||||
|
const {
|
||||||
|
episodeId,
|
||||||
|
episodeEntity = episodeEntities.EPISODES,
|
||||||
|
seriesId,
|
||||||
|
episodeTitle,
|
||||||
|
isSaving = false,
|
||||||
|
showOpenSeriesButton = false,
|
||||||
|
startInteractiveSearch = false,
|
||||||
|
selectedTab = 'details',
|
||||||
|
onTabChange,
|
||||||
|
onModalClose,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [currentlySelectedTab, setCurrentlySelectedTab] = useState(selectedTab);
|
||||||
|
|
||||||
|
const {
|
||||||
|
title: seriesTitle,
|
||||||
|
titleSlug,
|
||||||
|
monitored: seriesMonitored,
|
||||||
|
seriesType,
|
||||||
|
} = useSeries(seriesId) as Series;
|
||||||
|
|
||||||
|
const {
|
||||||
|
episodeFileId,
|
||||||
|
seasonNumber,
|
||||||
|
episodeNumber,
|
||||||
|
absoluteEpisodeNumber,
|
||||||
|
airDate,
|
||||||
|
monitored,
|
||||||
|
} = useEpisode(episodeId, episodeEntity) as Episode;
|
||||||
|
|
||||||
|
const handleTabSelect = useCallback(
|
||||||
|
(selectedIndex: number) => {
|
||||||
|
const tab = TABS[selectedIndex];
|
||||||
|
onTabChange(tab === 'search');
|
||||||
|
setCurrentlySelectedTab(tab);
|
||||||
|
},
|
||||||
|
[onTabChange]
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleMonitorEpisodePress = useCallback(
|
||||||
|
(monitored: boolean) => {
|
||||||
|
dispatch(
|
||||||
|
toggleEpisodeMonitored({
|
||||||
|
episodeEntity,
|
||||||
|
episodeId,
|
||||||
|
monitored,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[episodeEntity, episodeId, dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
// Clear pending releases here, so we can reshow the search
|
||||||
|
// results even after switching tabs.
|
||||||
|
dispatch(cancelFetchReleases());
|
||||||
|
dispatch(clearReleases());
|
||||||
|
};
|
||||||
|
}, [dispatch]);
|
||||||
|
|
||||||
|
const seriesLink = `/series/${titleSlug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalContent onModalClose={onModalClose}>
|
||||||
|
<ModalHeader>
|
||||||
|
<MonitorToggleButton
|
||||||
|
monitored={monitored}
|
||||||
|
size={18}
|
||||||
|
isDisabled={!seriesMonitored}
|
||||||
|
isSaving={isSaving}
|
||||||
|
onPress={handleMonitorEpisodePress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.seriesTitle}>{seriesTitle}</span>
|
||||||
|
|
||||||
|
<span className={styles.separator}>-</span>
|
||||||
|
|
||||||
|
<SeasonEpisodeNumber
|
||||||
|
seasonNumber={seasonNumber}
|
||||||
|
episodeNumber={episodeNumber}
|
||||||
|
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||||
|
airDate={airDate}
|
||||||
|
seriesType={seriesType}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span className={styles.separator}>-</span>
|
||||||
|
|
||||||
|
{episodeTitle}
|
||||||
|
</ModalHeader>
|
||||||
|
|
||||||
|
<ModalBody>
|
||||||
|
<Tabs
|
||||||
|
className={styles.tabs}
|
||||||
|
selectedIndex={TABS.indexOf(currentlySelectedTab)}
|
||||||
|
onSelect={handleTabSelect}
|
||||||
|
>
|
||||||
|
<TabList className={styles.tabList}>
|
||||||
|
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||||
|
{translate('Details')}
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||||
|
{translate('History')}
|
||||||
|
</Tab>
|
||||||
|
|
||||||
|
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||||
|
{translate('Search')}
|
||||||
|
</Tab>
|
||||||
|
</TabList>
|
||||||
|
|
||||||
|
<TabPanel>
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<EpisodeSummary
|
||||||
|
episodeId={episodeId}
|
||||||
|
episodeEntity={episodeEntity}
|
||||||
|
episodeFileId={episodeFileId}
|
||||||
|
seriesId={seriesId}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel>
|
||||||
|
<div className={styles.tabContent}>
|
||||||
|
<EpisodeHistoryConnector episodeId={episodeId} />
|
||||||
|
</div>
|
||||||
|
</TabPanel>
|
||||||
|
|
||||||
|
<TabPanel>
|
||||||
|
{/* Don't wrap in tabContent so we not have a top margin */}
|
||||||
|
<EpisodeSearchConnector
|
||||||
|
episodeId={episodeId}
|
||||||
|
startInteractiveSearch={startInteractiveSearch}
|
||||||
|
onModalClose={onModalClose}
|
||||||
|
/>
|
||||||
|
</TabPanel>
|
||||||
|
</Tabs>
|
||||||
|
</ModalBody>
|
||||||
|
|
||||||
|
<ModalFooter>
|
||||||
|
{showOpenSeriesButton && (
|
||||||
|
<Button
|
||||||
|
className={styles.openSeriesButton}
|
||||||
|
to={seriesLink}
|
||||||
|
onPress={onModalClose}
|
||||||
|
>
|
||||||
|
{translate('OpenSeries')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalContent>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EpisodeDetailsModalContent;
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import episodeEntities from 'Episode/episodeEntities';
|
|
||||||
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
|
|
||||||
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
|
|
||||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
|
||||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
|
||||||
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
createEpisodeSelector(),
|
|
||||||
createSeriesSelector(),
|
|
||||||
(episode, series) => {
|
|
||||||
const {
|
|
||||||
title: seriesTitle,
|
|
||||||
titleSlug,
|
|
||||||
monitored: seriesMonitored,
|
|
||||||
seriesType
|
|
||||||
} = series;
|
|
||||||
|
|
||||||
return {
|
|
||||||
seriesTitle,
|
|
||||||
titleSlug,
|
|
||||||
seriesMonitored,
|
|
||||||
seriesType,
|
|
||||||
...episode
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapDispatchToProps(dispatch, props) {
|
|
||||||
return {
|
|
||||||
dispatchCancelFetchReleases() {
|
|
||||||
dispatch(cancelFetchReleases());
|
|
||||||
},
|
|
||||||
|
|
||||||
dispatchClearReleases() {
|
|
||||||
dispatch(clearReleases());
|
|
||||||
},
|
|
||||||
|
|
||||||
onMonitorEpisodePress(monitored) {
|
|
||||||
const {
|
|
||||||
episodeId,
|
|
||||||
episodeEntity
|
|
||||||
} = props;
|
|
||||||
|
|
||||||
dispatch(toggleEpisodeMonitored({
|
|
||||||
episodeEntity,
|
|
||||||
episodeId,
|
|
||||||
monitored
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
class EpisodeDetailsModalContentConnector extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
componentWillUnmount() {
|
|
||||||
// Clear pending releases here, so we can reshow the search
|
|
||||||
// results even after switching tabs.
|
|
||||||
|
|
||||||
this.props.dispatchCancelFetchReleases();
|
|
||||||
this.props.dispatchClearReleases();
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
dispatchCancelFetchReleases,
|
|
||||||
dispatchClearReleases,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EpisodeDetailsModalContent {...otherProps} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EpisodeDetailsModalContentConnector.propTypes = {
|
|
||||||
episodeId: PropTypes.number.isRequired,
|
|
||||||
episodeEntity: PropTypes.string.isRequired,
|
|
||||||
seriesId: PropTypes.number.isRequired,
|
|
||||||
dispatchCancelFetchReleases: PropTypes.func.isRequired,
|
|
||||||
dispatchClearReleases: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
EpisodeDetailsModalContentConnector.defaultProps = {
|
|
||||||
episodeEntity: episodeEntities.EPISODES
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
type EpisodeDetailsTab = 'details' | 'history' | 'search';
|
||||||
|
|
||||||
|
export default EpisodeDetailsTab;
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
|
||||||
import Label from 'Components/Label';
|
|
||||||
import { kinds } from 'Helpers/Props';
|
|
||||||
|
|
||||||
function EpisodeFormats({ formats }) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
{
|
|
||||||
formats.map((format) => {
|
|
||||||
return (
|
|
||||||
<Label
|
|
||||||
key={format.id}
|
|
||||||
kind={kinds.INFO}
|
|
||||||
>
|
|
||||||
{format.name}
|
|
||||||
</Label>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
EpisodeFormats.propTypes = {
|
|
||||||
formats: PropTypes.arrayOf(PropTypes.object).isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
EpisodeFormats.defaultProps = {
|
|
||||||
formats: []
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EpisodeFormats;
|
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import Label from 'Components/Label';
|
||||||
|
import { kinds } from 'Helpers/Props';
|
||||||
|
import CustomFormat from 'typings/CustomFormat';
|
||||||
|
|
||||||
|
interface EpisodeFormatsProps {
|
||||||
|
formats: CustomFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeFormats({ formats }: EpisodeFormatsProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{formats.map(({ id, name }) => (
|
||||||
|
<Label key={id} kind={kinds.INFO}>
|
||||||
|
{name}
|
||||||
|
</Label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EpisodeFormats;
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React, { Component } from 'react';
|
|
||||||
import IconButton from 'Components/Link/IconButton';
|
|
||||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
|
||||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
|
||||||
import { icons } from 'Helpers/Props';
|
|
||||||
import translate from 'Utilities/String/translate';
|
|
||||||
import EpisodeDetailsModal from './EpisodeDetailsModal';
|
|
||||||
import styles from './EpisodeSearchCell.css';
|
|
||||||
|
|
||||||
class EpisodeSearchCell extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Lifecycle
|
|
||||||
|
|
||||||
constructor(props, context) {
|
|
||||||
super(props, context);
|
|
||||||
|
|
||||||
this.state = {
|
|
||||||
isDetailsModalOpen: false
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
//
|
|
||||||
// Listeners
|
|
||||||
|
|
||||||
onManualSearchPress = () => {
|
|
||||||
this.setState({ isDetailsModalOpen: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
onDetailsModalClose = () => {
|
|
||||||
this.setState({ isDetailsModalOpen: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
const {
|
|
||||||
episodeId,
|
|
||||||
seriesId,
|
|
||||||
episodeTitle,
|
|
||||||
isSearching,
|
|
||||||
onSearchPress,
|
|
||||||
...otherProps
|
|
||||||
} = this.props;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<TableRowCell className={styles.episodeSearchCell}>
|
|
||||||
<SpinnerIconButton
|
|
||||||
name={icons.SEARCH}
|
|
||||||
isSpinning={isSearching}
|
|
||||||
onPress={onSearchPress}
|
|
||||||
title={translate('AutomaticSearch')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<IconButton
|
|
||||||
name={icons.INTERACTIVE}
|
|
||||||
onPress={this.onManualSearchPress}
|
|
||||||
title={translate('InteractiveSearch')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<EpisodeDetailsModal
|
|
||||||
isOpen={this.state.isDetailsModalOpen}
|
|
||||||
episodeId={episodeId}
|
|
||||||
seriesId={seriesId}
|
|
||||||
episodeTitle={episodeTitle}
|
|
||||||
selectedTab="search"
|
|
||||||
startInteractiveSearch={true}
|
|
||||||
onModalClose={this.onDetailsModalClose}
|
|
||||||
{...otherProps}
|
|
||||||
/>
|
|
||||||
</TableRowCell>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EpisodeSearchCell.propTypes = {
|
|
||||||
episodeId: PropTypes.number.isRequired,
|
|
||||||
seriesId: PropTypes.number.isRequired,
|
|
||||||
episodeTitle: PropTypes.string.isRequired,
|
|
||||||
isSearching: PropTypes.bool.isRequired,
|
|
||||||
onSearchPress: PropTypes.func.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EpisodeSearchCell;
|
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
import React, { useCallback } from 'react';
|
||||||
|
import { useDispatch, useSelector } from 'react-redux';
|
||||||
|
import { EPISODE_SEARCH } from 'Commands/commandNames';
|
||||||
|
import IconButton from 'Components/Link/IconButton';
|
||||||
|
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||||
|
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||||
|
import { EpisodeEntities } from 'Episode/useEpisode';
|
||||||
|
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||||
|
import { icons } from 'Helpers/Props';
|
||||||
|
import { executeCommand } from 'Store/Actions/commandActions';
|
||||||
|
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
|
||||||
|
import translate from 'Utilities/String/translate';
|
||||||
|
import EpisodeDetailsModal from './EpisodeDetailsModal';
|
||||||
|
import styles from './EpisodeSearchCell.css';
|
||||||
|
|
||||||
|
interface EpisodeSearchCellProps {
|
||||||
|
episodeId: number;
|
||||||
|
episodeEntity: EpisodeEntities;
|
||||||
|
seriesId: number;
|
||||||
|
episodeTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeSearchCell(props: EpisodeSearchCellProps) {
|
||||||
|
const { episodeId, episodeEntity, seriesId, episodeTitle } = props;
|
||||||
|
|
||||||
|
const executingCommands = useSelector(createExecutingCommandsSelector());
|
||||||
|
const isSearching = executingCommands.some(({ name, body }) => {
|
||||||
|
const { episodeIds = [] } = body;
|
||||||
|
return name === EPISODE_SEARCH && episodeIds.indexOf(episodeId) > -1;
|
||||||
|
});
|
||||||
|
|
||||||
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
|
const [isDetailsModalOpen, setDetailsModalOpen, setDetailsModalClosed] =
|
||||||
|
useModalOpenState(false);
|
||||||
|
|
||||||
|
const handleSearchPress = useCallback(() => {
|
||||||
|
dispatch(
|
||||||
|
executeCommand({
|
||||||
|
name: EPISODE_SEARCH,
|
||||||
|
episodeIds: [episodeId],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}, [episodeId, dispatch]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRowCell className={styles.episodeSearchCell}>
|
||||||
|
<SpinnerIconButton
|
||||||
|
name={icons.SEARCH}
|
||||||
|
isSpinning={isSearching}
|
||||||
|
title={translate('AutomaticSearch')}
|
||||||
|
onPress={handleSearchPress}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<IconButton
|
||||||
|
name={icons.INTERACTIVE}
|
||||||
|
title={translate('InteractiveSearch')}
|
||||||
|
onPress={setDetailsModalOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EpisodeDetailsModal
|
||||||
|
isOpen={isDetailsModalOpen}
|
||||||
|
episodeId={episodeId}
|
||||||
|
episodeEntity={episodeEntity}
|
||||||
|
seriesId={seriesId}
|
||||||
|
episodeTitle={episodeTitle}
|
||||||
|
selectedTab="search"
|
||||||
|
startInteractiveSearch={true}
|
||||||
|
onModalClose={setDetailsModalClosed}
|
||||||
|
/>
|
||||||
|
</TableRowCell>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default EpisodeSearchCell;
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
import { connect } from 'react-redux';
|
|
||||||
import { createSelector } from 'reselect';
|
|
||||||
import * as commandNames from 'Commands/commandNames';
|
|
||||||
import { executeCommand } from 'Store/Actions/commandActions';
|
|
||||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
|
||||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
|
||||||
import { isCommandExecuting } from 'Utilities/Command';
|
|
||||||
import EpisodeSearchCell from './EpisodeSearchCell';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
(state, { episodeId }) => episodeId,
|
|
||||||
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
|
|
||||||
createSeriesSelector(),
|
|
||||||
createCommandsSelector(),
|
|
||||||
(episodeId, sceneSeasonNumber, series, commands) => {
|
|
||||||
const isSearching = commands.some((command) => {
|
|
||||||
const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
|
|
||||||
|
|
||||||
if (!episodeSearch) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
isCommandExecuting(command) &&
|
|
||||||
command.body.episodeIds.indexOf(episodeId) > -1
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
seriesMonitored: series.monitored,
|
|
||||||
seriesType: series.seriesType,
|
|
||||||
isSearching
|
|
||||||
};
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function createMapDispatchToProps(dispatch, props) {
|
|
||||||
return {
|
|
||||||
onSearchPress(name, path) {
|
|
||||||
dispatch(executeCommand({
|
|
||||||
name: commandNames.EPISODE_SEARCH,
|
|
||||||
episodeIds: [props.episodeId]
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);
|
|
||||||
@@ -1,34 +1,44 @@
|
|||||||
import PropTypes from 'prop-types';
|
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { useSelector } from 'react-redux';
|
||||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||||
import Icon from 'Components/Icon';
|
import Icon from 'Components/Icon';
|
||||||
import ProgressBar from 'Components/ProgressBar';
|
import ProgressBar from 'Components/ProgressBar';
|
||||||
|
import Episode from 'Episode/Episode';
|
||||||
|
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
|
||||||
|
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||||
|
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||||
import isBefore from 'Utilities/Date/isBefore';
|
import isBefore from 'Utilities/Date/isBefore';
|
||||||
import translate from 'Utilities/String/translate';
|
import translate from 'Utilities/String/translate';
|
||||||
import EpisodeQuality from './EpisodeQuality';
|
import EpisodeQuality from './EpisodeQuality';
|
||||||
import styles from './EpisodeStatus.css';
|
import styles from './EpisodeStatus.css';
|
||||||
|
|
||||||
function EpisodeStatus(props) {
|
interface EpisodeStatusProps {
|
||||||
|
episodeId: number;
|
||||||
|
episodeEntity?: EpisodeEntities;
|
||||||
|
episodeFileId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function EpisodeStatus(props: EpisodeStatusProps) {
|
||||||
|
const { episodeId, episodeEntity = 'episodes', episodeFileId } = props;
|
||||||
|
|
||||||
const {
|
const {
|
||||||
airDateUtc,
|
airDateUtc,
|
||||||
monitored,
|
monitored,
|
||||||
grabbed,
|
grabbed = false,
|
||||||
queueItem,
|
} = useEpisode(episodeId, episodeEntity) as Episode;
|
||||||
episodeFile
|
|
||||||
} = props;
|
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
|
||||||
|
const episodeFile = useEpisodeFile(episodeFileId);
|
||||||
|
|
||||||
const hasEpisodeFile = !!episodeFile;
|
const hasEpisodeFile = !!episodeFile;
|
||||||
const isQueued = !!queueItem;
|
const isQueued = !!queueItem;
|
||||||
const hasAired = isBefore(airDateUtc);
|
const hasAired = isBefore(airDateUtc);
|
||||||
|
|
||||||
if (isQueued) {
|
if (isQueued) {
|
||||||
const {
|
const { sizeleft, size } = queueItem;
|
||||||
sizeleft,
|
|
||||||
size
|
|
||||||
} = queueItem;
|
|
||||||
|
|
||||||
const progress = size ? (100 - sizeleft / size * 100) : 0;
|
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.center}>
|
<div className={styles.center}>
|
||||||
@@ -76,10 +86,7 @@ function EpisodeStatus(props) {
|
|||||||
if (!airDateUtc) {
|
if (!airDateUtc) {
|
||||||
return (
|
return (
|
||||||
<div className={styles.center}>
|
<div className={styles.center}>
|
||||||
<Icon
|
<Icon name={icons.TBA} title={translate('Tba')} />
|
||||||
name={icons.TBA}
|
|
||||||
title={translate('Tba')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -109,20 +116,9 @@ function EpisodeStatus(props) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.center}>
|
<div className={styles.center}>
|
||||||
<Icon
|
<Icon name={icons.NOT_AIRED} title={translate('EpisodeHasNotAired')} />
|
||||||
name={icons.NOT_AIRED}
|
|
||||||
title={translate('EpisodeHasNotAired')}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
EpisodeStatus.propTypes = {
|
|
||||||
airDateUtc: PropTypes.string,
|
|
||||||
monitored: PropTypes.bool.isRequired,
|
|
||||||
grabbed: PropTypes.bool,
|
|
||||||
queueItem: PropTypes.object,
|
|
||||||
episodeFile: PropTypes.object
|
|
||||||
};
|
|
||||||
|
|
||||||
export default EpisodeStatus;
|
export default EpisodeStatus;
|
||||||
@@ -1,53 +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 createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
|
||||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
|
||||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
|
||||||
import EpisodeStatus from './EpisodeStatus';
|
|
||||||
|
|
||||||
function createMapStateToProps() {
|
|
||||||
return createSelector(
|
|
||||||
createEpisodeSelector(),
|
|
||||||
createQueueItemSelector(),
|
|
||||||
createEpisodeFileSelector(),
|
|
||||||
(episode, queueItem, episodeFile) => {
|
|
||||||
const result = _.pick(episode, [
|
|
||||||
'airDateUtc',
|
|
||||||
'monitored',
|
|
||||||
'grabbed'
|
|
||||||
]);
|
|
||||||
|
|
||||||
result.queueItem = queueItem;
|
|
||||||
result.episodeFile = episodeFile;
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const mapDispatchToProps = {
|
|
||||||
};
|
|
||||||
|
|
||||||
class EpisodeStatusConnector extends Component {
|
|
||||||
|
|
||||||
//
|
|
||||||
// Render
|
|
||||||
|
|
||||||
render() {
|
|
||||||
return (
|
|
||||||
<EpisodeStatus
|
|
||||||
{...this.props}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
EpisodeStatusConnector.propTypes = {
|
|
||||||
episodeId: PropTypes.number.isRequired,
|
|
||||||
episodeFileId: PropTypes.number.isRequired
|
|
||||||
};
|
|
||||||
|
|
||||||
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user