mirror of
https://github.com/Radarr/Radarr.git
synced 2026-04-16 21:15:33 -04:00
Compare commits
66 Commits
v5.22.3.98
...
v5.24.0.10
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff393a3f65 | ||
|
|
f5faf52469 | ||
|
|
b5b4d4b971 | ||
|
|
873299701b | ||
|
|
d14cca30d7 | ||
|
|
5af61b5900 | ||
|
|
a10759c7e9 | ||
|
|
ac2d92007e | ||
|
|
09cfdc3fa2 | ||
|
|
04f26dbff7 | ||
|
|
159f5df8cc | ||
|
|
b823ad8e65 | ||
|
|
cc8bffc272 | ||
|
|
e0b93a03fd | ||
|
|
f7f5837d49 | ||
|
|
c3ee8b3c90 | ||
|
|
4de78e3bab | ||
|
|
426538c8af | ||
|
|
c82404c75b | ||
|
|
9bee9841c1 | ||
|
|
010959d915 | ||
|
|
a600728916 | ||
|
|
bbfb8c7cc2 | ||
|
|
32418ea521 | ||
|
|
2c5c99e9b7 | ||
|
|
a5e5a63e45 | ||
|
|
31b44d2c2e | ||
|
|
da8e8a12de | ||
|
|
6506c97ce1 | ||
|
|
5303a1992c | ||
|
|
042308c319 | ||
|
|
2e97e09f44 | ||
|
|
ccfb9c0dad | ||
|
|
b655d97e9e | ||
|
|
3afcb91db6 | ||
|
|
704e2d6176 | ||
|
|
8314c37b1d | ||
|
|
c2c3dfe917 | ||
|
|
c58a9b3f2c | ||
|
|
65a532a7fd | ||
|
|
704d920dab | ||
|
|
025cb0788f | ||
|
|
82c21d8bb1 | ||
|
|
96f973c961 | ||
|
|
a1ed440945 | ||
|
|
8caa839d99 | ||
|
|
9228e5dea0 | ||
|
|
371ac0921d | ||
|
|
937557e214 | ||
|
|
7fdaf41325 | ||
|
|
577eb4f4ca | ||
|
|
311f41b306 | ||
|
|
78f3b1f403 | ||
|
|
4dc02dcb80 | ||
|
|
2f649e413d | ||
|
|
107ddd3826 | ||
|
|
dfdd2cba99 | ||
|
|
c57d68c3dd | ||
|
|
6cc02b734e | ||
|
|
c5fa09dd86 | ||
|
|
29d59315b2 | ||
|
|
981a3c2db3 | ||
|
|
3f2ea56bf9 | ||
|
|
1679ed1327 | ||
|
|
69a1c1b21b | ||
|
|
5bd51832a0 |
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
]
|
||||
|
||||
@@ -145,7 +145,7 @@ function Blocklist() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setBlocklistFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -77,7 +77,7 @@ function History() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setHistoryFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -183,7 +183,7 @@ function Queue() {
|
||||
});
|
||||
|
||||
const handleFilterSelect = useCallback(
|
||||
(selectedFilterKey: string) => {
|
||||
(selectedFilterKey: string | number) => {
|
||||
dispatch(setQueueFilter({ selectedFilterKey }));
|
||||
},
|
||||
[dispatch]
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>[];
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
15
frontend/src/App/State/MessagesAppState.ts
Normal file
15
frontend/src/App/State/MessagesAppState.ts
Normal 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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
29
frontend/src/App/State/WantedAppState.ts
Normal file
29
frontend/src/App/State/WantedAppState.ts
Normal 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;
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -10,7 +10,8 @@ interface CssExports {
|
||||
'externalLinks': string;
|
||||
'link': string;
|
||||
'monitorToggleButton': string;
|
||||
'overlay': string;
|
||||
'overlayHover': string;
|
||||
'overlayHoverTitle': string;
|
||||
'overlayTitle': string;
|
||||
'poster': string;
|
||||
'posterContainer': string;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
35
frontend/src/Components/ImportListList.tsx
Normal file
35
frontend/src/Components/ImportListList.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
41
frontend/src/Components/Link/SpinnerButton.tsx
Normal file
41
frontend/src/Components/Link/SpinnerButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
143
frontend/src/Components/Link/SpinnerErrorButton.tsx
Normal file
143
frontend/src/Components/Link/SpinnerErrorButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
27
frontend/src/Components/Link/SpinnerIconButton.tsx
Normal file
27
frontend/src/Components/Link/SpinnerIconButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
82
frontend/src/Components/Menu/FilterMenu.tsx
Normal file
82
frontend/src/Components/Menu/FilterMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
69
frontend/src/Components/Menu/FilterMenuContent.tsx
Normal file
69
frontend/src/Components/Menu/FilterMenuContent.tsx
Normal 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;
|
||||
@@ -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;
|
||||
30
frontend/src/Components/Menu/FilterMenuItem.tsx
Normal file
30
frontend/src/Components/Menu/FilterMenuItem.tsx
Normal 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;
|
||||
@@ -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;
|
||||
205
frontend/src/Components/Menu/Menu.tsx
Normal file
205
frontend/src/Components/Menu/Menu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
30
frontend/src/Components/Menu/MenuButton.tsx
Normal file
30
frontend/src/Components/Menu/MenuButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
38
frontend/src/Components/Menu/MenuContent.tsx
Normal file
38
frontend/src/Components/Menu/MenuContent.tsx
Normal 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;
|
||||
@@ -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;
|
||||
29
frontend/src/Components/Menu/MenuItem.tsx
Normal file
29
frontend/src/Components/Menu/MenuItem.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
38
frontend/src/Components/Menu/PageMenuButton.tsx
Normal file
38
frontend/src/Components/Menu/PageMenuButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
41
frontend/src/Components/Menu/SelectedMenuItem.tsx
Normal file
41
frontend/src/Components/Menu/SelectedMenuItem.tsx
Normal 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;
|
||||
@@ -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;
|
||||
34
frontend/src/Components/Menu/SortMenu.tsx
Normal file
34
frontend/src/Components/Menu/SortMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
36
frontend/src/Components/Menu/SortMenuItem.tsx
Normal file
36
frontend/src/Components/Menu/SortMenuItem.tsx
Normal 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;
|
||||
@@ -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;
|
||||
43
frontend/src/Components/Menu/ToolbarMenuButton.tsx
Normal file
43
frontend/src/Components/Menu/ToolbarMenuButton.tsx
Normal 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;
|
||||
@@ -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;
|
||||
32
frontend/src/Components/Menu/ViewMenu.tsx
Normal file
32
frontend/src/Components/Menu/ViewMenu.tsx
Normal 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;
|
||||
@@ -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;
|
||||
23
frontend/src/Components/Menu/ViewMenuItem.tsx
Normal file
23
frontend/src/Components/Menu/ViewMenuItem.tsx
Normal 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;
|
||||
@@ -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);
|
||||
76
frontend/src/Components/Modal/ConfirmModal.tsx
Normal file
76
frontend/src/Components/Modal/ConfirmModal.tsx
Normal 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;
|
||||
16
frontend/src/Components/MovieTagList.tsx
Normal file
16
frontend/src/Components/MovieTagList.tsx
Normal 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;
|
||||
@@ -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;
|
||||
86
frontend/src/Components/Page/ErrorPage.tsx
Normal file
86
frontend/src/Components/Page/ErrorPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
457
frontend/src/Components/Page/Header/MovieSearchInput.tsx
Normal file
457
frontend/src/Components/Page/Header/MovieSearchInput.tsx
Normal 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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
70
frontend/src/Components/Page/Header/MovieSearchResult.tsx
Normal file
70
frontend/src/Components/Page/Header/MovieSearchResult.tsx
Normal 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;
|
||||
@@ -15,6 +15,10 @@
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.logoLink {
|
||||
line-height: 0;
|
||||
}
|
||||
|
||||
.logoFull,
|
||||
.logo {
|
||||
vertical-align: middle;
|
||||
|
||||
@@ -6,6 +6,7 @@ interface CssExports {
|
||||
'logo': string;
|
||||
'logoContainer': string;
|
||||
'logoFull': string;
|
||||
'logoLink': string;
|
||||
'right': string;
|
||||
'sidebarToggleContainer': string;
|
||||
'translate': string;
|
||||
|
||||
@@ -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);
|
||||
109
frontend/src/Components/Page/Header/PageHeader.tsx
Normal file
109
frontend/src/Components/Page/Header/PageHeader.tsx
Normal 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;
|
||||
@@ -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
19
frontend/src/Components/Page/LoadingPage.tsx
Normal file
19
frontend/src/Components/Page/LoadingPage.tsx
Normal file
File diff suppressed because one or more lines are too long
@@ -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;
|
||||
116
frontend/src/Components/Page/Page.tsx
Normal file
116
frontend/src/Components/Page/Page.tsx
Normal 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;
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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;
|
||||
33
frontend/src/Components/Page/PageContent.tsx
Normal file
33
frontend/src/Components/Page/PageContent.tsx
Normal 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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
16
frontend/src/Components/Page/PageContentFooter.tsx
Normal file
16
frontend/src/Components/Page/PageContentFooter.tsx
Normal 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;
|
||||
@@ -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;
|
||||
90
frontend/src/Components/Page/PageJumpBar.tsx
Normal file
90
frontend/src/Components/Page/PageJumpBar.tsx
Normal 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;
|
||||
@@ -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;
|
||||
22
frontend/src/Components/Page/PageJumpBarItem.tsx
Normal file
22
frontend/src/Components/Page/PageJumpBarItem.tsx
Normal 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;
|
||||
@@ -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;
|
||||
36
frontend/src/Components/Page/PageSectionContent.tsx
Normal file
36
frontend/src/Components/Page/PageSectionContent.tsx
Normal 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
Reference in New Issue
Block a user