mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-03-05 13:20:20 -05:00
Compare commits
84 Commits
v4.0.8.200
...
v4.0.9.233
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
14005d8d10 | ||
|
|
da7d17f5e8 | ||
|
|
ea331feb88 | ||
|
|
7dca9060ca | ||
|
|
8af12cc4e7 | ||
|
|
aa488019cf | ||
|
|
47a05ecb36 | ||
|
|
35baebaf72 | ||
|
|
aedcd046fc | ||
|
|
f45713bff8 | ||
|
|
911a3d4c1e | ||
|
|
e16ace54a8 | ||
|
|
84710a31bd | ||
|
|
093a239e77 | ||
|
|
ee69351733 | ||
|
|
e92a67ad78 | ||
|
|
3eca63a67c | ||
|
|
8484a8beba | ||
|
|
cd3a1c18ab | ||
|
|
dc7a16a03a | ||
|
|
84338f4c50 | ||
|
|
12ac123d5a | ||
|
|
ef829c6ace | ||
|
|
592b6f7f7c | ||
|
|
be5b449de4 | ||
|
|
9b144e9ade | ||
|
|
9af2f137f4 | ||
|
|
d4bd7865f6 | ||
|
|
cf921480ec | ||
|
|
639b53887d | ||
|
|
3b29096e40 | ||
|
|
2d237ae6b7 | ||
|
|
d713b83a36 | ||
|
|
2f04b037a1 | ||
|
|
7b87de2e93 | ||
|
|
eb2fd13509 | ||
|
|
ffdb08cfe6 | ||
|
|
37c4647f24 | ||
|
|
f7a58aab33 | ||
|
|
4b186e894e | ||
|
|
35a2bc9403 | ||
|
|
cc03ce04f1 | ||
|
|
363f8fc347 | ||
|
|
0877a6718d | ||
|
|
8b253c36ea | ||
|
|
e6f82270a9 | ||
|
|
813965e6a2 | ||
|
|
0d914f4c53 | ||
|
|
ae7f73208a | ||
|
|
4c86d673ea |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -22,7 +22,7 @@ env:
|
||||
FRAMEWORK: net6.0
|
||||
RAW_BRANCH_NAME: ${{ github.head_ref || github.ref_name }}
|
||||
SONARR_MAJOR_VERSION: 4
|
||||
VERSION: 4.0.8
|
||||
VERSION: 4.0.9
|
||||
|
||||
jobs:
|
||||
backend:
|
||||
|
||||
@@ -59,7 +59,7 @@ app_guid=$(echo "$app_guid" | tr -d ' ')
|
||||
app_guid=${app_guid:-media}
|
||||
|
||||
echo "This will install [${app^}] to [$bindir] and use [$datadir] for the AppData Directory"
|
||||
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that that user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
|
||||
echo "${app^} will run as the user [$app_uid] and group [$app_guid]. By continuing, you've confirmed that the selected user and group will have READ and WRITE access to your Media Library and Download Client Completed Download directories"
|
||||
read -n 1 -r -s -p $'Press enter to continue or ctrl+c to exit...\n' < /dev/tty
|
||||
|
||||
# Create User / Group as needed
|
||||
@@ -114,7 +114,7 @@ case "$ARCH" in
|
||||
esac
|
||||
echo ""
|
||||
echo "Removing previous tarballs"
|
||||
# -f to Force so we fail if it doesnt exist
|
||||
# -f to Force so we fail if it doesn't exist
|
||||
rm -f "${app^}".*.tar.gz
|
||||
echo ""
|
||||
echo "Downloading..."
|
||||
|
||||
2
frontend/.vscode/settings.json
vendored
2
frontend/.vscode/settings.json
vendored
@@ -9,7 +9,7 @@
|
||||
|
||||
"editor.formatOnSave": false,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": true
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
|
||||
"typescript.preferences.quoteStyle": "single",
|
||||
|
||||
@@ -134,6 +134,12 @@ module.exports = (env) => {
|
||||
{
|
||||
source: 'frontend/src/Content/robots.txt',
|
||||
destination: path.join(distFolder, 'Content/robots.txt')
|
||||
},
|
||||
|
||||
// manifest.json and browserconfig.xml
|
||||
{
|
||||
source: 'frontend/src/Content/*.(json|xml)',
|
||||
destination: path.join(distFolder, 'Content')
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ const mixinsFiles = [
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
'autoprefixer',
|
||||
['postcss-mixins', {
|
||||
mixinsFiles
|
||||
}],
|
||||
|
||||
@@ -27,8 +27,6 @@ interface HistoryDetailsProps {
|
||||
sourceTitle: string;
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
}
|
||||
|
||||
function HistoryDetails(props: HistoryDetailsProps) {
|
||||
|
||||
@@ -38,8 +38,6 @@ interface HistoryDetailsModalProps {
|
||||
data: HistoryData;
|
||||
downloadId?: string;
|
||||
isMarkingAsFailed: boolean;
|
||||
shortDateFormat: string;
|
||||
timeFormat: string;
|
||||
onMarkAsFailedPress: () => void;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
@@ -51,9 +49,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
sourceTitle,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
isMarkingAsFailed = false,
|
||||
onMarkAsFailedPress,
|
||||
onModalClose,
|
||||
} = props;
|
||||
@@ -69,8 +65,6 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
sourceTitle={sourceTitle}
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
/>
|
||||
</ModalBody>
|
||||
|
||||
@@ -93,8 +87,4 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
HistoryDetailsModal.defaultProps = {
|
||||
isMarkingAsFailed: false,
|
||||
};
|
||||
|
||||
export default HistoryDetailsModal;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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 RelativeDateCell from 'Components/Table/Cells/RelativeDateCell';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
@@ -15,11 +15,11 @@ import SeasonEpisodeNumber from 'Episode/SeasonEpisodeNumber';
|
||||
import useEpisode from 'Episode/useEpisode';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { icons, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import SeriesTitleLink from 'Series/SeriesTitleLink';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { fetchHistory, markAsFailed } from 'Store/Actions/historyActions';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { HistoryData, HistoryEventType } from 'typings/History';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
@@ -31,7 +31,7 @@ interface HistoryRowProps {
|
||||
id: number;
|
||||
episodeId: number;
|
||||
seriesId: number;
|
||||
languages: object[];
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
customFormats?: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
@@ -61,7 +61,7 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed,
|
||||
isMarkingAsFailed = false,
|
||||
markAsFailedError,
|
||||
columns,
|
||||
} = props;
|
||||
@@ -71,10 +71,6 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
const series = useSeries(seriesId);
|
||||
const episode = useEpisode(episodeId, 'episodes');
|
||||
|
||||
const { shortDateFormat, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
const [isDetailsModalOpen, setIsDetailsModalOpen] = useState(false);
|
||||
|
||||
const handleDetailsPress = useCallback(() => {
|
||||
@@ -259,8 +255,6 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
data={data}
|
||||
downloadId={downloadId}
|
||||
isMarkingAsFailed={isMarkingAsFailed}
|
||||
shortDateFormat={shortDateFormat}
|
||||
timeFormat={timeFormat}
|
||||
onMarkAsFailedPress={handleMarkAsFailedPress}
|
||||
onModalClose={handleDetailsModalClose}
|
||||
/>
|
||||
@@ -268,8 +262,4 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
HistoryRow.defaultProps = {
|
||||
customFormats: [],
|
||||
};
|
||||
|
||||
export default HistoryRow;
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 70px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds } from 'Helpers/Props';
|
||||
import TooltipPosition from 'Helpers/Props/TooltipPosition';
|
||||
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
|
||||
import {
|
||||
QueueTrackedDownloadState,
|
||||
QueueTrackedDownloadStatus,
|
||||
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
|
||||
// status === 'downloading'
|
||||
let iconName = icons.DOWNLOADING;
|
||||
let iconKind = kinds.DEFAULT;
|
||||
let iconKind: IconProps['kind'] = kinds.DEFAULT;
|
||||
let title = translate('Downloading');
|
||||
|
||||
if (status === 'paused') {
|
||||
@@ -155,10 +155,4 @@ function QueueStatus(props: QueueStatusProps) {
|
||||
);
|
||||
}
|
||||
|
||||
QueueStatus.defaultProps = {
|
||||
trackedDownloadStatus: 'ok',
|
||||
trackedDownloadState: 'downloading',
|
||||
canFlip: false,
|
||||
};
|
||||
|
||||
export default QueueStatus;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { ConnectedRouter, ConnectedRouterProps } from 'connected-react-router';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import DocumentTitle from 'react-document-title';
|
||||
import { Provider } from 'react-redux';
|
||||
@@ -20,7 +19,7 @@ function App({ store, history }: AppProps) {
|
||||
<ConnectedRouter history={history}>
|
||||
<ApplyTheme />
|
||||
<PageConnector>
|
||||
<AppRoutes app={App} />
|
||||
<AppRoutes />
|
||||
</PageConnector>
|
||||
</ConnectedRouter>
|
||||
</Provider>
|
||||
@@ -28,9 +27,4 @@ function App({ store, history }: AppProps) {
|
||||
);
|
||||
}
|
||||
|
||||
App.propTypes = {
|
||||
store: PropTypes.object.isRequired,
|
||||
history: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default App;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { Redirect, Route } from 'react-router-dom';
|
||||
import Blocklist from 'Activity/Blocklist/Blocklist';
|
||||
@@ -165,8 +164,4 @@ function AppRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
AppRoutes.propTypes = {
|
||||
app: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import { FilterBuilderProp, PropertyFilter } from './AppState';
|
||||
|
||||
export interface Error {
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import InteractiveImportAppState from 'App/State/InteractiveImportAppState';
|
||||
import BlocklistAppState from './BlocklistAppState';
|
||||
import CalendarAppState from './CalendarAppState';
|
||||
import CommandAppState from './CommandAppState';
|
||||
import EpisodeFilesAppState from './EpisodeFilesAppState';
|
||||
import EpisodesAppState from './EpisodesAppState';
|
||||
import HistoryAppState from './HistoryAppState';
|
||||
import InteractiveImportAppState from './InteractiveImportAppState';
|
||||
import ParseAppState from './ParseAppState';
|
||||
import PathsAppState from './PathsAppState';
|
||||
import QueueAppState from './QueueAppState';
|
||||
import ReleasesAppState from './ReleasesAppState';
|
||||
import RootFolderAppState from './RootFolderAppState';
|
||||
import SeriesAppState, { SeriesIndexAppState } from './SeriesAppState';
|
||||
import SettingsAppState from './SettingsAppState';
|
||||
import SystemAppState from './SystemAppState';
|
||||
import TagsAppState from './TagsAppState';
|
||||
import WantedAppState from './WantedAppState';
|
||||
|
||||
interface FilterBuilderPropOption {
|
||||
id: string;
|
||||
@@ -62,19 +65,22 @@ interface AppState {
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
commands: CommandAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodeFiles: EpisodeFilesAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodesSelection: EpisodesAppState;
|
||||
history: HistoryAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
parse: ParseAppState;
|
||||
paths: PathsAppState;
|
||||
queue: QueueAppState;
|
||||
releases: ReleasesAppState;
|
||||
rootFolders: RootFolderAppState;
|
||||
series: SeriesAppState;
|
||||
seriesIndex: SeriesIndexAppState;
|
||||
settings: SettingsAppState;
|
||||
system: SystemAppState;
|
||||
tags: TagsAppState;
|
||||
wanted: WantedAppState;
|
||||
}
|
||||
|
||||
export default AppState;
|
||||
|
||||
29
frontend/src/App/State/PathsAppState.ts
Normal file
29
frontend/src/App/State/PathsAppState.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
interface BasePath {
|
||||
name: string;
|
||||
path: string;
|
||||
size: number;
|
||||
lastModified: string;
|
||||
}
|
||||
|
||||
interface File extends BasePath {
|
||||
type: 'file';
|
||||
}
|
||||
|
||||
interface Folder extends BasePath {
|
||||
type: 'folder';
|
||||
}
|
||||
|
||||
export type PathType = 'file' | 'folder' | 'drive' | 'computer' | 'parent';
|
||||
export type Path = File | Folder;
|
||||
|
||||
interface PathsAppState {
|
||||
currentPath: string;
|
||||
isFetching: boolean;
|
||||
isPopulated: boolean;
|
||||
error: Error;
|
||||
directories: Folder[];
|
||||
files: File[];
|
||||
parent: string | null;
|
||||
}
|
||||
|
||||
export default PathsAppState;
|
||||
10
frontend/src/App/State/ReleasesAppState.ts
Normal file
10
frontend/src/App/State/ReleasesAppState.ts
Normal file
@@ -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,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Column from 'Components/Table/Column';
|
||||
import SortDirection from 'Helpers/Props/SortDirection';
|
||||
import { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
import Series from 'Series/Series';
|
||||
import { Filter, FilterBuilderProp } from './AppState';
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import AppSectionState, {
|
||||
PagedAppSectionState,
|
||||
} from 'App/State/AppSectionState';
|
||||
import Language from 'Language/Language';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import DownloadClient from 'typings/DownloadClient';
|
||||
import ImportList from 'typings/ImportList';
|
||||
import ImportListExclusion from 'typings/ImportListExclusion';
|
||||
@@ -24,7 +25,9 @@ export interface DownloadClientAppState
|
||||
isTestingAll: boolean;
|
||||
}
|
||||
|
||||
export type GeneralAppState = AppSectionItemState<General>;
|
||||
export interface GeneralAppState
|
||||
extends AppSectionItemState<General>,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListAppState
|
||||
extends AppSectionState<ImportList>,
|
||||
@@ -46,6 +49,11 @@ export interface QualityProfilesAppState
|
||||
extends AppSectionState<QualityProfile>,
|
||||
AppSectionItemSchemaState<QualityProfile> {}
|
||||
|
||||
export interface CustomFormatAppState
|
||||
extends AppSectionState<CustomFormat>,
|
||||
AppSectionDeleteState,
|
||||
AppSectionSaveState {}
|
||||
|
||||
export interface ImportListOptionsSettingsAppState
|
||||
extends AppSectionItemState<ImportListOptionsSettings>,
|
||||
AppSectionSaveState {}
|
||||
@@ -64,6 +72,7 @@ export type UiSettingsAppState = AppSectionItemState<UiSettings>;
|
||||
|
||||
interface SettingsAppState {
|
||||
advancedSettings: boolean;
|
||||
customFormats: CustomFormatAppState;
|
||||
downloadClients: DownloadClientAppState;
|
||||
general: GeneralAppState;
|
||||
importListExclusions: ImportListExclusionsSettingsAppState;
|
||||
|
||||
@@ -8,15 +8,15 @@ import AppSectionState, { AppSectionItemState } from './AppSectionState';
|
||||
export type DiskSpaceAppState = AppSectionState<DiskSpace>;
|
||||
export type HealthAppState = AppSectionState<Health>;
|
||||
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
|
||||
export type UpdateAppState = AppSectionState<Update>;
|
||||
export type TaskAppState = AppSectionState<Task>;
|
||||
export type UpdateAppState = AppSectionState<Update>;
|
||||
|
||||
interface SystemAppState {
|
||||
diskSpace: DiskSpaceAppState;
|
||||
health: HealthAppState;
|
||||
updates: UpdateAppState;
|
||||
status: SystemStatusAppState;
|
||||
tasks: TaskAppState;
|
||||
updates: UpdateAppState;
|
||||
}
|
||||
|
||||
export default SystemAppState;
|
||||
|
||||
13
frontend/src/App/State/WantedAppState.ts
Normal file
13
frontend/src/App/State/WantedAppState.ts
Normal file
@@ -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,7 +26,8 @@ export interface CommandBody {
|
||||
seriesId?: number;
|
||||
seriesIds?: number[];
|
||||
seasonNumber?: number;
|
||||
[key: string]: string | number | boolean | undefined | number[] | undefined;
|
||||
episodeIds?: number[];
|
||||
[key: string]: string | number | boolean | number[] | undefined;
|
||||
}
|
||||
|
||||
interface Command extends ModelBase {
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import styles from './Alert.css';
|
||||
|
||||
function Alert(props) {
|
||||
const { className, kind, children, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind]
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Alert.propTypes = {
|
||||
className: PropTypes.string,
|
||||
kind: PropTypes.oneOf(kinds.all),
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
Alert.defaultProps = {
|
||||
className: styles.alert,
|
||||
kind: kinds.INFO
|
||||
};
|
||||
|
||||
export default Alert;
|
||||
18
frontend/src/Components/Alert.tsx
Normal file
18
frontend/src/Components/Alert.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import styles from './Alert.css';
|
||||
|
||||
interface AlertProps {
|
||||
className?: string;
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Alert(props: AlertProps) {
|
||||
const { className = styles.alert, kind = 'info', children } = props;
|
||||
|
||||
return <div className={classNames(className, styles[kind])}>{children}</div>;
|
||||
}
|
||||
|
||||
export default Alert;
|
||||
@@ -1,60 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './Card.css';
|
||||
|
||||
class Card extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
overlayClassName,
|
||||
overlayContent,
|
||||
children,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
if (overlayContent) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link
|
||||
className={styles.underlay}
|
||||
onPress={onPress}
|
||||
/>
|
||||
|
||||
<div className={overlayClassName}>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={className}
|
||||
onPress={onPress}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Card.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
overlayClassName: PropTypes.string.isRequired,
|
||||
overlayContent: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
onPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
Card.defaultProps = {
|
||||
className: styles.card,
|
||||
overlayClassName: styles.overlay,
|
||||
overlayContent: false
|
||||
};
|
||||
|
||||
export default Card;
|
||||
39
frontend/src/Components/Card.tsx
Normal file
39
frontend/src/Components/Card.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import React from 'react';
|
||||
import Link, { LinkProps } from 'Components/Link/Link';
|
||||
import styles from './Card.css';
|
||||
|
||||
interface CardProps extends Pick<LinkProps, 'onPress'> {
|
||||
// TODO: Consider using different properties for classname depending if it's overlaying content or not
|
||||
className?: string;
|
||||
overlayClassName?: string;
|
||||
overlayContent?: boolean;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
function Card(props: CardProps) {
|
||||
const {
|
||||
className = styles.card,
|
||||
overlayClassName = styles.overlay,
|
||||
overlayContent = false,
|
||||
children,
|
||||
onPress,
|
||||
} = props;
|
||||
|
||||
if (overlayContent) {
|
||||
return (
|
||||
<div className={className}>
|
||||
<Link className={styles.underlay} onPress={onPress} />
|
||||
|
||||
<div className={overlayClassName}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Link className={className} onPress={onPress}>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default Card;
|
||||
@@ -1,33 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './DescriptionList.css';
|
||||
|
||||
class DescriptionList extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<dl className={className}>
|
||||
{children}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionList.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
DescriptionList.defaultProps = {
|
||||
className: styles.descriptionList
|
||||
};
|
||||
|
||||
export default DescriptionList;
|
||||
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
15
frontend/src/Components/DescriptionList/DescriptionList.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import styles from './DescriptionList.css';
|
||||
|
||||
interface DescriptionListProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionList(props: DescriptionListProps) {
|
||||
const { className = styles.descriptionList, children } = props;
|
||||
|
||||
return <dl className={className}>{children}</dl>;
|
||||
}
|
||||
|
||||
export default DescriptionList;
|
||||
@@ -1,46 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import DescriptionListItemDescription from './DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle from './DescriptionListItemTitle';
|
||||
|
||||
class DescriptionListItem extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
titleClassName,
|
||||
descriptionClassName,
|
||||
title,
|
||||
data
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DescriptionListItemTitle
|
||||
className={titleClassName}
|
||||
>
|
||||
{title}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription
|
||||
className={descriptionClassName}
|
||||
>
|
||||
{data}
|
||||
</DescriptionListItemDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
DescriptionListItem.propTypes = {
|
||||
className: PropTypes.string,
|
||||
titleClassName: PropTypes.string,
|
||||
descriptionClassName: PropTypes.string,
|
||||
title: PropTypes.string,
|
||||
data: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
export default DescriptionListItem;
|
||||
@@ -0,0 +1,34 @@
|
||||
import React from 'react';
|
||||
import DescriptionListItemDescription, {
|
||||
DescriptionListItemDescriptionProps,
|
||||
} from './DescriptionListItemDescription';
|
||||
import DescriptionListItemTitle, {
|
||||
DescriptionListItemTitleProps,
|
||||
} from './DescriptionListItemTitle';
|
||||
|
||||
interface DescriptionListItemProps {
|
||||
className?: string;
|
||||
titleClassName?: DescriptionListItemTitleProps['className'];
|
||||
descriptionClassName?: DescriptionListItemDescriptionProps['className'];
|
||||
title?: DescriptionListItemTitleProps['children'];
|
||||
data?: DescriptionListItemDescriptionProps['children'];
|
||||
}
|
||||
|
||||
function DescriptionListItem(props: DescriptionListItemProps) {
|
||||
const { className, titleClassName, descriptionClassName, title, data } =
|
||||
props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<DescriptionListItemTitle className={titleClassName}>
|
||||
{title}
|
||||
</DescriptionListItemTitle>
|
||||
|
||||
<DescriptionListItemDescription className={descriptionClassName}>
|
||||
{data}
|
||||
</DescriptionListItemDescription>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DescriptionListItem;
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemDescription.css';
|
||||
|
||||
function DescriptionListItemDescription(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dd className={className}>
|
||||
{children}
|
||||
</dd>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemDescription.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.node])
|
||||
};
|
||||
|
||||
DescriptionListItemDescription.defaultProps = {
|
||||
className: styles.description
|
||||
};
|
||||
|
||||
export default DescriptionListItemDescription;
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './DescriptionListItemDescription.css';
|
||||
|
||||
export interface DescriptionListItemDescriptionProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionListItemDescription(
|
||||
props: DescriptionListItemDescriptionProps
|
||||
) {
|
||||
const { className = styles.description, children } = props;
|
||||
|
||||
return <dd className={className}>{children}</dd>;
|
||||
}
|
||||
|
||||
export default DescriptionListItemDescription;
|
||||
@@ -1,27 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DescriptionListItemTitle.css';
|
||||
|
||||
function DescriptionListItemTitle(props) {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<dt className={className}>
|
||||
{children}
|
||||
</dt>
|
||||
);
|
||||
}
|
||||
|
||||
DescriptionListItemTitle.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
children: PropTypes.string
|
||||
};
|
||||
|
||||
DescriptionListItemTitle.defaultProps = {
|
||||
className: styles.title
|
||||
};
|
||||
|
||||
export default DescriptionListItemTitle;
|
||||
@@ -0,0 +1,15 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import styles from './DescriptionListItemTitle.css';
|
||||
|
||||
export interface DescriptionListItemTitleProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
function DescriptionListItemTitle(props: DescriptionListItemTitleProps) {
|
||||
const { className = styles.title, children } = props;
|
||||
|
||||
return <dt className={className}>{children}</dt>;
|
||||
}
|
||||
|
||||
export default DescriptionListItemTitle;
|
||||
@@ -1,22 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
function DragPreviewLayer({ children, ...otherProps }) {
|
||||
return (
|
||||
<div {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
DragPreviewLayer.propTypes = {
|
||||
children: PropTypes.node,
|
||||
className: PropTypes.string
|
||||
};
|
||||
|
||||
DragPreviewLayer.defaultProps = {
|
||||
className: styles.dragLayer
|
||||
};
|
||||
|
||||
export default DragPreviewLayer;
|
||||
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
21
frontend/src/Components/DragPreviewLayer.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import React from 'react';
|
||||
import styles from './DragPreviewLayer.css';
|
||||
|
||||
interface DragPreviewLayerProps {
|
||||
className?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function DragPreviewLayer({
|
||||
className = styles.dragLayer,
|
||||
children,
|
||||
...otherProps
|
||||
}: DragPreviewLayerProps) {
|
||||
return (
|
||||
<div className={className} {...otherProps}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DragPreviewLayer;
|
||||
@@ -1,62 +0,0 @@
|
||||
import * as sentry from '@sentry/browser';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
class ErrorBoundary extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
info: null
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error, info) {
|
||||
this.setState({
|
||||
error,
|
||||
info
|
||||
});
|
||||
|
||||
sentry.captureException(error);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
errorComponent: ErrorComponent,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
error,
|
||||
info
|
||||
} = this.state;
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<ErrorComponent
|
||||
error={error}
|
||||
info={info}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
ErrorBoundary.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
errorComponent: PropTypes.elementType.isRequired
|
||||
};
|
||||
|
||||
export default ErrorBoundary;
|
||||
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
46
frontend/src/Components/Error/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as sentry from '@sentry/browser';
|
||||
import React, { Component, ErrorInfo } from 'react';
|
||||
|
||||
interface ErrorBoundaryProps {
|
||||
children: React.ReactNode;
|
||||
errorComponent: React.ElementType;
|
||||
}
|
||||
|
||||
interface ErrorBoundaryState {
|
||||
error: Error | null;
|
||||
info: ErrorInfo | null;
|
||||
}
|
||||
|
||||
// Class component until componentDidCatch is supported in functional components
|
||||
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
|
||||
constructor(props: ErrorBoundaryProps) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
error: null,
|
||||
info: null,
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, info: ErrorInfo) {
|
||||
this.setState({
|
||||
error,
|
||||
info,
|
||||
});
|
||||
|
||||
sentry.captureException(error);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { children, errorComponent: ErrorComponent } = this.props;
|
||||
const { error, info } = this.state;
|
||||
|
||||
if (error) {
|
||||
return <ErrorComponent error={error} info={info} />;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
}
|
||||
|
||||
export default ErrorBoundary;
|
||||
@@ -1,41 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import styles from './FieldSet.css';
|
||||
|
||||
class FieldSet extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
size,
|
||||
legend,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<fieldset className={styles.fieldSet}>
|
||||
<legend className={classNames(styles.legend, (size === sizes.SMALL) && styles.small)}>
|
||||
{legend}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
FieldSet.propTypes = {
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
legend: PropTypes.oneOfType([PropTypes.node, PropTypes.string]),
|
||||
children: PropTypes.node
|
||||
};
|
||||
|
||||
FieldSet.defaultProps = {
|
||||
size: sizes.MEDIUM
|
||||
};
|
||||
|
||||
export default FieldSet;
|
||||
29
frontend/src/Components/FieldSet.tsx
Normal file
29
frontend/src/Components/FieldSet.tsx
Normal file
@@ -0,0 +1,29 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import styles from './FieldSet.css';
|
||||
|
||||
interface FieldSetProps {
|
||||
size?: Size;
|
||||
legend?: ComponentProps<'legend'>['children'];
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
function FieldSet({ size = sizes.MEDIUM, legend, children }: FieldSetProps) {
|
||||
return (
|
||||
<fieldset className={styles.fieldSet}>
|
||||
<legend
|
||||
className={classNames(
|
||||
styles.legend,
|
||||
size === sizes.SMALL && styles.small
|
||||
)}
|
||||
>
|
||||
{legend}
|
||||
</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export default FieldSet;
|
||||
@@ -1,39 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import FileBrowserModalContentConnector from './FileBrowserModalContentConnector';
|
||||
import styles from './FileBrowserModal.css';
|
||||
|
||||
class FileBrowserModal extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
className={styles.modal}
|
||||
isOpen={isOpen}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<FileBrowserModalContentConnector
|
||||
{...otherProps}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
FileBrowserModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default FileBrowserModal;
|
||||
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
23
frontend/src/Components/FileBrowser/FileBrowserModal.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import FileBrowserModalContent, {
|
||||
FileBrowserModalContentProps,
|
||||
} from './FileBrowserModalContent';
|
||||
import styles from './FileBrowserModal.css';
|
||||
|
||||
interface FileBrowserModalProps extends FileBrowserModalContentProps {
|
||||
isOpen: boolean;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function FileBrowserModal(props: FileBrowserModalProps) {
|
||||
const { isOpen, onModalClose, ...otherProps } = props;
|
||||
|
||||
return (
|
||||
<Modal className={styles.modal} isOpen={isOpen} onModalClose={onModalClose}>
|
||||
<FileBrowserModalContent {...otherProps} onModalClose={onModalClose} />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserModal;
|
||||
@@ -1,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;
|
||||
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
237
frontend/src/Components/FileBrowser/FileBrowserModalContent.tsx
Normal file
@@ -0,0 +1,237 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import PathInput from 'Components/Form/PathInput';
|
||||
import Button from 'Components/Link/Button';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import InlineMarkdown from 'Components/Markdown/InlineMarkdown';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { kinds, scrollDirections } from 'Helpers/Props';
|
||||
import { clearPaths, fetchPaths } from 'Store/Actions/pathActions';
|
||||
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import createPathsSelector from './createPathsSelector';
|
||||
import FileBrowserRow from './FileBrowserRow';
|
||||
import styles from './FileBrowserModalContent.css';
|
||||
|
||||
const columns: Column[] = [
|
||||
{
|
||||
name: 'type',
|
||||
label: () => translate('Type'),
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: () => translate('Name'),
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
const handleClearPaths = () => {};
|
||||
|
||||
export interface FileBrowserModalContentProps {
|
||||
name: string;
|
||||
value: string;
|
||||
includeFiles?: boolean;
|
||||
onChange: (args: InputChanged<string>) => unknown;
|
||||
onModalClose: () => void;
|
||||
}
|
||||
|
||||
function FileBrowserModalContent(props: FileBrowserModalContentProps) {
|
||||
const { name, value, includeFiles = true, onChange, onModalClose } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { isWindows, mode } = useSelector(createSystemStatusSelector());
|
||||
const { isFetching, isPopulated, error, parent, directories, files, paths } =
|
||||
useSelector(createPathsSelector());
|
||||
|
||||
const [currentPath, setCurrentPath] = useState(value);
|
||||
const scrollerRef = useRef(null);
|
||||
const previousValue = usePrevious(value);
|
||||
|
||||
const emptyParent = parent === '';
|
||||
const isWindowsService = isWindows && mode === 'service';
|
||||
|
||||
const handlePathInputChange = useCallback(
|
||||
({ value }: InputChanged<string>) => {
|
||||
setCurrentPath(value);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleRowPress = useCallback(
|
||||
(path: string) => {
|
||||
setCurrentPath(path);
|
||||
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
},
|
||||
[includeFiles, dispatch, setCurrentPath]
|
||||
);
|
||||
|
||||
const handleOkPress = useCallback(() => {
|
||||
onChange({
|
||||
name,
|
||||
value: currentPath,
|
||||
});
|
||||
|
||||
dispatch(clearPaths());
|
||||
onModalClose();
|
||||
}, [name, currentPath, dispatch, onChange, onModalClose]);
|
||||
|
||||
const handleFetchPaths = useCallback(
|
||||
(path: string) => {
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
},
|
||||
[includeFiles, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value !== previousValue && value !== currentPath) {
|
||||
setCurrentPath(value);
|
||||
}
|
||||
}, [value, previousValue, currentPath, setCurrentPath]);
|
||||
|
||||
useEffect(
|
||||
() => {
|
||||
dispatch(
|
||||
fetchPaths({
|
||||
path: currentPath,
|
||||
allowFoldersWithoutTrailingSlashes: true,
|
||||
includeFiles,
|
||||
})
|
||||
);
|
||||
|
||||
return () => {
|
||||
dispatch(clearPaths());
|
||||
};
|
||||
},
|
||||
// This should only run once when the component mounts,
|
||||
// so we don't need to include the other dependencies.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('FileBrowser')}</ModalHeader>
|
||||
|
||||
<ModalBody
|
||||
className={styles.modalBody}
|
||||
scrollDirection={scrollDirections.NONE}
|
||||
>
|
||||
{isWindowsService ? (
|
||||
<Alert className={styles.mappedDrivesWarning} kind={kinds.WARNING}>
|
||||
<InlineMarkdown
|
||||
data={translate('MappedNetworkDrivesWindowsService', {
|
||||
url: 'https://wiki.servarr.com/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;
|
||||
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
49
frontend/src/Components/FileBrowser/FileBrowserRow.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { PathType } from 'App/State/PathsAppState';
|
||||
import Icon from 'Components/Icon';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRowButton from 'Components/Table/TableRowButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import styles from './FileBrowserRow.css';
|
||||
|
||||
function getIconName(type: PathType) {
|
||||
switch (type) {
|
||||
case 'computer':
|
||||
return icons.COMPUTER;
|
||||
case 'drive':
|
||||
return icons.DRIVE;
|
||||
case 'file':
|
||||
return icons.FILE;
|
||||
case 'parent':
|
||||
return icons.PARENT;
|
||||
default:
|
||||
return icons.FOLDER;
|
||||
}
|
||||
}
|
||||
|
||||
interface FileBrowserRowProps {
|
||||
type: PathType;
|
||||
name: string;
|
||||
path: string;
|
||||
onPress: (path: string) => void;
|
||||
}
|
||||
|
||||
function FileBrowserRow(props: FileBrowserRowProps) {
|
||||
const { type, name, path, onPress } = props;
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
onPress(path);
|
||||
}, [path, onPress]);
|
||||
|
||||
return (
|
||||
<TableRowButton onPress={handlePress}>
|
||||
<TableRowCell className={styles.type}>
|
||||
<Icon name={getIconName(type)} />
|
||||
</TableRowCell>
|
||||
|
||||
<TableRowCell>{name}</TableRowCell>
|
||||
</TableRowButton>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileBrowserRow;
|
||||
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
36
frontend/src/Components/FileBrowser/createPathsSelector.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createPathsSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.paths,
|
||||
(paths) => {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
} = paths;
|
||||
|
||||
const filteredPaths = [...directories, ...files].filter(({ path }) => {
|
||||
return path.toLowerCase().startsWith(currentPath.toLowerCase());
|
||||
});
|
||||
|
||||
return {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
parent,
|
||||
currentPath,
|
||||
directories,
|
||||
files,
|
||||
paths: filteredPaths,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default createPathsSelector;
|
||||
@@ -12,7 +12,7 @@ import IndexerFilterBuilderRowValueConnector from './IndexerFilterBuilderRowValu
|
||||
import LanguageFilterBuilderRowValue from './LanguageFilterBuilderRowValue';
|
||||
import ProtocolFilterBuilderRowValue from './ProtocolFilterBuilderRowValue';
|
||||
import QualityFilterBuilderRowValueConnector from './QualityFilterBuilderRowValueConnector';
|
||||
import QualityProfileFilterBuilderRowValueConnector from './QualityProfileFilterBuilderRowValueConnector';
|
||||
import QualityProfileFilterBuilderRowValue from './QualityProfileFilterBuilderRowValue';
|
||||
import SeasonsMonitoredStatusFilterBuilderRowValue from './SeasonsMonitoredStatusFilterBuilderRowValue';
|
||||
import SeriesFilterBuilderRowValue from './SeriesFilterBuilderRowValue';
|
||||
import SeriesStatusFilterBuilderRowValue from './SeriesStatusFilterBuilderRowValue';
|
||||
@@ -78,7 +78,7 @@ function getRowValueConnector(selectedFilterBuilderProp) {
|
||||
return QualityFilterBuilderRowValueConnector;
|
||||
|
||||
case filterBuilderValueTypes.QUALITY_PROFILE:
|
||||
return QualityProfileFilterBuilderRowValueConnector;
|
||||
return QualityProfileFilterBuilderRowValue;
|
||||
|
||||
case filterBuilderValueTypes.SEASONS_MONITORED_STATUS:
|
||||
return SeasonsMonitoredStatusFilterBuilderRowValue;
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FilterBuilderRowValueProps from 'Components/Filter/Builder/FilterBuilderRowValueProps';
|
||||
import sortByProp from 'Utilities/Array/sortByProp';
|
||||
import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
|
||||
function createQualityProfilesSelector() {
|
||||
return createSelector(
|
||||
(state: AppState) => state.settings.qualityProfiles.items,
|
||||
(qualityProfiles) => {
|
||||
return qualityProfiles;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function QualityProfileFilterBuilderRowValue(
|
||||
props: FilterBuilderRowValueProps
|
||||
) {
|
||||
const qualityProfiles = useSelector(createQualityProfilesSelector());
|
||||
|
||||
const tagList = qualityProfiles
|
||||
.map(({ id, name }) => ({ id, name }))
|
||||
.sort(sortByProp('name'));
|
||||
|
||||
return <FilterBuilderRowValue {...props} tagList={tagList} />;
|
||||
}
|
||||
|
||||
export default QualityProfileFilterBuilderRowValue;
|
||||
@@ -1,28 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import FilterBuilderRowValue from './FilterBuilderRowValue';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.settings.qualityProfiles,
|
||||
(qualityProfiles) => {
|
||||
const tagList = qualityProfiles.items.map((qualityProfile) => {
|
||||
const {
|
||||
id,
|
||||
name
|
||||
} = qualityProfile;
|
||||
|
||||
return {
|
||||
id,
|
||||
name
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
tagList
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(FilterBuilderRowValue);
|
||||
@@ -46,9 +46,9 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
|
||||
const values = [...seriesTypeOptions];
|
||||
|
||||
const {
|
||||
includeNoChange,
|
||||
includeNoChange = false,
|
||||
includeNoChangeDisabled = true,
|
||||
includeMixed,
|
||||
includeMixed = false,
|
||||
} = props;
|
||||
|
||||
if (includeNoChange) {
|
||||
@@ -77,9 +77,4 @@ function SeriesTypeSelectInput(props: SeriesTypeSelectInputProps) {
|
||||
);
|
||||
}
|
||||
|
||||
SeriesTypeSelectInput.defaultProps = {
|
||||
includeNoChange: false,
|
||||
includeMixed: false,
|
||||
};
|
||||
|
||||
export default SeriesTypeSelectInput;
|
||||
|
||||
@@ -49,7 +49,11 @@ class TextTagInputConnector extends Component {
|
||||
const newTags = tag.name.startsWith('/') ? [tag.name] : split(tag.name);
|
||||
|
||||
newTags.forEach((newTag) => {
|
||||
newValue.push(newTag.trim());
|
||||
const newTagValue = newTag.trim();
|
||||
|
||||
if (newTagValue) {
|
||||
newValue.push(newTagValue);
|
||||
}
|
||||
});
|
||||
|
||||
onChange({ name, value: newValue });
|
||||
@@ -80,7 +84,12 @@ class TextTagInputConnector extends Component {
|
||||
|
||||
const newValue = [...valueArray];
|
||||
newValue.splice(tagToReplace.index, 1);
|
||||
newValue.push(newTag.name.trim());
|
||||
|
||||
const newTagValue = newTag.name.trim();
|
||||
|
||||
if (newTagValue) {
|
||||
newValue.push(newTagValue);
|
||||
}
|
||||
|
||||
onChange({ name, value: newValue });
|
||||
};
|
||||
|
||||
@@ -1,22 +1,22 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Icon, { IconProps } from 'Components/Icon';
|
||||
import Tooltip from 'Components/Tooltip/Tooltip';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
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 (
|
||||
<Tooltip
|
||||
anchor={
|
||||
<span className={styles.rating}>
|
||||
<Icon
|
||||
className={styles.heart}
|
||||
name={icons.HEART}
|
||||
size={iconSize}
|
||||
/>
|
||||
|
||||
<Icon className={styles.heart} name={icons.HEART} size={iconSize} />
|
||||
{rating * 10}%
|
||||
</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;
|
||||
@@ -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;
|
||||
60
frontend/src/Components/Icon.tsx
Normal file
60
frontend/src/Components/Icon.tsx
Normal file
@@ -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,48 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import styles from './Label.css';
|
||||
|
||||
function Label(props) {
|
||||
const {
|
||||
className,
|
||||
kind,
|
||||
size,
|
||||
outline,
|
||||
children,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
outline && styles.outline
|
||||
)}
|
||||
{...otherProps}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
Label.propTypes = {
|
||||
className: PropTypes.string.isRequired,
|
||||
title: PropTypes.string,
|
||||
kind: PropTypes.oneOf(kinds.all).isRequired,
|
||||
size: PropTypes.oneOf(sizes.all).isRequired,
|
||||
outline: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
Label.defaultProps = {
|
||||
className: styles.label,
|
||||
kind: kinds.DEFAULT,
|
||||
size: sizes.SMALL,
|
||||
outline: false
|
||||
};
|
||||
|
||||
export default Label;
|
||||
33
frontend/src/Components/Label.tsx
Normal file
33
frontend/src/Components/Label.tsx
Normal file
@@ -0,0 +1,33 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps, ReactNode } from 'react';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import { Kind } from 'Helpers/Props/kinds';
|
||||
import { Size } from 'Helpers/Props/sizes';
|
||||
import styles from './Label.css';
|
||||
|
||||
export interface LabelProps extends ComponentProps<'span'> {
|
||||
kind?: Extract<Kind, keyof typeof styles>;
|
||||
size?: Extract<Size, keyof typeof styles>;
|
||||
outline?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export default function Label({
|
||||
className = styles.label,
|
||||
kind = kinds.DEFAULT,
|
||||
size = sizes.SMALL,
|
||||
outline = false,
|
||||
...otherProps
|
||||
}: LabelProps) {
|
||||
return (
|
||||
<span
|
||||
className={classNames(
|
||||
className,
|
||||
styles[kind],
|
||||
styles[size],
|
||||
outline && styles.outline
|
||||
)}
|
||||
{...otherProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
37
frontend/src/Components/Link/Button.tsx
Normal file
37
frontend/src/Components/Link/Button.tsx
Normal file
@@ -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;
|
||||
41
frontend/src/Components/Link/IconButton.tsx
Normal file
41
frontend/src/Components/Link/IconButton.tsx
Normal file
@@ -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 React, {
|
||||
ComponentClass,
|
||||
FunctionComponent,
|
||||
ComponentPropsWithoutRef,
|
||||
ElementType,
|
||||
SyntheticEvent,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
import { Link as RouterLink } from 'react-router-dom';
|
||||
import styles from './Link.css';
|
||||
|
||||
interface ReactRouterLinkProps {
|
||||
to?: string;
|
||||
}
|
||||
export type LinkProps<C extends ElementType = 'button'> =
|
||||
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> {
|
||||
className?: string;
|
||||
component?:
|
||||
| string
|
||||
| FunctionComponent<LinkProps>
|
||||
| ComponentClass<LinkProps, unknown>;
|
||||
to?: string;
|
||||
target?: string;
|
||||
isDisabled?: boolean;
|
||||
noRouter?: boolean;
|
||||
onPress?(event: SyntheticEvent): void;
|
||||
}
|
||||
function Link(props: LinkProps) {
|
||||
const {
|
||||
className,
|
||||
component = 'button',
|
||||
to,
|
||||
target,
|
||||
type,
|
||||
isDisabled,
|
||||
noRouter = false,
|
||||
onPress,
|
||||
...otherProps
|
||||
} = props;
|
||||
export default function Link<C extends ElementType = 'button'>({
|
||||
className,
|
||||
component,
|
||||
to,
|
||||
target,
|
||||
type,
|
||||
isDisabled,
|
||||
noRouter,
|
||||
onPress,
|
||||
...otherProps
|
||||
}: LinkProps<C>) {
|
||||
const Component = component || 'button';
|
||||
|
||||
const onClick = useCallback(
|
||||
(event: SyntheticEvent) => {
|
||||
if (!isDisabled && onPress) {
|
||||
onPress(event);
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
onPress?.(event);
|
||||
},
|
||||
[isDisabled, onPress]
|
||||
);
|
||||
|
||||
const linkProps: React.HTMLProps<HTMLAnchorElement> & ReactRouterLinkProps = {
|
||||
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(
|
||||
const linkClass = classNames(
|
||||
className,
|
||||
styles.link,
|
||||
to && styles.to,
|
||||
isDisabled && 'isDisabled'
|
||||
);
|
||||
|
||||
const elementProps = {
|
||||
...otherProps,
|
||||
type,
|
||||
...linkProps,
|
||||
};
|
||||
if (to) {
|
||||
const toLink = /\w+?:\/\//.test(to);
|
||||
|
||||
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;
|
||||
32
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
32
frontend/src/Components/Loading/LoadingIndicator.tsx
Normal file
@@ -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;
|
||||
@@ -8,21 +8,21 @@ const messages = [
|
||||
'Bleep Bloop.',
|
||||
'Locating the required gigapixels to render...',
|
||||
'Spinning up the hamster wheel...',
|
||||
'At least you\'re not on hold',
|
||||
"At least you're not on hold",
|
||||
'Hum something loud while others stare',
|
||||
'Loading humorous message... Please Wait',
|
||||
'I could\'ve been faster in Python',
|
||||
'Don\'t forget to rewind your episodes',
|
||||
"I could've been faster in Python",
|
||||
"Don't forget to rewind your episodes",
|
||||
'Congratulations! You are the 1000th visitor.',
|
||||
'HELP! I\'m being held hostage and forced to write these stupid lines!',
|
||||
"HELP! I'm being held hostage and forced to write these stupid lines!",
|
||||
'RE-calibrating the internet...',
|
||||
'I\'ll be here all week',
|
||||
'Don\'t forget to tip your waitress',
|
||||
"I'll be here all week",
|
||||
"Don't forget to tip your waitress",
|
||||
'Apply directly to the forehead',
|
||||
'Loading Battlestation'
|
||||
'Loading Battlestation',
|
||||
];
|
||||
|
||||
let message = null;
|
||||
let message: string | null = null;
|
||||
|
||||
function LoadingMessage() {
|
||||
if (!message) {
|
||||
@@ -30,11 +30,7 @@ function LoadingMessage() {
|
||||
message = messages[index];
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.loadingMessage}>
|
||||
{message}
|
||||
</div>
|
||||
);
|
||||
return <div className={styles.loadingMessage}>{message}</div>;
|
||||
}
|
||||
|
||||
export default LoadingMessage;
|
||||
@@ -1,74 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
|
||||
class InlineMarkdown extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
data,
|
||||
blockClassName
|
||||
} = this.props;
|
||||
|
||||
// For now only replace links or code blocks (not both)
|
||||
const markdownBlocks = [];
|
||||
if (data) {
|
||||
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
|
||||
|
||||
let endIndex = 0;
|
||||
let match = null;
|
||||
|
||||
while ((match = linkRegex.exec(data)) !== null) {
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(<Link key={match.index} to={match[2]}>{match[1]}</Link>);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
|
||||
|
||||
endIndex = 0;
|
||||
match = null;
|
||||
let matchedCode = false;
|
||||
|
||||
while ((match = codeRegex.exec(data)) !== null) {
|
||||
matchedCode = true;
|
||||
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(<code key={`code-${match.index}`} className={blockClassName ?? null}>{match[0].substring(1, match[0].length - 1)}</code>);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
if (markdownBlocks.length === 0) {
|
||||
markdownBlocks.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
return <span className={className}>{markdownBlocks}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
InlineMarkdown.propTypes = {
|
||||
className: PropTypes.string,
|
||||
data: PropTypes.string,
|
||||
blockClassName: PropTypes.string
|
||||
};
|
||||
|
||||
export default InlineMarkdown;
|
||||
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
75
frontend/src/Components/Markdown/InlineMarkdown.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { ReactElement } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
|
||||
interface InlineMarkdownProps {
|
||||
className?: string;
|
||||
data?: string;
|
||||
blockClassName?: string;
|
||||
}
|
||||
|
||||
function InlineMarkdown(props: InlineMarkdownProps) {
|
||||
const { className, data, blockClassName } = props;
|
||||
|
||||
// For now only replace links or code blocks (not both)
|
||||
const markdownBlocks: (ReactElement | string)[] = [];
|
||||
|
||||
if (data) {
|
||||
const linkRegex = RegExp(/\[(.+?)\]\((.+?)\)/g);
|
||||
|
||||
let endIndex = 0;
|
||||
let match = null;
|
||||
|
||||
while ((match = linkRegex.exec(data)) !== null) {
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(
|
||||
<Link key={match.index} to={match[2]}>
|
||||
{match[1]}
|
||||
</Link>
|
||||
);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
const codeRegex = RegExp(/(?=`)`(?!`)[^`]*(?=`)`(?!`)/g);
|
||||
|
||||
endIndex = 0;
|
||||
match = null;
|
||||
let matchedCode = false;
|
||||
|
||||
while ((match = codeRegex.exec(data)) !== null) {
|
||||
matchedCode = true;
|
||||
|
||||
if (match.index > endIndex) {
|
||||
markdownBlocks.push(data.substr(endIndex, match.index - endIndex));
|
||||
}
|
||||
|
||||
markdownBlocks.push(
|
||||
<code
|
||||
key={`code-${match.index}`}
|
||||
className={blockClassName ?? undefined}
|
||||
>
|
||||
{match[0].substring(1, match[0].length - 1)}
|
||||
</code>
|
||||
);
|
||||
endIndex = match.index + match[0].length;
|
||||
}
|
||||
|
||||
if (endIndex !== data.length && markdownBlocks.length > 0 && matchedCode) {
|
||||
markdownBlocks.push(data.substr(endIndex, data.length - endIndex));
|
||||
}
|
||||
|
||||
if (markdownBlocks.length === 0) {
|
||||
markdownBlocks.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
return <span className={className}>{markdownBlocks}</span>;
|
||||
}
|
||||
|
||||
export default InlineMarkdown;
|
||||
@@ -6,10 +6,7 @@ import styles from './MetadataAttribution.css';
|
||||
export default function MetadataAttribution() {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Link
|
||||
className={styles.attribution}
|
||||
to="/settings/metadatasource"
|
||||
>
|
||||
<Link className={styles.attribution} to="/settings/metadatasource">
|
||||
{translate('MetadataProvidedBy', { provider: 'TheTVDB' })}
|
||||
</Link>
|
||||
</div>
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
color: var(--disabledColor);
|
||||
cursor: not-allowed;
|
||||
&.isDisabled {
|
||||
color: var(--disabledColor);
|
||||
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;
|
||||
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
65
frontend/src/Components/MonitorToggleButton.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { SyntheticEvent, useCallback, useMemo } from 'react';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './MonitorToggleButton.css';
|
||||
|
||||
interface MonitorToggleButtonProps {
|
||||
className?: string;
|
||||
monitored: boolean;
|
||||
size?: number;
|
||||
isDisabled?: boolean;
|
||||
isSaving?: boolean;
|
||||
onPress: (value: boolean, options: { shiftKey: boolean }) => unknown;
|
||||
}
|
||||
|
||||
function MonitorToggleButton(props: MonitorToggleButtonProps) {
|
||||
const {
|
||||
className = styles.toggleButton,
|
||||
monitored,
|
||||
isDisabled = false,
|
||||
isSaving = false,
|
||||
size,
|
||||
onPress,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
const iconName = monitored ? icons.MONITORED : icons.UNMONITORED;
|
||||
|
||||
const title = useMemo(() => {
|
||||
if (isDisabled) {
|
||||
return 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 PageContent from 'Components/Page/PageContent';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './NotFound.css';
|
||||
|
||||
function NotFound(props) {
|
||||
interface NotFoundProps {
|
||||
message?: string;
|
||||
}
|
||||
|
||||
function NotFound(props: NotFoundProps) {
|
||||
const { message = translate('DefaultNotFoundMessage') } = props;
|
||||
|
||||
return (
|
||||
<PageContent title="MIA">
|
||||
<div className={styles.container}>
|
||||
<div className={styles.message}>
|
||||
{message}
|
||||
</div>
|
||||
<div className={styles.message}>{message}</div>
|
||||
|
||||
<img
|
||||
className={styles.image}
|
||||
@@ -23,8 +24,4 @@ function NotFound(props) {
|
||||
);
|
||||
}
|
||||
|
||||
NotFound.propTypes = {
|
||||
message: PropTypes.string
|
||||
};
|
||||
|
||||
export default NotFound;
|
||||
@@ -6,7 +6,7 @@ import Link from 'Components/Link/Link';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
||||
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
|
||||
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||
import SeriesSearchInputConnector from './SeriesSearchInputConnector';
|
||||
import styles from './PageHeader.css';
|
||||
|
||||
@@ -83,7 +83,8 @@ class PageHeader extends Component {
|
||||
size={14}
|
||||
title={translate('Donate')}
|
||||
/>
|
||||
<PageHeaderActionsMenuConnector
|
||||
|
||||
<PageHeaderActionsMenu
|
||||
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
|
||||
/>
|
||||
</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 Scroller, { OnScroll } from 'Components/Scroller/Scroller';
|
||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
||||
import { isLocked } from 'Utilities/scrollLock';
|
||||
import styles from './PageContentBody.css';
|
||||
|
||||
@@ -36,7 +35,7 @@ const PageContentBody = forwardRef(
|
||||
ref={ref}
|
||||
{...otherProps}
|
||||
className={className}
|
||||
scrollDirection={ScrollDirection.Vertical}
|
||||
scrollDirection="vertical"
|
||||
onScroll={onScrollWrapper}
|
||||
>
|
||||
<div className={innerClassName}>{children}</div>
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
function Portal(props) {
|
||||
const { children, target } = props;
|
||||
return ReactDOM.createPortal(children, target);
|
||||
}
|
||||
|
||||
Portal.propTypes = {
|
||||
children: PropTypes.node.isRequired,
|
||||
target: PropTypes.object.isRequired
|
||||
};
|
||||
|
||||
Portal.defaultProps = {
|
||||
target: document.getElementById('portal-root')
|
||||
};
|
||||
|
||||
export default Portal;
|
||||
20
frontend/src/Components/Portal.tsx
Normal file
20
frontend/src/Components/Portal.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import ReactDOM from 'react-dom';
|
||||
|
||||
interface PortalProps {
|
||||
children: Parameters<typeof ReactDOM.createPortal>[0];
|
||||
target?: Parameters<typeof ReactDOM.createPortal>[1];
|
||||
}
|
||||
|
||||
const defaultTarget = document.getElementById('portal-root');
|
||||
|
||||
function Portal(props: PortalProps) {
|
||||
const { children, target = defaultTarget } = props;
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReactDOM.createPortal(children, target);
|
||||
}
|
||||
|
||||
export default Portal;
|
||||
@@ -1,44 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Switch as RouterSwitch } from 'react-router-dom';
|
||||
import { map } from 'Helpers/elementChildren';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
|
||||
class Switch extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<RouterSwitch>
|
||||
{
|
||||
map(children, (child) => {
|
||||
const {
|
||||
path: childPath,
|
||||
addUrlBase = true
|
||||
} = child.props;
|
||||
|
||||
if (!childPath) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
|
||||
|
||||
return React.cloneElement(child, { path });
|
||||
})
|
||||
}
|
||||
</RouterSwitch>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Switch.propTypes = {
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
export default Switch;
|
||||
38
frontend/src/Components/Router/Switch.tsx
Normal file
38
frontend/src/Components/Router/Switch.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { Children, ReactElement, ReactNode } from 'react';
|
||||
import { Switch as RouterSwitch } from 'react-router-dom';
|
||||
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
|
||||
interface ExtendedRoute {
|
||||
path: string;
|
||||
addUrlBase?: boolean;
|
||||
}
|
||||
|
||||
interface SwitchProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
function Switch({ children }: SwitchProps) {
|
||||
return (
|
||||
<RouterSwitch>
|
||||
{Children.map(children, (child) => {
|
||||
if (!React.isValidElement<ExtendedRoute>(child)) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const elementChild: ReactElement<ExtendedRoute> = child;
|
||||
|
||||
const { path: childPath, addUrlBase = true } = elementChild.props;
|
||||
|
||||
if (!childPath) {
|
||||
return child;
|
||||
}
|
||||
|
||||
const path = addUrlBase ? getPathWithUrlBase(childPath) : childPath;
|
||||
|
||||
return React.cloneElement(child, { path });
|
||||
})}
|
||||
</RouterSwitch>
|
||||
);
|
||||
}
|
||||
|
||||
export default Switch;
|
||||
@@ -1,179 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Scrollbars } from 'react-custom-scrollbars-2';
|
||||
import { scrollDirections } from 'Helpers/Props';
|
||||
import styles from './OverlayScroller.css';
|
||||
|
||||
const SCROLLBAR_SIZE = 10;
|
||||
|
||||
class OverlayScroller extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scroller = null;
|
||||
this._isScrolling = false;
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
scrollTop
|
||||
} = this.props;
|
||||
|
||||
if (
|
||||
!this._isScrolling &&
|
||||
scrollTop != null &&
|
||||
scrollTop !== prevProps.scrollTop
|
||||
) {
|
||||
this._scroller.scrollTop(scrollTop);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_setScrollRef = (ref) => {
|
||||
this._scroller = ref;
|
||||
|
||||
if (ref) {
|
||||
this.props.registerScroller(ref.view);
|
||||
}
|
||||
};
|
||||
|
||||
_renderThumb = (props) => {
|
||||
return (
|
||||
<div
|
||||
className={this.props.trackClassName}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderTrackHorizontal = ({ style, props }) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
borderRadius: 3,
|
||||
height: SCROLLBAR_SIZE
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.track}
|
||||
style={finalStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderTrackVertical = ({ style, props }) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
top: 2,
|
||||
borderRadius: 3,
|
||||
width: SCROLLBAR_SIZE
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={styles.track}
|
||||
style={finalStyle}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
_renderView = (props) => {
|
||||
return (
|
||||
<div
|
||||
className={this.props.className}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
//
|
||||
// Listers
|
||||
|
||||
onScrollStart = () => {
|
||||
this._isScrolling = true;
|
||||
};
|
||||
|
||||
onScrollStop = () => {
|
||||
this._isScrolling = false;
|
||||
};
|
||||
|
||||
onScroll = (event) => {
|
||||
const {
|
||||
scrollTop,
|
||||
scrollLeft
|
||||
} = event.currentTarget;
|
||||
|
||||
this._isScrolling = true;
|
||||
const onScroll = this.props.onScroll;
|
||||
|
||||
if (onScroll) {
|
||||
onScroll({ scrollTop, scrollLeft });
|
||||
}
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
autoHide,
|
||||
autoScroll,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
ref={this._setScrollRef}
|
||||
autoHide={autoHide}
|
||||
hideTracksWhenNotNeeded={autoScroll}
|
||||
renderTrackHorizontal={this._renderTrackHorizontal}
|
||||
renderTrackVertical={this._renderTrackVertical}
|
||||
renderThumbHorizontal={this._renderThumb}
|
||||
renderThumbVertical={this._renderThumb}
|
||||
renderView={this._renderView}
|
||||
onScrollStart={this.onScrollStart}
|
||||
onScrollStop={this.onScrollStop}
|
||||
onScroll={this.onScroll}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
OverlayScroller.propTypes = {
|
||||
className: PropTypes.string,
|
||||
trackClassName: PropTypes.string,
|
||||
scrollTop: PropTypes.number,
|
||||
scrollDirection: PropTypes.oneOf([scrollDirections.NONE, scrollDirections.HORIZONTAL, scrollDirections.VERTICAL]).isRequired,
|
||||
autoHide: PropTypes.bool.isRequired,
|
||||
autoScroll: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node,
|
||||
onScroll: PropTypes.func,
|
||||
registerScroller: PropTypes.func
|
||||
};
|
||||
|
||||
OverlayScroller.defaultProps = {
|
||||
className: styles.scroller,
|
||||
trackClassName: styles.thumb,
|
||||
scrollDirection: scrollDirections.VERTICAL,
|
||||
autoHide: false,
|
||||
autoScroll: true,
|
||||
registerScroller: () => { /* no-op */ }
|
||||
};
|
||||
|
||||
export default OverlayScroller;
|
||||
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
127
frontend/src/Components/Scroller/OverlayScroller.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import React, { ComponentPropsWithoutRef, useCallback, useRef } from 'react';
|
||||
import { Scrollbars } from 'react-custom-scrollbars-2';
|
||||
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
|
||||
import { OnScroll } from './Scroller';
|
||||
import styles from './OverlayScroller.css';
|
||||
|
||||
const SCROLLBAR_SIZE = 10;
|
||||
|
||||
interface OverlayScrollerProps {
|
||||
className?: string;
|
||||
trackClassName?: string;
|
||||
scrollTop?: number;
|
||||
scrollDirection: ScrollDirection;
|
||||
autoHide: boolean;
|
||||
autoScroll: boolean;
|
||||
children?: React.ReactNode;
|
||||
onScroll?: (payload: OnScroll) => void;
|
||||
}
|
||||
|
||||
interface ScrollbarTrackProps {
|
||||
style: React.CSSProperties;
|
||||
props: ComponentPropsWithoutRef<'div'>;
|
||||
}
|
||||
|
||||
function OverlayScroller(props: OverlayScrollerProps) {
|
||||
const {
|
||||
autoHide = false,
|
||||
autoScroll = true,
|
||||
className = styles.scroller,
|
||||
trackClassName = styles.thumb,
|
||||
children,
|
||||
onScroll,
|
||||
} = props;
|
||||
const scrollBarRef = useRef<Scrollbars>(null);
|
||||
const isScrolling = useRef(false);
|
||||
|
||||
const handleScrollStart = useCallback(() => {
|
||||
isScrolling.current = true;
|
||||
}, []);
|
||||
const handleScrollStop = useCallback(() => {
|
||||
isScrolling.current = false;
|
||||
}, []);
|
||||
|
||||
const handleScroll = useCallback(() => {
|
||||
if (!scrollBarRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { scrollTop, scrollLeft } = scrollBarRef.current.getValues();
|
||||
isScrolling.current = true;
|
||||
|
||||
if (onScroll) {
|
||||
onScroll({ scrollTop, scrollLeft });
|
||||
}
|
||||
}, [onScroll]);
|
||||
|
||||
const renderThumb = useCallback(
|
||||
(props: ComponentPropsWithoutRef<'div'>) => {
|
||||
return <div className={trackClassName} {...props} />;
|
||||
},
|
||||
[trackClassName]
|
||||
);
|
||||
|
||||
const renderTrackHorizontal = useCallback(
|
||||
({ style, props: trackProps }: ScrollbarTrackProps) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
left: 2,
|
||||
borderRadius: 3,
|
||||
height: SCROLLBAR_SIZE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.track} style={finalStyle} {...trackProps} />
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderTrackVertical = useCallback(
|
||||
({ style, props: trackProps }: ScrollbarTrackProps) => {
|
||||
const finalStyle = {
|
||||
...style,
|
||||
right: 2,
|
||||
bottom: 2,
|
||||
top: 2,
|
||||
borderRadius: 3,
|
||||
width: SCROLLBAR_SIZE,
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.track} style={finalStyle} {...trackProps} />
|
||||
);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const renderView = useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(props: any) => {
|
||||
return <div className={className} {...props} />;
|
||||
},
|
||||
[className]
|
||||
);
|
||||
|
||||
return (
|
||||
<Scrollbars
|
||||
ref={scrollBarRef}
|
||||
autoHide={autoHide}
|
||||
hideTracksWhenNotNeeded={autoScroll}
|
||||
renderTrackHorizontal={renderTrackHorizontal}
|
||||
renderTrackVertical={renderTrackVertical}
|
||||
renderThumbHorizontal={renderThumb}
|
||||
renderThumbVertical={renderThumb}
|
||||
renderView={renderView}
|
||||
onScrollStart={handleScrollStart}
|
||||
onScrollStop={handleScrollStop}
|
||||
onScroll={handleScroll}
|
||||
>
|
||||
{children}
|
||||
</Scrollbars>
|
||||
);
|
||||
}
|
||||
|
||||
export default OverlayScroller;
|
||||
@@ -8,7 +8,7 @@ import React, {
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import ScrollDirection from 'Helpers/Props/ScrollDirection';
|
||||
import { ScrollDirection } from 'Helpers/Props/scrollDirections';
|
||||
import styles from './Scroller.css';
|
||||
|
||||
export interface OnScroll {
|
||||
@@ -33,7 +33,7 @@ const Scroller = forwardRef(
|
||||
className,
|
||||
autoFocus = false,
|
||||
autoScroll = true,
|
||||
scrollDirection = ScrollDirection.Vertical,
|
||||
scrollDirection = 'vertical',
|
||||
children,
|
||||
scrollTop,
|
||||
initialScrollTop,
|
||||
@@ -59,7 +59,7 @@ const Scroller = forwardRef(
|
||||
currentRef.current.scrollTop = scrollTop;
|
||||
}
|
||||
|
||||
if (autoFocus && scrollDirection !== ScrollDirection.None) {
|
||||
if (autoFocus && scrollDirection !== 'none') {
|
||||
currentRef.current.focus({ preventScroll: true });
|
||||
}
|
||||
}, [autoFocus, currentRef, scrollDirection, scrollTop]);
|
||||
|
||||
@@ -212,6 +212,8 @@ class SignalRConnector extends Component {
|
||||
|
||||
if (action === 'updated') {
|
||||
this.props.dispatchUpdateItem({ section, ...body.resource });
|
||||
|
||||
repopulatePage('seriesUpdated');
|
||||
} else if (action === 'deleted') {
|
||||
this.props.dispatchRemoveItem({ section, id: body.resource.id });
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
23
frontend/src/Components/SpinnerIcon.tsx
Normal file
23
frontend/src/Components/SpinnerIcon.tsx
Normal file
@@ -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;
|
||||
11
frontend/src/Components/Table/Cells/TableRowCell.tsx
Normal file
11
frontend/src/Components/Table/Cells/TableRowCell.tsx
Normal file
@@ -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 { SortDirection } from 'Helpers/Props/sortDirections';
|
||||
|
||||
type PropertyFunction<T> = () => T;
|
||||
|
||||
@@ -9,6 +10,7 @@ interface Column {
|
||||
className?: string;
|
||||
columnLabel?: string;
|
||||
isSortable?: boolean;
|
||||
fixedSortDirection?: SortDirection;
|
||||
isVisible: boolean;
|
||||
isModifiable?: boolean;
|
||||
}
|
||||
|
||||
@@ -66,7 +66,9 @@ function Table(props) {
|
||||
columns.map((column) => {
|
||||
const {
|
||||
name,
|
||||
isVisible
|
||||
isVisible,
|
||||
isSortable,
|
||||
...otherColumnProps
|
||||
} = column;
|
||||
|
||||
if (!isVisible) {
|
||||
@@ -84,6 +86,7 @@ function Table(props) {
|
||||
name={name}
|
||||
isSortable={false}
|
||||
{...otherProps}
|
||||
{...otherColumnProps}
|
||||
>
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { tooltipPositions } from 'Helpers/Props';
|
||||
import Tooltip from './Tooltip';
|
||||
import styles from './Popover.css';
|
||||
|
||||
function Popover(props) {
|
||||
const {
|
||||
title,
|
||||
body,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
{...otherProps}
|
||||
bodyClassName={styles.tooltipBody}
|
||||
tooltip={
|
||||
<div>
|
||||
<div className={styles.title}>
|
||||
{title}
|
||||
</div>
|
||||
|
||||
<div className={styles.body}>
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Popover.propTypes = {
|
||||
className: PropTypes.string,
|
||||
bodyClassName: PropTypes.string,
|
||||
anchor: PropTypes.node.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
body: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
position: PropTypes.oneOf(tooltipPositions.all),
|
||||
canFlip: PropTypes.bool
|
||||
};
|
||||
|
||||
export default Popover;
|
||||
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
26
frontend/src/Components/Tooltip/Popover.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import React from 'react';
|
||||
import Tooltip, { TooltipProps } from './Tooltip';
|
||||
import styles from './Popover.css';
|
||||
|
||||
interface PopoverProps extends Omit<TooltipProps, 'tooltip' | 'bodyClassName'> {
|
||||
title: string;
|
||||
body: React.ReactNode;
|
||||
}
|
||||
|
||||
function Popover({ title, body, ...otherProps }: PopoverProps) {
|
||||
return (
|
||||
<Tooltip
|
||||
{...otherProps}
|
||||
bodyClassName={styles.tooltipBody}
|
||||
tooltip={
|
||||
<div>
|
||||
<div className={styles.title}>{title}</div>
|
||||
|
||||
<div className={styles.body}>{body}</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default Popover;
|
||||
@@ -1,235 +0,0 @@
|
||||
import classNames from 'classnames';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import Portal from 'Components/Portal';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import { isMobile as isMobileUtil } from 'Utilities/browser';
|
||||
import styles from './Tooltip.css';
|
||||
|
||||
let maxWidth = null;
|
||||
|
||||
function getMaxWidth() {
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
if (windowWidth >= parseInt(dimensions.breakpointLarge)) {
|
||||
maxWidth = 800;
|
||||
} else if (windowWidth >= parseInt(dimensions.breakpointMedium)) {
|
||||
maxWidth = 650;
|
||||
} else if (windowWidth >= parseInt(dimensions.breakpointSmall)) {
|
||||
maxWidth = 500;
|
||||
} else {
|
||||
maxWidth = 450;
|
||||
}
|
||||
|
||||
return maxWidth;
|
||||
}
|
||||
|
||||
class Tooltip extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._scheduleUpdate = null;
|
||||
this._closeTimeout = null;
|
||||
this._maxWidth = maxWidth || getMaxWidth();
|
||||
|
||||
this.state = {
|
||||
isOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
if (this._scheduleUpdate && this.state.isOpen) {
|
||||
this._scheduleUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._closeTimeout) {
|
||||
this._closeTimeout = clearTimeout(this._closeTimeout);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
computeMaxSize = (data) => {
|
||||
const {
|
||||
top,
|
||||
right,
|
||||
bottom,
|
||||
left
|
||||
} = data.offsets.reference;
|
||||
|
||||
const windowWidth = window.innerWidth;
|
||||
const windowHeight = window.innerHeight;
|
||||
|
||||
if ((/^top/).test(data.placement)) {
|
||||
data.styles.maxHeight = top - 20;
|
||||
} else if ((/^bottom/).test(data.placement)) {
|
||||
data.styles.maxHeight = windowHeight - bottom - 20;
|
||||
} else if ((/^right/).test(data.placement)) {
|
||||
data.styles.maxWidth = Math.min(this._maxWidth, windowWidth - right - 20);
|
||||
data.styles.maxHeight = top - 20;
|
||||
} else {
|
||||
data.styles.maxWidth = Math.min(this._maxWidth, left - 20);
|
||||
data.styles.maxHeight = top - 20;
|
||||
}
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({ width });
|
||||
};
|
||||
|
||||
onClick = () => {
|
||||
if (isMobileUtil()) {
|
||||
this.setState({ isOpen: !this.state.isOpen });
|
||||
}
|
||||
};
|
||||
|
||||
onMouseEnter = () => {
|
||||
if (this._closeTimeout) {
|
||||
this._closeTimeout = clearTimeout(this._closeTimeout);
|
||||
}
|
||||
|
||||
this.setState({ isOpen: true });
|
||||
};
|
||||
|
||||
onMouseLeave = () => {
|
||||
this._closeTimeout = setTimeout(() => {
|
||||
this.setState({ isOpen: false });
|
||||
}, 100);
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
bodyClassName,
|
||||
anchor,
|
||||
tooltip,
|
||||
kind,
|
||||
position,
|
||||
canFlip
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Manager>
|
||||
<Reference>
|
||||
{({ ref }) => (
|
||||
<span
|
||||
ref={ref}
|
||||
className={className}
|
||||
onClick={this.onClick}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
{anchor}
|
||||
</span>
|
||||
)}
|
||||
</Reference>
|
||||
|
||||
<Portal>
|
||||
<Popper
|
||||
placement={position}
|
||||
// Disable events to improve performance when many tooltips
|
||||
// are shown (Quality Definitions for example).
|
||||
eventsEnabled={false}
|
||||
modifiers={{
|
||||
computeMaxHeight: {
|
||||
order: 851,
|
||||
enabled: true,
|
||||
fn: this.computeMaxSize
|
||||
},
|
||||
preventOverflow: {
|
||||
// Fixes positioning for tooltips in the queue
|
||||
// and likely others.
|
||||
escapeWithReference: false
|
||||
},
|
||||
flip: {
|
||||
enabled: canFlip
|
||||
}
|
||||
}}
|
||||
>
|
||||
{({ ref, style, placement, arrowProps, scheduleUpdate }) => {
|
||||
this._scheduleUpdate = scheduleUpdate;
|
||||
|
||||
const popperPlacement = placement ? placement.split('-')[0] : position;
|
||||
const vertical = popperPlacement === 'top' || popperPlacement === 'bottom';
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={classNames(
|
||||
styles.tooltipContainer,
|
||||
vertical ? styles.verticalContainer : styles.horizontalContainer
|
||||
)}
|
||||
style={style}
|
||||
onMouseEnter={this.onMouseEnter}
|
||||
onMouseLeave={this.onMouseLeave}
|
||||
>
|
||||
<div
|
||||
className={this.state.isOpen ? classNames(
|
||||
styles.arrow,
|
||||
styles[kind],
|
||||
styles[popperPlacement]
|
||||
) : styles.arrowDisabled}
|
||||
ref={arrowProps.ref}
|
||||
style={arrowProps.style}
|
||||
/>
|
||||
{
|
||||
this.state.isOpen ?
|
||||
<div
|
||||
className={classNames(
|
||||
styles.tooltip,
|
||||
styles[kind]
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={bodyClassName}
|
||||
>
|
||||
{tooltip}
|
||||
</div>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}}
|
||||
</Popper>
|
||||
</Portal>
|
||||
</Manager>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Tooltip.propTypes = {
|
||||
className: PropTypes.string,
|
||||
bodyClassName: PropTypes.string.isRequired,
|
||||
anchor: PropTypes.node.isRequired,
|
||||
tooltip: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
|
||||
kind: PropTypes.oneOf([kinds.DEFAULT, kinds.INVERSE]),
|
||||
position: PropTypes.oneOf(tooltipPositions.all),
|
||||
canFlip: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
Tooltip.defaultProps = {
|
||||
bodyClassName: styles.body,
|
||||
kind: kinds.DEFAULT,
|
||||
position: tooltipPositions.TOP,
|
||||
canFlip: false
|
||||
};
|
||||
|
||||
export default Tooltip;
|
||||
216
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
216
frontend/src/Components/Tooltip/Tooltip.tsx
Normal file
@@ -0,0 +1,216 @@
|
||||
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.
|
||||
|
||||
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(() => {
|
||||
const currentTimeout = closeTimeout.current;
|
||||
|
||||
if (updater.current && isOpen) {
|
||||
updater.current();
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (currentTimeout) {
|
||||
window.clearTimeout(currentTimeout);
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
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;
|
||||
@@ -1,9 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="/Content/Images/Icons/mstile-150x150.png"/>
|
||||
<TileColor>#00ccff</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"name": "Sonarr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "../../../../",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "standalone"
|
||||
}
|
||||
11
frontend/src/Content/browserconfig.xml
Normal file
11
frontend/src/Content/browserconfig.xml
Normal file
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<browserconfig>
|
||||
<msapplication>
|
||||
<tile>
|
||||
<square150x150logo src="__URL_BASE__/Content/Images/Icons/mstile-150x150.png" />
|
||||
<TileColor>
|
||||
#00ccff
|
||||
</TileColor>
|
||||
</tile>
|
||||
</msapplication>
|
||||
</browserconfig>
|
||||
19
frontend/src/Content/manifest.json
Normal file
19
frontend/src/Content/manifest.json
Normal file
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "Sonarr",
|
||||
"icons": [
|
||||
{
|
||||
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "__URL_BASE__/Content/Images/Icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "__URL_BASE__/",
|
||||
"theme_color": "#3a3f51",
|
||||
"background_color": "#3a3f51",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -9,6 +9,7 @@ interface Episode extends ModelBase {
|
||||
episodeNumber: number;
|
||||
airDate: string;
|
||||
airDateUtc?: string;
|
||||
lastSearchTime?: string;
|
||||
runtime: number;
|
||||
absoluteEpisodeNumber?: number;
|
||||
sceneSeasonNumber?: number;
|
||||
@@ -19,6 +20,7 @@ interface Episode extends ModelBase {
|
||||
episodeFile?: object;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
grabbed?: boolean;
|
||||
unverifiedSceneNumbering: boolean;
|
||||
endTime?: string;
|
||||
grabDate?: string;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user