1
0
mirror of https://github.com/Radarr/Radarr.git synced 2026-04-16 21:15:33 -04:00

Compare commits

...

66 Commits

Author SHA1 Message Date
Bogdan
ff393a3f65 Show movie titles when poster is missing on collections page 2025-05-24 00:26:48 +03:00
Aden Northcote
f5faf52469 Fixed: Update AutoTags on movie add (#11079)
Co-authored-by: aden <aden@hughorse.net>
2025-05-23 18:56:02 +03:00
Bogdan
b5b4d4b971 Return error with missing field for movie files endpoint
Fixes #10555
2025-05-23 18:50:36 +03:00
Bogdan
873299701b Use UTC dates for TMDB Popular lists 2025-05-23 18:12:54 +03:00
Bogdan
d14cca30d7 Use the thrown exception in http timeout handling
(cherry picked from commit 14e324ee30694ae017a39fd6f66392dc2d104617)
2025-05-23 12:25:47 +03:00
Stevie Robinson
5af61b5900 New: Ignore volumes containing .timemachine from Disk Space
(cherry picked from commit a853c537db0a6bd499a2277987dc170d2a1f5645)
2025-05-23 12:24:50 +03:00
carrossos
a10759c7e9 Treat HTTP 410 response for failed download similarly to HTTP 404
(cherry picked from commit 818ae02a7a8f0a8ea0a44e0015e2667d96453332)
2025-05-23 12:24:37 +03:00
Stevie Robinson
ac2d92007e New: Don't allow remote path to start with space
(cherry picked from commit 5ba3ff598770fdf9e5a53d490c8bcbdd6a59c4cc)
2025-05-23 12:13:27 +03:00
Mark McDowall
09cfdc3fa2 Increase maximum backup restoration size to 5GB
(cherry picked from commit e38deb34221ebf131adcce9551774898f46b1f7f)
2025-05-23 12:13:12 +03:00
Stevie Robinson
04f26dbff7 Ensure Custom Format Maximum Size won't overflow
(cherry picked from commit a50d2562649bbe77d0feb9fbfc594d56952e0a5e)
2025-05-23 12:12:37 +03:00
Bogdan
159f5df8cc Fix jump to character for Collections and Discover
Fix for a regression introduced in react-virtualized 9.21.2 when WindowScroller is used with Grids
2025-05-22 15:40:29 +03:00
Elerir
b823ad8e65 New: Add Mongolian language
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2025-05-21 12:08:15 +03:00
Bogdan
cc8bffc272 Bump version to 5.24.0 2025-05-17 15:56:03 +03:00
Bogdan
e0b93a03fd Remove create_test_cases.py 2025-05-17 01:17:00 +03:00
Mark McDowall
f7f5837d49 Convert Missing to TypeScript
(cherry picked from commit 3035521b93ef54a6cc6193a526be862976228669)
2025-05-16 19:38:18 +03:00
Mark McDowall
c3ee8b3c90 Convert Cutoff Unmet to TypeScript
(cherry picked from commit 45c53bea865447aa543242e64e3d796c93117975)
2025-05-16 19:31:49 +03:00
Weblate
4de78e3bab Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: fordas <fordas15@gmail.com>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/cs/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_Hans/
Translation: Servarr/Radarr
2025-05-15 22:42:37 +03:00
Bogdan
426538c8af Remove console statement 2025-05-15 14:56:25 +03:00
Bogdan
c82404c75b Fixed: Loading suggestions for header search input 2025-05-15 14:48:40 +03:00
Bogdan
9bee9841c1 Fixed: (PTP) Download torrent files with API credentials 2025-05-15 00:58:38 +03:00
Bogdan
010959d915 Bump @babel/runtime 2025-05-14 18:48:34 +03:00
Bogdan
a600728916 Bump react-virtualized to 9.22.6
Bump @types/react
2025-05-14 18:37:10 +03:00
Bogdan
bbfb8c7cc2 Bump babel, fontawesome icons, fuse.js, react-lazyload, react-use-measure and react-window 2025-05-14 14:37:12 +03:00
Bogdan
32418ea521 Bump core-js to 3.42 2025-05-14 14:37:12 +03:00
Bogdan
2c5c99e9b7 New: Deprecate use of movie file tokens in Movie Folder Format 2025-05-13 14:41:32 +03:00
Bogdan
a5e5a63e45 Fixed: Upgrade notification title for Apprise 2025-05-13 00:43:46 +03:00
Bogdan
31b44d2c2e New: Include movie poster for Apprise 2025-05-12 22:54:42 +03:00
Bogdan
da8e8a12de New: Include year in interactive searches title
Fixes #11070
2025-05-12 22:50:32 +03:00
v3DJG6GL
6506c97ce1 Fixed: Map SwissGerman to German (#11068)
Co-authored-by: Bogdan <mynameisbogdan@users.noreply.github.com>
2025-05-12 16:50:38 +03:00
v3DJG6GL
5303a1992c New: Add Romansh language 2025-05-11 13:36:26 +03:00
Bogdan
042308c319 Bump version to 5.23.3 2025-05-11 10:53:03 +03:00
Bogdan
2e97e09f44 Fail build on missing test results
Ignore missing test results failure on FreeBSD
2025-05-10 13:46:35 +03:00
Bogdan
ccfb9c0dad Bump SixLabors.ImageSharp to 3.1.8 2025-05-10 13:43:52 +03:00
Weblate
b655d97e9e Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Havok Dan <havokdan@yahoo.com.br>
Co-authored-by: Oskari Lavinto <olavinto@protonmail.com>
Co-authored-by: Robi Korb <robi.korb@gmail.com>
Co-authored-by: Weblate <noreply@weblate.org>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translation: Servarr/Radarr
2025-05-04 21:05:34 +03:00
Bogdan
3afcb91db6 Bump version to 5.23.2 2025-05-04 21:04:29 +03:00
Bogdan
704e2d6176 Fixed: (PTP) Sorting releases by time added 2025-05-01 19:22:00 +03:00
Mark McDowall
8314c37b1d Improve messaging when NZB contains invalid XML
(cherry picked from commit 728df146ada115a367bf1ce808482a4625e6098d)
2025-04-29 11:36:09 +03:00
Bogdan
c2c3dfe917 Avoid varying logging message template between calls 2025-04-29 11:35:57 +03:00
Bogdan
c58a9b3f2c Pass messages with arguments to NLog in LoggerExtensions
(cherry picked from commit 9683b0af35220bb0af801779a06d73feaeba809a)
2025-04-29 11:32:12 +03:00
Bogdan
65a532a7fd Fixed: Sidebar flickering on mobile 2025-04-28 11:56:16 +03:00
Bogdan
704d920dab Remove unused preload.js 2025-04-27 21:34:21 +03:00
Bogdan
025cb0788f Update default log level message 2025-04-27 21:10:35 +03:00
Mark McDowall
82c21d8bb1 Convert Log FIles to TypeScript
(cherry picked from commit 95929dd9c2b4460ec58b63136d64bd584e7dd263)
2025-04-27 21:08:26 +03:00
Mark McDowall
96f973c961 Convert Spinner button components to TypeScript
(cherry picked from commit a1d4bb53997748ef348af50dd967bae8e23e234d)
2025-04-27 20:42:38 +03:00
Mark McDowall
a1ed440945 Convert Messages to TypeScript
(cherry picked from commit 0fdeb0566305311dcec344074b5de9e38b8d8090)
2025-04-27 20:38:21 +03:00
Mark McDowall
8caa839d99 Convert Table to TypeScript
(cherry picked from commit 699120a8fd54be9e70fb9a83298f94c8cb6a80bb)
2025-04-27 20:29:10 +03:00
Bogdan
9228e5dea0 Convert ImportListList component to TypeScript 2025-04-27 19:45:03 +03:00
Mark McDowall
371ac0921d Convert TagList components to TypeScript
(cherry picked from commit 20e1a8d116cf60d619f9525cc82867483d38df84)
2025-04-27 19:45:03 +03:00
Mark McDowall
937557e214 Convert Page components to TypeScript
(cherry picked from commit f35a27449d253260ba9c9fae28909cec8a87b4fe)
2025-04-27 19:45:03 +03:00
Mark McDowall
7fdaf41325 useMeasure instead of Measure in TypeScript components
(cherry picked from commit ee1a0a1f7175839c63595bef6d0221d3787189f4)
2025-04-27 19:45:03 +03:00
Bogdan
577eb4f4ca Bump version to 5.23.1 2025-04-27 11:47:54 +03:00
Weblate
311f41b306 Multiple Translations updated by Weblate
ignore-downstream

Co-authored-by: Gallyam Biktashev <gallyamb@gmail.com>
Co-authored-by: Hugoren Martinako <aumpfbahn@gmail.com>
Co-authored-by: Weblate <noreply-mt-weblate@weblate.org>
Co-authored-by: Weblate <noreply@weblate.org>
Co-authored-by: zefir6 <zefir@mj12.pl>
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ca/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/de/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/es/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fi/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/fr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/hu/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/it/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/nb_NO/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pl/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/pt_BR/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/ru/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/tr/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/uk/
Translate-URL: https://translate.servarr.com/projects/servarr/radarr/zh_CN/
Translation: Servarr/Radarr
2025-04-26 12:22:27 +03:00
Mark McDowall
78f3b1f403 Convert Menu components to TypeScript
(cherry picked from commit 12a1ef038753cdab89ae7137d06e1ba3810166b1)
2025-04-25 18:10:02 +03:00
Bogdan
4dc02dcb80 Bump core-js to 3.41 2025-04-24 17:01:45 +03:00
Bogdan
2f649e413d Bump caniuse db 2025-04-24 16:57:21 +03:00
Bogdan
107ddd3826 Fix maximum typo and clean unused CSS files 2025-04-24 16:53:36 +03:00
Bogdan
dfdd2cba99 Page titles for collections and discover 2025-04-24 16:16:23 +03:00
Bogdan
c57d68c3dd Remove unused register page populator 2025-04-24 15:21:20 +03:00
Bogdan
6cc02b734e Fixed: Refresh collections to clear stale state on bulk movies removal 2025-04-24 15:08:31 +03:00
Bogdan
c5fa09dd86 Fixed: Restore scroll position for collections and discover on go back 2025-04-24 15:08:31 +03:00
Bogdan
29d59315b2 Bump version to 5.23.0 2025-04-23 22:04:25 +03:00
Bogdan
981a3c2db3 Fixed: Selecting No Change for quality profile inputs 2025-04-23 20:21:58 +03:00
Bogdan
3f2ea56bf9 Clear collection changes on add movie modal close 2025-04-23 11:33:40 +03:00
Servarr
1679ed1327 Automated API Docs update 2025-04-20 14:15:50 +03:00
Bogdan
69a1c1b21b Custom format scoring is not usable on movies index 2025-04-20 12:57:34 +03:00
Bogdan
5bd51832a0 Bump version to 5.22.4 2025-04-20 08:59:55 +03:00
309 changed files with 7608 additions and 8660 deletions

View File

@@ -9,7 +9,7 @@ variables:
testsFolder: './_tests'
yarnCacheFolder: $(Pipeline.Workspace)/.yarn
nugetCacheFolder: $(Pipeline.Workspace)/.nuget/packages
majorVersion: '5.22.3'
majorVersion: '5.24.0'
minorVersion: $[counter('minorVersion', 2000)]
radarrVersion: '$(majorVersion).$(minorVersion)'
buildName: '$(Build.SourceBranchName).$(radarrVersion)'
@@ -481,6 +481,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(testName) Unit Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: ne(variables['testName'], 'freebsd-x64')
- job: Unit_Docker
displayName: Unit Docker
@@ -540,7 +541,8 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(testName) Unit Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
- job: Unit_LinuxCore_Postgres14
displayName: Unit Native LinuxCore with Postgres14 Database
dependsOn: Prepare
@@ -596,6 +598,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'LinuxCore Postgres14 Unit Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
- job: Unit_LinuxCore_Postgres15
displayName: Unit Native LinuxCore with Postgres15 Database
@@ -652,6 +655,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'LinuxCore Postgres15 Unit Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
- stage: Integration
displayName: Integration
@@ -734,6 +738,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(testName) Integration Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
displayName: Publish Test Results
- job: Integration_LinuxCore_Postgres14
@@ -796,6 +801,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'Integration LinuxCore Postgres14 Database Integration Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
displayName: Publish Test Results
@@ -859,6 +865,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'Integration LinuxCore Postgres15 Database Integration Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
displayName: Publish Test Results
- job: Integration_FreeBSD
@@ -905,6 +912,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: 'FreeBSD Integration Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: false
displayName: Publish Test Results
- job: Integration_Docker
@@ -974,6 +982,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(testName) Integration Tests'
failTaskOnFailedTests: true
failTaskOnMissingResultsFile: true
displayName: Publish Test Results
- stage: Automation
@@ -1055,6 +1064,7 @@ stages:
testResultsFiles: '**/TestResult.xml'
testRunTitle: '$(osName) Automation Tests'
failTaskOnFailedTests: $(failBuild)
failTaskOnMissingResultsFile: $(failBuild)
displayName: Publish Test Results
- stage: Analyze

View File

@@ -1,44 +0,0 @@
input1 = """Prometheus.Special.Edition.Fan Edit.2012..BRRip.x264.AAC-m2g
Star Wars Episode IV - A New Hope (Despecialized) 1999.mkv
Prometheus.(Special.Edition.Remastered).2012.[Bluray-1080p].mkv
Prometheus Extended 2012
Prometheus Extended Directors Cut Fan Edit 2012
Prometheus Director's Cut 2012
Prometheus Directors Cut 2012
Prometheus.(Extended.Theatrical.Version.IMAX).BluRay.1080p.2012.asdf
2001 A Space Odyssey Director's Cut (1968).mkv
2001: A Space Odyssey (Extended Directors Cut FanEdit) Bluray 1080p 1968
A Fake Movie 2035 Directors 2012.mkv
Blade Runner Director's Cut 2049.mkv
Prometheus 50th Anniversary Edition 2012.mkv
Movie 2in1 2012.mkv
Movie IMAX 2012.mkv"""
output1 = """Special.Edition.Fan Edit BRRip.x264.AAC-m2g
Despecialized mkv
Special.Edition.Remastered Bluray-1080p].mkv
Extended mkv
Extended Directors Cut Fan Edit mkv
Director's Cut mkv
Directors Cut mkv
Extended.Theatrical.Version.IMAX asdf
Director's Cut mkv
Extended Directors Cut FanEdit mkv
Directors mkv
Director's Cut mkv
50th Anniversary Edition mkv
2in1 mkv
IMAX mkv"""
inputs = input1.split("\n")
outputs = output1.split("\n")
real_o = []
for output in outputs:
real_o.append(output.split(" ")[0].replace(".", " ").strip())
count = 0
for inp in inputs:
o = real_o[count]
print "[TestCase(\"{0}\", \"{1}\")]".format(inp, o)
count += 1

View File

@@ -14,7 +14,6 @@ module.exports = (env) => {
const srcFolder = path.join(frontendFolder, 'src');
const isProduction = !!env.production;
const isProfiling = isProduction && !!env.profile;
const inlineWebWorkers = 'no-fallback';
const distFolder = path.resolve(frontendFolder, '..', '_output', uiFolder);
@@ -160,16 +159,6 @@ module.exports = (env) => {
module: {
rules: [
{
test: /\.worker\.js$/,
use: {
loader: 'worker-loader',
options: {
filename: '[name].js',
inline: inlineWebWorkers
}
}
},
{
test: [/\.jsx?$/, /\.tsx?$/],
exclude: /(node_modules|JsLibraries)/,
@@ -187,7 +176,7 @@ module.exports = (env) => {
loose: true,
debug: false,
useBuiltIns: 'entry',
corejs: '3.39'
corejs: '3.42'
}
]
]

View File

@@ -145,7 +145,7 @@ function Blocklist() {
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
(selectedFilterKey: string | number) => {
dispatch(setBlocklistFilter({ selectedFilterKey }));
},
[dispatch]

View File

@@ -77,7 +77,7 @@ function History() {
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
(selectedFilterKey: string | number) => {
dispatch(setHistoryFilter({ selectedFilterKey }));
},
[dispatch]

View File

@@ -183,7 +183,7 @@ function Queue() {
});
const handleFilterSelect = useCallback(
(selectedFilterKey: string) => {
(selectedFilterKey: string | number) => {
dispatch(setQueueFilter({ selectedFilterKey }));
},
[dispatch]

View File

@@ -1,5 +1,5 @@
import React from 'react';
import Icon, { IconProps } from 'Components/Icon';
import Icon, { IconKind } from 'Components/Icon';
import Popover from 'Components/Tooltip/Popover';
import { icons, kinds } from 'Helpers/Props';
import { TooltipPosition } from 'Helpers/Props/tooltipPositions';
@@ -61,7 +61,7 @@ function QueueStatus(props: QueueStatusProps) {
// status === 'downloading'
let iconName = icons.DOWNLOADING;
let iconKind: IconProps['kind'] = kinds.DEFAULT;
let iconKind: IconKind = kinds.DEFAULT;
let title = translate('Downloading');
if (status === 'paused') {

View File

@@ -4,7 +4,7 @@ import React from 'react';
import DocumentTitle from 'react-document-title';
import { Provider } from 'react-redux';
import { Store } from 'redux';
import PageConnector from 'Components/Page/PageConnector';
import Page from 'Components/Page/Page';
import ApplyTheme from './ApplyTheme';
import AppRoutes from './AppRoutes';
@@ -22,9 +22,9 @@ function App({ store, history }: AppProps) {
<Provider store={store}>
<ConnectedRouter history={history}>
<ApplyTheme />
<PageConnector>
<Page>
<AppRoutes />
</PageConnector>
</Page>
</ConnectedRouter>
</Provider>
</QueryClientProvider>

View File

@@ -32,8 +32,8 @@ import Status from 'System/Status/Status';
import Tasks from 'System/Tasks/Tasks';
import Updates from 'System/Updates/Updates';
import getPathWithUrlBase from 'Utilities/getPathWithUrlBase';
import CutoffUnmetConnector from 'Wanted/CutoffUnmet/CutoffUnmetConnector';
import MissingConnector from 'Wanted/Missing/MissingConnector';
import CutoffUnmet from 'Wanted/CutoffUnmet/CutoffUnmet';
import Missing from 'Wanted/Missing/Missing';
function RedirectWithUrlBase() {
return <Redirect to={getPathWithUrlBase('/')} />;
@@ -89,9 +89,9 @@ function AppRoutes() {
Wanted
*/}
<Route path="/wanted/missing" component={MissingConnector} />
<Route path="/wanted/missing" component={Missing} />
<Route path="/wanted/cutoffunmet" component={CutoffUnmetConnector} />
<Route path="/wanted/cutoffunmet" component={CutoffUnmet} />
{/*
Settings

View File

@@ -9,13 +9,13 @@ export type SelectContextAction =
| { type: 'unselectAll' }
| {
type: 'toggleSelected';
id: number;
isSelected: boolean;
id: number | string;
isSelected: boolean | null;
shiftKey: boolean;
}
| {
type: 'removeItem';
id: number;
id: number | string;
}
| {
type: 'updateItems';

View File

@@ -1,7 +1,7 @@
import Column from 'Components/Table/Column';
import { SortDirection } from 'Helpers/Props/sortDirections';
import { ValidationFailure } from 'typings/pending';
import { FilterBuilderProp, PropertyFilter } from './AppState';
import { Filter, FilterBuilderProp } from './AppState';
export interface Error {
status?: number;
@@ -35,7 +35,7 @@ export interface TableAppSectionState {
export interface AppSectionFilterState<T> {
selectedFilterKey: string;
filters: PropertyFilter[];
filters: Filter[];
filterBuilderProps: FilterBuilderProp<T>[];
}

View File

@@ -1,10 +1,13 @@
import { Error } from './AppSectionState';
import BlocklistAppState from './BlocklistAppState';
import CalendarAppState from './CalendarAppState';
import CaptchaAppState from './CaptchaAppState';
import CommandAppState from './CommandAppState';
import CustomFiltersAppState from './CustomFiltersAppState';
import ExtraFilesAppState from './ExtraFilesAppState';
import HistoryAppState, { MovieHistoryAppState } from './HistoryAppState';
import InteractiveImportAppState from './InteractiveImportAppState';
import MessagesAppState from './MessagesAppState';
import MovieBlocklistAppState from './MovieBlocklistAppState';
import MovieCollectionAppState from './MovieCollectionAppState';
import MovieCreditAppState from './MovieCreditAppState';
@@ -21,6 +24,7 @@ import RootFolderAppState from './RootFolderAppState';
import SettingsAppState from './SettingsAppState';
import SystemAppState from './SystemAppState';
import TagsAppState from './TagsAppState';
import WantedAppState from './WantedAppState';
interface FilterBuilderPropOption {
id: string;
@@ -43,19 +47,21 @@ export interface PropertyFilter {
export interface Filter {
key: string;
label: string;
filers: PropertyFilter[];
label: string | (() => string);
filters: PropertyFilter[];
}
export interface CustomFilter {
id: number;
type: string;
label: string;
filers: PropertyFilter[];
filters: PropertyFilter[];
}
export interface AppSectionState {
isUpdated: boolean;
isConnected: boolean;
isDisconnected: boolean;
isReconnecting: boolean;
isSidebarVisible: boolean;
version: string;
@@ -65,6 +71,11 @@ export interface AppSectionState {
width: number;
height: number;
};
translations: {
error?: Error;
isPopulated: boolean;
};
messages: MessagesAppState;
}
interface AppState {
@@ -73,6 +84,7 @@ interface AppState {
calendar: CalendarAppState;
captcha: CaptchaAppState;
commands: CommandAppState;
customFilters: CustomFiltersAppState;
extraFiles: ExtraFilesAppState;
history: HistoryAppState;
interactiveImport: InteractiveImportAppState;
@@ -94,6 +106,7 @@ interface AppState {
settings: SettingsAppState;
system: SystemAppState;
tags: TagsAppState;
wanted: WantedAppState;
}
export default AppState;

View File

@@ -0,0 +1,15 @@
import ModelBase from 'App/ModelBase';
import AppSectionState from 'App/State/AppSectionState';
export type MessageType = 'error' | 'info' | 'success' | 'warning';
export interface Message extends ModelBase {
hideAfter: number;
message: string;
name: string;
type: MessageType;
}
type MessagesAppState = AppSectionState<Message>;
export default MessagesAppState;

View File

@@ -1,5 +1,6 @@
import DiskSpace from 'typings/DiskSpace';
import Health from 'typings/Health';
import LogFile from 'typings/LogFile';
import SystemStatus from 'typings/SystemStatus';
import Task from 'typings/Task';
import Update from 'typings/Update';
@@ -9,13 +10,16 @@ export type DiskSpaceAppState = AppSectionState<DiskSpace>;
export type HealthAppState = AppSectionState<Health>;
export type SystemStatusAppState = AppSectionItemState<SystemStatus>;
export type TaskAppState = AppSectionState<Task>;
export type LogFilesAppState = AppSectionState<LogFile>;
export type UpdateAppState = AppSectionState<Update>;
interface SystemAppState {
diskSpace: DiskSpaceAppState;
health: HealthAppState;
logFiles: LogFilesAppState;
status: SystemStatusAppState;
tasks: TaskAppState;
updateLogFiles: LogFilesAppState;
updates: UpdateAppState;
}

View File

@@ -0,0 +1,29 @@
import AppSectionState, {
AppSectionFilterState,
PagedAppSectionState,
TableAppSectionState,
} from 'App/State/AppSectionState';
import Movie from 'Movie/Movie';
interface WantedMovie extends Movie {
isSaving?: boolean;
}
interface WantedCutoffUnmetAppState
extends AppSectionState<WantedMovie>,
AppSectionFilterState<WantedMovie>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedMissingAppState
extends AppSectionState<WantedMovie>,
AppSectionFilterState<WantedMovie>,
PagedAppSectionState,
TableAppSectionState {}
interface WantedAppState {
cutoffUnmet: WantedCutoffUnmetAppState;
missing: WantedMissingAppState;
}
export default WantedAppState;

View File

@@ -1,6 +1,7 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect } from 'react';
import { useDispatch } from 'react-redux';
import Modal from 'Components/Modal/Modal';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { clearPendingChanges } from 'Store/Actions/baseActions';
import AddNewMovieCollectionMovieModalContent, {
AddNewMovieCollectionMovieModalContentProps,
@@ -18,11 +19,19 @@ function AddNewMovieCollectionMovieModal({
}: AddNewCollectionMovieModalProps) {
const dispatch = useDispatch();
const wasOpen = usePrevious(isOpen);
const handleModalClose = useCallback(() => {
dispatch(clearPendingChanges({ section: 'movieCollections' }));
onModalClose();
}, [dispatch, onModalClose]);
useEffect(() => {
if (wasOpen && !isOpen) {
dispatch(clearPendingChanges({ section: 'movieCollections' }));
}
}, [wasOpen, isOpen, dispatch]);
return (
<Modal isOpen={isOpen} onModalClose={handleModalClose}>
<AddNewMovieCollectionMovieModalContent

View File

@@ -224,6 +224,7 @@ class Collection extends Component {
view,
onSortSelect,
onFilterSelect,
initialScrollTop,
onScroll,
isRefreshingCollections,
isSaving,
@@ -247,7 +248,7 @@ class Collection extends Component {
const hasNoCollection = !totalItems;
return (
<PageContent>
<PageContent title={translate('Collections')}>
<PageToolbar>
<PageToolbarSection>
<PageToolbarButton
@@ -306,6 +307,7 @@ class Collection extends Component {
ref={this.scrollerRef}
className={styles.contentBody}
innerClassName={styles[`${view}InnerContentBody`]}
onScroll={onScroll}
>
{
isFetching && !isPopulated &&
@@ -334,6 +336,7 @@ class Collection extends Component {
onSelectedChange={this.onSelectedChange}
onSelectAllChange={this.onSelectAllChange}
selectedState={selectedState}
scrollTop={initialScrollTop}
{...otherProps}
/>
</div>
@@ -374,6 +377,7 @@ class Collection extends Component {
}
Collection.propTypes = {
initialScrollTop: PropTypes.number,
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
isSaving: PropTypes.bool.isRequired,

View File

@@ -5,14 +5,17 @@ import { createSelector } from 'reselect';
import * as commandNames from 'Commands/commandNames';
import withScrollPosition from 'Components/withScrollPosition';
import { executeCommand } from 'Store/Actions/commandActions';
import { saveMovieCollections, setMovieCollectionsFilter, setMovieCollectionsSort } from 'Store/Actions/movieCollectionActions';
import {
fetchMovieCollections,
saveMovieCollections,
setMovieCollectionsFilter,
setMovieCollectionsSort
} from 'Store/Actions/movieCollectionActions';
import { clearQueueDetails, fetchQueueDetails } from 'Store/Actions/queueActions';
import { fetchRootFolders } from 'Store/Actions/rootFolderActions';
import scrollPositions from 'Store/scrollPositions';
import createCollectionClientSideCollectionItemsSelector from 'Store/Selectors/createCollectionClientSideCollectionItemsSelector';
import createCommandExecutingSelector from 'Store/Selectors/createCommandExecutingSelector';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import { registerPagePopulator, unregisterPagePopulator } from 'Utilities/pagePopulator';
import Collection from './Collection';
function createMapStateToProps() {
@@ -36,8 +39,8 @@ function createMapStateToProps() {
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchRootFolders() {
dispatch(fetchRootFolders());
dispatchFetchMovieCollections() {
dispatch(fetchMovieCollections());
},
dispatchFetchQueueDetails() {
dispatch(fetchQueueDetails());
@@ -68,13 +71,11 @@ class CollectionConnector extends Component {
// Lifecycle
componentDidMount() {
registerPagePopulator(this.repopulate);
this.props.dispatchFetchRootFolders();
this.props.dispatchFetchMovieCollections();
this.props.dispatchFetchQueueDetails();
}
componentWillUnmount() {
unregisterPagePopulator(this.repopulate);
this.props.dispatchClearQueueDetails();
}
@@ -93,9 +94,16 @@ class CollectionConnector extends Component {
// Render
render() {
const {
dispatchFetchMovieCollections,
dispatchFetchQueueDetails,
dispatchClearQueueDetails,
...otherProps
} = this.props;
return (
<Collection
{...this.props}
{...otherProps}
onViewSelect={this.onViewSelect}
onScroll={this.onScroll}
onUpdateSelectedPress={this.onUpdateSelectedPress}
@@ -108,7 +116,7 @@ CollectionConnector.propTypes = {
isSmallScreen: PropTypes.bool.isRequired,
view: PropTypes.string.isRequired,
onUpdateSelectedPress: PropTypes.func.isRequired,
dispatchFetchRootFolders: PropTypes.func.isRequired,
dispatchFetchMovieCollections: PropTypes.func.isRequired,
dispatchFetchQueueDetails: PropTypes.func.isRequired,
dispatchClearQueueDetails: PropTypes.func.isRequired
};

View File

@@ -9,12 +9,13 @@ $hoverScale: 1.05;
box-shadow: 0 0 10px var(--black);
transition: all 200ms ease-in;
.poster {
.poster,
.overlayTitle {
opacity: 0.5;
transition: opacity 100ms linear 100ms;
}
.overlayTitle {
.overlayHoverTitle {
opacity: 1;
transition: opacity 100ms linear 100ms;
}
@@ -31,7 +32,22 @@ $hoverScale: 1.05;
background-color: var(--defaultColor);
}
.overlay {
.overlayTitle {
position: absolute;
top: 0;
left: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 5px;
width: 100%;
height: 100%;
color: var(--offWhite);
text-align: center;
font-size: 20px;
}
.overlayHover {
position: absolute;
top: 0;
left: 0;
@@ -42,10 +58,10 @@ $hoverScale: 1.05;
height: 100%;
}
.overlayTitle {
.overlayHoverTitle {
padding: 5px;
color: var(--offWhite);
text-align: left;
text-align: center;
font-weight: bold;
font-size: 15px;
opacity: 0;

View File

@@ -10,7 +10,8 @@ interface CssExports {
'externalLinks': string;
'link': string;
'monitorToggleButton': string;
'overlay': string;
'overlayHover': string;
'overlayHoverTitle': string;
'overlayTitle': string;
'poster': string;
'posterContainer': string;

View File

@@ -82,6 +82,7 @@ class CollectionMovie extends Component {
} = this.props;
const {
hasPosterError,
isEditMovieModalOpen,
isNewAddMovieModalOpen
} = this.state;
@@ -134,26 +135,31 @@ class CollectionMovie extends Component {
onLoad={this.onPosterLoad}
/>
<div className={styles.overlay}>
<div className={styles.overlayTitle}>
{
hasPosterError &&
<div className={styles.overlayTitle}>
{title}
</div>
}
<div className={styles.overlayHover}>
<div className={styles.overlayHoverTitle}>
{title} {year > 0 ? `(${year})` : ''}
</div>
{
id ?
<div className={styles.overlayStatus}>
<MovieIndexProgressBar
movieId={id}
movieFile={movieFile}
monitored={monitored}
hasFile={hasFile}
status={status}
bottomRadius={true}
width={posterWidth}
detailedProgressBar={detailedProgressBar}
isAvailable={isAvailable}
/>
</div> :
<MovieIndexProgressBar
movieId={id}
movieFile={movieFile}
monitored={monitored}
hasFile={hasFile}
status={status}
bottomRadius={true}
width={posterWidth}
detailedProgressBar={detailedProgressBar}
isAvailable={isAvailable}
/> :
null
}
</div>

View File

@@ -14,17 +14,17 @@ const columnPadding = parseInt(dimensions.movieIndexColumnPadding);
const columnPaddingSmallScreen = parseInt(dimensions.movieIndexColumnPaddingSmallScreen);
function calculatePosterWidth(posterSize, isSmallScreen) {
const maxiumPosterWidth = isSmallScreen ? 152 : 162;
const maximumPosterWidth = isSmallScreen ? 152 : 162;
if (posterSize === 'large') {
return maxiumPosterWidth;
return maximumPosterWidth;
}
if (posterSize === 'medium') {
return Math.floor(maxiumPosterWidth * 0.75);
return Math.floor(maximumPosterWidth * 0.75);
}
return Math.floor(maxiumPosterWidth * 0.5);
return Math.floor(maximumPosterWidth * 0.5);
}
function calculateRowHeight(posterHeight, sortKey, isSmallScreen, overviewOptions) {
@@ -92,15 +92,14 @@ class CollectionOverviews extends Component {
if (this._grid && scrollTop !== 0 && !scrollRestored) {
this.setState({ scrollRestored: true });
this._grid.scrollToPosition({ scrollTop });
this._gridScrollToPosition({ scrollTop });
}
if (jumpToCharacter != null && jumpToCharacter !== prevProps.jumpToCharacter) {
const index = getIndexOfFirstCharacter(items, jumpToCharacter);
if (this._grid && index != null) {
this._grid.scrollToCell({
this._gridScrollToCell({
rowIndex: index,
columnIndex: 0
});
@@ -186,6 +185,19 @@ class CollectionOverviews extends Component {
);
};
_gridScrollToCell = ({ rowIndex = 0, columnIndex = 0 }) => {
const scrollOffset = this._grid.getOffsetForCell({
rowIndex,
columnIndex
});
this._gridScrollToPosition(scrollOffset);
};
_gridScrollToPosition = ({ scrollTop = 0, scrollLeft = 0 }) => {
this.props.scroller?.scrollTo({ top: scrollTop, left: scrollLeft });
};
//
// Listeners

View File

@@ -8,12 +8,14 @@ import styles from './FormInputButton.css';
export interface FormInputButtonProps extends ButtonProps {
canSpin?: boolean;
isLastButton?: boolean;
isSpinning?: boolean;
}
function FormInputButton({
className = styles.button,
canSpin = false,
isLastButton = true,
isSpinning = false,
kind = kinds.PRIMARY,
...otherProps
}: FormInputButtonProps) {
@@ -22,6 +24,7 @@ function FormInputButton({
<SpinnerButton
className={classNames(className, !isLastButton && styles.middleButton)}
kind={kind}
isSpinning={isSpinning}
{...otherProps}
/>
);

View File

@@ -13,11 +13,11 @@ import { Manager, Popper, Reference } from 'react-popper';
import Icon from 'Components/Icon';
import Link from 'Components/Link/Link';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import Measure from 'Components/Measure';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import Portal from 'Components/Portal';
import Scroller from 'Components/Scroller/Scroller';
import useMeasure from 'Helpers/Hooks/useMeasure';
import { icons } from 'Helpers/Props';
import ArrayElement from 'typings/Helpers/ArrayElement';
import { EnhancedSelectInputChanged, InputChanged } from 'typings/inputs';
@@ -162,13 +162,13 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
onOpen,
} = props;
const [measureRef, { width }] = useMeasure();
const updater = useRef<(() => void) | null>(null);
const buttonId = useMemo(() => getUniqueElementId(), []);
const optionsId = useMemo(() => getUniqueElementId(), []);
const [selectedIndex, setSelectedIndex] = useState(
getSelectedIndex(value, values)
);
const [width, setWidth] = useState(0);
const [isOpen, setIsOpen] = useState(false);
const isMobile = useMemo(() => isMobileUtil(), []);
@@ -378,13 +378,6 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
]
);
const handleMeasure = useCallback(
({ width: newWidth }: { width: number }) => {
setWidth(newWidth);
},
[setWidth]
);
const handleOptionsModalClose = useCallback(() => {
setIsOpen(false);
}, [setIsOpen]);
@@ -418,7 +411,7 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
<Reference>
{({ ref }) => (
<div ref={ref} id={buttonId}>
<Measure whitelist={['width']} onMeasure={handleMeasure}>
<div ref={measureRef}>
{isEditable && typeof value === 'string' ? (
<div className={styles.editableContainer}>
<TextInput
@@ -492,7 +485,7 @@ function EnhancedSelectInput<T extends EnhancedSelectInputValue<V>, V>(
</div>
</Link>
)}
</Measure>
</div>
</div>
)}
</Reference>

View File

@@ -88,13 +88,10 @@ function QualityProfileSelectInput({
);
const handleChange = useCallback(
({ value: newValue }: EnhancedSelectInputChanged<string | number>) => {
onChange({
name,
value: newValue === 'noChange' ? value : newValue,
});
({ value }: EnhancedSelectInputChanged<string | number>) => {
onChange({ name, value });
},
[name, value, onChange]
[name, onChange]
);
useEffect(() => {

View File

@@ -14,7 +14,7 @@ import {
RenderSuggestion,
SuggestionsFetchRequestedParams,
} from 'react-autosuggest';
import useDebouncedCallback from 'Helpers/Hooks/useDebouncedCallback';
import { useDebouncedCallback } from 'use-debounce';
import { Kind } from 'Helpers/Props/kinds';
import { InputChanged } from 'typings/inputs';
import AutoSuggestInput from '../AutoSuggestInput';

View File

@@ -1,43 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React from 'react';
import { kinds, sizes } from 'Helpers/Props';
import Label from './Label';
import styles from './ImportListList.css';
function ImportListList({ lists, importListList }) {
return (
<div className={styles.lists}>
{
lists.map((t) => {
const list = _.find(importListList, { id: t });
if (!list) {
return null;
}
return (
<Label
key={list.id}
kind={kinds.SUCCESS}
size={sizes.MEDIUM}
>
{list.name}
</Label>
);
})
}
</div>
);
}
ImportListList.propTypes = {
lists: PropTypes.arrayOf(PropTypes.number).isRequired,
importListList: PropTypes.arrayOf(PropTypes.object).isRequired
};
ImportListList.defaultProps = {
lists: []
};
export default ImportListList;

View File

@@ -0,0 +1,35 @@
import React from 'react';
import { useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import Label from './Label';
import styles from './ImportListList.css';
interface ImportListListProps {
lists: number[];
}
function ImportListList({ lists }: ImportListListProps) {
const allImportLists = useSelector(
(state: AppState) => state.settings.importLists.items
);
return (
<div className={styles.lists}>
{lists.map((id) => {
const importList = allImportLists.find((list) => list.id === id);
if (!importList) {
return null;
}
return (
<Label key={importList.id} kind="success" size="medium">
{importList.name}
</Label>
);
})}
</div>
);
}
export default ImportListList;

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createImportListSelector from 'Store/Selectors/createImportListSelector';
import ImportListList from './ImportListList';
function createMapStateToProps() {
return createSelector(
createImportListSelector(),
(importListList) => {
return {
importListList
};
}
);
}
export default connect(createMapStateToProps)(ImportListList);

View File

@@ -1,58 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import Button from './Button';
import styles from './SpinnerButton.css';
function SpinnerButton(props) {
const {
className,
isSpinning,
isDisabled,
spinnerIcon,
children,
...otherProps
} = props;
return (
<Button
className={classNames(
className,
styles.button,
isSpinning && styles.isSpinning
)}
isDisabled={isDisabled || isSpinning}
{...otherProps}
>
<span className={styles.spinnerContainer}>
<Icon
className={styles.spinner}
name={spinnerIcon}
isSpinning={true}
/>
</span>
<span className={styles.label}>
{children}
</span>
</Button>
);
}
SpinnerButton.propTypes = {
...Button.Props,
className: PropTypes.string.isRequired,
isSpinning: PropTypes.bool.isRequired,
isDisabled: PropTypes.bool,
spinnerIcon: PropTypes.object.isRequired,
children: PropTypes.node
};
SpinnerButton.defaultProps = {
className: styles.button,
spinnerIcon: icons.SPINNER
};
export default SpinnerButton;

View File

@@ -0,0 +1,41 @@
import classNames from 'classnames';
import React from 'react';
import Icon, { IconName } from 'Components/Icon';
import { icons } from 'Helpers/Props';
import Button, { ButtonProps } from './Button';
import styles from './SpinnerButton.css';
export interface SpinnerButtonProps extends ButtonProps {
isSpinning: boolean;
isDisabled?: boolean;
spinnerIcon?: IconName;
}
function SpinnerButton({
className = styles.button,
isSpinning,
isDisabled,
spinnerIcon = icons.SPINNER,
children,
...otherProps
}: SpinnerButtonProps) {
return (
<Button
className={classNames(
className,
styles.button,
isSpinning && styles.isSpinning
)}
isDisabled={isDisabled || isSpinning}
{...otherProps}
>
<span className={styles.spinnerContainer}>
<Icon className={styles.spinner} name={spinnerIcon} isSpinning={true} />
</span>
<span className={styles.label}>{children}</span>
</Button>
);
}
export default SpinnerButton;

View File

@@ -1,165 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import SpinnerButton from 'Components/Link/SpinnerButton';
import { icons, kinds } from 'Helpers/Props';
import styles from './SpinnerErrorButton.css';
function getTestResult(error) {
if (!error) {
return {
wasSuccessful: true,
hasWarning: false,
hasError: false
};
}
if (error.status !== 400) {
return {
wasSuccessful: false,
hasWarning: false,
hasError: true
};
}
const failures = error.responseJSON;
const hasWarning = _.some(failures, { isWarning: true });
const hasError = _.some(failures, (failure) => !failure.isWarning);
return {
wasSuccessful: false,
hasWarning,
hasError
};
}
class SpinnerErrorButton extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._testResultTimeout = null;
this.state = {
wasSuccessful: false,
hasWarning: false,
hasError: false
};
}
componentDidUpdate(prevProps) {
const {
isSpinning,
error
} = this.props;
if (prevProps.isSpinning && !isSpinning) {
const testResult = getTestResult(error);
this.setState(testResult, () => {
const {
wasSuccessful,
hasWarning,
hasError
} = testResult;
if (wasSuccessful || hasWarning || hasError) {
this._testResultTimeout = setTimeout(this.resetState, 3000);
}
});
}
}
componentWillUnmount() {
if (this._testResultTimeout) {
clearTimeout(this._testResultTimeout);
}
}
//
// Control
resetState = () => {
this.setState({
wasSuccessful: false,
hasWarning: false,
hasError: false
});
};
//
// Render
render() {
const {
kind,
isSpinning,
error,
children,
...otherProps
} = this.props;
const {
wasSuccessful,
hasWarning,
hasError
} = this.state;
const showIcon = wasSuccessful || hasWarning || hasError;
let iconName = icons.CHECK;
let iconKind = kind === kinds.PRIMARY ? kinds.DEFAULT : kinds.SUCCESS;
if (hasWarning) {
iconName = icons.WARNING;
iconKind = kinds.WARNING;
}
if (hasError) {
iconName = icons.DANGER;
iconKind = kinds.DANGER;
}
return (
<SpinnerButton
kind={kind}
isSpinning={isSpinning}
{...otherProps}
>
<span className={showIcon ? styles.showIcon : undefined}>
{
showIcon &&
<span className={styles.iconContainer}>
<Icon
name={iconName}
kind={iconKind}
/>
</span>
}
{
<span className={styles.label}>
{
children
}
</span>
}
</span>
</SpinnerButton>
);
}
}
SpinnerErrorButton.propTypes = {
kind: PropTypes.oneOf(kinds.all),
isSpinning: PropTypes.bool.isRequired,
error: PropTypes.object,
children: PropTypes.node.isRequired
};
export default SpinnerErrorButton;

View File

@@ -0,0 +1,143 @@
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Error } from 'App/State/AppSectionState';
import Icon, { IconKind, IconName } from 'Components/Icon';
import SpinnerButton, {
SpinnerButtonProps,
} from 'Components/Link/SpinnerButton';
import usePrevious from 'Helpers/Hooks/usePrevious';
import { icons } from 'Helpers/Props';
import { ValidationFailure } from 'typings/pending';
import styles from './SpinnerErrorButton.css';
function getTestResult(error: Error | string | undefined) {
if (!error) {
return {
wasSuccessful: true,
hasWarning: false,
hasError: false,
};
}
if (typeof error === 'string' || error.status !== 400) {
return {
wasSuccessful: false,
hasWarning: false,
hasError: true,
};
}
const failures = error.responseJSON as ValidationFailure[];
const { hasError, hasWarning } = failures.reduce(
(acc, failure) => {
if (failure.isWarning) {
acc.hasWarning = true;
} else {
acc.hasError = true;
}
return acc;
},
{ hasWarning: false, hasError: false }
);
return {
wasSuccessful: false,
hasWarning,
hasError,
};
}
interface SpinnerErrorButtonProps extends SpinnerButtonProps {
isSpinning: boolean;
error?: Error | string;
children: React.ReactNode;
}
function SpinnerErrorButton({
kind,
isSpinning,
error,
children,
...otherProps
}: SpinnerErrorButtonProps) {
const wasSpinning = usePrevious(isSpinning);
const updateTimeout = useRef<ReturnType<typeof setTimeout>>();
const [result, setResult] = useState({
wasSuccessful: false,
hasWarning: false,
hasError: false,
});
const { wasSuccessful, hasWarning, hasError } = result;
const showIcon = wasSuccessful || hasWarning || hasError;
const { iconName, iconKind } = useMemo<{
iconName: IconName;
iconKind: IconKind;
}>(() => {
if (hasWarning) {
return {
iconName: icons.WARNING,
iconKind: 'warning',
};
}
if (hasError) {
return {
iconName: icons.DANGER,
iconKind: 'danger',
};
}
return {
iconName: icons.CHECK,
iconKind: kind === 'primary' ? 'default' : 'success',
};
}, [kind, hasError, hasWarning]);
useEffect(() => {
if (wasSpinning && !isSpinning) {
const testResult = getTestResult(error);
setResult(testResult);
const { wasSuccessful, hasWarning, hasError } = testResult;
if (wasSuccessful || hasWarning || hasError) {
updateTimeout.current = setTimeout(() => {
setResult({
wasSuccessful: false,
hasWarning: false,
hasError: false,
});
}, 3000);
}
}
}, [isSpinning, wasSpinning, error]);
useEffect(() => {
return () => {
if (updateTimeout.current) {
clearTimeout(updateTimeout.current);
}
};
}, []);
return (
<SpinnerButton kind={kind} isSpinning={isSpinning} {...otherProps}>
<span className={showIcon ? styles.showIcon : undefined}>
{showIcon && (
<span className={styles.iconContainer}>
<Icon name={iconName} kind={iconKind} />
</span>
)}
<span className={styles.label}>{children}</span>
</span>
</SpinnerButton>
);
}
export default SpinnerErrorButton;

View File

@@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons } from 'Helpers/Props';
import IconButton from './IconButton';
function SpinnerIconButton(props) {
const {
name,
spinningName,
isDisabled,
isSpinning,
...otherProps
} = props;
return (
<IconButton
name={isSpinning ? (spinningName || name) : name}
isDisabled={isDisabled || isSpinning}
isSpinning={isSpinning}
{...otherProps}
/>
);
}
SpinnerIconButton.propTypes = {
...IconButton.propTypes,
className: PropTypes.string,
name: PropTypes.object.isRequired,
spinningName: PropTypes.object.isRequired,
isDisabled: PropTypes.bool.isRequired,
isSpinning: PropTypes.bool.isRequired
};
SpinnerIconButton.defaultProps = {
spinningName: icons.SPINNER,
isDisabled: false,
isSpinning: false
};
export default SpinnerIconButton;

View File

@@ -0,0 +1,27 @@
import React from 'react';
import { IconName } from 'Components/Icon';
import { icons } from 'Helpers/Props';
import IconButton, { IconButtonProps } from './IconButton';
interface SpinnerIconButtonProps extends IconButtonProps {
spinningName?: IconName;
}
function SpinnerIconButton({
name,
spinningName = icons.SPINNER,
isDisabled = false,
isSpinning = false,
...otherProps
}: SpinnerIconButtonProps) {
return (
<IconButton
name={isSpinning ? spinningName || name : name}
isDisabled={isDisabled || isSpinning}
isSpinning={isSpinning}
{...otherProps}
/>
);
}
export default SpinnerIconButton;

View File

@@ -1,112 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FilterMenuContent from './FilterMenuContent';
import Menu from './Menu';
import ToolbarMenuButton from './ToolbarMenuButton';
import styles from './FilterMenu.css';
class FilterMenu extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isFilterModalOpen: false
};
}
//
// Listeners
onCustomFiltersPress = () => {
this.setState({ isFilterModalOpen: true });
};
onFiltersModalClose = () => {
this.setState({ isFilterModalOpen: false });
};
//
// Render
render(props) {
const {
className,
isDisabled,
selectedFilterKey,
filters,
customFilters,
buttonComponent: ButtonComponent,
filterModalConnectorComponent: FilterModalConnectorComponent,
filterModalConnectorComponentProps,
onFilterSelect,
...otherProps
} = this.props;
const showCustomFilters = !!FilterModalConnectorComponent;
return (
<div>
<Menu
className={className}
{...otherProps}
>
<ButtonComponent
iconName={icons.FILTER}
showIndicator={selectedFilterKey !== 'all'}
text={translate('Filter')}
isDisabled={isDisabled}
/>
<FilterMenuContent
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
showCustomFilters={showCustomFilters}
onFilterSelect={onFilterSelect}
onCustomFiltersPress={this.onCustomFiltersPress}
/>
</Menu>
{
showCustomFilters &&
<FilterModalConnectorComponent
{...filterModalConnectorComponentProps}
isOpen={this.state.isFilterModalOpen}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
onModalClose={this.onFiltersModalClose}
/>
}
</div>
);
}
}
FilterMenu.propTypes = {
className: PropTypes.string,
isDisabled: PropTypes.bool.isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
buttonComponent: PropTypes.elementType.isRequired,
filterModalConnectorComponent: PropTypes.elementType,
filterModalConnectorComponentProps: PropTypes.object,
onFilterSelect: PropTypes.func.isRequired
};
FilterMenu.defaultProps = {
className: styles.filterMenu,
isDisabled: false,
buttonComponent: ToolbarMenuButton
};
export default FilterMenu;

View File

@@ -0,0 +1,82 @@
import React, { useCallback, useState } from 'react';
import { CustomFilter, Filter } from 'App/State/AppState';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FilterMenuContent from './FilterMenuContent';
import Menu from './Menu';
import ToolbarMenuButton from './ToolbarMenuButton';
import styles from './FilterMenu.css';
interface FilterMenuProps {
className?: string;
alignMenu: 'left' | 'right';
isDisabled?: boolean;
selectedFilterKey: string | number;
filters: Filter[];
customFilters: CustomFilter[];
buttonComponent?: React.ElementType;
filterModalConnectorComponent?: React.ElementType;
filterModalConnectorComponentProps?: object;
onFilterSelect: (filter: number | string) => void;
}
function FilterMenu({
className = styles.filterMenu,
isDisabled = false,
selectedFilterKey,
filters,
customFilters,
buttonComponent: ButtonComponent = ToolbarMenuButton,
filterModalConnectorComponent: FilterModalConnectorComponent,
filterModalConnectorComponentProps,
onFilterSelect,
...otherProps
}: FilterMenuProps) {
const [isFilterModalOpen, setIsFilterModalOpen] = useState(false);
const showCustomFilters = !!FilterModalConnectorComponent;
const handleCustomFiltersPress = useCallback(() => {
setIsFilterModalOpen(true);
}, []);
const handleFiltersModalClose = useCallback(() => {
setIsFilterModalOpen(false);
}, []);
return (
<div>
<Menu className={className} {...otherProps}>
<ButtonComponent
iconName={icons.FILTER}
showIndicator={selectedFilterKey !== 'all'}
text={translate('Filter')}
isDisabled={isDisabled}
/>
<FilterMenuContent
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
showCustomFilters={showCustomFilters}
onFilterSelect={onFilterSelect}
onCustomFiltersPress={handleCustomFiltersPress}
/>
</Menu>
{showCustomFilters ? (
<FilterModalConnectorComponent
{...filterModalConnectorComponentProps}
isOpen={isFilterModalOpen}
selectedFilterKey={selectedFilterKey}
filters={filters}
customFilters={customFilters}
onFilterSelect={onFilterSelect}
onModalClose={handleFiltersModalClose}
/>
) : null}
</div>
);
}
export default FilterMenu;

View File

@@ -1,95 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import FilterMenuItem from './FilterMenuItem';
import MenuContent from './MenuContent';
import MenuItem from './MenuItem';
import MenuItemSeparator from './MenuItemSeparator';
class FilterMenuContent extends Component {
//
// Render
render() {
const {
selectedFilterKey,
filters,
customFilters,
showCustomFilters,
onFilterSelect,
onCustomFiltersPress,
...otherProps
} = this.props;
return (
<MenuContent {...otherProps}>
{
filters.map((filter) => {
return (
<FilterMenuItem
key={filter.key}
filterKey={filter.key}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{typeof filter.label === 'function' ? filter.label() : filter.label}
</FilterMenuItem>
);
})
}
{
customFilters.length > 0 ?
<MenuItemSeparator /> :
null
}
{
customFilters
.sort(sortByProp('label'))
.map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})
}
{
showCustomFilters &&
<MenuItemSeparator />
}
{
showCustomFilters &&
<MenuItem onPress={onCustomFiltersPress}>
{translate('CustomFilters')}
</MenuItem>
}
</MenuContent>
);
}
}
FilterMenuContent.propTypes = {
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
filters: PropTypes.arrayOf(PropTypes.object).isRequired,
customFilters: PropTypes.arrayOf(PropTypes.object).isRequired,
showCustomFilters: PropTypes.bool.isRequired,
onFilterSelect: PropTypes.func.isRequired,
onCustomFiltersPress: PropTypes.func.isRequired
};
FilterMenuContent.defaultProps = {
showCustomFilters: false
};
export default FilterMenuContent;

View File

@@ -0,0 +1,69 @@
import React from 'react';
import { CustomFilter, Filter } from 'App/State/AppState';
import sortByProp from 'Utilities/Array/sortByProp';
import translate from 'Utilities/String/translate';
import FilterMenuItem from './FilterMenuItem';
import MenuContent from './MenuContent';
import MenuItem from './MenuItem';
import MenuItemSeparator from './MenuItemSeparator';
interface FilterMenuContentProps {
selectedFilterKey: string | number;
filters: Filter[];
customFilters: CustomFilter[];
showCustomFilters: boolean;
onFilterSelect: (filter: number | string) => void;
onCustomFiltersPress: () => void;
}
function FilterMenuContent({
selectedFilterKey,
filters,
customFilters,
showCustomFilters = false,
onFilterSelect,
onCustomFiltersPress,
...otherProps
}: FilterMenuContentProps) {
return (
<MenuContent {...otherProps}>
{filters.map((filter) => {
return (
<FilterMenuItem
key={filter.key}
filterKey={filter.key}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{typeof filter.label === 'function' ? filter.label() : filter.label}
</FilterMenuItem>
);
})}
{customFilters.length > 0 ? <MenuItemSeparator /> : null}
{customFilters.sort(sortByProp('label')).map((filter) => {
return (
<FilterMenuItem
key={filter.id}
filterKey={filter.id}
selectedFilterKey={selectedFilterKey}
onPress={onFilterSelect}
>
{filter.label}
</FilterMenuItem>
);
})}
{showCustomFilters && <MenuItemSeparator />}
{showCustomFilters && (
<MenuItem onPress={onCustomFiltersPress}>
{translate('CustomFilters')}
</MenuItem>
)}
</MenuContent>
);
}
export default FilterMenuContent;

View File

@@ -1,45 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import SelectedMenuItem from './SelectedMenuItem';
class FilterMenuItem extends Component {
//
// Listeners
onPress = () => {
const {
filterKey,
onPress
} = this.props;
onPress(filterKey);
};
//
// Render
render() {
const {
filterKey,
selectedFilterKey,
...otherProps
} = this.props;
return (
<SelectedMenuItem
isSelected={filterKey === selectedFilterKey}
{...otherProps}
onPress={this.onPress}
/>
);
}
}
FilterMenuItem.propTypes = {
filterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
selectedFilterKey: PropTypes.oneOfType([PropTypes.string, PropTypes.number]).isRequired,
onPress: PropTypes.func.isRequired
};
export default FilterMenuItem;

View File

@@ -0,0 +1,30 @@
import React, { useCallback } from 'react';
import SelectedMenuItem, { SelectedMenuItemProps } from './SelectedMenuItem';
interface FilterMenuItemProps
extends Omit<SelectedMenuItemProps, 'isSelected' | 'onPress'> {
filterKey: string | number;
selectedFilterKey: string | number;
onPress: (filter: number | string) => void;
}
function FilterMenuItem({
filterKey,
selectedFilterKey,
onPress,
...otherProps
}: FilterMenuItemProps) {
const handlePress = useCallback(() => {
onPress(filterKey);
}, [filterKey, onPress]);
return (
<SelectedMenuItem
{...otherProps}
isSelected={filterKey === selectedFilterKey}
onPress={handlePress}
/>
);
}
export default FilterMenuItem;

View File

@@ -1,252 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { Manager, Popper, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import { align } from 'Helpers/Props';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import styles from './Menu.css';
const sharedPopperOptions = {
modifiers: {
preventOverflow: {
padding: 0
},
flip: {
padding: 0
}
}
};
const popperOptions = {
[align.RIGHT]: {
...sharedPopperOptions,
placement: 'bottom-end'
},
[align.LEFT]: {
...sharedPopperOptions,
placement: 'bottom-start'
}
};
class Menu extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._scheduleUpdate = null;
this._menuButtonId = getUniqueElementId();
this._menuContentId = getUniqueElementId();
this.state = {
isMenuOpen: false,
maxHeight: 0
};
}
componentDidMount() {
this.setMaxHeight();
}
componentDidUpdate() {
if (this._scheduleUpdate) {
this._scheduleUpdate();
}
}
componentWillUnmount() {
this._removeListener();
}
//
// Control
getMaxHeight() {
if (!this.props.enforceMaxHeight) {
return;
}
const menuButton = document.getElementById(this._menuButtonId);
if (!menuButton) {
return;
}
const { bottom } = menuButton.getBoundingClientRect();
const maxHeight = window.innerHeight - bottom;
return maxHeight;
}
setMaxHeight() {
const maxHeight = this.getMaxHeight();
if (maxHeight !== this.state.maxHeight) {
this.setState({
maxHeight
});
}
}
_addListener() {
// Listen to resize events on the window and scroll events
// on all elements to ensure the menu is the best size possible.
// Listen for click events on the window to support closing the
// menu on clicks outside.
window.addEventListener('resize', this.onWindowResize);
window.addEventListener('scroll', this.onWindowScroll, { capture: true });
window.addEventListener('click', this.onWindowClick);
window.addEventListener('touchstart', this.onTouchStart);
}
_removeListener() {
window.removeEventListener('resize', this.onWindowResize);
window.removeEventListener('scroll', this.onWindowScroll, { capture: true });
window.removeEventListener('click', this.onWindowClick);
window.removeEventListener('touchstart', this.onTouchStart);
}
//
// Listeners
onWindowClick = (event) => {
const menuButton = document.getElementById(this._menuButtonId);
if (!menuButton) {
return;
}
if (!menuButton.contains(event.target) && this.state.isMenuOpen) {
this.setState({ isMenuOpen: false });
this._removeListener();
}
};
onTouchStart = (event) => {
const menuButton = document.getElementById(this._menuButtonId);
const menuContent = document.getElementById(this._menuContentId);
if (!menuButton || !menuContent) {
return;
}
if (event.targetTouches.length !== 1) {
return;
}
const target = event.targetTouches[0].target;
if (
!menuButton.contains(target) &&
!menuContent.contains(target) &&
this.state.isMenuOpen
) {
this.setState({ isMenuOpen: false });
this._removeListener();
}
};
onWindowResize = () => {
this.setMaxHeight();
};
onWindowScroll = (event) => {
if (this.state.isMenuOpen) {
this.setMaxHeight();
}
};
onMenuButtonPress = () => {
const state = {
isMenuOpen: !this.state.isMenuOpen
};
if (this.state.isMenuOpen) {
this._removeListener();
} else {
state.maxHeight = this.getMaxHeight();
this._addListener();
}
this.setState(state);
};
//
// Render
render() {
const {
className,
children,
alignMenu
} = this.props;
const {
maxHeight,
isMenuOpen
} = this.state;
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(
childrenArray[0],
{
onPress: this.onMenuButtonPress
}
);
return (
<Manager>
<Reference>
{({ ref }) => (
<div
ref={ref}
id={this._menuButtonId}
className={className}
>
{button}
</div>
)}
</Reference>
<Portal>
<Popper {...popperOptions[alignMenu]}>
{({ ref, style, scheduleUpdate }) => {
this._scheduleUpdate = scheduleUpdate;
return React.cloneElement(
childrenArray[1],
{
forwardedRef: ref,
style: {
...style,
maxHeight
},
isOpen: isMenuOpen
}
);
}}
</Popper>
</Portal>
</Manager>
);
}
}
Menu.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT]),
enforceMaxHeight: PropTypes.bool.isRequired
};
Menu.defaultProps = {
className: styles.menu,
alignMenu: align.LEFT,
enforceMaxHeight: true
};
export default Menu;

View File

@@ -0,0 +1,205 @@
import React, {
ReactElement,
useCallback,
useEffect,
useId,
useRef,
useState,
} from 'react';
import { Manager, Popper, PopperProps, Reference } from 'react-popper';
import Portal from 'Components/Portal';
import styles from './Menu.css';
const sharedPopperOptions = {
modifiers: {
preventOverflow: {
padding: 0,
},
flip: {
padding: 0,
},
},
};
const popperOptions: {
right: Partial<PopperProps>;
left: Partial<PopperProps>;
} = {
right: {
...sharedPopperOptions,
placement: 'bottom-end',
},
left: {
...sharedPopperOptions,
placement: 'bottom-start',
},
};
interface MenuProps {
className?: string;
children: React.ReactNode;
alignMenu?: 'left' | 'right';
enforceMaxHeight?: boolean;
}
function Menu({
className = styles.menu,
children,
alignMenu = 'left',
enforceMaxHeight = true,
}: MenuProps) {
const updater = useRef<(() => void) | null>(null);
const menuButtonId = useId();
const menuContentId = useId();
const [maxHeight, setMaxHeight] = useState(0);
const [isMenuOpen, setIsMenuOpen] = useState(false);
const updateMaxHeight = useCallback(() => {
const menuButton = document.getElementById(menuButtonId);
if (!menuButton) {
setMaxHeight(0);
return;
}
const { bottom } = menuButton.getBoundingClientRect();
const height = window.innerHeight - bottom;
setMaxHeight(height);
}, [menuButtonId]);
const handleWindowClick = useCallback(
(event: MouseEvent) => {
const menuButton = document.getElementById(menuButtonId);
if (!menuButton) {
return;
}
if (!menuButton.contains(event.target as Node)) {
setIsMenuOpen(false);
}
},
[menuButtonId]
);
const handleTouchStart = useCallback(
(event: TouchEvent) => {
const menuButton = document.getElementById(menuButtonId);
const menuContent = document.getElementById(menuContentId);
if (!menuButton || !menuContent) {
return;
}
if (event.targetTouches.length !== 1) {
return;
}
const target = event.targetTouches[0].target;
if (
!menuButton.contains(target as Node) &&
!menuContent.contains(target as Node)
) {
setIsMenuOpen(false);
}
},
[menuButtonId, menuContentId]
);
const handleWindowResize = useCallback(() => {
updateMaxHeight();
}, [updateMaxHeight]);
const handleWindowScroll = useCallback(() => {
if (isMenuOpen) {
updateMaxHeight();
}
}, [isMenuOpen, updateMaxHeight]);
const handleMenuButtonPress = useCallback(() => {
setIsMenuOpen((isOpen) => !isOpen);
}, []);
const childrenArray = React.Children.toArray(children);
const button = React.cloneElement(childrenArray[0] as ReactElement, {
onPress: handleMenuButtonPress,
});
useEffect(() => {
if (enforceMaxHeight) {
updateMaxHeight();
}
}, [enforceMaxHeight, updateMaxHeight]);
useEffect(() => {
if (updater.current && isMenuOpen) {
updater.current();
}
}, [isMenuOpen]);
useEffect(() => {
// Listen to resize events on the window and scroll events
// on all elements to ensure the menu is the best size possible.
// Listen for click events on the window to support closing the
// menu on clicks outside.
if (!isMenuOpen) {
return;
}
window.addEventListener('resize', handleWindowResize);
window.addEventListener('scroll', handleWindowScroll, { capture: true });
window.addEventListener('click', handleWindowClick);
window.addEventListener('touchstart', handleTouchStart);
return () => {
window.removeEventListener('resize', handleWindowResize);
window.removeEventListener('scroll', handleWindowScroll, {
capture: true,
});
window.removeEventListener('click', handleWindowClick);
window.removeEventListener('touchstart', handleTouchStart);
};
}, [
isMenuOpen,
handleWindowResize,
handleWindowScroll,
handleWindowClick,
handleTouchStart,
]);
return (
<Manager>
<Reference>
{({ ref }) => (
<div ref={ref} id={menuButtonId} className={className}>
{button}
</div>
)}
</Reference>
<Portal>
<Popper {...popperOptions[alignMenu]}>
{({ ref, style, scheduleUpdate }) => {
updater.current = scheduleUpdate;
return React.cloneElement(childrenArray[1] as ReactElement, {
forwardedRef: ref,
style: {
...style,
maxHeight,
},
isOpen: isMenuOpen,
});
}}
</Popper>
</Portal>
</Manager>
);
}
export default Menu;

View File

@@ -1,49 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import styles from './MenuButton.css';
class MenuButton extends Component {
//
// Render
render() {
const {
className,
children,
isDisabled,
onPress,
...otherProps
} = this.props;
return (
<Link
className={classNames(
className,
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled}
onPress={onPress}
{...otherProps}
>
{children}
</Link>
);
}
}
MenuButton.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired,
onPress: PropTypes.func
};
MenuButton.defaultProps = {
className: styles.menuButton,
isDisabled: false
};
export default MenuButton;

View File

@@ -0,0 +1,30 @@
import classNames from 'classnames';
import React from 'react';
import Link from 'Components/Link/Link';
import styles from './MenuButton.css';
export interface MenuButtonProps {
className?: string;
children: React.ReactNode;
isDisabled?: boolean;
onPress?: () => void;
}
function MenuButton({
className = styles.menuButton,
children,
isDisabled = false,
...otherProps
}: MenuButtonProps) {
return (
<Link
className={classNames(className, isDisabled && styles.isDisabled)}
isDisabled={isDisabled}
{...otherProps}
>
{children}
</Link>
);
}
export default MenuButton;

View File

@@ -1,55 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Scroller from 'Components/Scroller/Scroller';
import getUniqueElementId from 'Utilities/getUniqueElementId';
import styles from './MenuContent.css';
class MenuContent extends Component {
//
// Render
render() {
const {
forwardedRef,
className,
id,
children,
style,
isOpen
} = this.props;
return (
<div
id={id}
ref={forwardedRef}
className={className}
style={style}
>
{
isOpen ?
<Scroller className={styles.scroller}>
{children}
</Scroller> :
null
}
</div>
);
}
}
MenuContent.propTypes = {
forwardedRef: PropTypes.func,
className: PropTypes.string,
id: PropTypes.string.isRequired,
children: PropTypes.node.isRequired,
style: PropTypes.object,
isOpen: PropTypes.bool
};
MenuContent.defaultProps = {
className: styles.menuContent,
id: getUniqueElementId()
};
export default MenuContent;

View File

@@ -0,0 +1,38 @@
import React, { CSSProperties, LegacyRef, useId } from 'react';
import Scroller from 'Components/Scroller/Scroller';
import styles from './MenuContent.css';
interface MenuContentProps {
forwardedRef?: LegacyRef<HTMLDivElement> | undefined;
className?: string;
id?: string;
children: React.ReactNode;
style?: CSSProperties;
isOpen?: boolean;
}
function MenuContent({
forwardedRef,
className = styles.menuContent,
id,
children,
style,
isOpen,
}: MenuContentProps) {
const generatedId = useId();
return (
<div
ref={forwardedRef}
id={id ?? generatedId}
className={className}
style={style}
>
{isOpen ? (
<Scroller className={styles.scroller}>{children}</Scroller>
) : null}
</div>
);
}
export default MenuContent;

View File

@@ -1,46 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import styles from './MenuItem.css';
class MenuItem extends Component {
//
// Render
render() {
const {
className,
children,
isDisabled,
...otherProps
} = this.props;
return (
<Link
className={classNames(
className,
isDisabled && styles.isDisabled
)}
isDisabled={isDisabled}
{...otherProps}
>
{children}
</Link>
);
}
}
MenuItem.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired
};
MenuItem.defaultProps = {
className: styles.menuItem,
isDisabled: false
};
export default MenuItem;

View File

@@ -0,0 +1,29 @@
import classNames from 'classnames';
import React from 'react';
import Link, { LinkProps } from 'Components/Link/Link';
import styles from './MenuItem.css';
export interface MenuItemProps extends LinkProps {
className?: string;
children: React.ReactNode;
isDisabled?: boolean;
}
function MenuItem({
className = styles.menuItem,
children,
isDisabled = false,
...otherProps
}: MenuItemProps) {
return (
<Link
className={classNames(className, isDisabled && styles.isDisabled)}
isDisabled={isDisabled}
{...otherProps}
>
{children}
</Link>
);
}
export default MenuItem;

View File

@@ -2,9 +2,7 @@ import React from 'react';
import styles from './MenuItemSeparator.css';
function MenuItemSeparator() {
return (
<div className={styles.separator} />
);
return <div className={styles.separator} />;
}
export default MenuItemSeparator;

View File

@@ -1,60 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import MenuButton from 'Components/Menu/MenuButton';
import { icons } from 'Helpers/Props';
import styles from './PageMenuButton.css';
function PageMenuButton(props) {
const {
iconName,
showIndicator,
text,
...otherProps
} = props;
return (
<MenuButton
className={styles.menuButton}
{...otherProps}
>
<Icon
name={iconName}
size={18}
/>
{
showIndicator ?
<span
className={classNames(
styles.indicatorContainer,
'fa-layers fa-fw'
)}
>
<Icon
name={icons.CIRCLE}
size={9}
/>
</span> :
null
}
<div className={styles.label}>
{text}
</div>
</MenuButton>
);
}
PageMenuButton.propTypes = {
iconName: PropTypes.object.isRequired,
text: PropTypes.string,
showIndicator: PropTypes.bool.isRequired
};
PageMenuButton.defaultProps = {
showIndicator: false
};
export default PageMenuButton;

View File

@@ -0,0 +1,38 @@
import { IconName } from '@fortawesome/free-regular-svg-icons';
import classNames from 'classnames';
import React from 'react';
import Icon from 'Components/Icon';
import MenuButton from 'Components/Menu/MenuButton';
import { icons } from 'Helpers/Props';
import styles from './PageMenuButton.css';
interface PageMenuButtonProps {
iconName: IconName;
showIndicator: boolean;
text?: string;
}
function PageMenuButton({
iconName,
showIndicator = false,
text,
...otherProps
}: PageMenuButtonProps) {
return (
<MenuButton className={styles.menuButton} {...otherProps}>
<Icon name={iconName} size={18} />
{showIndicator ? (
<span
className={classNames(styles.indicatorContainer, 'fa-layers fa-fw')}
>
<Icon name={icons.CIRCLE} size={9} />
</span>
) : null}
<div className={styles.label}>{text}</div>
</MenuButton>
);
}
export default PageMenuButton;

View File

@@ -1,47 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import MenuItem from './MenuItem';
class SearchMenuItem extends Component {
//
// Listeners
onPress = () => {
const {
name,
onPress
} = this.props;
onPress(name);
};
//
// Render
render() {
const {
children,
...otherProps
} = this.props;
return (
<MenuItem
{...otherProps}
onPress={this.onPress}
>
<div>
{children}
</div>
</MenuItem>
);
}
}
SearchMenuItem.propTypes = {
name: PropTypes.string,
children: PropTypes.node.isRequired,
onPress: PropTypes.func.isRequired
};
export default SearchMenuItem;

View File

@@ -1,63 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Icon from 'Components/Icon';
import { icons } from 'Helpers/Props';
import MenuItem from './MenuItem';
import styles from './SelectedMenuItem.css';
class SelectedMenuItem extends Component {
//
// Listeners
onPress = () => {
const {
name,
onPress
} = this.props;
onPress(name);
};
//
// Render
render() {
const {
children,
selectedIconName,
isSelected,
...otherProps
} = this.props;
return (
<MenuItem
{...otherProps}
onPress={this.onPress}
>
<div className={styles.item}>
{children}
<Icon
className={isSelected ? styles.isSelected : styles.isNotSelected}
name={selectedIconName}
/>
</div>
</MenuItem>
);
}
}
SelectedMenuItem.propTypes = {
name: PropTypes.string,
children: PropTypes.node.isRequired,
selectedIconName: PropTypes.object.isRequired,
isSelected: PropTypes.bool.isRequired,
onPress: PropTypes.func.isRequired
};
SelectedMenuItem.defaultProps = {
selectedIconName: icons.CHECK
};
export default SelectedMenuItem;

View File

@@ -0,0 +1,41 @@
import React, { useCallback } from 'react';
import Icon, { IconName } from 'Components/Icon';
import { icons } from 'Helpers/Props';
import MenuItem, { MenuItemProps } from './MenuItem';
import styles from './SelectedMenuItem.css';
export interface SelectedMenuItemProps extends Omit<MenuItemProps, 'onPress'> {
name?: string;
children: React.ReactNode;
selectedIconName?: IconName;
isSelected: boolean;
onPress: (name: string) => void;
}
function SelectedMenuItem({
children,
name,
selectedIconName = icons.CHECK,
isSelected,
onPress,
...otherProps
}: SelectedMenuItemProps) {
const handlePress = useCallback(() => {
onPress(name ?? '');
}, [name, onPress]);
return (
<MenuItem {...otherProps} onPress={handlePress}>
<div className={styles.item}>
{children}
<Icon
className={isSelected ? styles.isSelected : styles.isNotSelected}
name={selectedIconName}
/>
</div>
</MenuItem>
);
}
export default SelectedMenuItem;

View File

@@ -1,42 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import { align, icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function SortMenu(props) {
const {
className,
children,
isDisabled,
...otherProps
} = props;
return (
<Menu
className={className}
{...otherProps}
>
<ToolbarMenuButton
iconName={icons.SORT}
text={translate('Sort')}
isDisabled={isDisabled}
/>
{children}
</Menu>
);
}
SortMenu.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired,
alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT])
};
SortMenu.defaultProps = {
isDisabled: false
};
export default SortMenu;

View File

@@ -0,0 +1,34 @@
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton, {
ToolbarMenuButtonProps,
} from 'Components/Menu/ToolbarMenuButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
interface SortMenuProps extends Omit<ToolbarMenuButtonProps, 'iconName'> {
className?: string;
children: React.ReactNode;
isDisabled?: boolean;
alignMenu?: 'left' | 'right';
}
function SortMenu({
className,
children,
isDisabled = false,
...otherProps
}: SortMenuProps) {
return (
<Menu className={className} {...otherProps}>
<ToolbarMenuButton
iconName={icons.SORT}
text={translate('Sort')}
isDisabled={isDisabled}
/>
{children}
</Menu>
);
}
export default SortMenu;

View File

@@ -1,38 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import { icons, sortDirections } from 'Helpers/Props';
import SelectedMenuItem from './SelectedMenuItem';
function SortMenuItem(props) {
const {
name,
sortKey,
sortDirection,
...otherProps
} = props;
const isSelected = name === sortKey;
return (
<SelectedMenuItem
name={name}
selectedIconName={sortDirection === sortDirections.ASCENDING ? icons.SORT_ASCENDING : icons.SORT_DESCENDING}
isSelected={isSelected}
{...otherProps}
/>
);
}
SortMenuItem.propTypes = {
name: PropTypes.string,
sortKey: PropTypes.string,
sortDirection: PropTypes.oneOf(sortDirections.all),
children: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
onPress: PropTypes.func.isRequired
};
SortMenuItem.defaultProps = {
name: null
};
export default SortMenuItem;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import { icons } from 'Helpers/Props';
import { SortDirection } from 'Helpers/Props/sortDirections';
import SelectedMenuItem from './SelectedMenuItem';
interface SortMenuItemProps {
name?: string;
sortKey?: string;
sortDirection?: SortDirection;
children: string | React.ReactNode;
onPress: (sortKey: string) => void;
}
function SortMenuItem({
name,
sortKey,
sortDirection,
...otherProps
}: SortMenuItemProps) {
const isSelected = name === sortKey;
return (
<SelectedMenuItem
name={name}
selectedIconName={
sortDirection === 'ascending'
? icons.SORT_ASCENDING
: icons.SORT_DESCENDING
}
isSelected={isSelected}
{...otherProps}
/>
);
}
export default SortMenuItem;

View File

@@ -1,63 +0,0 @@
import classNames from 'classnames';
import PropTypes from 'prop-types';
import React from 'react';
import Icon from 'Components/Icon';
import MenuButton from 'Components/Menu/MenuButton';
import { icons } from 'Helpers/Props';
import styles from './ToolbarMenuButton.css';
function ToolbarMenuButton(props) {
const {
iconName,
showIndicator,
text,
...otherProps
} = props;
return (
<MenuButton
className={styles.menuButton}
{...otherProps}
>
<div>
<Icon
name={iconName}
size={21}
/>
{
showIndicator &&
<span
className={classNames(
styles.indicatorContainer,
'fa-layers fa-fw'
)}
>
<Icon
name={icons.CIRCLE}
size={9}
/>
</span>
}
<div className={styles.labelContainer}>
<div className={styles.label}>
{text}
</div>
</div>
</div>
</MenuButton>
);
}
ToolbarMenuButton.propTypes = {
iconName: PropTypes.object.isRequired,
text: PropTypes.string,
showIndicator: PropTypes.bool.isRequired
};
ToolbarMenuButton.defaultProps = {
showIndicator: false
};
export default ToolbarMenuButton;

View File

@@ -0,0 +1,43 @@
import classNames from 'classnames';
import React from 'react';
import Icon, { IconName } from 'Components/Icon';
import MenuButton, { MenuButtonProps } from 'Components/Menu/MenuButton';
import { icons } from 'Helpers/Props';
import styles from './ToolbarMenuButton.css';
export interface ToolbarMenuButtonProps
extends Omit<MenuButtonProps, 'children'> {
className?: string;
iconName: IconName;
showIndicator?: boolean;
text?: string;
}
function ToolbarMenuButton({
iconName,
showIndicator = false,
text,
...otherProps
}: ToolbarMenuButtonProps) {
return (
<MenuButton className={styles.menuButton} {...otherProps}>
<div>
<Icon name={iconName} size={21} />
{showIndicator ? (
<span
className={classNames(styles.indicatorContainer, 'fa-layers fa-fw')}
>
<Icon name={icons.CIRCLE} size={9} />
</span>
) : null}
<div className={styles.labelContainer}>
<div className={styles.label}>{text}</div>
</div>
</div>
</MenuButton>
);
}
export default ToolbarMenuButton;

View File

@@ -1,39 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
import { align, icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
function ViewMenu(props) {
const {
children,
isDisabled,
...otherProps
} = props;
return (
<Menu
{...otherProps}
>
<ToolbarMenuButton
iconName={icons.VIEW}
text={translate('View')}
isDisabled={isDisabled}
/>
{children}
</Menu>
);
}
ViewMenu.propTypes = {
children: PropTypes.node.isRequired,
isDisabled: PropTypes.bool.isRequired,
alignMenu: PropTypes.oneOf([align.LEFT, align.RIGHT])
};
ViewMenu.defaultProps = {
isDisabled: false
};
export default ViewMenu;

View File

@@ -0,0 +1,32 @@
import React from 'react';
import Menu from 'Components/Menu/Menu';
import ToolbarMenuButton, {
ToolbarMenuButtonProps,
} from 'Components/Menu/ToolbarMenuButton';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
interface ViewMenuProps extends Omit<ToolbarMenuButtonProps, 'iconName'> {
children: React.ReactNode;
isDisabled?: boolean;
alignMenu?: 'left' | 'right';
}
function ViewMenu({
children,
isDisabled = false,
...otherProps
}: ViewMenuProps) {
return (
<Menu {...otherProps}>
<ToolbarMenuButton
iconName={icons.VIEW}
text={translate('View')}
isDisabled={isDisabled}
/>
{children}
</Menu>
);
}
export default ViewMenu;

View File

@@ -1,30 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import SelectedMenuItem from './SelectedMenuItem';
function ViewMenuItem(props) {
const {
name,
selectedView,
...otherProps
} = props;
const isSelected = name === selectedView;
return (
<SelectedMenuItem
name={name}
isSelected={isSelected}
{...otherProps}
/>
);
}
ViewMenuItem.propTypes = {
name: PropTypes.string,
selectedView: PropTypes.string.isRequired,
children: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired,
onPress: PropTypes.func.isRequired
};
export default ViewMenuItem;

View File

@@ -0,0 +1,23 @@
import React from 'react';
import SelectedMenuItem, { SelectedMenuItemProps } from './SelectedMenuItem';
interface ViewMenuItemProps extends Omit<SelectedMenuItemProps, 'isSelected'> {
name?: string;
selectedView: string;
children: React.ReactNode;
onPress: (view: string) => void;
}
function ViewMenuItem({
name,
selectedView,
...otherProps
}: ViewMenuItemProps) {
const isSelected = name === selectedView;
return (
<SelectedMenuItem name={name} isSelected={isSelected} {...otherProps} />
);
}
export default ViewMenuItem;

View File

@@ -1,101 +0,0 @@
import PropTypes from 'prop-types';
import React, { useEffect } from 'react';
import keyboardShortcuts from 'Components/keyboardShortcuts';
import Button from 'Components/Link/Button';
import SpinnerButton from 'Components/Link/SpinnerButton';
import Modal from 'Components/Modal/Modal';
import ModalBody from 'Components/Modal/ModalBody';
import ModalContent from 'Components/Modal/ModalContent';
import ModalFooter from 'Components/Modal/ModalFooter';
import ModalHeader from 'Components/Modal/ModalHeader';
import { kinds, sizes } from 'Helpers/Props';
function ConfirmModal(props) {
const {
isOpen,
kind,
size,
title,
message,
confirmLabel,
cancelLabel,
hideCancelButton,
isSpinning,
onConfirm,
onCancel,
bindShortcut,
unbindShortcut
} = props;
useEffect(() => {
if (isOpen) {
bindShortcut('enter', onConfirm);
return () => unbindShortcut('enter', onConfirm);
}
}, [bindShortcut, unbindShortcut, isOpen, onConfirm]);
return (
<Modal
isOpen={isOpen}
size={size}
onModalClose={onCancel}
>
<ModalContent onModalClose={onCancel}>
<ModalHeader>{title}</ModalHeader>
<ModalBody>
{message}
</ModalBody>
<ModalFooter>
{
!hideCancelButton &&
<Button
kind={kinds.DEFAULT}
onPress={onCancel}
>
{cancelLabel}
</Button>
}
<SpinnerButton
autoFocus={true}
kind={kind}
isSpinning={isSpinning}
onPress={onConfirm}
>
{confirmLabel}
</SpinnerButton>
</ModalFooter>
</ModalContent>
</Modal>
);
}
ConfirmModal.propTypes = {
className: PropTypes.string,
isOpen: PropTypes.bool.isRequired,
kind: PropTypes.oneOf(kinds.all),
size: PropTypes.oneOf(sizes.all),
title: PropTypes.string.isRequired,
message: PropTypes.oneOfType([PropTypes.string, PropTypes.node]).isRequired,
confirmLabel: PropTypes.string,
cancelLabel: PropTypes.string,
hideCancelButton: PropTypes.bool,
isSpinning: PropTypes.bool.isRequired,
onConfirm: PropTypes.func.isRequired,
onCancel: PropTypes.func.isRequired,
bindShortcut: PropTypes.func.isRequired,
unbindShortcut: PropTypes.func.isRequired
};
ConfirmModal.defaultProps = {
kind: kinds.PRIMARY,
size: sizes.MEDIUM,
confirmLabel: 'OK',
cancelLabel: 'Cancel',
isSpinning: false
};
export default keyboardShortcuts(ConfirmModal);

View File

@@ -0,0 +1,76 @@
import React, { useEffect } from 'react';
import Button from 'Components/Link/Button';
import SpinnerButton, {
SpinnerButtonProps,
} from 'Components/Link/SpinnerButton';
import Modal, { ModalProps } from 'Components/Modal/Modal';
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 useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
interface ConfirmModalProps extends Omit<ModalProps, 'onModalClose'> {
kind?: SpinnerButtonProps['kind'];
title: string;
message: React.ReactNode;
confirmLabel?: string;
cancelLabel?: string;
hideCancelButton?: boolean;
isSpinning?: boolean;
onConfirm: () => void;
onCancel: () => void;
}
function ConfirmModal({
isOpen,
kind = 'primary',
size = 'medium',
title,
message,
confirmLabel = 'OK',
cancelLabel = 'Cancel',
hideCancelButton,
isSpinning = false,
onConfirm,
onCancel,
}: ConfirmModalProps) {
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
useEffect(() => {
if (isOpen) {
bindShortcut('acceptConfirmModal', onConfirm);
}
return () => unbindShortcut('acceptConfirmModal');
}, [bindShortcut, unbindShortcut, isOpen, onConfirm]);
return (
<Modal isOpen={isOpen} size={size} onModalClose={onCancel}>
<ModalContent onModalClose={onCancel}>
<ModalHeader>{title}</ModalHeader>
<ModalBody>{message}</ModalBody>
<ModalFooter>
{!hideCancelButton && (
<Button kind="default" onPress={onCancel}>
{cancelLabel}
</Button>
)}
<SpinnerButton
autoFocus={true}
kind={kind}
isSpinning={isSpinning}
onPress={onConfirm}
>
{confirmLabel}
</SpinnerButton>
</ModalFooter>
</ModalContent>
</Modal>
);
}
export default ConfirmModal;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import { useSelector } from 'react-redux';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import TagList from './TagList';
interface MovieTagListProps {
tags: number[];
}
function MovieTagList({ tags }: MovieTagListProps) {
const tagList = useSelector(createTagsSelector());
return <TagList tags={tags} tagList={tagList} />;
}
export default MovieTagList;

View File

@@ -1,68 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import styles from './ErrorPage.css';
function ErrorPage(props) {
const {
version,
isLocalStorageSupported,
translationsError,
moviesError,
customFiltersError,
tagsError,
qualityProfilesError,
languagesError,
uiSettingsError,
systemStatusError
} = props;
let errorMessage = 'Failed to load Radarr';
if (!isLocalStorageSupported) {
errorMessage = 'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
} else if (translationsError) {
errorMessage = getErrorMessage(translationsError, 'Failed to load translations from API');
} else if (moviesError) {
errorMessage = getErrorMessage(moviesError, 'Failed to load movie from API');
} else if (customFiltersError) {
errorMessage = getErrorMessage(customFiltersError, 'Failed to load custom filters from API');
} else if (tagsError) {
errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API');
} else if (qualityProfilesError) {
errorMessage = getErrorMessage(qualityProfilesError, 'Failed to load quality profiles from API');
} else if (languagesError) {
errorMessage = getErrorMessage(languagesError, 'Failed to load languages from API');
} else if (uiSettingsError) {
errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings from API');
} else if (systemStatusError) {
errorMessage = getErrorMessage(uiSettingsError, 'Failed to load system status from API');
}
return (
<div className={styles.page}>
<div className={styles.errorMessage}>
{errorMessage}
</div>
<div className={styles.version}>
Version {version}
</div>
</div>
);
}
ErrorPage.propTypes = {
version: PropTypes.string.isRequired,
isLocalStorageSupported: PropTypes.bool.isRequired,
translationsError: PropTypes.object,
moviesError: PropTypes.object,
customFiltersError: PropTypes.object,
tagsError: PropTypes.object,
qualityProfilesError: PropTypes.object,
languagesError: PropTypes.object,
uiSettingsError: PropTypes.object,
systemStatusError: PropTypes.object
};
export default ErrorPage;

View File

@@ -0,0 +1,86 @@
import React from 'react';
import { Error } from 'App/State/AppSectionState';
import getErrorMessage from 'Utilities/Object/getErrorMessage';
import styles from './ErrorPage.css';
interface ErrorPageProps {
version: string;
isLocalStorageSupported: boolean;
translationsError?: Error;
moviesError?: Error;
customFiltersError?: Error;
tagsError?: Error;
qualityProfilesError?: Error;
languagesError?: Error;
uiSettingsError?: Error;
systemStatusError?: Error;
}
function ErrorPage(props: ErrorPageProps) {
const {
version,
isLocalStorageSupported,
translationsError,
moviesError,
customFiltersError,
tagsError,
qualityProfilesError,
languagesError,
uiSettingsError,
systemStatusError,
} = props;
let errorMessage = 'Failed to load Radarr';
if (!isLocalStorageSupported) {
errorMessage =
'Local Storage is not supported or disabled. A plugin or private browsing may have disabled it.';
} else if (translationsError) {
errorMessage = getErrorMessage(
translationsError,
'Failed to load translations from API'
);
} else if (moviesError) {
errorMessage = getErrorMessage(
moviesError,
'Failed to load movie from API'
);
} else if (customFiltersError) {
errorMessage = getErrorMessage(
customFiltersError,
'Failed to load custom filters from API'
);
} else if (tagsError) {
errorMessage = getErrorMessage(tagsError, 'Failed to load tags from API');
} else if (qualityProfilesError) {
errorMessage = getErrorMessage(
qualityProfilesError,
'Failed to load quality profiles from API'
);
} else if (languagesError) {
errorMessage = getErrorMessage(
languagesError,
'Failed to load languages from API'
);
} else if (uiSettingsError) {
errorMessage = getErrorMessage(
uiSettingsError,
'Failed to load UI settings from API'
);
} else if (systemStatusError) {
errorMessage = getErrorMessage(
uiSettingsError,
'Failed to load system status from API'
);
}
return (
<div className={styles.page}>
<div>{errorMessage}</div>
<div className={styles.version}>Version {version}</div>
</div>
);
}
export default ErrorPage;

View File

@@ -1,31 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import KeyboardShortcutsModalContentConnector from './KeyboardShortcutsModalContentConnector';
function KeyboardShortcutsModal(props) {
const {
isOpen,
onModalClose
} = props;
return (
<Modal
isOpen={isOpen}
size={sizes.SMALL}
onModalClose={onModalClose}
>
<KeyboardShortcutsModalContentConnector
onModalClose={onModalClose}
/>
</Modal>
);
}
KeyboardShortcutsModal.propTypes = {
isOpen: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default KeyboardShortcutsModal;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import Modal from 'Components/Modal/Modal';
import { sizes } from 'Helpers/Props';
import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
interface KeyboardShortcutsModalProps {
isOpen: boolean;
onModalClose: () => void;
}
function KeyboardShortcutsModal(props: KeyboardShortcutsModalProps) {
const { isOpen, onModalClose } = props;
return (
<Modal isOpen={isOpen} size={sizes.SMALL} onModalClose={onModalClose}>
<KeyboardShortcutsModalContent onModalClose={onModalClose} />
</Modal>
);
}
export default KeyboardShortcutsModal;

View File

@@ -1,16 +1,17 @@
import PropTypes from 'prop-types';
import React from 'react';
import { shortcuts } from 'Components/keyboardShortcuts';
import { useSelector } from 'react-redux';
import { Shortcut, shortcuts } from 'Components/keyboardShortcuts';
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 createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import translate from 'Utilities/String/translate';
import styles from './KeyboardShortcutsModalContent.css';
function getShortcuts() {
const allShortcuts = [];
const allShortcuts: Shortcut[] = [];
Object.keys(shortcuts).forEach((key) => {
allShortcuts.push(shortcuts[key]);
@@ -19,8 +20,8 @@ function getShortcuts() {
return allShortcuts;
}
function getShortcutKey(combo, isOsx) {
const comboMatch = combo.match(/(.+?)\+(.*)/);
function getShortcutKey(combo: string, isOsx: boolean) {
const comboMatch = combo.match(/(.+?)\+(.)/);
if (!comboMatch) {
return combo;
@@ -45,55 +46,39 @@ function getShortcutKey(combo, isOsx) {
return `${osModifier} + ${key}`;
}
function KeyboardShortcutsModalContent(props) {
const {
isOsx,
onModalClose
} = props;
interface KeyboardShortcutsModalContentProps {
onModalClose: () => void;
}
function KeyboardShortcutsModalContent({
onModalClose,
}: KeyboardShortcutsModalContentProps) {
const { isOsx } = useSelector(createSystemStatusSelector());
const allShortcuts = getShortcuts();
return (
<ModalContent onModalClose={onModalClose}>
<ModalHeader>
{translate('KeyboardShortcuts')}
</ModalHeader>
<ModalHeader>{translate('KeyboardShortcuts')}</ModalHeader>
<ModalBody>
{
allShortcuts.map((shortcut) => {
return (
<div
key={shortcut.name}
className={styles.shortcut}
>
<div className={styles.key}>
{getShortcutKey(shortcut.key, isOsx)}
</div>
<div>
{shortcut.name}
</div>
{allShortcuts.map((shortcut) => {
return (
<div key={shortcut.name} className={styles.shortcut}>
<div className={styles.key}>
{getShortcutKey(shortcut.key, isOsx)}
</div>
);
})
}
<div>{shortcut.name}</div>
</div>
);
})}
</ModalBody>
<ModalFooter>
<Button
onPress={onModalClose}
>
{translate('Close')}
</Button>
<Button onPress={onModalClose}>{translate('Close')}</Button>
</ModalFooter>
</ModalContent>
);
}
KeyboardShortcutsModalContent.propTypes = {
isOsx: PropTypes.bool.isRequired,
onModalClose: PropTypes.func.isRequired
};
export default KeyboardShortcutsModalContent;

View File

@@ -1,17 +0,0 @@
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import KeyboardShortcutsModalContent from './KeyboardShortcutsModalContent';
function createMapStateToProps() {
return createSelector(
createSystemStatusSelector(),
(systemStatus) => {
return {
isOsx: systemStatus.isOsx
};
}
);
}
export default connect(createMapStateToProps)(KeyboardShortcutsModalContent);

View File

@@ -1,346 +0,0 @@
import _ from 'lodash';
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Autosuggest from 'react-autosuggest';
import Icon from 'Components/Icon';
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import FuseWorker from './fuse.worker';
import MovieSearchResult from './MovieSearchResult';
import styles from './MovieSearchInput.css';
const ADD_NEW_TYPE = 'addNew';
class MovieSearchInput extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this._autosuggest = null;
this._worker = null;
this.state = {
value: '',
suggestions: []
};
}
componentDidMount() {
this.props.bindShortcut(shortcuts.MOVIE_SEARCH_INPUT.key, this.focusInput);
}
componentWillUnmount() {
if (this._worker) {
this._worker.removeEventListener('message', this.onSuggestionsReceived, false);
this._worker.terminate();
this._worker = null;
}
}
getWorker() {
if (!this._worker) {
this._worker = new FuseWorker();
this._worker.addEventListener('message', this.onSuggestionsReceived, false);
}
return this._worker;
}
//
// Control
setAutosuggestRef = (ref) => {
this._autosuggest = ref;
};
focusInput = (event) => {
event.preventDefault();
this._autosuggest.input.focus();
};
getSectionSuggestions(section) {
return section.suggestions;
}
renderSectionTitle(section) {
return (
<div className={styles.sectionTitle}>
{section.title}
{
section.loading &&
<LoadingIndicator
className={styles.loading}
rippleClassName={styles.ripple}
size={20}
/>
}
</div>
);
}
getSuggestionValue({ title }) {
return title;
}
renderSuggestion(item, { query }) {
if (item.type === ADD_NEW_TYPE) {
return (
<div className={styles.addNewMovieSuggestion}>
{translate('SearchForQuery', { query })}
</div>
);
}
return (
<MovieSearchResult
{...item.item}
match={item.matches[0]}
/>
);
}
goToMovie(item) {
this.setState({ value: '' });
this.props.onGoToMovie(item.item.titleSlug);
}
reset() {
this.setState({
value: '',
suggestions: [],
loading: false
});
}
//
// Listeners
onChange = (event, { newValue, method }) => {
if (method === 'up' || method === 'down') {
return;
}
this.setState({ value: newValue });
};
onKeyDown = (event) => {
if (event.shiftKey || event.altKey || event.ctrlKey) {
return;
}
if (event.key === 'Escape') {
this.reset();
return;
}
if (event.key !== 'Tab' && event.key !== 'Enter') {
return;
}
const {
suggestions,
value
} = this.state;
const {
highlightedSectionIndex,
highlightedSuggestionIndex
} = this._autosuggest.state;
if (!suggestions.length || highlightedSectionIndex) {
this.props.onGoToAddNewMovie(value);
this._autosuggest.input.blur();
this.reset();
return;
}
// If an suggestion is not selected go to the first movie,
// otherwise go to the selected movie.
if (highlightedSuggestionIndex == null) {
this.goToMovie(suggestions[0]);
} else {
this.goToMovie(suggestions[highlightedSuggestionIndex]);
}
this._autosuggest.input.blur();
this.reset();
};
onBlur = () => {
this.reset();
};
onSuggestionsFetchRequested = ({ value }) => {
if (!this.state.loading) {
this.setState({
loading: true
});
}
this.requestSuggestions(value);
};
requestSuggestions = _.debounce((value) => {
if (!this.state.loading) {
return;
}
const requestLoading = this.state.requestLoading;
this.setState({
requestValue: value,
requestLoading: true
});
if (!requestLoading) {
const payload = {
value,
movies: this.props.movies
};
this.getWorker().postMessage(payload);
}
}, 250);
onSuggestionsReceived = (message) => {
const {
value,
suggestions
} = message.data;
if (!this.state.loading) {
this.setState({
requestValue: null,
requestLoading: false
});
} else if (value === this.state.requestValue) {
this.setState({
suggestions,
requestValue: null,
requestLoading: false,
loading: false
});
} else {
this.setState({
suggestions,
requestLoading: true
});
const payload = {
value: this.state.requestValue,
movies: this.props.movies
};
this.getWorker().postMessage(payload);
}
};
onSuggestionsClearRequested = () => {
this.setState({
suggestions: [],
loading: false
});
};
onSuggestionSelected = (event, { suggestion }) => {
if (suggestion.type === ADD_NEW_TYPE) {
this.props.onGoToAddNewMovie(this.state.value);
} else {
this.goToMovie(suggestion);
}
};
//
// Render
render() {
const {
value,
loading,
suggestions
} = this.state;
const suggestionGroups = [];
if (suggestions.length || loading) {
suggestionGroups.push({
title: translate('ExistingMovies'),
loading,
suggestions
});
}
suggestionGroups.push({
title: translate('AddNewMovie'),
suggestions: [
{
type: ADD_NEW_TYPE,
title: value
}
]
});
const inputProps = {
ref: this.setInputRef,
className: styles.input,
name: 'movieSearch',
value,
placeholder: translate('Search'),
autoComplete: 'off',
spellCheck: false,
onChange: this.onChange,
onKeyDown: this.onKeyDown,
onBlur: this.onBlur,
onFocus: this.onFocus
};
const theme = {
container: styles.container,
containerOpen: styles.containerOpen,
suggestionsContainer: styles.movieContainer,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted
};
return (
<div className={styles.wrapper}>
<Icon name={icons.SEARCH} />
<Autosuggest
ref={this.setAutosuggestRef}
id={name}
inputProps={inputProps}
theme={theme}
focusInputOnSuggestionClick={false}
multiSection={true}
suggestions={suggestionGroups}
getSectionSuggestions={this.getSectionSuggestions}
renderSectionTitle={this.renderSectionTitle}
getSuggestionValue={this.getSuggestionValue}
renderSuggestion={this.renderSuggestion}
onSuggestionSelected={this.onSuggestionSelected}
onSuggestionsFetchRequested={this.onSuggestionsFetchRequested}
onSuggestionsClearRequested={this.onSuggestionsClearRequested}
/>
</div>
);
}
}
MovieSearchInput.propTypes = {
movies: PropTypes.arrayOf(PropTypes.object).isRequired,
onGoToMovie: PropTypes.func.isRequired,
onGoToAddNewMovie: PropTypes.func.isRequired,
bindShortcut: PropTypes.func.isRequired
};
export default keyboardShortcuts(MovieSearchInput);

View File

@@ -0,0 +1,457 @@
import { push } from 'connected-react-router';
import { ExtendedKeyboardEvent } from 'mousetrap';
import React, {
FormEvent,
KeyboardEvent,
SyntheticEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import Autosuggest from 'react-autosuggest';
import { useDispatch, useSelector } from 'react-redux';
import { createSelector } from 'reselect';
import { useDebouncedCallback } from 'use-debounce';
import { Tag } from 'App/State/TagsAppState';
import Icon from 'Components/Icon';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import Movie from 'Movie/Movie';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import translate from 'Utilities/String/translate';
import MovieSearchResult from './MovieSearchResult';
import styles from './MovieSearchInput.css';
const ADD_NEW_TYPE = 'addNew';
interface Match {
key: string;
refIndex: number;
}
interface AddNewMovieSuggestion {
type: 'addNew';
title: string;
}
export interface SuggestedMovie
extends Pick<
Movie,
| 'title'
| 'year'
| 'titleSlug'
| 'sortTitle'
| 'images'
| 'alternateTitles'
| 'tmdbId'
| 'imdbId'
> {
firstCharacter: string;
tags: Tag[];
}
interface MovieSuggestion {
title: string;
indices: number[];
item: SuggestedMovie;
matches: Match[];
refIndex: number;
}
interface Section {
title: string;
loading?: boolean;
suggestions: MovieSuggestion[] | AddNewMovieSuggestion[];
}
function createUnoptimizedSelector() {
return createSelector(
createAllMoviesSelector(),
createTagsSelector(),
(allMovies, allTags) => {
return allMovies.map((movie): SuggestedMovie => {
const {
title,
year,
titleSlug,
sortTitle,
images,
alternateTitles = [],
tmdbId,
imdbId,
tags = [],
} = movie;
return {
title,
year,
titleSlug,
sortTitle,
images,
alternateTitles,
tmdbId,
imdbId,
firstCharacter: title.charAt(0).toLowerCase(),
tags: tags.reduce<Tag[]>((acc, id) => {
const matchingTag = allTags.find((tag) => tag.id === id);
if (matchingTag) {
acc.push(matchingTag);
}
return acc;
}, []),
};
});
}
);
}
function createMoviesSelector() {
return createDeepEqualSelector(
createUnoptimizedSelector(),
(movies) => movies
);
}
function MovieSearchInput() {
const movies = useSelector(createMoviesSelector());
const dispatch = useDispatch();
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
const [value, setValue] = useState('');
const [requestLoading, setRequestLoading] = useState(false);
const [suggestions, setSuggestions] = useState<MovieSuggestion[]>([]);
const autosuggestRef = useRef<Autosuggest>(null);
const inputRef = useRef<HTMLInputElement>(null);
const worker = useRef<Worker | null>(null);
const isLoading = useRef(false);
const requestValue = useRef<string | null>(null);
const suggestionGroups = useMemo(() => {
const result: Section[] = [];
if (suggestions.length || isLoading.current) {
result.push({
title: translate('ExistingMovies'),
loading: isLoading.current,
suggestions,
});
}
result.push({
title: translate('AddNewMovie'),
suggestions: [
{
type: ADD_NEW_TYPE,
title: value,
},
],
});
return result;
}, [suggestions, value]);
const handleSuggestionsReceived = useCallback(
(message: { data: { value: string; suggestions: MovieSuggestion[] } }) => {
const { value, suggestions } = message.data;
if (!isLoading.current) {
requestValue.current = null;
setRequestLoading(false);
} else if (value === requestValue.current) {
setSuggestions(suggestions);
requestValue.current = null;
setRequestLoading(false);
isLoading.current = false;
// setLoading(false);
} else {
setSuggestions(suggestions);
setRequestLoading(true);
const payload = {
value: requestValue,
movies,
};
worker.current?.postMessage(payload);
}
},
[movies]
);
const requestSuggestions = useDebouncedCallback((value: string) => {
if (!isLoading.current) {
return;
}
requestValue.current = value;
setRequestLoading(true);
if (!requestLoading) {
const payload = {
value,
movies,
};
worker.current?.postMessage(payload);
}
}, 250);
const reset = useCallback(() => {
setValue('');
setSuggestions([]);
// setLoading(false);
isLoading.current = false;
}, []);
const focusInput = useCallback((event: ExtendedKeyboardEvent) => {
event.preventDefault();
inputRef.current?.focus();
}, []);
const getSectionSuggestions = useCallback((section: Section) => {
return section.suggestions;
}, []);
const renderSectionTitle = useCallback((section: Section) => {
return (
<div className={styles.sectionTitle}>
{section.title}
{section.loading && (
<LoadingIndicator
className={styles.loading}
rippleClassName={styles.ripple}
size={20}
/>
)}
</div>
);
}, []);
const getSuggestionValue = useCallback(({ title }: { title: string }) => {
return title;
}, []);
const renderSuggestion = useCallback(
(
item: AddNewMovieSuggestion | MovieSuggestion,
{ query }: { query: string }
) => {
if ('type' in item) {
return (
<div className={styles.addNewMovieSuggestion}>
{translate('SearchForQuery', { query })}
</div>
);
}
return <MovieSearchResult {...item.item} match={item.matches[0]} />;
},
[]
);
const handleChange = useCallback(
(
_event: FormEvent<HTMLElement>,
{
newValue,
method,
}: {
newValue: string;
method: 'down' | 'up' | 'escape' | 'enter' | 'click' | 'type';
}
) => {
if (method === 'up' || method === 'down') {
return;
}
setValue(newValue);
},
[]
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLElement>) => {
if (event.shiftKey || event.altKey || event.ctrlKey) {
return;
}
if (event.key === 'Escape') {
reset();
return;
}
if (event.key !== 'Tab' && event.key !== 'Enter') {
return;
}
if (!autosuggestRef.current) {
return;
}
const { highlightedSectionIndex, highlightedSuggestionIndex } =
autosuggestRef.current.state;
if (!suggestions.length || highlightedSectionIndex) {
dispatch(
push(
`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
)
);
inputRef.current?.blur();
reset();
return;
}
// If a suggestion is not selected go to the first movie,
// otherwise go to the selected movie.
const selectedSuggestion =
highlightedSuggestionIndex == null
? suggestions[0]
: suggestions[highlightedSuggestionIndex];
dispatch(
push(
`${window.Radarr.urlBase}/movie/${selectedSuggestion.item.titleSlug}`
)
);
inputRef.current?.blur();
reset();
},
[value, suggestions, dispatch, reset]
);
const handleBlur = useCallback(() => {
reset();
}, [reset]);
const handleSuggestionsFetchRequested = useCallback(
({ value }: { value: string }) => {
isLoading.current = true;
requestSuggestions(value);
},
[requestSuggestions]
);
const handleSuggestionsClearRequested = useCallback(() => {
setSuggestions([]);
isLoading.current = false;
}, []);
const handleSuggestionSelected = useCallback(
(
_event: SyntheticEvent,
{ suggestion }: { suggestion: MovieSuggestion | AddNewMovieSuggestion }
) => {
if ('type' in suggestion) {
dispatch(
push(
`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(value)}`
)
);
} else {
setValue('');
dispatch(
push(`${window.Radarr.urlBase}/movie/${suggestion.item.titleSlug}`)
);
}
},
[value, dispatch]
);
const inputProps = {
ref: inputRef,
className: styles.input,
name: 'movieSearch',
value,
placeholder: translate('Search'),
autoComplete: 'off',
spellCheck: false,
onChange: handleChange,
onKeyDown: handleKeyDown,
onBlur: handleBlur,
};
const theme = {
container: styles.container,
containerOpen: styles.containerOpen,
suggestionsContainer: styles.movieContainer,
suggestionsList: styles.list,
suggestion: styles.listItem,
suggestionHighlighted: styles.highlighted,
};
useEffect(() => {
worker.current = new Worker(new URL('./fuse.worker.ts', import.meta.url));
return () => {
if (worker.current) {
worker.current.terminate();
worker.current = null;
}
};
}, []);
useEffect(() => {
worker.current?.addEventListener(
'message',
handleSuggestionsReceived,
false
);
return () => {
if (worker.current) {
worker.current.removeEventListener(
'message',
handleSuggestionsReceived,
false
);
}
};
}, [handleSuggestionsReceived]);
useEffect(() => {
bindShortcut('focusMovieSearchInput', focusInput);
return () => {
unbindShortcut('focusMovieSearchInput');
};
}, [bindShortcut, unbindShortcut, focusInput]);
return (
<div className={styles.wrapper}>
<Icon name={icons.SEARCH} />
<Autosuggest
ref={autosuggestRef}
inputProps={inputProps}
theme={theme}
focusInputOnSuggestionClick={false}
multiSection={true}
suggestions={suggestionGroups}
getSectionSuggestions={getSectionSuggestions}
renderSectionTitle={renderSectionTitle}
getSuggestionValue={getSuggestionValue}
renderSuggestion={renderSuggestion}
onSuggestionSelected={handleSuggestionSelected}
onSuggestionsFetchRequested={handleSuggestionsFetchRequested}
onSuggestionsClearRequested={handleSuggestionsClearRequested}
/>
</div>
);
}
export default MovieSearchInput;

View File

@@ -1,75 +0,0 @@
import { push } from 'connected-react-router';
import { connect } from 'react-redux';
import { createSelector } from 'reselect';
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
import createDeepEqualSelector from 'Store/Selectors/createDeepEqualSelector';
import createTagsSelector from 'Store/Selectors/createTagsSelector';
import MovieSearchInput from './MovieSearchInput';
function createCleanMovieSelector() {
return createSelector(
createAllMoviesSelector(),
createTagsSelector(),
(allMovies, allTags) => {
return allMovies.map((movie) => {
const {
title,
titleSlug,
sortTitle,
year,
images,
alternateTitles = [],
tmdbId,
imdbId,
tags = []
} = movie;
return {
title,
titleSlug,
sortTitle,
year,
images,
alternateTitles,
tmdbId,
imdbId,
firstCharacter: title.charAt(0).toLowerCase(),
tags: tags.reduce((acc, id) => {
const matchingTag = allTags.find((tag) => tag.id === id);
if (matchingTag) {
acc.push(matchingTag);
}
return acc;
}, [])
};
});
}
);
}
function createMapStateToProps() {
return createDeepEqualSelector(
createCleanMovieSelector(),
(movies) => {
return {
movies
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
onGoToMovie(titleSlug) {
dispatch(push(`${window.Radarr.urlBase}/movie/${titleSlug}`));
},
onGoToAddNewMovie(query) {
dispatch(push(`${window.Radarr.urlBase}/add/new?term=${encodeURIComponent(query)}`));
}
};
}
export default connect(createMapStateToProps, createMapDispatchToProps)(MovieSearchInput);

View File

@@ -1,96 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import MoviePoster from 'Movie/MoviePoster';
import styles from './MovieSearchResult.css';
function MovieSearchResult(props) {
const {
match,
title,
year,
images,
alternateTitles,
tmdbId,
imdbId,
tags
} = props;
let alternateTitle = null;
let tag = null;
if (match.key === 'alternateTitles.title') {
alternateTitle = alternateTitles[match.refIndex];
} else if (match.key === 'tags.label') {
tag = tags[match.refIndex];
}
return (
<div className={styles.result}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
lazy={false}
overflow={true}
/>
<div className={styles.titles}>
<div className={styles.title}>
{title} { year > 0 ? `(${year})` : ''}
</div>
{
alternateTitle ?
<div className={styles.alternateTitle}>
{alternateTitle.title}
</div> :
null
}
{
match.key === 'tmdbId' && tmdbId ?
<div className={styles.alternateTitle}>
TmdbId: {tmdbId}
</div> :
null
}
{
match.key === 'imdbId' && imdbId ?
<div className={styles.alternateTitle}>
ImdbId: {imdbId}
</div> :
null
}
{
tag ?
<div className={styles.tagContainer}>
<Label
key={tag.id}
kind={kinds.INFO}
>
{tag.label}
</Label>
</div> :
null
}
</div>
</div>
);
}
MovieSearchResult.propTypes = {
title: PropTypes.string.isRequired,
year: PropTypes.number.isRequired,
images: PropTypes.arrayOf(PropTypes.object).isRequired,
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
tmdbId: PropTypes.number,
imdbId: PropTypes.string,
tags: PropTypes.arrayOf(PropTypes.object).isRequired,
match: PropTypes.object.isRequired
};
export default MovieSearchResult;

View File

@@ -0,0 +1,70 @@
import React from 'react';
import { Tag } from 'App/State/TagsAppState';
import Label from 'Components/Label';
import { kinds } from 'Helpers/Props';
import MoviePoster from 'Movie/MoviePoster';
import { SuggestedMovie } from './MovieSearchInput';
import styles from './MovieSearchResult.css';
interface Match {
key: string;
refIndex: number;
}
interface MovieSearchResultProps extends SuggestedMovie {
match: Match;
}
function MovieSearchResult(props: MovieSearchResultProps) {
const { match, title, year, images, alternateTitles, tmdbId, imdbId, tags } =
props;
let alternateTitle = null;
let tag: Tag | null = null;
if (match.key === 'alternateTitles.title') {
alternateTitle = alternateTitles[match.refIndex];
} else if (match.key === 'tags.label') {
tag = tags[match.refIndex];
}
return (
<div className={styles.result}>
<MoviePoster
className={styles.poster}
images={images}
size={250}
lazy={false}
overflow={true}
/>
<div className={styles.titles}>
<div className={styles.title}>
{title} {year > 0 ? `(${year})` : ''}
</div>
{alternateTitle ? (
<div className={styles.alternateTitle}>{alternateTitle.title}</div>
) : null}
{match.key === 'tmdbId' && tmdbId ? (
<div className={styles.alternateTitle}>TmdbId: {tmdbId}</div>
) : null}
{match.key === 'imdbId' && imdbId ? (
<div className={styles.alternateTitle}>ImdbId: {imdbId}</div>
) : null}
{tag ? (
<div className={styles.tagContainer}>
<Label key={tag.id} kind={kinds.INFO}>
{tag.label}
</Label>
</div>
) : null}
</div>
</div>
);
}
export default MovieSearchResult;

View File

@@ -15,6 +15,10 @@
padding-left: 20px;
}
.logoLink {
line-height: 0;
}
.logoFull,
.logo {
vertical-align: middle;

View File

@@ -6,6 +6,7 @@ interface CssExports {
'logo': string;
'logoContainer': string;
'logoFull': string;
'logoLink': string;
'right': string;
'sidebarToggleContainer': string;
'translate': string;

View File

@@ -1,116 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import { icons } from 'Helpers/Props';
import translate from 'Utilities/String/translate';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import MovieSearchInputConnector from './MovieSearchInputConnector';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import styles from './PageHeader.css';
class PageHeader extends Component {
//
// Lifecycle
constructor(props, context) {
super(props);
this.state = {
isKeyboardShortcutsModalOpen: false
};
}
componentDidMount() {
this.props.bindShortcut(shortcuts.OPEN_KEYBOARD_SHORTCUTS_MODAL.key, this.onOpenKeyboardShortcutsModal);
}
//
// Control
onOpenKeyboardShortcutsModal = () => {
this.setState({ isKeyboardShortcutsModalOpen: true });
};
//
// Listeners
onKeyboardShortcutsModalClose = () => {
this.setState({ isKeyboardShortcutsModalOpen: false });
};
//
// Render
render() {
const {
onSidebarToggle,
isSmallScreen
} = this.props;
return (
<div className={styles.header}>
<div className={styles.logoContainer}>
<Link
className={styles.logoLink}
to={'/'}
>
<img
className={isSmallScreen ? styles.logo : styles.logoFull}
src={isSmallScreen ? `${window.Radarr.urlBase}/Content/Images/logo.png` : `${window.Radarr.urlBase}/Content/Images/logo-full.png`}
alt="Radarr Logo"
/>
</Link>
</div>
<div className={styles.sidebarToggleContainer}>
<IconButton
id="sidebar-toggle-button"
name={icons.NAVBAR_COLLAPSE}
onPress={onSidebarToggle}
/>
</div>
<MovieSearchInputConnector />
<div className={styles.right}>
<IconButton
className={styles.donate}
name={icons.HEART}
aria-label={translate('Donate')}
to="https://radarr.video/donate"
size={14}
title={translate('Donate')}
/>
<IconButton
className={styles.translate}
title={translate('SuggestTranslationChange')}
name={icons.TRANSLATE}
to="https://translate.servarr.com/projects/radarr/radarr/"
size={24}
/>
<PageHeaderActionsMenu
onKeyboardShortcutsPress={this.onOpenKeyboardShortcutsModal}
/>
</div>
<KeyboardShortcutsModal
isOpen={this.state.isKeyboardShortcutsModalOpen}
onModalClose={this.onKeyboardShortcutsModalClose}
/>
</div>
);
}
}
PageHeader.propTypes = {
onSidebarToggle: PropTypes.func.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
bindShortcut: PropTypes.func.isRequired
};
export default keyboardShortcuts(PageHeader);

View File

@@ -0,0 +1,109 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppState from 'App/State/AppState';
import IconButton from 'Components/Link/IconButton';
import Link from 'Components/Link/Link';
import useKeyboardShortcuts from 'Helpers/Hooks/useKeyboardShortcuts';
import { icons } from 'Helpers/Props';
import { setIsSidebarVisible } from 'Store/Actions/appActions';
import translate from 'Utilities/String/translate';
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
import MovieSearchInput from './MovieSearchInput';
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
import styles from './PageHeader.css';
interface PageHeaderProps {
isSmallScreen: boolean;
}
function PageHeader({ isSmallScreen }: PageHeaderProps) {
const dispatch = useDispatch();
const { isSidebarVisible } = useSelector((state: AppState) => state.app);
const [isKeyboardShortcutsModalOpen, setIsKeyboardShortcutsModalOpen] =
useState(false);
const { bindShortcut, unbindShortcut } = useKeyboardShortcuts();
const handleSidebarToggle = useCallback(() => {
dispatch(setIsSidebarVisible({ isSidebarVisible: !isSidebarVisible }));
}, [isSidebarVisible, dispatch]);
const handleOpenKeyboardShortcutsModal = useCallback(() => {
setIsKeyboardShortcutsModalOpen(true);
}, []);
const handleKeyboardShortcutsModalClose = useCallback(() => {
setIsKeyboardShortcutsModalOpen(false);
}, []);
useEffect(() => {
bindShortcut(
'openKeyboardShortcutsModal',
handleOpenKeyboardShortcutsModal
);
return () => {
unbindShortcut('openKeyboardShortcutsModal');
};
}, [handleOpenKeyboardShortcutsModal, bindShortcut, unbindShortcut]);
return (
<div className={styles.header}>
<div className={styles.logoContainer}>
<Link className={styles.logoLink} to="/">
<img
className={isSmallScreen ? styles.logo : styles.logoFull}
src={
isSmallScreen
? `${window.Radarr.urlBase}/Content/Images/logo.png`
: `${window.Radarr.urlBase}/Content/Images/logo-full.png`
}
alt="Radarr Logo"
/>
</Link>
</div>
<div className={styles.sidebarToggleContainer}>
<IconButton
id="sidebar-toggle-button"
name={icons.NAVBAR_COLLAPSE}
onPress={handleSidebarToggle}
/>
</div>
<MovieSearchInput />
<div className={styles.right}>
<IconButton
className={styles.donate}
name={icons.HEART}
aria-label={translate('Donate')}
to="https://radarr.video/donate"
size={14}
title={translate('Donate')}
/>
<IconButton
className={styles.translate}
title={translate('SuggestTranslationChange')}
name={icons.TRANSLATE}
to="https://translate.servarr.com/projects/radarr/radarr/"
size={24}
/>
<PageHeaderActionsMenu
onKeyboardShortcutsPress={handleOpenKeyboardShortcutsModal}
/>
</div>
<KeyboardShortcutsModal
isOpen={isKeyboardShortcutsModalOpen}
onModalClose={handleKeyboardShortcutsModalClose}
/>
</div>
);
}
export default PageHeader;

View File

@@ -1,4 +1,5 @@
import Fuse from 'fuse.js';
import { SuggestedMovie } from './MovieSearchInput';
const fuseOptions = {
shouldSort: true,
@@ -6,35 +7,27 @@ const fuseOptions = {
ignoreLocation: true,
threshold: 0.3,
minMatchCharLength: 1,
keys: [
'title',
'alternateTitles.title',
'tmdbId',
'imdbId',
'tags.label'
]
keys: ['title', 'alternateTitles.title', 'tmdbId', 'imdbId', 'tags.label'],
};
function getSuggestions(movies, value) {
function getSuggestions(movies: SuggestedMovie[], value: string) {
const limit = 10;
let suggestions = [];
if (value.length === 1) {
for (let i = 0; i < movies.length; i++) {
const s = movies[i];
if (s.firstCharacter === value.toLowerCase()) {
const m = movies[i];
if (m.firstCharacter === value.toLowerCase()) {
suggestions.push({
item: movies[i],
indices: [
[0, 0]
],
indices: [[0, 0]],
matches: [
{
value: s.title,
key: 'title'
}
value: m.title,
key: 'title',
},
],
refIndex: 0
refIndex: 0,
});
if (suggestions.length > limit) {
break;
@@ -49,21 +42,18 @@ function getSuggestions(movies, value) {
return suggestions;
}
onmessage = function(e) {
onmessage = function (e) {
if (!e) {
return;
}
const {
movies,
value
} = e.data;
const { movies, value } = e.data;
const suggestions = getSuggestions(movies, value);
const results = {
value,
suggestions
suggestions,
};
self.postMessage(results);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,143 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import AppUpdatedModal from 'App/AppUpdatedModal';
import ColorImpairedContext from 'App/ColorImpairedContext';
import ConnectionLostModal from 'App/ConnectionLostModal';
import SignalRConnector from 'Components/SignalRConnector';
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
import locationShape from 'Helpers/Props/Shapes/locationShape';
import PageHeader from './Header/PageHeader';
import PageSidebar from './Sidebar/PageSidebar';
import styles from './Page.css';
class Page extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isUpdatedModalOpen: false,
isConnectionLostModalOpen: false
};
}
componentDidMount() {
window.addEventListener('resize', this.onResize);
}
componentDidUpdate(prevProps) {
const {
isDisconnected,
isUpdated
} = this.props;
if (!prevProps.isUpdated && isUpdated) {
this.setState({ isUpdatedModalOpen: true });
}
if (prevProps.isDisconnected !== isDisconnected) {
this.setState({ isConnectionLostModalOpen: isDisconnected });
}
}
componentWillUnmount() {
window.removeEventListener('resize', this.onResize);
}
//
// Listeners
onResize = () => {
this.props.onResize({
width: window.innerWidth,
height: window.innerHeight
});
};
onUpdatedModalClose = () => {
this.setState({ isUpdatedModalOpen: false });
};
onConnectionLostModalClose = () => {
this.setState({ isConnectionLostModalOpen: false });
};
//
// Render
render() {
const {
className,
location,
children,
isSmallScreen,
isSidebarVisible,
enableColorImpairedMode,
authenticationEnabled,
onSidebarToggle,
onSidebarVisibleChange
} = this.props;
return (
<ColorImpairedContext.Provider value={enableColorImpairedMode}>
<div className={className}>
<SignalRConnector />
<PageHeader
onSidebarToggle={onSidebarToggle}
isSmallScreen={isSmallScreen}
/>
<div className={styles.main}>
<PageSidebar
location={location}
isSmallScreen={isSmallScreen}
isSidebarVisible={isSidebarVisible}
onSidebarVisibleChange={onSidebarVisibleChange}
/>
{children}
</div>
<AppUpdatedModal
isOpen={this.state.isUpdatedModalOpen}
onModalClose={this.onUpdatedModalClose}
/>
<ConnectionLostModal
isOpen={this.state.isConnectionLostModalOpen}
onModalClose={this.onConnectionLostModalClose}
/>
<AuthenticationRequiredModal
isOpen={!authenticationEnabled}
/>
</div>
</ColorImpairedContext.Provider>
);
}
}
Page.propTypes = {
className: PropTypes.string,
location: locationShape.isRequired,
children: PropTypes.node.isRequired,
isSmallScreen: PropTypes.bool.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
isUpdated: PropTypes.bool.isRequired,
isDisconnected: PropTypes.bool.isRequired,
enableColorImpairedMode: PropTypes.bool.isRequired,
authenticationEnabled: PropTypes.bool.isRequired,
onResize: PropTypes.func.isRequired,
onSidebarToggle: PropTypes.func.isRequired,
onSidebarVisibleChange: PropTypes.func.isRequired
};
Page.defaultProps = {
className: styles.page
};
export default Page;

View File

@@ -0,0 +1,116 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import AppUpdatedModal from 'App/AppUpdatedModal';
import ColorImpairedContext from 'App/ColorImpairedContext';
import ConnectionLostModal from 'App/ConnectionLostModal';
import AppState from 'App/State/AppState';
import SignalRConnector from 'Components/SignalRConnector';
import AuthenticationRequiredModal from 'FirstRun/AuthenticationRequiredModal';
import useAppPage from 'Helpers/Hooks/useAppPage';
import { saveDimensions } from 'Store/Actions/appActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import createUISettingsSelector from 'Store/Selectors/createUISettingsSelector';
import ErrorPage from './ErrorPage';
import PageHeader from './Header/PageHeader';
import LoadingPage from './LoadingPage';
import PageSidebar from './Sidebar/PageSidebar';
import styles from './Page.css';
interface PageProps {
children: React.ReactNode;
}
function Page({ children }: PageProps) {
const dispatch = useDispatch();
const { hasError, errors, isPopulated, isLocalStorageSupported } =
useAppPage();
const [isUpdatedModalOpen, setIsUpdatedModalOpen] = useState(false);
const [isConnectionLostModalOpen, setIsConnectionLostModalOpen] =
useState(false);
const { enableColorImpairedMode } = useSelector(createUISettingsSelector());
const { isSmallScreen } = useSelector(createDimensionsSelector());
const { authentication } = useSelector(createSystemStatusSelector());
const authenticationEnabled = authentication !== 'none';
const { isSidebarVisible, isUpdated, isDisconnected, version } = useSelector(
(state: AppState) => state.app
);
const handleUpdatedModalClose = useCallback(() => {
setIsUpdatedModalOpen(false);
}, []);
const handleResize = useCallback(() => {
dispatch(
saveDimensions({
width: window.innerWidth,
height: window.innerHeight,
})
);
}, [dispatch]);
useEffect(() => {
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
};
}, [handleResize]);
useEffect(() => {
if (isDisconnected) {
setIsConnectionLostModalOpen(true);
}
}, [isDisconnected]);
useEffect(() => {
if (isUpdated) {
setIsUpdatedModalOpen(true);
}
}, [isUpdated]);
if (hasError || !isLocalStorageSupported) {
return (
<ErrorPage
{...errors}
version={version}
isLocalStorageSupported={isLocalStorageSupported}
/>
);
}
if (!isPopulated) {
return <LoadingPage />;
}
return (
<ColorImpairedContext.Provider value={enableColorImpairedMode}>
<div className={styles.page}>
<SignalRConnector />
<PageHeader isSmallScreen={isSmallScreen} />
<div className={styles.main}>
<PageSidebar
isSmallScreen={isSmallScreen}
isSidebarVisible={isSidebarVisible}
/>
{children}
</div>
<AppUpdatedModal
isOpen={isUpdatedModalOpen}
onModalClose={handleUpdatedModalClose}
/>
<ConnectionLostModal isOpen={isConnectionLostModalOpen} />
<AuthenticationRequiredModal isOpen={!authenticationEnabled} />
</div>
</ColorImpairedContext.Provider>
);
}
export default Page;

View File

@@ -1,315 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';
import { createSelector } from 'reselect';
import { fetchTranslations, saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
import { fetchMovies } from 'Store/Actions/movieActions';
import { fetchMovieCollections } from 'Store/Actions/movieCollectionActions';
import { fetchImportLists, fetchIndexerFlags, fetchLanguages, fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions';
import { fetchStatus } from 'Store/Actions/systemActions';
import { fetchTags } from 'Store/Actions/tagActions';
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
import createSystemStatusSelector from 'Store/Selectors/createSystemStatusSelector';
import ErrorPage from './ErrorPage';
import LoadingPage from './LoadingPage';
import Page from './Page';
function testLocalStorage() {
const key = 'radarrTest';
try {
localStorage.setItem(key, key);
localStorage.removeItem(key);
return true;
} catch (e) {
return false;
}
}
const selectAppProps = createSelector(
(state) => state.app.isSidebarVisible,
(state) => state.app.version,
(state) => state.app.isUpdated,
(state) => state.app.isDisconnected,
(isSidebarVisible, version, isUpdated, isDisconnected) => {
return {
isSidebarVisible,
version,
isUpdated,
isDisconnected
};
}
);
const selectIsPopulated = createSelector(
(state) => state.movies.isPopulated,
(state) => state.customFilters.isPopulated,
(state) => state.tags.isPopulated,
(state) => state.settings.ui.isPopulated,
(state) => state.settings.qualityProfiles.isPopulated,
(state) => state.settings.languages.isPopulated,
(state) => state.settings.indexerFlags.isPopulated,
(state) => state.settings.importLists.isPopulated,
(state) => state.system.status.isPopulated,
(state) => state.movieCollections.isPopulated,
(state) => state.app.translations.isPopulated,
(
moviesIsPopulated,
customFiltersIsPopulated,
tagsIsPopulated,
uiSettingsIsPopulated,
qualityProfilesIsPopulated,
languagesIsPopulated,
indexerFlagsIsPopulated,
importListsIsPopulated,
systemStatusIsPopulated,
movieCollectionsIsPopulated,
translationsIsPopulated
) => {
return (
moviesIsPopulated &&
customFiltersIsPopulated &&
tagsIsPopulated &&
uiSettingsIsPopulated &&
qualityProfilesIsPopulated &&
languagesIsPopulated &&
indexerFlagsIsPopulated &&
importListsIsPopulated &&
systemStatusIsPopulated &&
movieCollectionsIsPopulated &&
translationsIsPopulated
);
}
);
const selectErrors = createSelector(
(state) => state.movies.error,
(state) => state.customFilters.error,
(state) => state.tags.error,
(state) => state.settings.ui.error,
(state) => state.settings.qualityProfiles.error,
(state) => state.settings.languages.error,
(state) => state.settings.indexerFlags.error,
(state) => state.settings.importLists.error,
(state) => state.system.status.error,
(state) => state.movieCollections.error,
(state) => state.app.translations.error,
(
moviesError,
customFiltersError,
tagsError,
uiSettingsError,
qualityProfilesError,
languagesError,
indexerFlagsError,
importListsError,
systemStatusError,
movieCollectionsError,
translationsError
) => {
const hasError = !!(
moviesError ||
customFiltersError ||
tagsError ||
uiSettingsError ||
qualityProfilesError ||
languagesError ||
indexerFlagsError ||
importListsError ||
systemStatusError ||
movieCollectionsError ||
translationsError
);
return {
hasError,
customFiltersError,
tagsError,
uiSettingsError,
qualityProfilesError,
languagesError,
indexerFlagsError,
importListsError,
systemStatusError,
movieCollectionsError,
translationsError
};
}
);
function createMapStateToProps() {
return createSelector(
(state) => state.settings.ui.item.enableColorImpairedMode,
selectIsPopulated,
selectErrors,
selectAppProps,
createDimensionsSelector(),
createSystemStatusSelector(),
(
enableColorImpairedMode,
isPopulated,
errors,
app,
dimensions,
systemStatus
) => {
return {
...app,
...errors,
isPopulated,
isSmallScreen: dimensions.isSmallScreen,
authenticationEnabled: systemStatus.authentication !== 'none',
enableColorImpairedMode
};
}
);
}
function createMapDispatchToProps(dispatch, props) {
return {
dispatchFetchMovies() {
dispatch(fetchMovies());
},
dispatchFetchMovieCollections() {
dispatch(fetchMovieCollections());
},
dispatchFetchCustomFilters() {
dispatch(fetchCustomFilters());
},
dispatchFetchTags() {
dispatch(fetchTags());
},
dispatchFetchQualityProfiles() {
dispatch(fetchQualityProfiles());
},
dispatchFetchLanguages() {
dispatch(fetchLanguages());
},
dispatchFetchIndexerFlags() {
dispatch(fetchIndexerFlags());
},
dispatchFetchImportLists() {
dispatch(fetchImportLists());
},
dispatchFetchUISettings() {
dispatch(fetchUISettings());
},
dispatchFetchStatus() {
dispatch(fetchStatus());
},
dispatchFetchTranslations() {
dispatch(fetchTranslations());
},
onResize(dimensions) {
dispatch(saveDimensions(dimensions));
},
onSidebarVisibleChange(isSidebarVisible) {
dispatch(setIsSidebarVisible({ isSidebarVisible }));
}
};
}
class PageConnector extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
isLocalStorageSupported: testLocalStorage()
};
}
componentDidMount() {
if (!this.props.isPopulated) {
this.props.dispatchFetchMovies();
this.props.dispatchFetchMovieCollections();
this.props.dispatchFetchCustomFilters();
this.props.dispatchFetchTags();
this.props.dispatchFetchQualityProfiles();
this.props.dispatchFetchLanguages();
this.props.dispatchFetchIndexerFlags();
this.props.dispatchFetchImportLists();
this.props.dispatchFetchUISettings();
this.props.dispatchFetchStatus();
this.props.dispatchFetchTranslations();
}
}
//
// Listeners
onSidebarToggle = () => {
this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
};
//
// Render
render() {
const {
isPopulated,
hasError,
dispatchFetchMovies,
dispatchFetchMovieCollections,
dispatchFetchTags,
dispatchFetchQualityProfiles,
dispatchFetchLanguages,
dispatchFetchIndexerFlags,
dispatchFetchImportLists,
dispatchFetchUISettings,
dispatchFetchStatus,
dispatchFetchTranslations,
...otherProps
} = this.props;
if (hasError || !this.state.isLocalStorageSupported) {
return (
<ErrorPage
{...this.state}
{...otherProps}
/>
);
}
if (isPopulated) {
return (
<Page
{...otherProps}
onSidebarToggle={this.onSidebarToggle}
/>
);
}
return (
<LoadingPage />
);
}
}
PageConnector.propTypes = {
isPopulated: PropTypes.bool.isRequired,
hasError: PropTypes.bool.isRequired,
isSidebarVisible: PropTypes.bool.isRequired,
dispatchFetchMovies: PropTypes.func.isRequired,
dispatchFetchMovieCollections: PropTypes.func.isRequired,
dispatchFetchCustomFilters: PropTypes.func.isRequired,
dispatchFetchTags: PropTypes.func.isRequired,
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
dispatchFetchLanguages: PropTypes.func.isRequired,
dispatchFetchIndexerFlags: PropTypes.func.isRequired,
dispatchFetchImportLists: PropTypes.func.isRequired,
dispatchFetchUISettings: PropTypes.func.isRequired,
dispatchFetchStatus: PropTypes.func.isRequired,
dispatchFetchTranslations: PropTypes.func.isRequired,
onSidebarVisibleChange: PropTypes.func.isRequired
};
export default withRouter(
connect(createMapStateToProps, createMapDispatchToProps)(PageConnector)
);

View File

@@ -1,36 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import DocumentTitle from 'react-document-title';
import ErrorBoundary from 'Components/Error/ErrorBoundary';
import PageContentError from './PageContentError';
import styles from './PageContent.css';
function PageContent(props) {
const {
className,
title,
children
} = props;
return (
<ErrorBoundary errorComponent={PageContentError}>
<DocumentTitle title={title ? `${title} - ${window.Radarr.instanceName}` : window.Radarr.instanceName}>
<div className={className}>
{children}
</div>
</DocumentTitle>
</ErrorBoundary>
);
}
PageContent.propTypes = {
className: PropTypes.string,
title: PropTypes.string,
children: PropTypes.node.isRequired
};
PageContent.defaultProps = {
className: styles.content
};
export default PageContent;

View File

@@ -0,0 +1,33 @@
import React from 'react';
import DocumentTitle from 'react-document-title';
import ErrorBoundary from 'Components/Error/ErrorBoundary';
import PageContentError from './PageContentError';
import styles from './PageContent.css';
interface PageContentProps {
className?: string;
title?: string;
children: React.ReactNode;
}
function PageContent({
className = styles.content,
title,
children,
}: PageContentProps) {
return (
<ErrorBoundary errorComponent={PageContentError}>
<DocumentTitle
title={
title
? `${title} - ${window.Radarr.instanceName}`
: window.Radarr.instanceName
}
>
<div className={className}>{children}</div>
</DocumentTitle>
</ErrorBoundary>
);
}
export default PageContent;

View File

@@ -1,10 +1,12 @@
import React from 'react';
import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
import ErrorBoundaryError, {
ErrorBoundaryErrorProps,
} from 'Components/Error/ErrorBoundaryError';
import translate from 'Utilities/String/translate';
import PageContentBody from './PageContentBody';
import styles from './PageContentError.css';
function PageContentError(props) {
function PageContentError(props: ErrorBoundaryErrorProps) {
return (
<div className={styles.content}>
<PageContentBody>

View File

@@ -1,33 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import styles from './PageContentFooter.css';
class PageContentFooter extends Component {
//
// Render
render() {
const {
className,
children
} = this.props;
return (
<div className={className}>
{children}
</div>
);
}
}
PageContentFooter.propTypes = {
className: PropTypes.string,
children: PropTypes.node.isRequired
};
PageContentFooter.defaultProps = {
className: styles.contentFooter
};
export default PageContentFooter;

View File

@@ -0,0 +1,16 @@
import React from 'react';
import styles from './PageContentFooter.css';
interface PageContentFooterProps {
className?: string;
children: React.ReactNode;
}
function PageContentFooter({
className = styles.contentFooter,
children,
}: PageContentFooterProps) {
return <div className={className}>{children}</div>;
}
export default PageContentFooter;

View File

@@ -1,160 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Measure from 'Components/Measure';
import dimensions from 'Styles/Variables/dimensions';
import PageJumpBarItem from './PageJumpBarItem';
import styles from './PageJumpBar.css';
const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
class PageJumpBar extends Component {
//
// Lifecycle
constructor(props, context) {
super(props, context);
this.state = {
height: 0,
visibleItems: props.items.order
};
}
componentDidMount() {
this.computeVisibleItems();
}
shouldComponentUpdate(nextProps, nextState) {
return (
nextProps.items !== this.props.items ||
nextState.height !== this.state.height ||
nextState.visibleItems !== this.state.visibleItems
);
}
componentDidUpdate(prevProps, prevState) {
if (
prevProps.items !== this.props.items ||
prevState.height !== this.state.height
) {
this.computeVisibleItems();
}
}
//
// Control
computeVisibleItems() {
const {
items,
minimumItems
} = this.props;
if (!items) {
return;
}
const {
characters,
order
} = items;
const height = this.state.height;
const maximumItems = Math.floor(height / ITEM_HEIGHT);
const diff = order.length - maximumItems;
if (diff < 0) {
this.setState({ visibleItems: order });
return;
}
if (order.length < minimumItems) {
this.setState({ visibleItems: order });
return;
}
// get first, last, and most common in between to make up numbers
const visibleItems = [order[0]];
const sorted = order.slice(1, -1).map((x) => characters[x]).sort((a, b) => b - a);
const minCount = sorted[maximumItems - 3];
const greater = sorted.reduce((acc, value) => acc + (value > minCount ? 1 : 0), 0);
let minAllowed = maximumItems - 2 - greater;
for (let i = 1; i < order.length - 1; i++) {
if (characters[order[i]] > minCount) {
visibleItems.push(order[i]);
} else if (characters[order[i]] === minCount && minAllowed > 0) {
visibleItems.push(order[i]);
minAllowed--;
}
}
visibleItems.push(order[order.length - 1]);
this.setState({ visibleItems });
}
//
// Listeners
onMeasure = ({ height }) => {
if (height > 0) {
this.setState({ height });
}
};
//
// Render
render() {
const {
minimumItems,
onItemPress
} = this.props;
const {
visibleItems
} = this.state;
if (!visibleItems.length || visibleItems.length < minimumItems) {
return null;
}
return (
<div className={styles.jumpBar}>
<Measure
whitelist={['height']}
onMeasure={this.onMeasure}
>
<div className={styles.jumpBarItems}>
{
visibleItems.map((item) => {
return (
<PageJumpBarItem
key={item}
label={item}
onItemPress={onItemPress}
/>
);
})
}
</div>
</Measure>
</div>
);
}
}
PageJumpBar.propTypes = {
items: PropTypes.object.isRequired,
minimumItems: PropTypes.number.isRequired,
onItemPress: PropTypes.func.isRequired
};
PageJumpBar.defaultProps = {
minimumItems: 5
};
export default PageJumpBar;

View File

@@ -0,0 +1,90 @@
import React, { useMemo } from 'react';
import useMeasure from 'Helpers/Hooks/useMeasure';
import dimensions from 'Styles/Variables/dimensions';
import PageJumpBarItem, { PageJumpBarItemProps } from './PageJumpBarItem';
import styles from './PageJumpBar.css';
const ITEM_HEIGHT = parseInt(dimensions.jumpBarItemHeight);
export interface PageJumpBarItems {
characters: Record<string, number>;
order: string[];
}
interface PageJumpBarProps {
items: PageJumpBarItems;
minimumItems?: number;
onItemPress: PageJumpBarItemProps['onItemPress'];
}
function PageJumpBar({
items,
minimumItems = 5,
onItemPress,
}: PageJumpBarProps) {
const [jumpBarRef, { height }] = useMeasure();
const visibleItems = useMemo(() => {
const { characters, order } = items;
const maximumItems = Math.floor(height / ITEM_HEIGHT);
const diff = order.length - maximumItems;
if (diff < 0) {
return order;
}
if (order.length < minimumItems) {
return order;
}
// get first, last, and most common in between to make up numbers
const result = [order[0]];
const sorted = order
.slice(1, -1)
.map((x) => characters[x])
.sort((a, b) => b - a);
const minCount = sorted[maximumItems - 3];
const greater = sorted.reduce(
(acc, value) => acc + (value > minCount ? 1 : 0),
0
);
let minAllowed = maximumItems - 2 - greater;
for (let i = 1; i < order.length - 1; i++) {
if (characters[order[i]] > minCount) {
result.push(order[i]);
} else if (characters[order[i]] === minCount && minAllowed > 0) {
result.push(order[i]);
minAllowed--;
}
}
result.push(order[order.length - 1]);
return result;
}, [items, height, minimumItems]);
if (!items.order.length || items.order.length < minimumItems) {
return null;
}
return (
<div ref={jumpBarRef} className={styles.jumpBar}>
<div className={styles.jumpBarItems}>
{visibleItems.map((item) => {
return (
<PageJumpBarItem
key={item}
label={item}
onItemPress={onItemPress}
/>
);
})}
</div>
</div>
);
}
export default PageJumpBar;

View File

@@ -1,40 +0,0 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import Link from 'Components/Link/Link';
import styles from './PageJumpBarItem.css';
class PageJumpBarItem extends Component {
//
// Listeners
onPress = () => {
const {
label,
onItemPress
} = this.props;
onItemPress(label);
};
//
// Render
render() {
return (
<Link
className={styles.jumpBarItem}
onPress={this.onPress}
>
{this.props.label.toUpperCase()}
</Link>
);
}
}
PageJumpBarItem.propTypes = {
label: PropTypes.string.isRequired,
onItemPress: PropTypes.func.isRequired
};
export default PageJumpBarItem;

View File

@@ -0,0 +1,22 @@
import React, { useCallback } from 'react';
import Link from 'Components/Link/Link';
import styles from './PageJumpBarItem.css';
export interface PageJumpBarItemProps {
label: string;
onItemPress: (label: string) => void;
}
function PageJumpBarItem({ label, onItemPress }: PageJumpBarItemProps) {
const handlePress = useCallback(() => {
onItemPress(label);
}, [label, onItemPress]);
return (
<Link className={styles.jumpBarItem} onPress={handlePress}>
{label.toUpperCase()}
</Link>
);
}
export default PageJumpBarItem;

View File

@@ -1,41 +0,0 @@
import PropTypes from 'prop-types';
import React from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
function PageSectionContent(props) {
const {
isFetching,
isPopulated,
error,
errorMessage,
children
} = props;
if (isFetching) {
return (
<LoadingIndicator />
);
} else if (!isFetching && !!error) {
return (
<Alert kind={kinds.DANGER}>{errorMessage}</Alert>
);
} else if (isPopulated && !error) {
return (
<div>{children}</div>
);
}
return null;
}
PageSectionContent.propTypes = {
isFetching: PropTypes.bool.isRequired,
isPopulated: PropTypes.bool.isRequired,
error: PropTypes.object,
errorMessage: PropTypes.string.isRequired,
children: PropTypes.node.isRequired
};
export default PageSectionContent;

View File

@@ -0,0 +1,36 @@
import React from 'react';
import Alert from 'Components/Alert';
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
import { kinds } from 'Helpers/Props';
interface PageSectionContentProps {
isFetching: boolean;
isPopulated: boolean;
error?: object;
errorMessage: string;
children: React.ReactNode;
}
function PageSectionContent({
isFetching,
isPopulated,
error,
errorMessage,
children,
}: PageSectionContentProps) {
if (isFetching) {
return <LoadingIndicator />;
}
if (!isFetching && !!error) {
return <Alert kind={kinds.DANGER}>{errorMessage}</Alert>;
}
if (isPopulated && !error) {
return <div>{children}</div>;
}
return null;
}
export default PageSectionContent;

Some files were not shown because too many files have changed in this diff Show More