mirror of
https://github.com/Sonarr/Sonarr.git
synced 2026-04-17 21:26:13 -04:00
Compare commits
85 Commits
v4.0.8.196
...
v4.0.9.227
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
b1527f9abb | ||
|
|
291d792810 | ||
|
|
9b528eb829 | ||
|
|
4c0b896174 | ||
|
|
4ff83f9efc | ||
|
|
217611d716 | ||
|
|
1299a97579 | ||
|
|
4c0de55672 | ||
|
|
78a0def46a | ||
|
|
11a9dcb389 | ||
|
|
4eab168267 | ||
|
|
c9b5a1258a | ||
|
|
9127a91dfc | ||
|
|
cc85a28ff7 |
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..."
|
||||
|
||||
@@ -359,11 +359,16 @@ module.exports = {
|
||||
],
|
||||
|
||||
rules: Object.assign(typescriptEslintRecommended.rules, {
|
||||
'no-shadow': 'off',
|
||||
// These should be enabled after cleaning things up
|
||||
'@typescript-eslint/no-unused-vars': 'warn',
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
{
|
||||
args: 'after-used',
|
||||
argsIgnorePattern: '^_',
|
||||
ignoreRestSiblings: true
|
||||
}
|
||||
],
|
||||
'@typescript-eslint/explicit-function-return-type': 'off',
|
||||
'react/prop-types': 'off',
|
||||
'no-shadow': 'off',
|
||||
'prettier/prettier': 'error',
|
||||
'simple-import-sort/imports': [
|
||||
'error',
|
||||
@@ -376,7 +381,41 @@ module.exports = {
|
||||
['^@?\\w', `^(${dirs})(/.*|$)`, '^\\.', '^\\..*css$']
|
||||
]
|
||||
}
|
||||
]
|
||||
],
|
||||
|
||||
// React Hooks
|
||||
'react-hooks/rules-of-hooks': 'error',
|
||||
'react-hooks/exhaustive-deps': 'error',
|
||||
|
||||
// React
|
||||
'react/function-component-definition': 'error',
|
||||
'react/hook-use-state': 'error',
|
||||
'react/jsx-boolean-value': ['error', 'always'],
|
||||
'react/jsx-curly-brace-presence': [
|
||||
'error',
|
||||
{ props: 'never', children: 'never' }
|
||||
],
|
||||
'react/jsx-fragments': 'error',
|
||||
'react/jsx-handler-names': [
|
||||
'error',
|
||||
{
|
||||
eventHandlerPrefix: 'on',
|
||||
eventHandlerPropPrefix: 'on'
|
||||
}
|
||||
],
|
||||
'react/jsx-no-bind': ['error', { ignoreRefs: true }],
|
||||
'react/jsx-no-useless-fragment': ['error', { allowExpressions: true }],
|
||||
'react/jsx-pascal-case': ['error', { allowAllCaps: true }],
|
||||
'react/jsx-sort-props': [
|
||||
'error',
|
||||
{
|
||||
callbacksLast: true,
|
||||
noSortAlphabetically: true,
|
||||
reservedFirst: true
|
||||
}
|
||||
],
|
||||
'react/prop-types': 'off',
|
||||
'react/self-closing-comp': 'error'
|
||||
})
|
||||
},
|
||||
{
|
||||
|
||||
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
|
||||
}],
|
||||
|
||||
@@ -59,6 +59,7 @@ function Blocklist() {
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isRemoving,
|
||||
@@ -223,6 +224,7 @@ function Blocklist() {
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
@@ -264,6 +266,7 @@ function Blocklist() {
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
|
||||
@@ -51,7 +51,7 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
sourceTitle,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed,
|
||||
isMarkingAsFailed = false,
|
||||
shortDateFormat,
|
||||
timeFormat,
|
||||
onMarkAsFailedPress,
|
||||
@@ -93,8 +93,4 @@ function HistoryDetailsModal(props: HistoryDetailsModalProps) {
|
||||
);
|
||||
}
|
||||
|
||||
HistoryDetailsModal.defaultProps = {
|
||||
isMarkingAsFailed: false,
|
||||
};
|
||||
|
||||
export default HistoryDetailsModal;
|
||||
|
||||
@@ -53,6 +53,7 @@ function History() {
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
} = useSelector((state: AppState) => state.history);
|
||||
@@ -154,6 +155,7 @@ function History() {
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
>
|
||||
<PageToolbarButton
|
||||
@@ -193,6 +195,7 @@ function History() {
|
||||
<div>
|
||||
<Table
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
|
||||
@@ -15,6 +15,7 @@ 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';
|
||||
@@ -31,7 +32,7 @@ interface HistoryRowProps {
|
||||
id: number;
|
||||
episodeId: number;
|
||||
seriesId: number;
|
||||
languages: object[];
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
customFormats?: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
@@ -61,7 +62,7 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
date,
|
||||
data,
|
||||
downloadId,
|
||||
isMarkingAsFailed,
|
||||
isMarkingAsFailed = false,
|
||||
markAsFailedError,
|
||||
columns,
|
||||
} = props;
|
||||
@@ -268,8 +269,4 @@ function HistoryRow(props: HistoryRowProps) {
|
||||
);
|
||||
}
|
||||
|
||||
HistoryRow.defaultProps = {
|
||||
customFormats: [],
|
||||
};
|
||||
|
||||
export default HistoryRow;
|
||||
|
||||
@@ -73,6 +73,7 @@ function Queue() {
|
||||
sortKey,
|
||||
sortDirection,
|
||||
page,
|
||||
pageSize,
|
||||
totalPages,
|
||||
totalRecords,
|
||||
isGrabbing,
|
||||
@@ -269,8 +270,10 @@ function Queue() {
|
||||
allSelected={allSelected}
|
||||
allUnselected={allUnselected}
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
sortKey={sortKey}
|
||||
sortDirection={sortDirection}
|
||||
optionsComponent={QueueOptions}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
onSelectAllChange={handleSelectAllChange}
|
||||
onSortPress={handleSortPress}
|
||||
@@ -344,6 +347,7 @@ function Queue() {
|
||||
<PageToolbarSection alignContent={align.RIGHT}>
|
||||
<TableOptionsModalWrapper
|
||||
columns={columns}
|
||||
pageSize={pageSize}
|
||||
maxPageSize={200}
|
||||
optionsComponent={QueueOptions}
|
||||
onTableOptionChange={handleTableOptionChange}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import React, { Fragment, useCallback } from 'react';
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import AppState from 'App/State/AppState';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import { inputTypes } from 'Helpers/Props';
|
||||
import { setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { gotoQueuePage, setQueueOption } from 'Store/Actions/queueActions';
|
||||
import { CheckInputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
@@ -22,24 +22,26 @@ function QueueOptions() {
|
||||
[name]: value,
|
||||
})
|
||||
);
|
||||
|
||||
if (name === 'includeUnknownSeriesItems') {
|
||||
dispatch(gotoQueuePage({ page: 1 }));
|
||||
}
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('ShowUnknownSeriesItems')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeUnknownSeriesItems"
|
||||
value={includeUnknownSeriesItems}
|
||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
</Fragment>
|
||||
<FormInputGroup
|
||||
type={inputTypes.CHECK}
|
||||
name="includeUnknownSeriesItems"
|
||||
value={includeUnknownSeriesItems}
|
||||
helpText={translate('ShowUnknownSeriesItemsHelpText')}
|
||||
onChange={handleOptionChange}
|
||||
/>
|
||||
</FormGroup>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -26,4 +26,5 @@
|
||||
composes: cell from '~Components/Table/Cells/TableRowCell.css';
|
||||
|
||||
width: 70px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
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';
|
||||
@@ -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';
|
||||
@@ -35,6 +34,10 @@ import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
|
||||
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
|
||||
import MissingConnector from 'Wanted/Missing/MissingConnector';
|
||||
|
||||
function RedirectWithUrlBase() {
|
||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Switch>
|
||||
@@ -51,9 +54,7 @@ function AppRoutes() {
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
addUrlBase={false}
|
||||
render={() => {
|
||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||
}}
|
||||
render={RedirectWithUrlBase}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -61,21 +62,9 @@ function AppRoutes() {
|
||||
|
||||
<Route path="/add/import" component={ImportSeries} />
|
||||
|
||||
<Route
|
||||
path="/serieseditor"
|
||||
exact={true}
|
||||
render={() => {
|
||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||
}}
|
||||
/>
|
||||
<Route path="/serieseditor" exact={true} render={RedirectWithUrlBase} />
|
||||
|
||||
<Route
|
||||
path="/seasonpass"
|
||||
exact={true}
|
||||
render={() => {
|
||||
return <Redirect to={getPathWithUrlBase('/')} />;
|
||||
}}
|
||||
/>
|
||||
<Route path="/seasonpass" exact={true} render={RedirectWithUrlBase} />
|
||||
|
||||
<Route path="/series/:titleSlug" component={SeriesDetailsPageConnector} />
|
||||
|
||||
@@ -175,8 +164,4 @@ function AppRoutes() {
|
||||
);
|
||||
}
|
||||
|
||||
AppRoutes.propTypes = {
|
||||
app: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default AppRoutes;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
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 QueueAppState from './QueueAppState';
|
||||
import RootFolderAppState from './RootFolderAppState';
|
||||
@@ -12,6 +12,7 @@ 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,8 +63,8 @@ interface AppState {
|
||||
blocklist: BlocklistAppState;
|
||||
calendar: CalendarAppState;
|
||||
commands: CommandAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodeFiles: EpisodeFilesAppState;
|
||||
episodes: EpisodesAppState;
|
||||
episodesSelection: EpisodesAppState;
|
||||
history: HistoryAppState;
|
||||
interactiveImport: InteractiveImportAppState;
|
||||
@@ -75,6 +76,7 @@ interface AppState {
|
||||
settings: SettingsAppState;
|
||||
system: SystemAppState;
|
||||
tags: TagsAppState;
|
||||
wanted: WantedAppState;
|
||||
}
|
||||
|
||||
export default 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 {
|
||||
|
||||
@@ -64,7 +64,7 @@ function ErrorBoundaryError(props: ErrorBoundaryErrorProps) {
|
||||
<div>{info.componentStack}</div>
|
||||
)}
|
||||
|
||||
{<div className={styles.version}>Version: {window.Sonarr.version}</div>}
|
||||
<div className={styles.version}>Version: {window.Sonarr.version}</div>
|
||||
</details>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
59
frontend/src/Components/Icon.tsx
Normal file
59
frontend/src/Components/Icon.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from '@fortawesome/react-fontawesome';
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps } from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
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<(typeof kinds.all)[number], 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;
|
||||
31
frontend/src/Components/Label.tsx
Normal file
31
frontend/src/Components/Label.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
import classNames from 'classnames';
|
||||
import React, { ComponentProps, ReactNode } from 'react';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import styles from './Label.css';
|
||||
|
||||
export interface LabelProps extends ComponentProps<'span'> {
|
||||
kind?: Extract<(typeof kinds.all)[number], keyof typeof styles>;
|
||||
size?: Extract<(typeof sizes.all)[number], 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;
|
||||
35
frontend/src/Components/Link/Button.tsx
Normal file
35
frontend/src/Components/Link/Button.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import classNames from 'classnames';
|
||||
import React from 'react';
|
||||
import { align, kinds, sizes } from 'Helpers/Props';
|
||||
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<(typeof kinds.all)[number], keyof typeof styles>;
|
||||
size?: Extract<(typeof sizes.all)[number], 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;
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
color: var(--disabledColor);
|
||||
cursor: not-allowed;
|
||||
&.isDisabled {
|
||||
color: var(--disabledColor);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
21
frontend/src/Components/SpinnerIcon.tsx
Normal file
21
frontend/src/Components/SpinnerIcon.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
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,
|
||||
...otherProps
|
||||
}: SpinnerIconProps) {
|
||||
return (
|
||||
<Icon
|
||||
name={(otherProps.isSpinning && spinningName) || name}
|
||||
{...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} />;
|
||||
}
|
||||
@@ -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,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"
|
||||
}
|
||||
@@ -19,6 +19,7 @@ interface Episode extends ModelBase {
|
||||
episodeFile?: object;
|
||||
hasFile: boolean;
|
||||
monitored: boolean;
|
||||
grabbed?: boolean;
|
||||
unverifiedSceneNumbering: boolean;
|
||||
endTime?: string;
|
||||
grabDate?: string;
|
||||
|
||||
@@ -1,60 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EpisodeDetailsModalContentConnector from './EpisodeDetailsModalContentConnector';
|
||||
|
||||
class EpisodeDetailsModal extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
closeOnBackgroundClick: props.selectedTab !== 'search'
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onTabChange = (isSearch) => {
|
||||
this.setState({ closeOnBackgroundClick: !isSearch });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isOpen,
|
||||
onModalClose,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
size={sizes.EXTRA_EXTRA_LARGE}
|
||||
closeOnBackgroundClick={this.state.closeOnBackgroundClick}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EpisodeDetailsModalContentConnector
|
||||
{...otherProps}
|
||||
onTabChange={this.onTabChange}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeDetailsModal.propTypes = {
|
||||
selectedTab: PropTypes.string,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeDetailsModal;
|
||||
52
frontend/src/Episode/EpisodeDetailsModal.tsx
Normal file
52
frontend/src/Episode/EpisodeDetailsModal.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
|
||||
import { EpisodeEntities } from 'Episode/useEpisode';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
|
||||
|
||||
interface EpisodeDetailsModalProps {
|
||||
isOpen: boolean;
|
||||
episodeId: number;
|
||||
episodeEntity: EpisodeEntities;
|
||||
seriesId: number;
|
||||
episodeTitle: string;
|
||||
isSaving?: boolean;
|
||||
showOpenSeriesButton?: boolean;
|
||||
selectedTab?: EpisodeDetailsTab;
|
||||
startInteractiveSearch?: boolean;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function EpisodeDetailsModal(props: EpisodeDetailsModalProps) {
|
||||
const { selectedTab, isOpen, onModalClose, ...otherProps } = props;
|
||||
|
||||
const [closeOnBackgroundClick, setCloseOnBackgroundClick] = useState(
|
||||
selectedTab !== 'search'
|
||||
);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(isSearch: boolean) => {
|
||||
setCloseOnBackgroundClick(!isSearch);
|
||||
},
|
||||
[setCloseOnBackgroundClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
size={sizes.EXTRA_EXTRA_LARGE}
|
||||
closeOnBackgroundClick={closeOnBackgroundClick}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<EpisodeDetailsModalContent
|
||||
{...otherProps}
|
||||
selectedTab={selectedTab}
|
||||
onTabChange={handleTabChange}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeDetailsModal;
|
||||
@@ -1,222 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
|
||||
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
|
||||
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
|
||||
import EpisodeSummaryConnector from './Summary/EpisodeSummaryConnector';
|
||||
import styles from './EpisodeDetailsModalContent.css';
|
||||
|
||||
const tabs = [
|
||||
'details',
|
||||
'history',
|
||||
'search'
|
||||
];
|
||||
|
||||
class EpisodeDetailsModalContent extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
selectedTab: props.selectedTab
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onTabSelect = (index, lastIndex) => {
|
||||
const selectedTab = tabs[index];
|
||||
this.props.onTabChange(selectedTab === 'search');
|
||||
this.setState({ selectedTab });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
episodeId,
|
||||
episodeEntity,
|
||||
episodeFileId,
|
||||
seriesId,
|
||||
seriesTitle,
|
||||
titleSlug,
|
||||
seriesMonitored,
|
||||
seriesType,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
episodeTitle,
|
||||
airDate,
|
||||
monitored,
|
||||
isSaving,
|
||||
showOpenSeriesButton,
|
||||
startInteractiveSearch,
|
||||
onMonitorEpisodePress,
|
||||
onModalClose
|
||||
} = this.props;
|
||||
|
||||
const seriesLink = `/series/${titleSlug}`;
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
<MonitorToggleButton
|
||||
className={styles.toggleButton}
|
||||
id={episodeId}
|
||||
monitored={monitored}
|
||||
size={18}
|
||||
isDisabled={!seriesMonitored}
|
||||
isSaving={isSaving}
|
||||
onPress={onMonitorEpisodePress}
|
||||
/>
|
||||
|
||||
<span className={styles.seriesTitle}>
|
||||
{seriesTitle}
|
||||
</span>
|
||||
|
||||
<span className={styles.separator}>-</span>
|
||||
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={seasonNumber}
|
||||
episodeNumber={episodeNumber}
|
||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||
airDate={airDate}
|
||||
seriesType={seriesType}
|
||||
/>
|
||||
|
||||
<span className={styles.separator}>-</span>
|
||||
|
||||
{episodeTitle}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Tabs
|
||||
className={styles.tabs}
|
||||
selectedIndex={tabs.indexOf(this.state.selectedTab)}
|
||||
onSelect={this.onTabSelect}
|
||||
>
|
||||
<TabList
|
||||
className={styles.tabList}
|
||||
>
|
||||
<Tab
|
||||
className={styles.tab}
|
||||
selectedClassName={styles.selectedTab}
|
||||
>
|
||||
{translate('Details')}
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
className={styles.tab}
|
||||
selectedClassName={styles.selectedTab}
|
||||
>
|
||||
{translate('History')}
|
||||
</Tab>
|
||||
|
||||
<Tab
|
||||
className={styles.tab}
|
||||
selectedClassName={styles.selectedTab}
|
||||
>
|
||||
{translate('Search')}
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<EpisodeSummaryConnector
|
||||
episodeId={episodeId}
|
||||
episodeEntity={episodeEntity}
|
||||
episodeFileId={episodeFileId}
|
||||
seriesId={seriesId}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<EpisodeHistoryConnector
|
||||
episodeId={episodeId}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{/* Don't wrap in tabContent so we not have a top margin */}
|
||||
<EpisodeSearchConnector
|
||||
episodeId={episodeId}
|
||||
startInteractiveSearch={startInteractiveSearch}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{
|
||||
showOpenSeriesButton &&
|
||||
<Button
|
||||
className={styles.openSeriesButton}
|
||||
to={seriesLink}
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('OpenSeries')}
|
||||
</Button>
|
||||
}
|
||||
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('Close')}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeDetailsModalContent.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
episodeEntity: PropTypes.string.isRequired,
|
||||
episodeFileId: PropTypes.number,
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
seriesTitle: PropTypes.string.isRequired,
|
||||
titleSlug: PropTypes.string.isRequired,
|
||||
seriesMonitored: PropTypes.bool.isRequired,
|
||||
seriesType: PropTypes.string.isRequired,
|
||||
seasonNumber: PropTypes.number.isRequired,
|
||||
episodeNumber: PropTypes.number.isRequired,
|
||||
absoluteEpisodeNumber: PropTypes.number,
|
||||
airDate: PropTypes.string.isRequired,
|
||||
episodeTitle: PropTypes.string.isRequired,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
isSaving: PropTypes.bool,
|
||||
showOpenSeriesButton: PropTypes.bool,
|
||||
selectedTab: PropTypes.string.isRequired,
|
||||
startInteractiveSearch: PropTypes.bool.isRequired,
|
||||
onMonitorEpisodePress: PropTypes.func.isRequired,
|
||||
onTabChange: PropTypes.func.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EpisodeDetailsModalContent.defaultProps = {
|
||||
selectedTab: 'details',
|
||||
episodeEntity: episodeEntities.EPISODES,
|
||||
startInteractiveSearch: false
|
||||
};
|
||||
|
||||
export default EpisodeDetailsModalContent;
|
||||
204
frontend/src/Episode/EpisodeDetailsModalContent.tsx
Normal file
204
frontend/src/Episode/EpisodeDetailsModalContent.tsx
Normal file
@@ -0,0 +1,204 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { Tab, TabList, TabPanel, Tabs } from 'react-tabs';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import MonitorToggleButton from 'Components/MonitorToggleButton';
|
||||
import Episode from 'Episode/Episode';
|
||||
import EpisodeDetailsTab from 'Episode/EpisodeDetailsTab';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
|
||||
import Series from 'Series/Series';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
|
||||
import {
|
||||
cancelFetchReleases,
|
||||
clearReleases,
|
||||
} from 'Store/Actions/releaseActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeHistoryConnector from './History/EpisodeHistoryConnector';
|
||||
import EpisodeSearchConnector from './Search/EpisodeSearchConnector';
|
||||
import SeasonEpisodeNumber from './SeasonEpisodeNumber';
|
||||
import EpisodeSummary from './Summary/EpisodeSummary';
|
||||
import styles from './EpisodeDetailsModalContent.css';
|
||||
|
||||
const TABS: EpisodeDetailsTab[] = ['details', 'history', 'search'];
|
||||
|
||||
export interface EpisodeDetailsModalContentProps {
|
||||
episodeId: number;
|
||||
episodeEntity: EpisodeEntities;
|
||||
seriesId: number;
|
||||
episodeTitle: string;
|
||||
isSaving?: boolean;
|
||||
showOpenSeriesButton?: boolean;
|
||||
selectedTab?: EpisodeDetailsTab;
|
||||
startInteractiveSearch?: boolean;
|
||||
onTabChange(isSearch: boolean): void;
|
||||
onModalClose(): void;
|
||||
}
|
||||
|
||||
function EpisodeDetailsModalContent(props: EpisodeDetailsModalContentProps) {
|
||||
const {
|
||||
episodeId,
|
||||
episodeEntity = episodeEntities.EPISODES,
|
||||
seriesId,
|
||||
episodeTitle,
|
||||
isSaving = false,
|
||||
showOpenSeriesButton = false,
|
||||
startInteractiveSearch = false,
|
||||
selectedTab = 'details',
|
||||
onTabChange,
|
||||
onModalClose,
|
||||
} = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [currentlySelectedTab, setCurrentlySelectedTab] = useState(selectedTab);
|
||||
|
||||
const {
|
||||
title: seriesTitle,
|
||||
titleSlug,
|
||||
monitored: seriesMonitored,
|
||||
seriesType,
|
||||
} = useSeries(seriesId) as Series;
|
||||
|
||||
const {
|
||||
episodeFileId,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
absoluteEpisodeNumber,
|
||||
airDate,
|
||||
monitored,
|
||||
} = useEpisode(episodeId, episodeEntity) as Episode;
|
||||
|
||||
const handleTabSelect = useCallback(
|
||||
(selectedIndex: number) => {
|
||||
const tab = TABS[selectedIndex];
|
||||
onTabChange(tab === 'search');
|
||||
setCurrentlySelectedTab(tab);
|
||||
},
|
||||
[onTabChange]
|
||||
);
|
||||
|
||||
const handleMonitorEpisodePress = useCallback(
|
||||
(monitored: boolean) => {
|
||||
dispatch(
|
||||
toggleEpisodeMonitored({
|
||||
episodeEntity,
|
||||
episodeId,
|
||||
monitored,
|
||||
})
|
||||
);
|
||||
},
|
||||
[episodeEntity, episodeId, dispatch]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
// Clear pending releases here, so we can reshow the search
|
||||
// results even after switching tabs.
|
||||
dispatch(cancelFetchReleases());
|
||||
dispatch(clearReleases());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const seriesLink = `/series/${titleSlug}`;
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
<MonitorToggleButton
|
||||
id={episodeId}
|
||||
monitored={monitored}
|
||||
size={18}
|
||||
isDisabled={!seriesMonitored}
|
||||
isSaving={isSaving}
|
||||
onPress={handleMonitorEpisodePress}
|
||||
/>
|
||||
|
||||
<span className={styles.seriesTitle}>{seriesTitle}</span>
|
||||
|
||||
<span className={styles.separator}>-</span>
|
||||
|
||||
<SeasonEpisodeNumber
|
||||
seasonNumber={seasonNumber}
|
||||
episodeNumber={episodeNumber}
|
||||
absoluteEpisodeNumber={absoluteEpisodeNumber}
|
||||
airDate={airDate}
|
||||
seriesType={seriesType}
|
||||
/>
|
||||
|
||||
<span className={styles.separator}>-</span>
|
||||
|
||||
{episodeTitle}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Tabs
|
||||
className={styles.tabs}
|
||||
selectedIndex={TABS.indexOf(currentlySelectedTab)}
|
||||
onSelect={handleTabSelect}
|
||||
>
|
||||
<TabList className={styles.tabList}>
|
||||
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||
{translate('Details')}
|
||||
</Tab>
|
||||
|
||||
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||
{translate('History')}
|
||||
</Tab>
|
||||
|
||||
<Tab className={styles.tab} selectedClassName={styles.selectedTab}>
|
||||
{translate('Search')}
|
||||
</Tab>
|
||||
</TabList>
|
||||
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<EpisodeSummary
|
||||
episodeId={episodeId}
|
||||
episodeEntity={episodeEntity}
|
||||
episodeFileId={episodeFileId}
|
||||
seriesId={seriesId}
|
||||
/>
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
<div className={styles.tabContent}>
|
||||
<EpisodeHistoryConnector episodeId={episodeId} />
|
||||
</div>
|
||||
</TabPanel>
|
||||
|
||||
<TabPanel>
|
||||
{/* Don't wrap in tabContent so we not have a top margin */}
|
||||
<EpisodeSearchConnector
|
||||
episodeId={episodeId}
|
||||
startInteractiveSearch={startInteractiveSearch}
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</TabPanel>
|
||||
</Tabs>
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
{showOpenSeriesButton && (
|
||||
<Button
|
||||
className={styles.openSeriesButton}
|
||||
to={seriesLink}
|
||||
onPress={onModalClose}
|
||||
>
|
||||
{translate('OpenSeries')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button onPress={onModalClose}>{translate('Close')}</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeDetailsModalContent;
|
||||
@@ -1,101 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import episodeEntities from 'Episode/episodeEntities';
|
||||
import { toggleEpisodeMonitored } from 'Store/Actions/episodeActions';
|
||||
import { cancelFetchReleases, clearReleases } from 'Store/Actions/releaseActions';
|
||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import EpisodeDetailsModalContent from './EpisodeDetailsModalContent';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createEpisodeSelector(),
|
||||
createSeriesSelector(),
|
||||
(episode, series) => {
|
||||
const {
|
||||
title: seriesTitle,
|
||||
titleSlug,
|
||||
monitored: seriesMonitored,
|
||||
seriesType
|
||||
} = series;
|
||||
|
||||
return {
|
||||
seriesTitle,
|
||||
titleSlug,
|
||||
seriesMonitored,
|
||||
seriesType,
|
||||
...episode
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchCancelFetchReleases() {
|
||||
dispatch(cancelFetchReleases());
|
||||
},
|
||||
|
||||
dispatchClearReleases() {
|
||||
dispatch(clearReleases());
|
||||
},
|
||||
|
||||
onMonitorEpisodePress(monitored) {
|
||||
const {
|
||||
episodeId,
|
||||
episodeEntity
|
||||
} = props;
|
||||
|
||||
dispatch(toggleEpisodeMonitored({
|
||||
episodeEntity,
|
||||
episodeId,
|
||||
monitored
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class EpisodeDetailsModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentWillUnmount() {
|
||||
// Clear pending releases here, so we can reshow the search
|
||||
// results even after switching tabs.
|
||||
|
||||
this.props.dispatchCancelFetchReleases();
|
||||
this.props.dispatchClearReleases();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchCancelFetchReleases,
|
||||
dispatchClearReleases,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<EpisodeDetailsModalContent {...otherProps} />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeDetailsModalContentConnector.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
episodeEntity: PropTypes.string.isRequired,
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
dispatchCancelFetchReleases: PropTypes.func.isRequired,
|
||||
dispatchClearReleases: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
EpisodeDetailsModalContentConnector.defaultProps = {
|
||||
episodeEntity: episodeEntities.EPISODES
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeDetailsModalContentConnector);
|
||||
3
frontend/src/Episode/EpisodeDetailsTab.ts
Normal file
3
frontend/src/Episode/EpisodeDetailsTab.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
type EpisodeDetailsTab = 'details' | 'history' | 'search';
|
||||
|
||||
export default EpisodeDetailsTab;
|
||||
@@ -1,33 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
|
||||
function EpisodeFormats({ formats }) {
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
formats.map((format) => {
|
||||
return (
|
||||
<Label
|
||||
key={format.id}
|
||||
kind={kinds.INFO}
|
||||
>
|
||||
{format.name}
|
||||
</Label>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeFormats.propTypes = {
|
||||
formats: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
EpisodeFormats.defaultProps = {
|
||||
formats: []
|
||||
};
|
||||
|
||||
export default EpisodeFormats;
|
||||
22
frontend/src/Episode/EpisodeFormats.tsx
Normal file
22
frontend/src/Episode/EpisodeFormats.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
|
||||
interface EpisodeFormatsProps {
|
||||
formats: CustomFormat[];
|
||||
}
|
||||
|
||||
function EpisodeFormats({ formats }: EpisodeFormatsProps) {
|
||||
return (
|
||||
<div>
|
||||
{formats.map(({ id, name }) => (
|
||||
<Label key={id} kind={kinds.INFO}>
|
||||
{name}
|
||||
</Label>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeFormats;
|
||||
@@ -1,18 +1,21 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Label from 'Components/Label';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function EpisodeLanguages(props) {
|
||||
const {
|
||||
className,
|
||||
languages,
|
||||
isCutoffNotMet
|
||||
} = props;
|
||||
interface EpisodeLanguagesProps {
|
||||
className?: string;
|
||||
languages: Language[];
|
||||
isCutoffNotMet?: boolean;
|
||||
}
|
||||
|
||||
if (!languages) {
|
||||
function EpisodeLanguages(props: EpisodeLanguagesProps) {
|
||||
const { className, languages, isCutoffNotMet = true } = props;
|
||||
|
||||
// TODO: Typescript - Remove once everything is converted
|
||||
if (!languages || languages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -41,15 +44,9 @@ function EpisodeLanguages(props) {
|
||||
title={translate('Languages')}
|
||||
body={
|
||||
<ul>
|
||||
{
|
||||
languages.map((language) => {
|
||||
return (
|
||||
<li key={language.id}>
|
||||
{language.name}
|
||||
</li>
|
||||
);
|
||||
})
|
||||
}
|
||||
{languages.map((language) => (
|
||||
<li key={language.id}>{language.name}</li>
|
||||
))}
|
||||
</ul>
|
||||
}
|
||||
position={tooltipPositions.LEFT}
|
||||
@@ -57,14 +54,4 @@ function EpisodeLanguages(props) {
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeLanguages.propTypes = {
|
||||
className: PropTypes.string,
|
||||
languages: PropTypes.arrayOf(PropTypes.object),
|
||||
isCutoffNotMet: PropTypes.bool
|
||||
};
|
||||
|
||||
EpisodeLanguages.defaultProps = {
|
||||
isCutoffNotMet: true
|
||||
};
|
||||
|
||||
export default EpisodeLanguages;
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Fragment } from 'react';
|
||||
import React from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
@@ -82,9 +82,7 @@ function EpisodeNumber(props: EpisodeNumberProps) {
|
||||
<Popover
|
||||
anchor={
|
||||
<span>
|
||||
{showSeasonNumber && seasonNumber != null && (
|
||||
<Fragment>{seasonNumber}x</Fragment>
|
||||
)}
|
||||
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
|
||||
|
||||
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
|
||||
|
||||
@@ -111,9 +109,7 @@ function EpisodeNumber(props: EpisodeNumberProps) {
|
||||
/>
|
||||
) : (
|
||||
<span>
|
||||
{showSeasonNumber && seasonNumber != null && (
|
||||
<Fragment>{seasonNumber}x</Fragment>
|
||||
)}
|
||||
{showSeasonNumber && seasonNumber != null && <>{seasonNumber}x</>}
|
||||
|
||||
{showSeasonNumber ? padNumber(episodeNumber, 2) : episodeNumber}
|
||||
|
||||
|
||||
@@ -1,86 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeDetailsModal from './EpisodeDetailsModal';
|
||||
import styles from './EpisodeSearchCell.css';
|
||||
|
||||
class EpisodeSearchCell extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isDetailsModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onManualSearchPress = () => {
|
||||
this.setState({ isDetailsModalOpen: true });
|
||||
};
|
||||
|
||||
onDetailsModalClose = () => {
|
||||
this.setState({ isDetailsModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
episodeId,
|
||||
seriesId,
|
||||
episodeTitle,
|
||||
isSearching,
|
||||
onSearchPress,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.episodeSearchCell}>
|
||||
<SpinnerIconButton
|
||||
name={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
onPress={onSearchPress}
|
||||
title={translate('AutomaticSearch')}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name={icons.INTERACTIVE}
|
||||
onPress={this.onManualSearchPress}
|
||||
title={translate('InteractiveSearch')}
|
||||
/>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={this.state.isDetailsModalOpen}
|
||||
episodeId={episodeId}
|
||||
seriesId={seriesId}
|
||||
episodeTitle={episodeTitle}
|
||||
selectedTab="search"
|
||||
startInteractiveSearch={true}
|
||||
onModalClose={this.onDetailsModalClose}
|
||||
{...otherProps}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeSearchCell.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
seriesId: PropTypes.number.isRequired,
|
||||
episodeTitle: PropTypes.string.isRequired,
|
||||
isSearching: PropTypes.bool.isRequired,
|
||||
onSearchPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeSearchCell;
|
||||
75
frontend/src/Episode/EpisodeSearchCell.tsx
Normal file
75
frontend/src/Episode/EpisodeSearchCell.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { EPISODE_SEARCH } from 'Commands/commandNames';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import SpinnerIconButton from 'Components/Link/SpinnerIconButton';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import { EpisodeEntities } from 'Episode/useEpisode';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import createExecutingCommandsSelector from 'Store/Selectors/createExecutingCommandsSelector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeDetailsModal from './EpisodeDetailsModal';
|
||||
import styles from './EpisodeSearchCell.css';
|
||||
|
||||
interface EpisodeSearchCellProps {
|
||||
episodeId: number;
|
||||
episodeEntity: EpisodeEntities;
|
||||
seriesId: number;
|
||||
episodeTitle: string;
|
||||
}
|
||||
|
||||
function EpisodeSearchCell(props: EpisodeSearchCellProps) {
|
||||
const { episodeId, episodeEntity, seriesId, episodeTitle } = props;
|
||||
|
||||
const executingCommands = useSelector(createExecutingCommandsSelector());
|
||||
const isSearching = executingCommands.some(({ name, body }) => {
|
||||
const { episodeIds = [] } = body;
|
||||
return name === EPISODE_SEARCH && episodeIds.indexOf(episodeId) > -1;
|
||||
});
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const [isDetailsModalOpen, setDetailsModalOpen, setDetailsModalClosed] =
|
||||
useModalOpenState(false);
|
||||
|
||||
const handleSearchPress = useCallback(() => {
|
||||
dispatch(
|
||||
executeCommand({
|
||||
name: EPISODE_SEARCH,
|
||||
episodeIds: [episodeId],
|
||||
})
|
||||
);
|
||||
}, [episodeId, dispatch]);
|
||||
|
||||
return (
|
||||
<TableRowCell className={styles.episodeSearchCell}>
|
||||
<SpinnerIconButton
|
||||
name={icons.SEARCH}
|
||||
isSpinning={isSearching}
|
||||
title={translate('AutomaticSearch')}
|
||||
onPress={handleSearchPress}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
name={icons.INTERACTIVE}
|
||||
title={translate('InteractiveSearch')}
|
||||
onPress={setDetailsModalOpen}
|
||||
/>
|
||||
|
||||
<EpisodeDetailsModal
|
||||
isOpen={isDetailsModalOpen}
|
||||
episodeId={episodeId}
|
||||
episodeEntity={episodeEntity}
|
||||
seriesId={seriesId}
|
||||
episodeTitle={episodeTitle}
|
||||
selectedTab="search"
|
||||
startInteractiveSearch={true}
|
||||
onModalClose={setDetailsModalClosed}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeSearchCell;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import * as commandNames from 'Commands/commandNames';
|
||||
import { executeCommand } from 'Store/Actions/commandActions';
|
||||
import createCommandsSelector from 'Store/Selectors/createCommandsSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import { isCommandExecuting } from 'Utilities/Command';
|
||||
import EpisodeSearchCell from './EpisodeSearchCell';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state, { episodeId }) => episodeId,
|
||||
(state, { sceneSeasonNumber }) => sceneSeasonNumber,
|
||||
createSeriesSelector(),
|
||||
createCommandsSelector(),
|
||||
(episodeId, sceneSeasonNumber, series, commands) => {
|
||||
const isSearching = commands.some((command) => {
|
||||
const episodeSearch = command.name === commandNames.EPISODE_SEARCH;
|
||||
|
||||
if (!episodeSearch) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
isCommandExecuting(command) &&
|
||||
command.body.episodeIds.indexOf(episodeId) > -1
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
seriesMonitored: series.monitored,
|
||||
seriesType: series.seriesType,
|
||||
isSearching
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onSearchPress(name, path) {
|
||||
dispatch(executeCommand({
|
||||
name: commandNames.EPISODE_SEARCH,
|
||||
episodeIds: [props.episodeId]
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSearchCell);
|
||||
@@ -1,34 +1,44 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import QueueDetails from 'Activity/Queue/QueueDetails';
|
||||
import Icon from 'Components/Icon';
|
||||
import ProgressBar from 'Components/ProgressBar';
|
||||
import Episode from 'Episode/Episode';
|
||||
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import { createQueueItemSelectorForHook } from 'Store/Selectors/createQueueItemSelector';
|
||||
import isBefore from 'Utilities/Date/isBefore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeQuality from './EpisodeQuality';
|
||||
import styles from './EpisodeStatus.css';
|
||||
|
||||
function EpisodeStatus(props) {
|
||||
interface EpisodeStatusProps {
|
||||
episodeId: number;
|
||||
episodeEntity?: EpisodeEntities;
|
||||
episodeFileId: number;
|
||||
}
|
||||
|
||||
function EpisodeStatus(props: EpisodeStatusProps) {
|
||||
const { episodeId, episodeEntity = 'episodes', episodeFileId } = props;
|
||||
|
||||
const {
|
||||
airDateUtc,
|
||||
monitored,
|
||||
grabbed,
|
||||
queueItem,
|
||||
episodeFile
|
||||
} = props;
|
||||
grabbed = false,
|
||||
} = useEpisode(episodeId, episodeEntity) as Episode;
|
||||
|
||||
const queueItem = useSelector(createQueueItemSelectorForHook(episodeId));
|
||||
const episodeFile = useEpisodeFile(episodeFileId);
|
||||
|
||||
const hasEpisodeFile = !!episodeFile;
|
||||
const isQueued = !!queueItem;
|
||||
const hasAired = isBefore(airDateUtc);
|
||||
|
||||
if (isQueued) {
|
||||
const {
|
||||
sizeleft,
|
||||
size
|
||||
} = queueItem;
|
||||
const { sizeleft, size } = queueItem;
|
||||
|
||||
const progress = size ? (100 - sizeleft / size * 100) : 0;
|
||||
const progress = size ? 100 - (sizeleft / size) * 100 : 0;
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
@@ -76,10 +86,7 @@ function EpisodeStatus(props) {
|
||||
if (!airDateUtc) {
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.TBA}
|
||||
title={translate('Tba')}
|
||||
/>
|
||||
<Icon name={icons.TBA} title={translate('Tba')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -109,20 +116,9 @@ function EpisodeStatus(props) {
|
||||
|
||||
return (
|
||||
<div className={styles.center}>
|
||||
<Icon
|
||||
name={icons.NOT_AIRED}
|
||||
title={translate('EpisodeHasNotAired')}
|
||||
/>
|
||||
<Icon name={icons.NOT_AIRED} title={translate('EpisodeHasNotAired')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeStatus.propTypes = {
|
||||
airDateUtc: PropTypes.string,
|
||||
monitored: PropTypes.bool.isRequired,
|
||||
grabbed: PropTypes.bool,
|
||||
queueItem: PropTypes.object,
|
||||
episodeFile: PropTypes.object
|
||||
};
|
||||
|
||||
export default EpisodeStatus;
|
||||
@@ -1,53 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
||||
import createQueueItemSelector from 'Store/Selectors/createQueueItemSelector';
|
||||
import EpisodeStatus from './EpisodeStatus';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createEpisodeSelector(),
|
||||
createQueueItemSelector(),
|
||||
createEpisodeFileSelector(),
|
||||
(episode, queueItem, episodeFile) => {
|
||||
const result = _.pick(episode, [
|
||||
'airDateUtc',
|
||||
'monitored',
|
||||
'grabbed'
|
||||
]);
|
||||
|
||||
result.queueItem = queueItem;
|
||||
result.episodeFile = episodeFile;
|
||||
|
||||
return result;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
};
|
||||
|
||||
class EpisodeStatusConnector extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<EpisodeStatus
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeStatusConnector.propTypes = {
|
||||
episodeId: PropTypes.number.isRequired,
|
||||
episodeFileId: PropTypes.number.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(EpisodeStatusConnector);
|
||||
@@ -1,13 +1,14 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import Link from 'Components/Link/Link';
|
||||
import EpisodeDetailsModal from 'Episode/EpisodeDetailsModal';
|
||||
import { EpisodeEntities } from 'Episode/useEpisode';
|
||||
import FinaleType from './FinaleType';
|
||||
import styles from './EpisodeTitleLink.css';
|
||||
|
||||
interface EpisodeTitleLinkProps {
|
||||
episodeId: number;
|
||||
seriesId: number;
|
||||
episodeEntity: string;
|
||||
episodeEntity: EpisodeEntities;
|
||||
episodeTitle: string;
|
||||
finaleType?: string;
|
||||
showOpenSeriesButton: boolean;
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SceneInfo.css';
|
||||
|
||||
function SceneInfo(props) {
|
||||
const {
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumber,
|
||||
sceneAbsoluteEpisodeNumber,
|
||||
alternateTitles,
|
||||
seriesType
|
||||
} = props;
|
||||
|
||||
const reducedAlternateTitles = alternateTitles.map((alternateTitle) => {
|
||||
let suffix = '';
|
||||
|
||||
const altSceneSeasonNumber = sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber;
|
||||
const altSceneEpisodeNumber = sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber;
|
||||
|
||||
const mappingSeasonNumber = alternateTitle.sceneOrigin === 'tvdb' ? seasonNumber : altSceneSeasonNumber;
|
||||
const altSeasonNumber = (alternateTitle.sceneSeasonNumber !== -1 && alternateTitle.sceneSeasonNumber !== undefined) ? alternateTitle.sceneSeasonNumber : mappingSeasonNumber;
|
||||
const altEpisodeNumber = alternateTitle.sceneOrigin === 'tvdb' ? episodeNumber : altSceneEpisodeNumber;
|
||||
|
||||
if (altEpisodeNumber !== altSceneEpisodeNumber) {
|
||||
suffix = `S${padNumber(altSeasonNumber, 2)}E${padNumber(altEpisodeNumber, 2)}`;
|
||||
} else if (altSeasonNumber !== altSceneSeasonNumber) {
|
||||
suffix = `S${padNumber(altSeasonNumber, 2)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
alternateTitle,
|
||||
title: alternateTitle.title,
|
||||
suffix,
|
||||
comment: alternateTitle.comment
|
||||
};
|
||||
});
|
||||
|
||||
const groupedAlternateTitles = _.map(_.groupBy(reducedAlternateTitles, (item) => `${item.title} ${item.suffix}`), (group) => {
|
||||
return {
|
||||
title: group[0].title,
|
||||
suffix: group[0].suffix,
|
||||
comment: _.uniq(group.map((item) => item.comment)).join('/')
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<DescriptionList className={styles.descriptionList}>
|
||||
{
|
||||
sceneSeasonNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Season')}
|
||||
data={sceneSeasonNumber}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
sceneEpisodeNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Episode')}
|
||||
data={sceneEpisodeNumber}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Absolute')}
|
||||
data={sceneAbsoluteEpisodeNumber}
|
||||
/>
|
||||
}
|
||||
|
||||
{
|
||||
!!alternateTitles.length &&
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={groupedAlternateTitles.length === 1 ? translate('Title') : translate('Titles')}
|
||||
data={
|
||||
<div>
|
||||
{
|
||||
groupedAlternateTitles.map(({ title, suffix, comment }) => {
|
||||
return (
|
||||
<div
|
||||
key={`${title} ${suffix}`}
|
||||
>
|
||||
{title}
|
||||
{
|
||||
suffix &&
|
||||
<span> ({suffix})</span>
|
||||
}
|
||||
{
|
||||
comment &&
|
||||
<span className={styles.comment}> {comment}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
SceneInfo.propTypes = {
|
||||
seasonNumber: PropTypes.number,
|
||||
episodeNumber: PropTypes.number,
|
||||
sceneSeasonNumber: PropTypes.number,
|
||||
sceneEpisodeNumber: PropTypes.number,
|
||||
sceneAbsoluteEpisodeNumber: PropTypes.number,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
seriesType: PropTypes.string
|
||||
};
|
||||
|
||||
export default SceneInfo;
|
||||
168
frontend/src/Episode/SceneInfo.tsx
Normal file
168
frontend/src/Episode/SceneInfo.tsx
Normal file
@@ -0,0 +1,168 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import DescriptionList from 'Components/DescriptionList/DescriptionList';
|
||||
import DescriptionListItem from 'Components/DescriptionList/DescriptionListItem';
|
||||
import { AlternateTitle } from 'Series/Series';
|
||||
import padNumber from 'Utilities/Number/padNumber';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './SceneInfo.css';
|
||||
|
||||
interface SceneInfoProps {
|
||||
seasonNumber?: number;
|
||||
episodeNumber?: number;
|
||||
sceneSeasonNumber?: number;
|
||||
sceneEpisodeNumber?: number;
|
||||
sceneAbsoluteEpisodeNumber?: number;
|
||||
alternateTitles: AlternateTitle[];
|
||||
seriesType?: string;
|
||||
}
|
||||
|
||||
function SceneInfo(props: SceneInfoProps) {
|
||||
const {
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumber,
|
||||
sceneAbsoluteEpisodeNumber,
|
||||
alternateTitles,
|
||||
seriesType,
|
||||
} = props;
|
||||
|
||||
const groupedAlternateTitles = useMemo(() => {
|
||||
const reducedAlternateTitles = alternateTitles.map((alternateTitle) => {
|
||||
let suffix = '';
|
||||
|
||||
const altSceneSeasonNumber =
|
||||
sceneSeasonNumber === undefined ? seasonNumber : sceneSeasonNumber;
|
||||
const altSceneEpisodeNumber =
|
||||
sceneEpisodeNumber === undefined ? episodeNumber : sceneEpisodeNumber;
|
||||
|
||||
const mappingSeasonNumber =
|
||||
alternateTitle.sceneOrigin === 'tvdb'
|
||||
? seasonNumber
|
||||
: altSceneSeasonNumber;
|
||||
const altSeasonNumber =
|
||||
alternateTitle.sceneSeasonNumber !== -1 &&
|
||||
alternateTitle.sceneSeasonNumber !== undefined
|
||||
? alternateTitle.sceneSeasonNumber
|
||||
: mappingSeasonNumber;
|
||||
const altEpisodeNumber =
|
||||
alternateTitle.sceneOrigin === 'tvdb'
|
||||
? episodeNumber
|
||||
: altSceneEpisodeNumber;
|
||||
|
||||
if (altEpisodeNumber !== altSceneEpisodeNumber) {
|
||||
suffix = `S${padNumber(altSeasonNumber as number, 2)}E${padNumber(
|
||||
altEpisodeNumber as number,
|
||||
2
|
||||
)}`;
|
||||
} else if (altSeasonNumber !== altSceneSeasonNumber) {
|
||||
suffix = `S${padNumber(altSeasonNumber as number, 2)}`;
|
||||
}
|
||||
|
||||
return {
|
||||
alternateTitle,
|
||||
title: alternateTitle.title,
|
||||
suffix,
|
||||
comment: alternateTitle.comment,
|
||||
};
|
||||
});
|
||||
|
||||
return Object.values(
|
||||
reducedAlternateTitles.reduce(
|
||||
(
|
||||
acc: Record<
|
||||
string,
|
||||
{ title: string; suffix: string; comment: string }
|
||||
>,
|
||||
alternateTitle
|
||||
) => {
|
||||
const key = alternateTitle.suffix
|
||||
? `${alternateTitle.title} ${alternateTitle.suffix}`
|
||||
: alternateTitle.title;
|
||||
const item = acc[key];
|
||||
|
||||
if (item) {
|
||||
item.comment = alternateTitle.comment
|
||||
? `${item.comment}/${alternateTitle.comment}`
|
||||
: item.comment;
|
||||
} else {
|
||||
acc[key] = {
|
||||
title: alternateTitle.title,
|
||||
suffix: alternateTitle.suffix,
|
||||
comment: alternateTitle.comment ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
)
|
||||
);
|
||||
}, [
|
||||
alternateTitles,
|
||||
seasonNumber,
|
||||
episodeNumber,
|
||||
sceneSeasonNumber,
|
||||
sceneEpisodeNumber,
|
||||
]);
|
||||
|
||||
return (
|
||||
<DescriptionList className={styles.descriptionList}>
|
||||
{sceneSeasonNumber === undefined ? null : (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Season')}
|
||||
data={sceneSeasonNumber}
|
||||
/>
|
||||
)}
|
||||
|
||||
{sceneEpisodeNumber === undefined ? null : (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Episode')}
|
||||
data={sceneEpisodeNumber}
|
||||
/>
|
||||
)}
|
||||
|
||||
{seriesType === 'anime' && sceneAbsoluteEpisodeNumber !== undefined ? (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={translate('Absolute')}
|
||||
data={sceneAbsoluteEpisodeNumber}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{alternateTitles.length ? (
|
||||
<DescriptionListItem
|
||||
titleClassName={styles.title}
|
||||
descriptionClassName={styles.description}
|
||||
title={
|
||||
groupedAlternateTitles.length === 1
|
||||
? translate('Title')
|
||||
: translate('Titles')
|
||||
}
|
||||
data={
|
||||
<div>
|
||||
{groupedAlternateTitles.map(({ title, suffix, comment }) => {
|
||||
return (
|
||||
<div key={`${title} ${suffix}`}>
|
||||
{title}
|
||||
{suffix && <span> ({suffix})</span>}
|
||||
{comment ? (
|
||||
<span className={styles.comment}> {comment}</span>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
</DescriptionList>
|
||||
);
|
||||
}
|
||||
|
||||
export default SceneInfo;
|
||||
@@ -1,28 +1,29 @@
|
||||
import moment from 'moment';
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import Label from 'Components/Label';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import formatTime from 'Utilities/Date/formatTime';
|
||||
import isInNextWeek from 'Utilities/Date/isInNextWeek';
|
||||
import isToday from 'Utilities/Date/isToday';
|
||||
import isTomorrow from 'Utilities/Date/isTomorrow';
|
||||
import translate from 'Utilities/String/translate';
|
||||
|
||||
function EpisodeAiring(props) {
|
||||
const {
|
||||
airDateUtc,
|
||||
network,
|
||||
shortDateFormat,
|
||||
showRelativeDates,
|
||||
timeFormat
|
||||
} = props;
|
||||
interface EpisodeAiringProps {
|
||||
airDateUtc?: string;
|
||||
network: string;
|
||||
}
|
||||
|
||||
function EpisodeAiring(props: EpisodeAiringProps) {
|
||||
const { airDateUtc, network } = props;
|
||||
|
||||
const { shortDateFormat, showRelativeDates, timeFormat } = useSelector(
|
||||
createUISettingsSelector()
|
||||
);
|
||||
|
||||
const networkLabel = (
|
||||
<Label
|
||||
kind={kinds.INFO}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<Label kind={kinds.INFO} size={sizes.MEDIUM}>
|
||||
{network}
|
||||
</Label>
|
||||
);
|
||||
@@ -31,7 +32,8 @@ function EpisodeAiring(props) {
|
||||
if (!airDateUtc) {
|
||||
return (
|
||||
<span>
|
||||
{translate('AirsTbaOn', { networkLabel: '' })}{networkLabel}
|
||||
{translate('AirsTbaOn', { networkLabel: '' })}
|
||||
{networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -41,7 +43,12 @@ function EpisodeAiring(props) {
|
||||
if (!showRelativeDates) {
|
||||
return (
|
||||
<span>
|
||||
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format(shortDateFormat), time, networkLabel: '' })}{networkLabel}
|
||||
{translate('AirsDateAtTimeOn', {
|
||||
date: moment(airDateUtc).format(shortDateFormat),
|
||||
time,
|
||||
networkLabel: '',
|
||||
})}
|
||||
{networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -49,7 +56,8 @@ function EpisodeAiring(props) {
|
||||
if (isToday(airDateUtc)) {
|
||||
return (
|
||||
<span>
|
||||
{translate('AirsTimeOn', { time, networkLabel: '' })}{networkLabel}
|
||||
{translate('AirsTimeOn', { time, networkLabel: '' })}
|
||||
{networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -57,7 +65,8 @@ function EpisodeAiring(props) {
|
||||
if (isTomorrow(airDateUtc)) {
|
||||
return (
|
||||
<span>
|
||||
{translate('AirsTomorrowOn', { time, networkLabel: '' })}{networkLabel}
|
||||
{translate('AirsTomorrowOn', { time, networkLabel: '' })}
|
||||
{networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -65,24 +74,26 @@ function EpisodeAiring(props) {
|
||||
if (isInNextWeek(airDateUtc)) {
|
||||
return (
|
||||
<span>
|
||||
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format('dddd'), time, networkLabel: '' })}{networkLabel}
|
||||
{translate('AirsDateAtTimeOn', {
|
||||
date: moment(airDateUtc).format('dddd'),
|
||||
time,
|
||||
networkLabel: '',
|
||||
})}
|
||||
{networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span>
|
||||
{translate('AirsDateAtTimeOn', { date: moment(airDateUtc).format(shortDateFormat), time, networkLabel: '' })}{networkLabel}
|
||||
{translate('AirsDateAtTimeOn', {
|
||||
date: moment(airDateUtc).format(shortDateFormat),
|
||||
time,
|
||||
networkLabel: '',
|
||||
})}
|
||||
{networkLabel}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
EpisodeAiring.propTypes = {
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
network: PropTypes.string.isRequired,
|
||||
shortDateFormat: PropTypes.string.isRequired,
|
||||
showRelativeDates: PropTypes.bool.isRequired,
|
||||
timeFormat: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeAiring;
|
||||
@@ -1,20 +0,0 @@
|
||||
import _ from 'lodash';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
|
||||
import EpisodeAiring from './EpisodeAiring';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createUISettingsSelector(),
|
||||
(uiSettings) => {
|
||||
return _.pick(uiSettings, [
|
||||
'shortDateFormat',
|
||||
'showRelativeDates',
|
||||
'timeFormat'
|
||||
]);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(EpisodeAiring);
|
||||
@@ -1,205 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MediaInfo from './MediaInfo';
|
||||
import styles from './EpisodeFileRow.css';
|
||||
|
||||
class EpisodeFileRow extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isRemoveEpisodeFileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRemoveEpisodeFilePress = () => {
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: true });
|
||||
};
|
||||
|
||||
onConfirmRemoveEpisodeFile = () => {
|
||||
this.props.onDeleteEpisodeFile();
|
||||
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: false });
|
||||
};
|
||||
|
||||
onRemoveEpisodeFileModalClose = () => {
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
path,
|
||||
size,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
qualityCutoffNotMet,
|
||||
mediaInfo,
|
||||
columns
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{
|
||||
columns.map((column) => {
|
||||
const {
|
||||
name,
|
||||
isVisible
|
||||
} = column;
|
||||
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'path') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{path}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'size') {
|
||||
return (
|
||||
<TableRowCell key={name}>
|
||||
{formatBytes(size)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.languages}
|
||||
>
|
||||
<EpisodeLanguages languages={languages} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'quality') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.quality}
|
||||
>
|
||||
<EpisodeQuality
|
||||
quality={quality}
|
||||
isCutoffNotMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormats') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.customFormats}
|
||||
>
|
||||
<EpisodeFormats
|
||||
formats={customFormats}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormatScore') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.customFormatScore}
|
||||
>
|
||||
{formatCustomFormatScore(customFormatScore, customFormats.length)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'actions') {
|
||||
return (
|
||||
<TableRowCell
|
||||
key={name}
|
||||
className={styles.actions}
|
||||
>
|
||||
{
|
||||
mediaInfo ?
|
||||
<Popover
|
||||
anchor={
|
||||
<Icon
|
||||
name={icons.MEDIA_INFO}
|
||||
/>
|
||||
}
|
||||
title={translate('MediaInfo')}
|
||||
body={<MediaInfo {...mediaInfo} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/> :
|
||||
null
|
||||
}
|
||||
|
||||
<IconButton
|
||||
title={translate('DeleteEpisodeFromDisk')}
|
||||
name={icons.REMOVE}
|
||||
onPress={this.onRemoveEpisodeFilePress}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})
|
||||
}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isRemoveEpisodeFileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteEpisodeFile')}
|
||||
message={translate('DeleteEpisodeFileMessage', { path })}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={this.onConfirmRemoveEpisodeFile}
|
||||
onCancel={this.onRemoveEpisodeFileModalClose}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
EpisodeFileRow.propTypes = {
|
||||
path: PropTypes.string.isRequired,
|
||||
size: PropTypes.number.isRequired,
|
||||
languages: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
quality: PropTypes.object.isRequired,
|
||||
qualityCutoffNotMet: PropTypes.bool.isRequired,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
||||
customFormatScore: PropTypes.number.isRequired,
|
||||
mediaInfo: PropTypes.object,
|
||||
columns: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onDeleteEpisodeFile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeFileRow;
|
||||
149
frontend/src/Episode/Summary/EpisodeFileRow.tsx
Normal file
149
frontend/src/Episode/Summary/EpisodeFileRow.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import TableRowCell from 'Components/Table/Cells/TableRowCell';
|
||||
import Column from 'Components/Table/Column';
|
||||
import TableRow from 'Components/Table/TableRow';
|
||||
import Popover from 'Components/Tooltip/Popover';
|
||||
import EpisodeFormats from 'Episode/EpisodeFormats';
|
||||
import EpisodeLanguages from 'Episode/EpisodeLanguages';
|
||||
import EpisodeQuality from 'Episode/EpisodeQuality';
|
||||
import useModalOpenState from 'Helpers/Hooks/useModalOpenState';
|
||||
import { icons, kinds, tooltipPositions } from 'Helpers/Props';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
import formatCustomFormatScore from 'Utilities/Number/formatCustomFormatScore';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import MediaInfo from './MediaInfo';
|
||||
import styles from './EpisodeFileRow.css';
|
||||
|
||||
interface EpisodeFileRowProps {
|
||||
path: string;
|
||||
size: number;
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
qualityCutoffNotMet: boolean;
|
||||
customFormats: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
mediaInfo: object;
|
||||
columns: Column[];
|
||||
onDeleteEpisodeFile(): void;
|
||||
}
|
||||
|
||||
function EpisodeFileRow(props: EpisodeFileRowProps) {
|
||||
const {
|
||||
path,
|
||||
size,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
qualityCutoffNotMet,
|
||||
mediaInfo,
|
||||
columns,
|
||||
onDeleteEpisodeFile,
|
||||
} = props;
|
||||
|
||||
const [
|
||||
isRemoveEpisodeFileModalOpen,
|
||||
setRemoveEpisodeFileModalOpen,
|
||||
setRemoveEpisodeFileModalClosed,
|
||||
] = useModalOpenState(false);
|
||||
|
||||
const handleRemoveEpisodeFilePress = useCallback(() => {
|
||||
onDeleteEpisodeFile();
|
||||
|
||||
setRemoveEpisodeFileModalClosed();
|
||||
}, [onDeleteEpisodeFile, setRemoveEpisodeFileModalClosed]);
|
||||
|
||||
return (
|
||||
<TableRow>
|
||||
{columns.map(({ name, isVisible }) => {
|
||||
if (!isVisible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (name === 'path') {
|
||||
return <TableRowCell key={name}>{path}</TableRowCell>;
|
||||
}
|
||||
|
||||
if (name === 'size') {
|
||||
return <TableRowCell key={name}>{formatBytes(size)}</TableRowCell>;
|
||||
}
|
||||
|
||||
if (name === 'languages') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.languages}>
|
||||
<EpisodeLanguages languages={languages} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'quality') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.quality}>
|
||||
<EpisodeQuality
|
||||
quality={quality}
|
||||
isCutoffNotMet={qualityCutoffNotMet}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormats') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.customFormats}>
|
||||
<EpisodeFormats formats={customFormats} />
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'customFormatScore') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.customFormatScore}>
|
||||
{formatCustomFormatScore(customFormatScore, customFormats.length)}
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
if (name === 'actions') {
|
||||
return (
|
||||
<TableRowCell key={name} className={styles.actions}>
|
||||
{mediaInfo ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.MEDIA_INFO} />}
|
||||
title={translate('MediaInfo')}
|
||||
body={<MediaInfo {...mediaInfo} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<IconButton
|
||||
title={translate('DeleteEpisodeFromDisk')}
|
||||
name={icons.REMOVE}
|
||||
onPress={setRemoveEpisodeFileModalOpen}
|
||||
/>
|
||||
</TableRowCell>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={isRemoveEpisodeFileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteEpisodeFile')}
|
||||
message={translate('DeleteEpisodeFileMessage', { path })}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={handleRemoveEpisodeFilePress}
|
||||
onCancel={setRemoveEpisodeFileModalClosed}
|
||||
/>
|
||||
</TableRow>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeFileRow;
|
||||
@@ -1,198 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import ConfirmModal from 'Components/Modal/ConfirmModal';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeAiringConnector from './EpisodeAiringConnector';
|
||||
import EpisodeFileRow from './EpisodeFileRow';
|
||||
import styles from './EpisodeSummary.css';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
name: 'path',
|
||||
label: () => translate('Path'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: () => translate('Size'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore')
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
isSortable: false,
|
||||
isVisible: true
|
||||
}
|
||||
];
|
||||
|
||||
class EpisodeSummary extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isRemoveEpisodeFileModalOpen: false
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRemoveEpisodeFilePress = () => {
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: true });
|
||||
};
|
||||
|
||||
onConfirmRemoveEpisodeFile = () => {
|
||||
this.props.onDeleteEpisodeFile();
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: false });
|
||||
};
|
||||
|
||||
onRemoveEpisodeFileModalClose = () => {
|
||||
this.setState({ isRemoveEpisodeFileModalOpen: false });
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
qualityProfileId,
|
||||
network,
|
||||
overview,
|
||||
airDateUtc,
|
||||
mediaInfo,
|
||||
path,
|
||||
size,
|
||||
languages,
|
||||
quality,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
qualityCutoffNotMet,
|
||||
onDeleteEpisodeFile
|
||||
} = this.props;
|
||||
|
||||
const hasOverview = !!overview;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<span className={styles.infoTitle}>{translate('Airs')}</span>
|
||||
|
||||
<EpisodeAiringConnector
|
||||
airDateUtc={airDateUtc}
|
||||
network={network}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
|
||||
|
||||
<Label
|
||||
kind={kinds.PRIMARY}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
<QualityProfileNameConnector
|
||||
qualityProfileId={qualityProfileId}
|
||||
/>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>
|
||||
{
|
||||
hasOverview ?
|
||||
overview :
|
||||
translate('NoEpisodeOverview')
|
||||
}
|
||||
</div>
|
||||
|
||||
{
|
||||
path ?
|
||||
<Table columns={columns}>
|
||||
<TableBody>
|
||||
<EpisodeFileRow
|
||||
path={path}
|
||||
size={size}
|
||||
languages={languages}
|
||||
quality={quality}
|
||||
qualityCutoffNotMet={qualityCutoffNotMet}
|
||||
customFormats={customFormats}
|
||||
customFormatScore={customFormatScore}
|
||||
mediaInfo={mediaInfo}
|
||||
columns={columns}
|
||||
onDeleteEpisodeFile={onDeleteEpisodeFile}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table> :
|
||||
null
|
||||
}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={this.state.isRemoveEpisodeFileModalOpen}
|
||||
kind={kinds.DANGER}
|
||||
title={translate('DeleteEpisodeFile')}
|
||||
message={translate('DeleteEpisodeFileMessage', { path })}
|
||||
confirmLabel={translate('Delete')}
|
||||
onConfirm={this.onConfirmRemoveEpisodeFile}
|
||||
onCancel={this.onRemoveEpisodeFileModalClose}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeSummary.propTypes = {
|
||||
episodeFileId: PropTypes.number.isRequired,
|
||||
qualityProfileId: PropTypes.number.isRequired,
|
||||
network: PropTypes.string.isRequired,
|
||||
overview: PropTypes.string,
|
||||
airDateUtc: PropTypes.string.isRequired,
|
||||
mediaInfo: PropTypes.object,
|
||||
path: PropTypes.string,
|
||||
size: PropTypes.number,
|
||||
languages: PropTypes.arrayOf(PropTypes.object),
|
||||
quality: PropTypes.object,
|
||||
qualityCutoffNotMet: PropTypes.bool,
|
||||
customFormats: PropTypes.arrayOf(PropTypes.object),
|
||||
customFormatScore: PropTypes.number.isRequired,
|
||||
onDeleteEpisodeFile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default EpisodeSummary;
|
||||
161
frontend/src/Episode/Summary/EpisodeSummary.tsx
Normal file
161
frontend/src/Episode/Summary/EpisodeSummary.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import Icon from 'Components/Icon';
|
||||
import Label from 'Components/Label';
|
||||
import Column from 'Components/Table/Column';
|
||||
import Table from 'Components/Table/Table';
|
||||
import TableBody from 'Components/Table/TableBody';
|
||||
import Episode from 'Episode/Episode';
|
||||
import useEpisode, { EpisodeEntities } from 'Episode/useEpisode';
|
||||
import useEpisodeFile from 'EpisodeFile/useEpisodeFile';
|
||||
import { icons, kinds, sizes } from 'Helpers/Props';
|
||||
import Series from 'Series/Series';
|
||||
import useSeries from 'Series/useSeries';
|
||||
import QualityProfileNameConnector from 'Settings/Profiles/Quality/QualityProfileNameConnector';
|
||||
import {
|
||||
deleteEpisodeFile,
|
||||
fetchEpisodeFile,
|
||||
} from 'Store/Actions/episodeFileActions';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import EpisodeAiring from './EpisodeAiring';
|
||||
import EpisodeFileRow from './EpisodeFileRow';
|
||||
import styles from './EpisodeSummary.css';
|
||||
|
||||
const COLUMNS: Column[] = [
|
||||
{
|
||||
name: 'path',
|
||||
label: () => translate('Path'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'size',
|
||||
label: () => translate('Size'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'languages',
|
||||
label: () => translate('Languages'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'quality',
|
||||
label: () => translate('Quality'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormats',
|
||||
label: () => translate('Formats'),
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'customFormatScore',
|
||||
label: React.createElement(Icon, {
|
||||
name: icons.SCORE,
|
||||
title: () => translate('CustomFormatScore'),
|
||||
}),
|
||||
isSortable: true,
|
||||
isVisible: true,
|
||||
},
|
||||
{
|
||||
name: 'actions',
|
||||
label: '',
|
||||
isSortable: false,
|
||||
isVisible: true,
|
||||
},
|
||||
];
|
||||
|
||||
interface EpisodeSummaryProps {
|
||||
seriesId: number;
|
||||
episodeId: number;
|
||||
episodeEntity: EpisodeEntities;
|
||||
episodeFileId?: number;
|
||||
}
|
||||
|
||||
function EpisodeSummary(props: EpisodeSummaryProps) {
|
||||
const { seriesId, episodeId, episodeEntity, episodeFileId } = props;
|
||||
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const { qualityProfileId, network } = useSeries(seriesId) as Series;
|
||||
|
||||
const { airDateUtc, overview } = useEpisode(
|
||||
episodeId,
|
||||
episodeEntity
|
||||
) as Episode;
|
||||
|
||||
const {
|
||||
path,
|
||||
mediaInfo,
|
||||
size,
|
||||
languages,
|
||||
quality,
|
||||
qualityCutoffNotMet,
|
||||
customFormats,
|
||||
customFormatScore,
|
||||
} = useEpisodeFile(episodeFileId) || {};
|
||||
|
||||
const handleDeleteEpisodeFile = useCallback(() => {
|
||||
dispatch(
|
||||
deleteEpisodeFile({
|
||||
id: episodeFileId,
|
||||
episodeEntity,
|
||||
})
|
||||
);
|
||||
}, [episodeFileId, episodeEntity, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (episodeFileId && !path) {
|
||||
dispatch(fetchEpisodeFile({ id: episodeFileId }));
|
||||
}
|
||||
}, [episodeFileId, path, dispatch]);
|
||||
|
||||
const hasOverview = !!overview;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<span className={styles.infoTitle}>{translate('Airs')}</span>
|
||||
|
||||
<EpisodeAiring airDateUtc={airDateUtc} network={network} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={styles.infoTitle}>{translate('QualityProfile')}</span>
|
||||
|
||||
<Label kind={kinds.PRIMARY} size={sizes.MEDIUM}>
|
||||
<QualityProfileNameConnector qualityProfileId={qualityProfileId} />
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className={styles.overview}>
|
||||
{hasOverview ? overview : translate('NoEpisodeOverview')}
|
||||
</div>
|
||||
|
||||
{path ? (
|
||||
<Table columns={COLUMNS}>
|
||||
<TableBody>
|
||||
<EpisodeFileRow
|
||||
path={path}
|
||||
size={size!}
|
||||
languages={languages!}
|
||||
quality={quality!}
|
||||
qualityCutoffNotMet={qualityCutoffNotMet!}
|
||||
customFormats={customFormats!}
|
||||
customFormatScore={customFormatScore!}
|
||||
mediaInfo={mediaInfo!}
|
||||
columns={COLUMNS}
|
||||
onDeleteEpisodeFile={handleDeleteEpisodeFile}
|
||||
/>
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EpisodeSummary;
|
||||
@@ -1,109 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { deleteEpisodeFile, fetchEpisodeFile } from 'Store/Actions/episodeFileActions';
|
||||
import createEpisodeFileSelector from 'Store/Selectors/createEpisodeFileSelector';
|
||||
import createEpisodeSelector from 'Store/Selectors/createEpisodeSelector';
|
||||
import createSeriesSelector from 'Store/Selectors/createSeriesSelector';
|
||||
import EpisodeSummary from './EpisodeSummary';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSeriesSelector(),
|
||||
createEpisodeSelector(),
|
||||
createEpisodeFileSelector(),
|
||||
(series, episode, episodeFile = {}) => {
|
||||
const {
|
||||
qualityProfileId,
|
||||
network
|
||||
} = series;
|
||||
|
||||
const {
|
||||
airDateUtc,
|
||||
overview
|
||||
} = episode;
|
||||
|
||||
const {
|
||||
mediaInfo,
|
||||
path,
|
||||
size,
|
||||
languages,
|
||||
quality,
|
||||
qualityCutoffNotMet,
|
||||
customFormats,
|
||||
customFormatScore
|
||||
} = episodeFile;
|
||||
|
||||
return {
|
||||
network,
|
||||
qualityProfileId,
|
||||
airDateUtc,
|
||||
overview,
|
||||
mediaInfo,
|
||||
path,
|
||||
size,
|
||||
languages,
|
||||
quality,
|
||||
qualityCutoffNotMet,
|
||||
customFormats,
|
||||
customFormatScore
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
onDeleteEpisodeFile() {
|
||||
dispatch(deleteEpisodeFile({
|
||||
id: props.episodeFileId,
|
||||
episodeEntity: props.episodeEntity
|
||||
}));
|
||||
},
|
||||
|
||||
dispatchFetchEpisodeFile() {
|
||||
dispatch(fetchEpisodeFile({
|
||||
id: props.episodeFileId
|
||||
}));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
class EpisodeSummaryConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
const {
|
||||
episodeFileId,
|
||||
path,
|
||||
dispatchFetchEpisodeFile
|
||||
} = this.props;
|
||||
|
||||
if (episodeFileId && !path) {
|
||||
dispatchFetchEpisodeFile({ id: episodeFileId });
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchFetchEpisodeFile,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return <EpisodeSummary {...otherProps} />;
|
||||
}
|
||||
}
|
||||
|
||||
EpisodeSummaryConnector.propTypes = {
|
||||
episodeFileId: PropTypes.number,
|
||||
path: PropTypes.string,
|
||||
dispatchFetchEpisodeFile: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(EpisodeSummaryConnector);
|
||||
@@ -9,5 +9,5 @@ export default {
|
||||
EPISODES,
|
||||
INTERACTIVE_IMPORT,
|
||||
WANTED_CUTOFF_UNMET,
|
||||
WANTED_MISSING
|
||||
};
|
||||
WANTED_MISSING,
|
||||
} as const;
|
||||
@@ -5,15 +5,15 @@ import AppState from 'App/State/AppState';
|
||||
export type EpisodeEntities =
|
||||
| 'calendar'
|
||||
| 'episodes'
|
||||
| 'interactiveImport'
|
||||
| 'cutoffUnmet'
|
||||
| 'missing';
|
||||
| 'interactiveImport.episodes'
|
||||
| 'wanted.cutoffUnmet'
|
||||
| 'wanted.missing';
|
||||
|
||||
function createEpisodeSelector(episodeId?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.episodes.items,
|
||||
(episodes) => {
|
||||
return episodes.find((e) => e.id === episodeId);
|
||||
return episodes.find(({ id }) => id === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -22,7 +22,25 @@ function createCalendarEpisodeSelector(episodeId?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.calendar.items,
|
||||
(episodes) => {
|
||||
return episodes.find((e) => e.id === episodeId);
|
||||
return episodes.find(({ id }) => id === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createWantedCutoffUnmetEpisodeSelector(episodeId?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.wanted.cutoffUnmet.items,
|
||||
(episodes) => {
|
||||
return episodes.find(({ id }) => id === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createWantedMissingEpisodeSelector(episodeId?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.wanted.missing.items,
|
||||
(episodes) => {
|
||||
return episodes.find(({ id }) => id === episodeId);
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -37,6 +55,12 @@ function useEpisode(
|
||||
case 'calendar':
|
||||
selector = createCalendarEpisodeSelector;
|
||||
break;
|
||||
case 'wanted.cutoffUnmet':
|
||||
selector = createWantedCutoffUnmetEpisodeSelector;
|
||||
break;
|
||||
case 'wanted.missing':
|
||||
selector = createWantedMissingEpisodeSelector;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface EpisodeFile extends ModelBase {
|
||||
languages: Language[];
|
||||
quality: QualityModel;
|
||||
customFormats: CustomFormat[];
|
||||
customFormatScore: number;
|
||||
indexerFlags: number;
|
||||
releaseType: ReleaseType;
|
||||
mediaInfo: MediaInfo;
|
||||
|
||||
18
frontend/src/EpisodeFile/useEpisodeFile.ts
Normal file
18
frontend/src/EpisodeFile/useEpisodeFile.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import { useSelector } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import AppState from 'App/State/AppState';
|
||||
|
||||
function createEpisodeFileSelector(episodeFileId?: number) {
|
||||
return createSelector(
|
||||
(state: AppState) => state.episodeFiles.items,
|
||||
(episodeFiles) => {
|
||||
return episodeFiles.find(({ id }) => id === episodeFileId);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function useEpisodeFile(episodeFileId: number | undefined) {
|
||||
return useSelector(createEpisodeFileSelector(episodeFileId));
|
||||
}
|
||||
|
||||
export default useEpisodeFile;
|
||||
@@ -1,34 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import AuthenticationRequiredModalContentConnector from './AuthenticationRequiredModalContentConnector';
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
function AuthenticationRequiredModal(props) {
|
||||
const {
|
||||
isOpen
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
closeOnBackgroundClick={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AuthenticationRequiredModalContentConnector
|
||||
onModalClose={onModalClose}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
AuthenticationRequiredModal.propTypes = {
|
||||
isOpen: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default AuthenticationRequiredModal;
|
||||
27
frontend/src/FirstRun/AuthenticationRequiredModal.tsx
Normal file
27
frontend/src/FirstRun/AuthenticationRequiredModal.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent';
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
interface AuthenticationRequiredModalProps {
|
||||
isOpen: boolean;
|
||||
}
|
||||
|
||||
export default function AuthenticationRequiredModal({
|
||||
isOpen,
|
||||
}: AuthenticationRequiredModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
size={sizes.MEDIUM}
|
||||
isOpen={isOpen}
|
||||
closeOnBackgroundClick={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<AuthenticationRequiredModalContent />
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,170 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import Alert from 'Components/Alert';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import { authenticationMethodOptions, authenticationRequiredOptions } from 'Settings/General/SecuritySettings';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AuthenticationRequiredModalContent.css';
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
function AuthenticationRequiredModalContent(props) {
|
||||
const {
|
||||
isPopulated,
|
||||
error,
|
||||
isSaving,
|
||||
settings,
|
||||
onInputChange,
|
||||
onSavePress,
|
||||
dispatchFetchStatus
|
||||
} = props;
|
||||
|
||||
const {
|
||||
authenticationMethod,
|
||||
authenticationRequired,
|
||||
username,
|
||||
password,
|
||||
passwordConfirmation
|
||||
} = settings;
|
||||
|
||||
const authenticationEnabled = authenticationMethod && authenticationMethod.value !== 'none';
|
||||
|
||||
const didMount = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSaving && didMount.current) {
|
||||
dispatchFetchStatus();
|
||||
}
|
||||
|
||||
didMount.current = true;
|
||||
}, [isSaving, dispatchFetchStatus]);
|
||||
|
||||
return (
|
||||
<ModalContent
|
||||
showCloseButton={false}
|
||||
onModalClose={onModalClose}
|
||||
>
|
||||
<ModalHeader>
|
||||
{translate('AuthenticationRequired')}
|
||||
</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Alert
|
||||
className={styles.authRequiredAlert}
|
||||
kind={kinds.WARNING}
|
||||
>
|
||||
{translate('AuthenticationRequiredWarning')}
|
||||
</Alert>
|
||||
|
||||
{
|
||||
isPopulated && !error ?
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationMethod')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationMethod"
|
||||
values={authenticationMethodOptions}
|
||||
helpText={translate('AuthenticationMethodHelpText')}
|
||||
helpTextWarning={authenticationMethod.value === 'none' ? translate('AuthenticationMethodHelpTextWarning') : undefined}
|
||||
helpLink="https://wiki.servarr.com/sonarr/faq#forced-authentication"
|
||||
onChange={onInputChange}
|
||||
{...authenticationMethod}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationRequired"
|
||||
values={authenticationRequiredOptions}
|
||||
helpText={translate('AuthenticationRequiredHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...authenticationRequired}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Username')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
onChange={onInputChange}
|
||||
helpTextWarning={username?.value ? undefined : translate('AuthenticationRequiredUsernameHelpTextWarning')}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Password')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
onChange={onInputChange}
|
||||
helpTextWarning={password?.value ? undefined : translate('AuthenticationRequiredPasswordHelpTextWarning')}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="passwordConfirmation"
|
||||
onChange={onInputChange}
|
||||
helpTextWarning={passwordConfirmation?.value ? undefined : translate('AuthenticationRequiredPasswordConfirmationHelpTextWarning')}
|
||||
{...passwordConfirmation}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
|
||||
{
|
||||
!isPopulated && !error ? <LoadingIndicator /> : null
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<SpinnerButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isSaving}
|
||||
isDisabled={!authenticationEnabled}
|
||||
onPress={onSavePress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
AuthenticationRequiredModalContent.propTypes = {
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
error: PropTypes.object,
|
||||
isSaving: PropTypes.bool.isRequired,
|
||||
saveError: PropTypes.object,
|
||||
settings: PropTypes.object.isRequired,
|
||||
onInputChange: PropTypes.func.isRequired,
|
||||
onSavePress: PropTypes.func.isRequired,
|
||||
dispatchFetchStatus: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default AuthenticationRequiredModalContent;
|
||||
194
frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx
Normal file
194
frontend/src/FirstRun/AuthenticationRequiredModalContent.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
import React, { useCallback, useEffect } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import Alert from 'Components/Alert';
|
||||
import FormGroup from 'Components/Form/FormGroup';
|
||||
import FormInputGroup from 'Components/Form/FormInputGroup';
|
||||
import FormLabel from 'Components/Form/FormLabel';
|
||||
import SpinnerButton from 'Components/Link/SpinnerButton';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import usePrevious from 'Helpers/Hooks/usePrevious';
|
||||
import { inputTypes, kinds } from 'Helpers/Props';
|
||||
import {
|
||||
authenticationMethodOptions,
|
||||
authenticationRequiredOptions,
|
||||
} from 'Settings/General/SecuritySettings';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import {
|
||||
fetchGeneralSettings,
|
||||
saveGeneralSettings,
|
||||
setGeneralSettingsValue,
|
||||
} from 'Store/Actions/settingsActions';
|
||||
import { fetchStatus } from 'Store/Actions/systemActions';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import { InputChanged } from 'typings/inputs';
|
||||
import translate from 'Utilities/String/translate';
|
||||
import styles from './AuthenticationRequiredModalContent.css';
|
||||
|
||||
const SECTION = 'general';
|
||||
|
||||
const selector = createSettingsSectionSelector(SECTION);
|
||||
|
||||
function onModalClose() {
|
||||
// No-op
|
||||
}
|
||||
|
||||
export default function AuthenticationRequiredModalContent() {
|
||||
const { isPopulated, error, isSaving, settings } = useSelector(selector);
|
||||
const dispatch = useDispatch();
|
||||
|
||||
const {
|
||||
authenticationMethod,
|
||||
authenticationRequired,
|
||||
username,
|
||||
password,
|
||||
passwordConfirmation,
|
||||
} = settings;
|
||||
|
||||
const wasSaving = usePrevious(isSaving);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(fetchGeneralSettings());
|
||||
|
||||
return () => {
|
||||
dispatch(clearPendingChanges());
|
||||
};
|
||||
}, [dispatch]);
|
||||
|
||||
const onInputChange = useCallback(
|
||||
(args: InputChanged) => {
|
||||
// @ts-expect-error Actions aren't typed
|
||||
dispatch(setGeneralSettingsValue(args));
|
||||
},
|
||||
[dispatch]
|
||||
);
|
||||
|
||||
const authenticationEnabled =
|
||||
authenticationMethod && authenticationMethod.value !== 'none';
|
||||
|
||||
useEffect(() => {
|
||||
if (isSaving || !wasSaving) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatch(fetchStatus());
|
||||
}, [isSaving, wasSaving, dispatch]);
|
||||
|
||||
const onPress = useCallback(() => {
|
||||
dispatch(saveGeneralSettings());
|
||||
}, [dispatch]);
|
||||
|
||||
return (
|
||||
<ModalContent showCloseButton={false} onModalClose={onModalClose}>
|
||||
<ModalHeader>{translate('AuthenticationRequired')}</ModalHeader>
|
||||
|
||||
<ModalBody>
|
||||
<Alert className={styles.authRequiredAlert} kind={kinds.WARNING}>
|
||||
{translate('AuthenticationRequiredWarning')}
|
||||
</Alert>
|
||||
|
||||
{isPopulated && !error ? (
|
||||
<div>
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationMethod')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationMethod"
|
||||
values={authenticationMethodOptions}
|
||||
helpText={translate('AuthenticationMethodHelpText')}
|
||||
helpTextWarning={
|
||||
authenticationMethod.value === 'none'
|
||||
? translate('AuthenticationMethodHelpTextWarning')
|
||||
: undefined
|
||||
}
|
||||
helpLink="https://wiki.servarr.com/sonarr/faq#forced-authentication"
|
||||
onChange={onInputChange}
|
||||
{...authenticationMethod}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('AuthenticationRequired')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.SELECT}
|
||||
name="authenticationRequired"
|
||||
values={authenticationRequiredOptions}
|
||||
helpText={translate('AuthenticationRequiredHelpText')}
|
||||
onChange={onInputChange}
|
||||
{...authenticationRequired}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Username')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.TEXT}
|
||||
name="username"
|
||||
helpTextWarning={
|
||||
username?.value
|
||||
? undefined
|
||||
: translate('AuthenticationRequiredUsernameHelpTextWarning')
|
||||
}
|
||||
onChange={onInputChange}
|
||||
{...username}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('Password')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="password"
|
||||
helpTextWarning={
|
||||
password?.value
|
||||
? undefined
|
||||
: translate('AuthenticationRequiredPasswordHelpTextWarning')
|
||||
}
|
||||
onChange={onInputChange}
|
||||
{...password}
|
||||
/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<FormLabel>{translate('PasswordConfirmation')}</FormLabel>
|
||||
|
||||
<FormInputGroup
|
||||
type={inputTypes.PASSWORD}
|
||||
name="passwordConfirmation"
|
||||
helpTextWarning={
|
||||
passwordConfirmation?.value
|
||||
? undefined
|
||||
: translate(
|
||||
'AuthenticationRequiredPasswordConfirmationHelpTextWarning'
|
||||
)
|
||||
}
|
||||
onChange={onInputChange}
|
||||
{...passwordConfirmation}
|
||||
/>
|
||||
</FormGroup>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!isPopulated && !error ? <LoadingIndicator /> : null}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<SpinnerButton
|
||||
kind={kinds.PRIMARY}
|
||||
isSpinning={isSaving}
|
||||
isDisabled={!authenticationEnabled}
|
||||
onPress={onPress}
|
||||
>
|
||||
{translate('Save')}
|
||||
</SpinnerButton>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
@@ -1,86 +0,0 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { clearPendingChanges } from 'Store/Actions/baseActions';
|
||||
import { fetchGeneralSettings, saveGeneralSettings, setGeneralSettingsValue } from 'Store/Actions/settingsActions';
|
||||
import { fetchStatus } from 'Store/Actions/systemActions';
|
||||
import createSettingsSectionSelector from 'Store/Selectors/createSettingsSectionSelector';
|
||||
import AuthenticationRequiredModalContent from './AuthenticationRequiredModalContent';
|
||||
|
||||
const SECTION = 'general';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createSettingsSectionSelector(SECTION),
|
||||
(sectionSettings) => {
|
||||
return {
|
||||
...sectionSettings
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
dispatchClearPendingChanges: clearPendingChanges,
|
||||
dispatchSetGeneralSettingsValue: setGeneralSettingsValue,
|
||||
dispatchSaveGeneralSettings: saveGeneralSettings,
|
||||
dispatchFetchGeneralSettings: fetchGeneralSettings,
|
||||
dispatchFetchStatus: fetchStatus
|
||||
};
|
||||
|
||||
class AuthenticationRequiredModalContentConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
componentDidMount() {
|
||||
this.props.dispatchFetchGeneralSettings();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
this.props.dispatchClearPendingChanges({ section: `settings.${SECTION}` });
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onInputChange = ({ name, value }) => {
|
||||
this.props.dispatchSetGeneralSettingsValue({ name, value });
|
||||
};
|
||||
|
||||
onSavePress = () => {
|
||||
this.props.dispatchSaveGeneralSettings();
|
||||
};
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
dispatchClearPendingChanges,
|
||||
dispatchFetchGeneralSettings,
|
||||
dispatchSetGeneralSettingsValue,
|
||||
dispatchSaveGeneralSettings,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<AuthenticationRequiredModalContent
|
||||
{...otherProps}
|
||||
onInputChange={this.onInputChange}
|
||||
onSavePress={this.onSavePress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
AuthenticationRequiredModalContentConnector.propTypes = {
|
||||
dispatchClearPendingChanges: PropTypes.func.isRequired,
|
||||
dispatchFetchGeneralSettings: PropTypes.func.isRequired,
|
||||
dispatchSetGeneralSettingsValue: PropTypes.func.isRequired,
|
||||
dispatchSaveGeneralSettings: PropTypes.func.isRequired,
|
||||
dispatchFetchStatus: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(AuthenticationRequiredModalContentConnector);
|
||||
@@ -3,15 +3,15 @@ import { useCallback, useState } from 'react';
|
||||
export default function useModalOpenState(
|
||||
initialState: boolean
|
||||
): [boolean, () => void, () => void] {
|
||||
const [isOpen, setOpen] = useState(initialState);
|
||||
const [isOpen, setIsOpen] = useState(initialState);
|
||||
|
||||
const setModalOpen = useCallback(() => {
|
||||
setOpen(true);
|
||||
}, [setOpen]);
|
||||
setIsOpen(true);
|
||||
}, [setIsOpen]);
|
||||
|
||||
const setModalClosed = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
setIsOpen(false);
|
||||
}, [setIsOpen]);
|
||||
|
||||
return [isOpen, setModalOpen, setModalClosed];
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
faKeyboard as farKeyboard,
|
||||
faObjectGroup as farObjectGroup,
|
||||
faObjectUngroup as farObjectUngroup,
|
||||
faSquare as farSquare
|
||||
faSquare as farSquare,
|
||||
} from '@fortawesome/free-regular-svg-icons';
|
||||
//
|
||||
// Solid
|
||||
@@ -107,7 +107,7 @@ import {
|
||||
faUser as fasUser,
|
||||
faUserPlus as fasUserPlus,
|
||||
faVial as fasVial,
|
||||
faWrench as fasWrench
|
||||
faWrench as fasWrench,
|
||||
} from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
//
|
||||
@@ -19,5 +19,5 @@ export const all = [
|
||||
PRIMARY,
|
||||
PURPLE,
|
||||
SUCCESS,
|
||||
WARNING
|
||||
];
|
||||
WARNING,
|
||||
] as const;
|
||||
@@ -4,4 +4,12 @@ export const MEDIUM = 'medium';
|
||||
export const LARGE = 'large';
|
||||
export const EXTRA_LARGE = 'extraLarge';
|
||||
export const EXTRA_EXTRA_LARGE = 'extraExtraLarge';
|
||||
export const all = [EXTRA_SMALL, SMALL, MEDIUM, LARGE, EXTRA_LARGE, EXTRA_EXTRA_LARGE];
|
||||
|
||||
export const all = [
|
||||
EXTRA_SMALL,
|
||||
SMALL,
|
||||
MEDIUM,
|
||||
LARGE,
|
||||
EXTRA_LARGE,
|
||||
EXTRA_EXTRA_LARGE,
|
||||
] as const;
|
||||
@@ -857,7 +857,7 @@ function InteractiveImportModalContent(
|
||||
|
||||
<MenuContent>
|
||||
<SelectedMenuItem
|
||||
name={'all'}
|
||||
name="all"
|
||||
isSelected={!filterExistingFiles}
|
||||
onPress={onFilterExistingFilesChange}
|
||||
>
|
||||
@@ -865,7 +865,7 @@ function InteractiveImportModalContent(
|
||||
</SelectedMenuItem>
|
||||
|
||||
<SelectedMenuItem
|
||||
name={'new'}
|
||||
name="new"
|
||||
isSelected={filterExistingFiles}
|
||||
onPress={onFilterExistingFilesChange}
|
||||
>
|
||||
@@ -945,7 +945,7 @@ function InteractiveImportModalContent(
|
||||
<SelectInput
|
||||
className={styles.bulkSelect}
|
||||
name="select"
|
||||
value={'select'}
|
||||
value="select"
|
||||
values={bulkSelectOptions}
|
||||
isDisabled={!selectedIds.length}
|
||||
onChange={onSelectModalSelect}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
reprocessInteractiveImportItems,
|
||||
updateInteractiveImportItem,
|
||||
} from 'Store/Actions/interactiveImportActions';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import { SelectStateInputProps } from 'typings/props';
|
||||
import Rejection from 'typings/Rejection';
|
||||
import formatBytes from 'Utilities/Number/formatBytes';
|
||||
@@ -66,7 +67,7 @@ interface InteractiveImportRowProps {
|
||||
languages?: Language[];
|
||||
size: number;
|
||||
releaseType: ReleaseType;
|
||||
customFormats?: object[];
|
||||
customFormats?: CustomFormat[];
|
||||
customFormatScore?: number;
|
||||
indexerFlags: number;
|
||||
rejections: Rejection[];
|
||||
@@ -92,7 +93,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
releaseGroup,
|
||||
size,
|
||||
releaseType,
|
||||
customFormats,
|
||||
customFormats = [],
|
||||
customFormatScore,
|
||||
indexerFlags,
|
||||
rejections,
|
||||
@@ -525,7 +526,7 @@ function InteractiveImportRow(props: InteractiveImportRowProps) {
|
||||
<>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
anchor={<Icon name={icons.FLAG} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ReleaseType from 'InteractiveImport/ReleaseType';
|
||||
import Language from 'Language/Language';
|
||||
import { QualityModel } from 'Quality/Quality';
|
||||
import Series from 'Series/Series';
|
||||
import CustomFormat from 'typings/CustomFormat';
|
||||
import Rejection from 'typings/Rejection';
|
||||
|
||||
export interface InteractiveImportCommandOptions {
|
||||
@@ -33,7 +34,7 @@ interface InteractiveImport extends ModelBase {
|
||||
seasonNumber: number;
|
||||
episodes: Episode[];
|
||||
qualityWeight: number;
|
||||
customFormats: object[];
|
||||
customFormats: CustomFormat[];
|
||||
indexerFlags: number;
|
||||
releaseType: ReleaseType;
|
||||
rejections: Rejection[];
|
||||
|
||||
@@ -17,7 +17,7 @@ function SelectLanguageModal(props: SelectLanguageModalProps) {
|
||||
props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
|
||||
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
|
||||
<SelectLanguageModalContent
|
||||
languageIds={languageIds}
|
||||
modalTitle={modalTitle}
|
||||
|
||||
@@ -64,19 +64,20 @@ interface RowItemData {
|
||||
onSeriesSelect(seriesId: number): void;
|
||||
}
|
||||
|
||||
const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
||||
index,
|
||||
style,
|
||||
data,
|
||||
}) => {
|
||||
function Row({ index, style, data }: ListChildComponentProps<RowItemData>) {
|
||||
const { items, columns, onSeriesSelect } = data;
|
||||
const series = index >= items.length ? null : items[index];
|
||||
|
||||
if (index >= items.length) {
|
||||
const handlePress = useCallback(() => {
|
||||
if (series?.id) {
|
||||
onSeriesSelect(series.id);
|
||||
}
|
||||
}, [series?.id, onSeriesSelect]);
|
||||
|
||||
if (series == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const series = items[index];
|
||||
|
||||
return (
|
||||
<VirtualTableRowButton
|
||||
style={{
|
||||
@@ -84,7 +85,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
||||
justifyContent: 'space-between',
|
||||
...style,
|
||||
}}
|
||||
onPress={() => onSeriesSelect(series.id)}
|
||||
onPress={handlePress}
|
||||
>
|
||||
<SelectSeriesRow
|
||||
key={series.id}
|
||||
@@ -98,7 +99,7 @@ const Row: React.FC<ListChildComponentProps<RowItemData>> = ({
|
||||
/>
|
||||
</VirtualTableRowButton>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
|
||||
const { modalTitle, onSeriesSelect, onModalClose } = props;
|
||||
@@ -197,9 +198,9 @@ function SelectSeriesModalContent(props: SelectSeriesModalContentProps) {
|
||||
/>
|
||||
|
||||
<Scroller
|
||||
ref={scrollerRef}
|
||||
className={styles.scroller}
|
||||
autoFocus={false}
|
||||
ref={scrollerRef}
|
||||
>
|
||||
<SelectSeriesModalTableHeader columns={columns} />
|
||||
<List<RowItemData>
|
||||
|
||||
@@ -86,9 +86,10 @@ class InteractiveSearchConnector extends Component {
|
||||
}
|
||||
|
||||
InteractiveSearchConnector.propTypes = {
|
||||
type: PropTypes.string.isRequired,
|
||||
searchPayload: PropTypes.object.isRequired,
|
||||
isPopulated: PropTypes.bool.isRequired,
|
||||
dispatchFetchReleases: PropTypes.func.isRequired
|
||||
isPopulated: PropTypes.bool,
|
||||
dispatchFetchReleases: PropTypes.func
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, createMapDispatchToProps)(InteractiveSearchConnector);
|
||||
|
||||
@@ -264,7 +264,7 @@ function InteractiveSearchRow(props: InteractiveSearchRowProps) {
|
||||
<TableRowCell className={styles.indexerFlags}>
|
||||
{indexerFlags ? (
|
||||
<Popover
|
||||
anchor={<Icon name={icons.FLAG} kind={kinds.PRIMARY} />}
|
||||
anchor={<Icon name={icons.FLAG} />}
|
||||
title={translate('IndexerFlags')}
|
||||
body={<IndexerFlags indexerFlags={indexerFlags} />}
|
||||
position={tooltipPositions.LEFT}
|
||||
|
||||
@@ -17,7 +17,7 @@ function SelectDownloadClientModal(props: SelectDownloadClientModalProps) {
|
||||
props;
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onModalClose={onModalClose} size={sizes.MEDIUM}>
|
||||
<Modal isOpen={isOpen} size={sizes.MEDIUM} onModalClose={onModalClose}>
|
||||
<SelectDownloadClientModalContent
|
||||
protocol={protocol}
|
||||
modalTitle={modalTitle}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { Fragment, useCallback, useState } from 'react';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import PageToolbarButton from 'Components/Page/Toolbar/PageToolbarButton';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import ParseModal from 'Parse/ParseModal';
|
||||
@@ -16,7 +16,7 @@ function ParseToolbarButton() {
|
||||
}, [setIsParseModalOpen]);
|
||||
|
||||
return (
|
||||
<Fragment>
|
||||
<>
|
||||
<PageToolbarButton
|
||||
label={translate('TestParsing')}
|
||||
iconName={icons.PARSE}
|
||||
@@ -24,7 +24,7 @@ function ParseToolbarButton() {
|
||||
/>
|
||||
|
||||
<ParseModal isOpen={isParseModalOpen} onModalClose={onParseModalClose} />
|
||||
</Fragment>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ interface RootFolderRowProps {
|
||||
}
|
||||
|
||||
function RootFolderRow(props: RootFolderRowProps) {
|
||||
const { id, path, accessible, freeSpace, unmappedFolders = [] } = props;
|
||||
const { id, path, accessible, freeSpace = 0, unmappedFolders = [] } = props;
|
||||
|
||||
const isUnavailable = !accessible;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user