mirror of
https://github.com/Prowlarr/Prowlarr.git
synced 2026-04-25 22:59:10 -04:00
New: Project Aphrodite
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
.page {
|
||||
composes: page from './Page.css';
|
||||
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.version {
|
||||
margin-top: 20px;
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
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,
|
||||
moviesError,
|
||||
customFiltersError,
|
||||
tagsError,
|
||||
qualityProfilesError,
|
||||
uiSettingsError
|
||||
} = 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 (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 (uiSettingsError) {
|
||||
errorMessage = getErrorMessage(uiSettingsError, 'Failed to load UI settings 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,
|
||||
moviesError: PropTypes.object,
|
||||
customFiltersError: PropTypes.object,
|
||||
tagsError: PropTypes.object,
|
||||
qualityProfilesError: PropTypes.object,
|
||||
uiSettingsError: PropTypes.object
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
@@ -0,0 +1,31 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { sizes } from 'Helpers/Props';
|
||||
import Modal from 'Components/Modal/Modal';
|
||||
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,15 @@
|
||||
.shortcut {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 5px 20px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.key {
|
||||
padding: 2px 4px;
|
||||
border-radius: 3px;
|
||||
background-color: $defaultColor;
|
||||
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.25);
|
||||
color: $white;
|
||||
font-size: 16px;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { shortcuts } from 'Components/keyboardShortcuts';
|
||||
import Button from 'Components/Link/Button';
|
||||
import ModalContent from 'Components/Modal/ModalContent';
|
||||
import ModalHeader from 'Components/Modal/ModalHeader';
|
||||
import ModalBody from 'Components/Modal/ModalBody';
|
||||
import ModalFooter from 'Components/Modal/ModalFooter';
|
||||
import styles from './KeyboardShortcutsModalContent.css';
|
||||
|
||||
function getShortcuts() {
|
||||
const allShortcuts = [];
|
||||
|
||||
Object.keys(shortcuts).forEach((key) => {
|
||||
allShortcuts.push(shortcuts[key]);
|
||||
});
|
||||
|
||||
return allShortcuts;
|
||||
}
|
||||
|
||||
function getShortcutKey(combo, isOsx) {
|
||||
const comboMatch = combo.match(/(.+?)\+(.)/);
|
||||
|
||||
if (!comboMatch) {
|
||||
return combo;
|
||||
}
|
||||
|
||||
const modifier = comboMatch[1];
|
||||
const key = comboMatch[2];
|
||||
let osModifier = modifier;
|
||||
|
||||
if (modifier === 'mod') {
|
||||
osModifier = isOsx ? 'cmd' : 'ctrl';
|
||||
}
|
||||
|
||||
return `${osModifier} + ${key}`;
|
||||
}
|
||||
|
||||
function KeyboardShortcutsModalContent(props) {
|
||||
const {
|
||||
isOsx,
|
||||
onModalClose
|
||||
} = props;
|
||||
|
||||
const allShortcuts = getShortcuts();
|
||||
|
||||
return (
|
||||
<ModalContent onModalClose={onModalClose}>
|
||||
<ModalHeader>
|
||||
Keyboard Shortcuts
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
}
|
||||
</ModalBody>
|
||||
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onPress={onModalClose}
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
);
|
||||
}
|
||||
|
||||
KeyboardShortcutsModalContent.propTypes = {
|
||||
isOsx: PropTypes.bool.isRequired,
|
||||
onModalClose: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default KeyboardShortcutsModalContent;
|
||||
@@ -0,0 +1,17 @@
|
||||
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);
|
||||
@@ -0,0 +1,96 @@
|
||||
.wrapper {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input {
|
||||
margin-left: 8px;
|
||||
width: 200px;
|
||||
border: none;
|
||||
border-bottom: solid 1px $white;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
color: $white;
|
||||
transition: border 0.3s ease-out;
|
||||
|
||||
&::placeholder {
|
||||
color: $white;
|
||||
transition: color 0.3s ease-out;
|
||||
}
|
||||
|
||||
&:focus {
|
||||
outline: 0;
|
||||
border-bottom-color: transparent;
|
||||
|
||||
&::placeholder {
|
||||
color: transparent;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.container {
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.movieContainer {
|
||||
@add-mixin scrollbar;
|
||||
@add-mixin scrollbarTrack;
|
||||
@add-mixin scrollbarThumb;
|
||||
}
|
||||
|
||||
.containerOpen {
|
||||
.movieContainer {
|
||||
position: absolute;
|
||||
top: 42px;
|
||||
z-index: 1;
|
||||
overflow-y: auto;
|
||||
min-width: 100%;
|
||||
max-height: 230px;
|
||||
border: 1px solid $themeDarkColor;
|
||||
border-radius: 4px;
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: 0;
|
||||
background-color: $themeDarkColor;
|
||||
box-shadow: inset 0 1px 1px $inputBoxShadowColor;
|
||||
color: $menuItemColor;
|
||||
}
|
||||
}
|
||||
|
||||
.list {
|
||||
margin: 5px 0;
|
||||
padding-left: 0;
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.listItem {
|
||||
padding: 0 16px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
background-color: $themeLightColor;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
padding: 5px 8px;
|
||||
color: $disabledColor;
|
||||
}
|
||||
|
||||
.addNewSeriesSuggestion {
|
||||
padding: 0 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.input {
|
||||
min-width: 150px;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.container {
|
||||
min-width: 0;
|
||||
max-width: 200px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import Autosuggest from 'react-autosuggest';
|
||||
import jdu from 'jdu';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
|
||||
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.state = {
|
||||
value: '',
|
||||
suggestions: []
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.bindShortcut(shortcuts.MOVIE_SEARCH_INPUT.key, this.focusInput);
|
||||
}
|
||||
|
||||
//
|
||||
// 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}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
getSuggestionValue({ title }) {
|
||||
return title;
|
||||
}
|
||||
|
||||
renderSuggestion(item, { query }) {
|
||||
if (item.type === ADD_NEW_TYPE) {
|
||||
return (
|
||||
<div className={styles.addNewSeriesSuggestion}>
|
||||
Search for {query}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MovieSearchResult
|
||||
query={query}
|
||||
cleanQuery={jdu.replace(query).toLowerCase()}
|
||||
{...item}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
goToMovie(movie) {
|
||||
this.setState({ value: '' });
|
||||
this.props.onGoToMovie(movie.titleSlug);
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.setState({
|
||||
value: '',
|
||||
suggestions: []
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onChange = (event, { newValue, method }) => {
|
||||
if (method === 'up' || method === 'down') {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState({ value: newValue });
|
||||
}
|
||||
|
||||
onKeyDown = (event) => {
|
||||
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 series,
|
||||
// otherwise go to the selected series.
|
||||
|
||||
if (highlightedSuggestionIndex == null) {
|
||||
this.goToMovie(suggestions[0]);
|
||||
} else {
|
||||
this.goToMovie(suggestions[highlightedSuggestionIndex]);
|
||||
}
|
||||
|
||||
this._autosuggest.input.blur();
|
||||
this.reset();
|
||||
}
|
||||
|
||||
onBlur = () => {
|
||||
this.reset();
|
||||
}
|
||||
|
||||
onSuggestionsFetchRequested = ({ value }) => {
|
||||
const lowerCaseValue = jdu.replace(value).toLowerCase();
|
||||
|
||||
const suggestions = this.props.movie.filter((movie) => {
|
||||
// Check the title first and if there isn't a match fallback to
|
||||
// the alternate titles and finally the tags.
|
||||
|
||||
if (value.length === 1) {
|
||||
return (
|
||||
movie.cleanTitle.startsWith(lowerCaseValue) ||
|
||||
movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.startsWith(lowerCaseValue)) ||
|
||||
movie.tags.some((tag) => tag.cleanLabel.startsWith(lowerCaseValue))
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
movie.cleanTitle.contains(lowerCaseValue) ||
|
||||
movie.alternateTitles.some((alternateTitle) => alternateTitle.cleanTitle.contains(lowerCaseValue)) ||
|
||||
movie.tags.some((tag) => tag.cleanLabel.contains(lowerCaseValue))
|
||||
);
|
||||
});
|
||||
|
||||
this.setState({ suggestions });
|
||||
}
|
||||
|
||||
onSuggestionsClearRequested = () => {
|
||||
this.setState({
|
||||
suggestions: []
|
||||
});
|
||||
}
|
||||
|
||||
onSuggestionSelected = (event, { suggestion }) => {
|
||||
if (suggestion.type === ADD_NEW_TYPE) {
|
||||
this.props.onGoToAddNewMovie(this.state.value);
|
||||
} else {
|
||||
this.goToMovie(suggestion);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
value,
|
||||
suggestions
|
||||
} = this.state;
|
||||
|
||||
const suggestionGroups = [];
|
||||
|
||||
if (suggestions.length) {
|
||||
suggestionGroups.push({
|
||||
title: 'Existing Movie',
|
||||
suggestions
|
||||
});
|
||||
}
|
||||
|
||||
suggestionGroups.push({
|
||||
title: 'Add New Movie',
|
||||
suggestions: [
|
||||
{
|
||||
type: ADD_NEW_TYPE,
|
||||
title: value
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const inputProps = {
|
||||
ref: this.setInputRef,
|
||||
className: styles.input,
|
||||
name: 'seriesSearch',
|
||||
value,
|
||||
placeholder: '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 = {
|
||||
movie: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
onGoToMovie: PropTypes.func.isRequired,
|
||||
onGoToAddNewMovie: PropTypes.func.isRequired,
|
||||
bindShortcut: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default keyboardShortcuts(MovieSearchInput);
|
||||
@@ -0,0 +1,98 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { push } from 'react-router-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import jdu from 'jdu';
|
||||
import createAllMoviesSelector from 'Store/Selectors/createAllMoviesSelector';
|
||||
import createTagsSelector from 'Store/Selectors/createTagsSelector';
|
||||
import MovieSearchInput from './MovieSearchInput';
|
||||
|
||||
function createCleanTagsSelector() {
|
||||
return createSelector(
|
||||
createTagsSelector(),
|
||||
(tags) => {
|
||||
return tags.map((tag) => {
|
||||
const {
|
||||
id,
|
||||
label
|
||||
} = tag;
|
||||
|
||||
return {
|
||||
id,
|
||||
label,
|
||||
cleanLabel: jdu.replace(label).toLowerCase()
|
||||
};
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createCleanMovieSelector() {
|
||||
return createSelector(
|
||||
createAllMoviesSelector(),
|
||||
createCleanTagsSelector(),
|
||||
(allMovies, allTags) => {
|
||||
return allMovies.map((movie) => {
|
||||
const {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
alternateTitles = [],
|
||||
tags = []
|
||||
} = movie;
|
||||
|
||||
return {
|
||||
title,
|
||||
titleSlug,
|
||||
sortTitle,
|
||||
images,
|
||||
cleanTitle: jdu.replace(title).toLowerCase(),
|
||||
alternateTitles: alternateTitles.map((alternateTitle) => {
|
||||
return {
|
||||
title: alternateTitle.title,
|
||||
sortTitle: alternateTitle.sortTitle,
|
||||
cleanTitle: jdu.replace(alternateTitle.title).toLowerCase()
|
||||
};
|
||||
}),
|
||||
tags: tags.map((id) => {
|
||||
return allTags.find((tag) => tag.id === id);
|
||||
})
|
||||
};
|
||||
}).sort((a, b) => {
|
||||
if (a.sortTitle < b.sortTitle) {
|
||||
return -1;
|
||||
}
|
||||
if (a.sortTitle > b.sortTitle) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createCleanMovieSelector(),
|
||||
(movie) => {
|
||||
return {
|
||||
movie
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -0,0 +1,38 @@
|
||||
.result {
|
||||
display: flex;
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.poster {
|
||||
width: 35px;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.titles {
|
||||
flex: 1 1 1px;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1 1 1px;
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.alternateTitle {
|
||||
composes: title;
|
||||
|
||||
color: $disabledColor;
|
||||
font-size: $smallFontSize;
|
||||
}
|
||||
|
||||
.tagContainer {
|
||||
composes: title;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.titles,
|
||||
.title,
|
||||
.alternateTitle {
|
||||
@add-mixin truncate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds } from 'Helpers/Props';
|
||||
import Label from 'Components/Label';
|
||||
import MoviePoster from 'Movie/MoviePoster';
|
||||
import styles from './MovieSearchResult.css';
|
||||
|
||||
function findMatchingAlternateTitle(alternateTitles, cleanQuery) {
|
||||
return alternateTitles.find((alternateTitle) => {
|
||||
return alternateTitle.cleanTitle.contains(cleanQuery);
|
||||
});
|
||||
}
|
||||
|
||||
function getMatchingTag(tags, cleanQuery) {
|
||||
return tags.find((tag) => {
|
||||
return tag.cleanLabel.contains(cleanQuery);
|
||||
});
|
||||
}
|
||||
|
||||
function MovieSearchResult(props) {
|
||||
const {
|
||||
cleanQuery,
|
||||
title,
|
||||
cleanTitle,
|
||||
images,
|
||||
alternateTitles,
|
||||
tags
|
||||
} = props;
|
||||
|
||||
const titleContains = cleanTitle.contains(cleanQuery);
|
||||
let alternateTitle = null;
|
||||
let tag = null;
|
||||
|
||||
if (!titleContains) {
|
||||
alternateTitle = findMatchingAlternateTitle(alternateTitles, cleanQuery);
|
||||
}
|
||||
|
||||
if (!titleContains && !alternateTitle) {
|
||||
tag = getMatchingTag(tags, cleanQuery);
|
||||
}
|
||||
|
||||
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}
|
||||
</div>
|
||||
|
||||
{
|
||||
!!alternateTitle &&
|
||||
<div className={styles.alternateTitle}>
|
||||
{alternateTitle.title}
|
||||
</div>
|
||||
}
|
||||
|
||||
{
|
||||
!!tag &&
|
||||
<div className={styles.tagContainer}>
|
||||
<Label
|
||||
key={tag.id}
|
||||
kind={kinds.INFO}
|
||||
>
|
||||
{tag.label}
|
||||
</Label>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
MovieSearchResult.propTypes = {
|
||||
cleanQuery: PropTypes.string.isRequired,
|
||||
title: PropTypes.string.isRequired,
|
||||
cleanTitle: PropTypes.string.isRequired,
|
||||
images: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
alternateTitles: PropTypes.arrayOf(PropTypes.object).isRequired,
|
||||
tags: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default MovieSearchResult;
|
||||
@@ -0,0 +1,65 @@
|
||||
.header {
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex: 0 0 auto;
|
||||
height: $headerHeight;
|
||||
background-color: #464b51;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
.logoContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex: 0 0 $sidebarWidth;
|
||||
}
|
||||
|
||||
.logoFull {
|
||||
width: 144px;
|
||||
height: 48px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.sidebarToggleContainer {
|
||||
display: none;
|
||||
justify-content: center;
|
||||
flex: 0 0 45px;
|
||||
margin-right: 14px;
|
||||
}
|
||||
|
||||
.right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.donate {
|
||||
composes: link from 'Components/Link/Link.css';
|
||||
|
||||
width: 30px;
|
||||
color: $themeRed;
|
||||
text-align: center;
|
||||
line-height: 60px;
|
||||
|
||||
&:hover {
|
||||
color: #9c1f30;
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.logoContainer {
|
||||
flex: 0 0 60px;
|
||||
}
|
||||
|
||||
.sidebarToggleContainer {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.donate {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import keyboardShortcuts, { shortcuts } from 'Components/keyboardShortcuts';
|
||||
import IconButton from 'Components/Link/IconButton';
|
||||
import Link from 'Components/Link/Link';
|
||||
import MovieSearchInputConnector from './MovieSearchInputConnector';
|
||||
import PageHeaderActionsMenuConnector from './PageHeaderActionsMenuConnector';
|
||||
import KeyboardShortcutsModal from './KeyboardShortcutsModal';
|
||||
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 to={`${window.Radarr.urlBase}/`}>
|
||||
<img
|
||||
className={isSmallScreen ? styles.logo : styles.logoFull}
|
||||
src={isSmallScreen ? `${window.Radarr.urlBase}/Content/Images/logo.png` : `${window.Radarr.urlBase}/Content/Images/logo-full.png`}
|
||||
/>
|
||||
</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}
|
||||
to="https://radarr.video/donate.html"
|
||||
size={14}
|
||||
/>
|
||||
<PageHeaderActionsMenuConnector
|
||||
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);
|
||||
@@ -0,0 +1,21 @@
|
||||
.menuButton {
|
||||
margin-right: 15px;
|
||||
width: 30px;
|
||||
height: 60px;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: $themeDarkColor;
|
||||
}
|
||||
}
|
||||
|
||||
.itemIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.menuButton {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { align, icons, kinds } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuButton from 'Components/Menu/MenuButton';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import MenuItem from 'Components/Menu/MenuItem';
|
||||
import MenuItemSeparator from 'Components/Menu/MenuItemSeparator';
|
||||
import styles from './PageHeaderActionsMenu.css';
|
||||
|
||||
function PageHeaderActionsMenu(props) {
|
||||
const {
|
||||
formsAuth,
|
||||
onKeyboardShortcutsPress,
|
||||
onRestartPress,
|
||||
onShutdownPress
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Menu alignMenu={align.RIGHT}>
|
||||
<MenuButton className={styles.menuButton}>
|
||||
<Icon
|
||||
name={icons.INTERACTIVE}
|
||||
/>
|
||||
</MenuButton>
|
||||
|
||||
<MenuContent>
|
||||
<MenuItem onPress={onKeyboardShortcutsPress}>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.KEYBOARD}
|
||||
/>
|
||||
Keyboard Shortcuts
|
||||
</MenuItem>
|
||||
|
||||
<MenuItemSeparator />
|
||||
|
||||
<MenuItem onPress={onRestartPress}>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.RESTART}
|
||||
/>
|
||||
Restart
|
||||
</MenuItem>
|
||||
|
||||
<MenuItem onPress={onShutdownPress}>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.SHUTDOWN}
|
||||
kind={kinds.DANGER}
|
||||
/>
|
||||
Shutdown
|
||||
</MenuItem>
|
||||
|
||||
{
|
||||
formsAuth &&
|
||||
<div className={styles.separator} />
|
||||
}
|
||||
|
||||
{
|
||||
formsAuth &&
|
||||
<MenuItem
|
||||
to={`${window.Radarr.urlBase}/logout`}
|
||||
noRouter={true}
|
||||
>
|
||||
<Icon
|
||||
className={styles.itemIcon}
|
||||
name={icons.LOGOUT}
|
||||
/>
|
||||
Logout
|
||||
</MenuItem>
|
||||
}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
PageHeaderActionsMenu.propTypes = {
|
||||
formsAuth: PropTypes.bool.isRequired,
|
||||
onKeyboardShortcutsPress: PropTypes.func.isRequired,
|
||||
onRestartPress: PropTypes.func.isRequired,
|
||||
onShutdownPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PageHeaderActionsMenu;
|
||||
@@ -0,0 +1,56 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import { restart, shutdown } from 'Store/Actions/systemActions';
|
||||
import PageHeaderActionsMenu from './PageHeaderActionsMenu';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.system.status,
|
||||
(status) => {
|
||||
return {
|
||||
formsAuth: status.item.authentication === 'forms'
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const mapDispatchToProps = {
|
||||
restart,
|
||||
shutdown
|
||||
};
|
||||
|
||||
class PageHeaderActionsMenuConnector extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onRestartPress = () => {
|
||||
this.props.restart();
|
||||
}
|
||||
|
||||
onShutdownPress = () => {
|
||||
this.props.shutdown();
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<PageHeaderActionsMenu
|
||||
{...this.props}
|
||||
onRestartPress={this.onRestartPress}
|
||||
onShutdownPress={this.onShutdownPress}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageHeaderActionsMenuConnector.propTypes = {
|
||||
restart: PropTypes.func.isRequired,
|
||||
shutdown: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default connect(createMapStateToProps, mapDispatchToProps)(PageHeaderActionsMenuConnector);
|
||||
@@ -0,0 +1,3 @@
|
||||
.page {
|
||||
composes: page from './Page.css';
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
import LoadingMessage from 'Components/Loading/LoadingMessage';
|
||||
import styles from './LoadingPage.css';
|
||||
|
||||
function LoadingPage() {
|
||||
return (
|
||||
<div className={styles.page}>
|
||||
<LoadingMessage />
|
||||
<LoadingIndicator />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingPage;
|
||||
@@ -0,0 +1,18 @@
|
||||
.page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.main {
|
||||
position: relative; /* need this to position inner content - is this really needed? */
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.page {
|
||||
flex-grow: 1;
|
||||
height: initial;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import locationShape from 'Helpers/Props/Shapes/locationShape';
|
||||
import SignalRConnector from 'Components/SignalRConnector';
|
||||
import ColorImpairedContext from 'App/ColorImpairedContext';
|
||||
import ConnectionLostModalConnector from 'App/ConnectionLostModalConnector';
|
||||
import AppUpdatedModalConnector from 'App/AppUpdatedModalConnector';
|
||||
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,
|
||||
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>
|
||||
|
||||
<AppUpdatedModalConnector
|
||||
isOpen={this.state.isUpdatedModalOpen}
|
||||
onModalClose={this.onUpdatedModalClose}
|
||||
/>
|
||||
|
||||
<ConnectionLostModalConnector
|
||||
isOpen={this.state.isConnectionLostModalOpen}
|
||||
onModalClose={this.onConnectionLostModalClose}
|
||||
/>
|
||||
</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,
|
||||
onResize: PropTypes.func.isRequired,
|
||||
onSidebarToggle: PropTypes.func.isRequired,
|
||||
onSidebarVisibleChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
Page.defaultProps = {
|
||||
className: styles.page
|
||||
};
|
||||
|
||||
export default Page;
|
||||
@@ -0,0 +1,197 @@
|
||||
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 createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import { saveDimensions, setIsSidebarVisible } from 'Store/Actions/appActions';
|
||||
import { fetchCustomFilters } from 'Store/Actions/customFilterActions';
|
||||
import { fetchMovies } from 'Store/Actions/movieActions';
|
||||
import { fetchTags } from 'Store/Actions/tagActions';
|
||||
import { fetchQualityProfiles, fetchUISettings } from 'Store/Actions/settingsActions';
|
||||
import { fetchStatus } from 'Store/Actions/systemActions';
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.movies,
|
||||
(state) => state.customFilters,
|
||||
(state) => state.tags,
|
||||
(state) => state.settings.ui,
|
||||
(state) => state.settings.qualityProfiles,
|
||||
(state) => state.app,
|
||||
createDimensionsSelector(),
|
||||
(
|
||||
movies,
|
||||
customFilters,
|
||||
tags,
|
||||
uiSettings,
|
||||
qualityProfiles,
|
||||
app,
|
||||
dimensions
|
||||
) => {
|
||||
const isPopulated = (
|
||||
movies.isPopulated &&
|
||||
customFilters.isPopulated &&
|
||||
tags.isPopulated &&
|
||||
qualityProfiles.isPopulated &&
|
||||
uiSettings.isPopulated
|
||||
);
|
||||
|
||||
const hasError = !!(
|
||||
movies.error ||
|
||||
customFilters.error ||
|
||||
tags.error ||
|
||||
qualityProfiles.error ||
|
||||
uiSettings.error
|
||||
);
|
||||
|
||||
return {
|
||||
isPopulated,
|
||||
hasError,
|
||||
moviesError: movies.error,
|
||||
customFiltersError: tags.error,
|
||||
tagsError: tags.error,
|
||||
qualityProfilesError: qualityProfiles.error,
|
||||
uiSettingsError: uiSettings.error,
|
||||
isSmallScreen: dimensions.isSmallScreen,
|
||||
isSidebarVisible: app.isSidebarVisible,
|
||||
enableColorImpairedMode: uiSettings.item.enableColorImpairedMode,
|
||||
version: app.version,
|
||||
isUpdated: app.isUpdated,
|
||||
isDisconnected: app.isDisconnected
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createMapDispatchToProps(dispatch, props) {
|
||||
return {
|
||||
dispatchFetchMovies() {
|
||||
dispatch(fetchMovies());
|
||||
},
|
||||
dispatchFetchCustomFilters() {
|
||||
dispatch(fetchCustomFilters());
|
||||
},
|
||||
dispatchFetchTags() {
|
||||
dispatch(fetchTags());
|
||||
},
|
||||
dispatchFetchQualityProfiles() {
|
||||
dispatch(fetchQualityProfiles());
|
||||
},
|
||||
dispatchFetchUISettings() {
|
||||
dispatch(fetchUISettings());
|
||||
},
|
||||
dispatchFetchStatus() {
|
||||
dispatch(fetchStatus());
|
||||
},
|
||||
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.dispatchFetchCustomFilters();
|
||||
this.props.dispatchFetchTags();
|
||||
this.props.dispatchFetchQualityProfiles();
|
||||
this.props.dispatchFetchUISettings();
|
||||
this.props.dispatchFetchStatus();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onSidebarToggle = () => {
|
||||
this.props.onSidebarVisibleChange(!this.props.isSidebarVisible);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
isPopulated,
|
||||
hasError,
|
||||
dispatchFetchMovies,
|
||||
dispatchFetchTags,
|
||||
dispatchFetchQualityProfiles,
|
||||
dispatchFetchUISettings,
|
||||
dispatchFetchStatus,
|
||||
...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,
|
||||
dispatchFetchCustomFilters: PropTypes.func.isRequired,
|
||||
dispatchFetchTags: PropTypes.func.isRequired,
|
||||
dispatchFetchQualityProfiles: PropTypes.func.isRequired,
|
||||
dispatchFetchUISettings: PropTypes.func.isRequired,
|
||||
dispatchFetchStatus: PropTypes.func.isRequired,
|
||||
onSidebarVisibleChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default withRouter(
|
||||
connect(createMapStateToProps, createMapDispatchToProps)(PageConnector)
|
||||
);
|
||||
@@ -0,0 +1,8 @@
|
||||
.content {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-grow: 1;
|
||||
overflow-x: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
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} - Radarr` : 'Radarr'}>
|
||||
<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;
|
||||
@@ -0,0 +1,19 @@
|
||||
.contentBody {
|
||||
/* 1px for flex-basis so the div grows correctly in Edge/Firefox */
|
||||
flex: 1 0 1px;
|
||||
}
|
||||
|
||||
.innerContentBody {
|
||||
padding: $pageContentBodyPadding;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.contentBody {
|
||||
flex-basis: auto;
|
||||
overflow-y: hidden !important;
|
||||
}
|
||||
|
||||
.innerContentBody {
|
||||
padding: $pageContentBodyPaddingSmallScreen;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { scrollDirections } from 'Helpers/Props';
|
||||
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import styles from './PageContentBody.css';
|
||||
|
||||
class PageContentBody extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
innerClassName,
|
||||
isSmallScreen,
|
||||
children,
|
||||
dispatch,
|
||||
...otherProps
|
||||
} = this.props;
|
||||
|
||||
const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
|
||||
|
||||
return (
|
||||
<ScrollerComponent
|
||||
className={className}
|
||||
scrollDirection={scrollDirections.VERTICAL}
|
||||
{...otherProps}
|
||||
>
|
||||
<div className={innerClassName}>
|
||||
{children}
|
||||
</div>
|
||||
</ScrollerComponent>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageContentBody.propTypes = {
|
||||
className: PropTypes.string,
|
||||
innerClassName: PropTypes.string,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
dispatch: PropTypes.func
|
||||
};
|
||||
|
||||
PageContentBody.defaultProps = {
|
||||
className: styles.contentBody,
|
||||
innerClassName: styles.innerContentBody
|
||||
};
|
||||
|
||||
export default PageContentBody;
|
||||
@@ -0,0 +1,17 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import createDimensionsSelector from 'Store/Selectors/createDimensionsSelector';
|
||||
import PageContentBody from './PageContentBody';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
createDimensionsSelector(),
|
||||
(dimensions) => {
|
||||
return {
|
||||
isSmallScreen: dimensions.isSmallScreen
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(PageContentBody);
|
||||
@@ -0,0 +1,3 @@
|
||||
.content {
|
||||
composes: content from './PageContent.css';
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ErrorBoundaryError from 'Components/Error/ErrorBoundaryError';
|
||||
import PageContentBodyConnector from './PageContentBodyConnector';
|
||||
import styles from './PageContentError.css';
|
||||
|
||||
function PageContentError(props) {
|
||||
return (
|
||||
<div className={styles.content}>
|
||||
<PageContentBodyConnector>
|
||||
<ErrorBoundaryError
|
||||
{...props}
|
||||
message='There was an error loading this page'
|
||||
/>
|
||||
</PageContentBodyConnector>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PageContentError;
|
||||
@@ -0,0 +1,26 @@
|
||||
.contentFooter {
|
||||
display: flex;
|
||||
flex: 0 0 auto;
|
||||
padding: 20px;
|
||||
background-color: #f1f1f1;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.contentFooter {
|
||||
display: block;
|
||||
|
||||
div {
|
||||
margin-top: 10px;
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointLarge) {
|
||||
.contentFooter {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
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;
|
||||
@@ -0,0 +1,22 @@
|
||||
.jumpBar {
|
||||
display: flex;
|
||||
align-content: stretch;
|
||||
align-items: stretch;
|
||||
align-self: stretch;
|
||||
justify-content: center;
|
||||
flex: 0 0 30px;
|
||||
}
|
||||
|
||||
.jumpBarItems {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex: 0 0 100%;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.jumpBar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import Measure from 'Components/Measure';
|
||||
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
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.computeVisibleItems();
|
||||
}
|
||||
|
||||
shouldComponentUpdate(nextProps, nextState) {
|
||||
return (
|
||||
nextProps.items !== this.props.items ||
|
||||
nextState.height !== this.state.height
|
||||
);
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps, prevState) {
|
||||
if (
|
||||
prevProps.items !== this.props.items ||
|
||||
prevState.height !== this.state.height
|
||||
) {
|
||||
this.computeVisibleItems();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
computeVisibleItems() {
|
||||
const {
|
||||
items,
|
||||
minimumItems
|
||||
} = this.props;
|
||||
|
||||
const height = this.state.height;
|
||||
const maximumItems = Math.floor(height / ITEM_HEIGHT);
|
||||
const diff = items.length - maximumItems;
|
||||
|
||||
if (diff < 0) {
|
||||
this.setState({ visibleItems: items });
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length < minimumItems) {
|
||||
this.setState({ visibleItems: items });
|
||||
return;
|
||||
}
|
||||
|
||||
const removeDiff = Math.ceil(items.length / maximumItems);
|
||||
|
||||
const visibleItems = _.reduce(items, (acc, item, index) => {
|
||||
if (index % removeDiff === 0) {
|
||||
acc.push(item);
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
this.setState({ visibleItems });
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ height }) => {
|
||||
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.arrayOf(PropTypes.string).isRequired,
|
||||
minimumItems: PropTypes.number.isRequired,
|
||||
onItemPress: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
PageJumpBar.defaultProps = {
|
||||
minimumItems: 5
|
||||
};
|
||||
|
||||
export default PageJumpBar;
|
||||
@@ -0,0 +1,14 @@
|
||||
.jumpBarItem {
|
||||
flex: 1 0 $jumpBarItemHeight;
|
||||
border-bottom: 1px solid $borderColor;
|
||||
text-align: center;
|
||||
font-weight: bold;
|
||||
|
||||
&:hover {
|
||||
color: #777;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
@@ -0,0 +1,39 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import LoadingIndicator from 'Components/Loading/LoadingIndicator';
|
||||
|
||||
function PageSectionContent(props) {
|
||||
const {
|
||||
isFetching,
|
||||
isPopulated,
|
||||
error,
|
||||
errorMessage,
|
||||
children
|
||||
} = props;
|
||||
|
||||
if (isFetching) {
|
||||
return (
|
||||
<LoadingIndicator />
|
||||
);
|
||||
} else if (!isFetching && !!error) {
|
||||
return (
|
||||
<div>{errorMessage}</div>
|
||||
);
|
||||
} 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;
|
||||
@@ -0,0 +1,42 @@
|
||||
.message {
|
||||
display: flex;
|
||||
border-left: 3px solid $infoColor;
|
||||
}
|
||||
|
||||
.iconContainer,
|
||||
.text {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
padding: 2px 0;
|
||||
color: $sidebarColor;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
flex: 0 0 25px;
|
||||
margin-left: 24px;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.text {
|
||||
margin-right: 24px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
/* Types */
|
||||
|
||||
.error {
|
||||
border-left-color: $dangerColor;
|
||||
}
|
||||
|
||||
.info {
|
||||
border-left-color: $infoColor;
|
||||
}
|
||||
|
||||
.success {
|
||||
border-left-color: $successColor;
|
||||
}
|
||||
|
||||
.warning {
|
||||
border-left-color: $warningColor;
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import styles from './Message.css';
|
||||
|
||||
function getIconName(name) {
|
||||
switch (name) {
|
||||
case 'ApplicationUpdate':
|
||||
return icons.RESTART;
|
||||
case 'Backup':
|
||||
return icons.BACKUP;
|
||||
case 'CheckHealth':
|
||||
return icons.HEALTH;
|
||||
case 'EpisodeSearch':
|
||||
return icons.SEARCH;
|
||||
case 'Housekeeping':
|
||||
return icons.HOUSEKEEPING;
|
||||
case 'RefreshMovie':
|
||||
return icons.REFRESH;
|
||||
case 'RssSync':
|
||||
return icons.RSS;
|
||||
case 'SeasonSearch':
|
||||
return icons.SEARCH;
|
||||
case 'MovieSearch':
|
||||
return icons.SEARCH;
|
||||
case 'UpdateSceneMapping':
|
||||
return icons.REFRESH;
|
||||
default:
|
||||
return icons.SPINNER;
|
||||
}
|
||||
}
|
||||
|
||||
function Message(props) {
|
||||
const {
|
||||
name,
|
||||
message,
|
||||
type
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<div className={classNames(
|
||||
styles.message,
|
||||
styles[type]
|
||||
)}
|
||||
>
|
||||
<div className={styles.iconContainer}>
|
||||
<Icon
|
||||
name={getIconName(name)}
|
||||
title={name}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={styles.text}
|
||||
>
|
||||
{message}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Message.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
message: PropTypes.string.isRequired,
|
||||
type: PropTypes.string.isRequired
|
||||
};
|
||||
|
||||
export default Message;
|
||||
@@ -0,0 +1,67 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { hideMessage } from 'Store/Actions/appActions';
|
||||
import Message from './Message';
|
||||
|
||||
const mapDispatchToProps = {
|
||||
hideMessage
|
||||
};
|
||||
|
||||
class MessageConnector extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._hideTimeoutId = null;
|
||||
this.scheduleHideMessage(props.hideAfter);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.scheduleHideMessage(this.props.hideAfter);
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
scheduleHideMessage = (hideAfter) => {
|
||||
if (this._hideTimeoutId) {
|
||||
clearTimeout(this._hideTimeoutId);
|
||||
}
|
||||
|
||||
if (hideAfter) {
|
||||
this._hideTimeoutId = setTimeout(this.hideMessage, hideAfter * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
hideMessage = () => {
|
||||
this.props.hideMessage({ id: this.props.id });
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<Message
|
||||
{...this.props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
MessageConnector.propTypes = {
|
||||
id: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
|
||||
hideAfter: PropTypes.number.isRequired,
|
||||
hideMessage: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
MessageConnector.defaultProps = {
|
||||
// Hide messages after 60 seconds if there is no activity
|
||||
// hideAfter: 60
|
||||
};
|
||||
|
||||
export default connect(undefined, mapDispatchToProps)(MessageConnector);
|
||||
@@ -0,0 +1,11 @@
|
||||
.messages {
|
||||
margin-top: auto;
|
||||
margin-bottom: 20px;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.messages {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import MessageConnector from './MessageConnector';
|
||||
import styles from './Messages.css';
|
||||
|
||||
function Messages({ messages }) {
|
||||
return (
|
||||
<div className={styles.messages}>
|
||||
{
|
||||
messages.map((message) => {
|
||||
return (
|
||||
<MessageConnector
|
||||
key={message.id}
|
||||
{...message}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Messages.propTypes = {
|
||||
messages: PropTypes.arrayOf(PropTypes.object).isRequired
|
||||
};
|
||||
|
||||
export default Messages;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { connect } from 'react-redux';
|
||||
import { createSelector } from 'reselect';
|
||||
import Messages from './Messages';
|
||||
|
||||
function createMapStateToProps() {
|
||||
return createSelector(
|
||||
(state) => state.app.messages.items,
|
||||
(messages) => {
|
||||
return {
|
||||
messages: messages.slice().reverse()
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(createMapStateToProps)(Messages);
|
||||
@@ -0,0 +1,34 @@
|
||||
.sidebarContainer {
|
||||
flex: 0 0 $sidebarWidth;
|
||||
overflow: hidden;
|
||||
width: $sidebarWidth;
|
||||
background-color: $sidebarBackgroundColor;
|
||||
transition: transform 300ms ease-in-out;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background-color: $sidebarBackgroundColor;
|
||||
color: $white;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.sidebarContainer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
z-index: 2;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
z-index: 2;
|
||||
overflow-y: auto;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import locationShape from 'Helpers/Props/Shapes/locationShape';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import OverlayScroller from 'Components/Scroller/OverlayScroller';
|
||||
import Scroller from 'Components/Scroller/Scroller';
|
||||
import QueueStatusConnector from 'Activity/Queue/Status/QueueStatusConnector';
|
||||
import HealthStatusConnector from 'System/Status/Health/HealthStatusConnector';
|
||||
import MessagesConnector from './Messages/MessagesConnector';
|
||||
import PageSidebarItem from './PageSidebarItem';
|
||||
import styles from './PageSidebar.css';
|
||||
|
||||
const HEADER_HEIGHT = parseInt(dimensions.headerHeight);
|
||||
const SIDEBAR_WIDTH = parseInt(dimensions.sidebarWidth);
|
||||
|
||||
const links = [
|
||||
{
|
||||
iconName: icons.SERIES_CONTINUING,
|
||||
title: 'Movies',
|
||||
to: '/',
|
||||
alias: '/movies',
|
||||
children: [
|
||||
{
|
||||
title: 'Add New',
|
||||
to: '/add/new'
|
||||
},
|
||||
{
|
||||
title: 'Import',
|
||||
to: '/add/import'
|
||||
},
|
||||
{
|
||||
title: 'Discover',
|
||||
to: '/add/discover'
|
||||
},
|
||||
{
|
||||
title: 'Lists',
|
||||
to: '/add/lists'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
iconName: icons.CALENDAR,
|
||||
title: 'Calendar',
|
||||
to: '/calendar'
|
||||
},
|
||||
|
||||
{
|
||||
iconName: icons.ACTIVITY,
|
||||
title: 'Activity',
|
||||
to: '/activity/queue',
|
||||
children: [
|
||||
{
|
||||
title: 'Queue',
|
||||
to: '/activity/queue',
|
||||
statusComponent: QueueStatusConnector
|
||||
},
|
||||
{
|
||||
title: 'History',
|
||||
to: '/activity/history'
|
||||
},
|
||||
{
|
||||
title: 'Blacklist',
|
||||
to: '/activity/blacklist'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
iconName: icons.SETTINGS,
|
||||
title: 'Settings',
|
||||
to: '/settings',
|
||||
children: [
|
||||
{
|
||||
title: 'Media Management',
|
||||
to: '/settings/mediamanagement'
|
||||
},
|
||||
{
|
||||
title: 'Profiles',
|
||||
to: '/settings/profiles'
|
||||
},
|
||||
{
|
||||
title: 'Quality',
|
||||
to: '/settings/quality'
|
||||
},
|
||||
{
|
||||
title: 'Indexers',
|
||||
to: '/settings/indexers'
|
||||
},
|
||||
{
|
||||
title: 'Download Clients',
|
||||
to: '/settings/downloadclients'
|
||||
},
|
||||
{
|
||||
title: 'Lists',
|
||||
to: '/settings/netimports'
|
||||
},
|
||||
{
|
||||
title: 'Connect',
|
||||
to: '/settings/connect'
|
||||
},
|
||||
{
|
||||
title: 'Metadata',
|
||||
to: '/settings/metadata'
|
||||
},
|
||||
{
|
||||
title: 'Tags',
|
||||
to: '/settings/tags'
|
||||
},
|
||||
{
|
||||
title: 'General',
|
||||
to: '/settings/general'
|
||||
},
|
||||
{
|
||||
title: 'UI',
|
||||
to: '/settings/ui'
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
{
|
||||
iconName: icons.SYSTEM,
|
||||
title: 'System',
|
||||
to: '/system/status',
|
||||
children: [
|
||||
{
|
||||
title: 'Status',
|
||||
to: '/system/status',
|
||||
statusComponent: HealthStatusConnector
|
||||
},
|
||||
{
|
||||
title: 'Tasks',
|
||||
to: '/system/tasks'
|
||||
},
|
||||
{
|
||||
title: 'Backup',
|
||||
to: '/system/backup'
|
||||
},
|
||||
{
|
||||
title: 'Updates',
|
||||
to: '/system/updates'
|
||||
},
|
||||
{
|
||||
title: 'Events',
|
||||
to: '/system/events'
|
||||
},
|
||||
{
|
||||
title: 'Log Files',
|
||||
to: '/system/logs/files'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
function getActiveParent(pathname) {
|
||||
let activeParent = links[0].to;
|
||||
|
||||
links.forEach((link) => {
|
||||
if (link.to && link.to === pathname) {
|
||||
activeParent = link.to;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const children = link.children;
|
||||
|
||||
if (children) {
|
||||
children.forEach((childLink) => {
|
||||
if (pathname.startsWith(childLink.to)) {
|
||||
activeParent = link.to;
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
(link.to !== '/' && pathname.startsWith(link.to)) ||
|
||||
(link.alias && pathname.startsWith(link.alias))
|
||||
) {
|
||||
activeParent = link.to;
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
return activeParent;
|
||||
}
|
||||
|
||||
function hasActiveChildLink(link, pathname) {
|
||||
const children = link.children;
|
||||
|
||||
if (!children || !children.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.some(children, (child) => {
|
||||
return child.to === pathname;
|
||||
});
|
||||
}
|
||||
|
||||
function getPositioning() {
|
||||
const windowScroll = window.scrollY == null ? document.documentElement.scrollTop : window.scrollY;
|
||||
const top = Math.max(HEADER_HEIGHT - windowScroll, 0);
|
||||
const height = window.innerHeight - top;
|
||||
|
||||
return {
|
||||
top: `${top}px`,
|
||||
height: `${height}px`
|
||||
};
|
||||
}
|
||||
|
||||
class PageSidebar extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this._touchStartX = null;
|
||||
this._touchStartY = null;
|
||||
this._sidebarRef = null;
|
||||
|
||||
this.state = {
|
||||
top: dimensions.headerHeight,
|
||||
height: `${window.innerHeight - HEADER_HEIGHT}px`,
|
||||
transition: null,
|
||||
transform: props.isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.isSmallScreen) {
|
||||
window.addEventListener('click', this.onWindowClick, { capture: true });
|
||||
window.addEventListener('scroll', this.onWindowScroll);
|
||||
window.addEventListener('touchstart', this.onTouchStart);
|
||||
window.addEventListener('touchmove', this.onTouchMove);
|
||||
window.addEventListener('touchend', this.onTouchEnd);
|
||||
window.addEventListener('touchcancel', this.onTouchCancel);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
const {
|
||||
isSidebarVisible
|
||||
} = this.props;
|
||||
|
||||
const transform = this.state.transform;
|
||||
|
||||
if (prevProps.isSidebarVisible !== isSidebarVisible) {
|
||||
this._setSidebarTransform(isSidebarVisible);
|
||||
} else if (transform === 0 && !isSidebarVisible) {
|
||||
this.props.onSidebarVisibleChange(true);
|
||||
} else if (transform === -SIDEBAR_WIDTH && isSidebarVisible) {
|
||||
this.props.onSidebarVisibleChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this.props.isSmallScreen) {
|
||||
window.removeEventListener('click', this.onWindowClick, { capture: true });
|
||||
window.removeEventListener('scroll', this.onWindowScroll);
|
||||
window.removeEventListener('touchstart', this.onTouchStart);
|
||||
window.removeEventListener('touchmove', this.onTouchMove);
|
||||
window.removeEventListener('touchend', this.onTouchEnd);
|
||||
window.removeEventListener('touchcancel', this.onTouchCancel);
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Control
|
||||
|
||||
_setSidebarRef = (ref) => {
|
||||
this._sidebarRef = ref;
|
||||
}
|
||||
|
||||
_setSidebarTransform(isSidebarVisible, transition, callback) {
|
||||
this.setState({
|
||||
transition,
|
||||
transform: isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1
|
||||
}, callback);
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onWindowClick = (event) => {
|
||||
const sidebar = ReactDOM.findDOMNode(this._sidebarRef);
|
||||
const toggleButton = document.getElementById('sidebar-toggle-button');
|
||||
|
||||
if (!sidebar) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!sidebar.contains(event.target) &&
|
||||
!toggleButton.contains(event.target) &&
|
||||
this.props.isSidebarVisible
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.props.onSidebarVisibleChange(false);
|
||||
}
|
||||
}
|
||||
|
||||
onWindowScroll = () => {
|
||||
this.setState(getPositioning());
|
||||
}
|
||||
|
||||
onTouchStart = (event) => {
|
||||
const touches = event.touches;
|
||||
const touchStartX = touches[0].pageX;
|
||||
const touchStartY = touches[0].pageY;
|
||||
const isSidebarVisible = this.props.isSidebarVisible;
|
||||
|
||||
if (touches.length !== 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isSidebarVisible && (touchStartX > 210 || touchStartX < 180)) {
|
||||
return;
|
||||
} else if (!isSidebarVisible && touchStartX > 40) {
|
||||
return;
|
||||
}
|
||||
|
||||
this._touchStartX = touchStartX;
|
||||
this._touchStartY = touchStartY;
|
||||
}
|
||||
|
||||
onTouchMove = (event) => {
|
||||
const touches = event.touches;
|
||||
const currentTouchX = touches[0].pageX;
|
||||
// const currentTouchY = touches[0].pageY;
|
||||
// const isSidebarVisible = this.props.isSidebarVisible;
|
||||
|
||||
if (!this._touchStartX) {
|
||||
return;
|
||||
}
|
||||
|
||||
// This is a bit funky when trying to close and you scroll
|
||||
// vertical too much by mistake, commenting out for now.
|
||||
// TODO: Evaluate if this should be nuked
|
||||
|
||||
// if (Math.abs(this._touchStartY - currentTouchY) > 40) {
|
||||
// const transform = isSidebarVisible ? 0 : SIDEBAR_WIDTH * -1;
|
||||
|
||||
// this.setState({
|
||||
// transition: 'none',
|
||||
// transform
|
||||
// });
|
||||
|
||||
// return;
|
||||
// }
|
||||
|
||||
if (Math.abs(this._touchStartX - currentTouchX) < 40) {
|
||||
return;
|
||||
}
|
||||
|
||||
const transform = Math.min(currentTouchX - SIDEBAR_WIDTH, 0);
|
||||
|
||||
this.setState({
|
||||
transition: 'none',
|
||||
transform
|
||||
});
|
||||
}
|
||||
|
||||
onTouchEnd = (event) => {
|
||||
const touches = event.changedTouches;
|
||||
const currentTouch = touches[0].pageX;
|
||||
|
||||
if (!this._touchStartX) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (currentTouch > this._touchStartX && currentTouch > 50) {
|
||||
this._setSidebarTransform(true, 'none');
|
||||
} else if (currentTouch < this._touchStartX && currentTouch < 80) {
|
||||
this._setSidebarTransform(false, 'transform 50ms ease-in-out');
|
||||
} else {
|
||||
this._setSidebarTransform(this.props.isSidebarVisible);
|
||||
}
|
||||
|
||||
this._touchStartX = null;
|
||||
this._touchStartY = null;
|
||||
}
|
||||
|
||||
onTouchCancel = (event) => {
|
||||
this._touchStartX = null;
|
||||
this._touchStartY = null;
|
||||
}
|
||||
|
||||
onItemPress = () => {
|
||||
this.props.onSidebarVisibleChange(false);
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
location,
|
||||
isSmallScreen
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
top,
|
||||
height,
|
||||
transition,
|
||||
transform
|
||||
} = this.state;
|
||||
|
||||
const urlBase = window.Radarr.urlBase;
|
||||
const pathname = urlBase ? location.pathname.substr(urlBase.length) || '/' : location.pathname;
|
||||
const activeParent = getActiveParent(pathname);
|
||||
|
||||
let containerStyle = {};
|
||||
let sidebarStyle = {};
|
||||
|
||||
if (isSmallScreen) {
|
||||
containerStyle = {
|
||||
transition,
|
||||
transform: `translateX(${transform}px)`
|
||||
};
|
||||
|
||||
sidebarStyle = {
|
||||
top,
|
||||
height
|
||||
};
|
||||
}
|
||||
|
||||
const ScrollerComponent = isSmallScreen ? Scroller : OverlayScroller;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={this._setSidebarRef}
|
||||
className={classNames(
|
||||
styles.sidebarContainer
|
||||
)}
|
||||
style={containerStyle}
|
||||
>
|
||||
<ScrollerComponent
|
||||
className={styles.sidebar}
|
||||
style={sidebarStyle}
|
||||
>
|
||||
<div>
|
||||
{
|
||||
links.map((link) => {
|
||||
const childWithStatusComponent = _.find(link.children, (child) => {
|
||||
return !!child.statusComponent;
|
||||
});
|
||||
|
||||
const childStatusComponent = childWithStatusComponent ?
|
||||
childWithStatusComponent.statusComponent :
|
||||
null;
|
||||
|
||||
const isActiveParent = activeParent === link.to;
|
||||
const hasActiveChild = hasActiveChildLink(link, pathname);
|
||||
|
||||
return (
|
||||
<PageSidebarItem
|
||||
key={link.to}
|
||||
iconName={link.iconName}
|
||||
title={link.title}
|
||||
to={link.to}
|
||||
statusComponent={isActiveParent || !childStatusComponent ? link.statusComponent : childStatusComponent}
|
||||
isActive={pathname === link.to && !hasActiveChild}
|
||||
isActiveParent={isActiveParent}
|
||||
isParentItem={!!link.children}
|
||||
onPress={this.onItemPress}
|
||||
>
|
||||
{
|
||||
link.children && link.to === activeParent &&
|
||||
link.children.map((child) => {
|
||||
return (
|
||||
<PageSidebarItem
|
||||
key={child.to}
|
||||
title={child.title}
|
||||
to={child.to}
|
||||
isActive={pathname.startsWith(child.to)}
|
||||
isParentItem={false}
|
||||
isChildItem={true}
|
||||
statusComponent={child.statusComponent}
|
||||
onPress={this.onItemPress}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</PageSidebarItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
</div>
|
||||
|
||||
<MessagesConnector />
|
||||
</ScrollerComponent>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageSidebar.propTypes = {
|
||||
location: locationShape.isRequired,
|
||||
isSmallScreen: PropTypes.bool.isRequired,
|
||||
isSidebarVisible: PropTypes.bool.isRequired,
|
||||
onSidebarVisibleChange: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default PageSidebar;
|
||||
@@ -0,0 +1,50 @@
|
||||
.item {
|
||||
border-left: 3px solid transparent;
|
||||
color: $sidebarColor;
|
||||
transition: border-left 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.isActiveItem {
|
||||
border-left: 3px solid $themeBlue;
|
||||
}
|
||||
|
||||
.link {
|
||||
display: block;
|
||||
padding: 12px 24px;
|
||||
color: $sidebarColor;
|
||||
|
||||
&:hover,
|
||||
&:focus {
|
||||
color: $themeBlue;
|
||||
text-decoration: none;
|
||||
}
|
||||
}
|
||||
|
||||
.childLink {
|
||||
composes: link;
|
||||
|
||||
padding: 10px 24px;
|
||||
}
|
||||
|
||||
.isActiveLink {
|
||||
color: $themeBlue;
|
||||
}
|
||||
|
||||
.isActiveParentLink {
|
||||
background-color: $sidebarActiveBackgroundColor;
|
||||
}
|
||||
|
||||
.iconContainer {
|
||||
display: inline-block;
|
||||
margin-right: 7px;
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.noIcon {
|
||||
margin-left: 25px;
|
||||
}
|
||||
|
||||
.status {
|
||||
float: right;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { map } from 'Helpers/elementChildren';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './PageSidebarItem.css';
|
||||
|
||||
class PageSidebarItem extends Component {
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onPress = () => {
|
||||
const {
|
||||
isChildItem,
|
||||
isParentItem,
|
||||
onPress
|
||||
} = this.props;
|
||||
|
||||
if (isChildItem || !isParentItem) {
|
||||
onPress();
|
||||
}
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
iconName,
|
||||
title,
|
||||
to,
|
||||
isActive,
|
||||
isActiveParent,
|
||||
isChildItem,
|
||||
statusComponent: StatusComponent,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
styles.item,
|
||||
isActiveParent && styles.isActiveItem
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
className={classNames(
|
||||
isChildItem ? styles.childLink : styles.link,
|
||||
isActiveParent && styles.isActiveParentLink,
|
||||
isActive && styles.isActiveLink
|
||||
)}
|
||||
to={to}
|
||||
onPress={this.onPress}
|
||||
>
|
||||
{
|
||||
!!iconName &&
|
||||
<span className={styles.iconContainer}>
|
||||
<Icon
|
||||
name={iconName}
|
||||
/>
|
||||
</span>
|
||||
}
|
||||
|
||||
<span className={isChildItem ? styles.noIcon : null}>
|
||||
{title}
|
||||
</span>
|
||||
|
||||
{
|
||||
!!StatusComponent &&
|
||||
<span className={styles.status}>
|
||||
<StatusComponent />
|
||||
</span>
|
||||
}
|
||||
</Link>
|
||||
|
||||
{
|
||||
children &&
|
||||
map(children, (child) => {
|
||||
return React.cloneElement(child, { isChildItem: true });
|
||||
})
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageSidebarItem.propTypes = {
|
||||
iconName: PropTypes.object,
|
||||
title: PropTypes.string.isRequired,
|
||||
to: PropTypes.string.isRequired,
|
||||
isActive: PropTypes.bool,
|
||||
isActiveParent: PropTypes.bool,
|
||||
isParentItem: PropTypes.bool.isRequired,
|
||||
isChildItem: PropTypes.bool.isRequired,
|
||||
statusComponent: PropTypes.func,
|
||||
children: PropTypes.node,
|
||||
onPress: PropTypes.func
|
||||
};
|
||||
|
||||
PageSidebarItem.defaultProps = {
|
||||
isChildItem: false
|
||||
};
|
||||
|
||||
export default PageSidebarItem;
|
||||
@@ -0,0 +1,3 @@
|
||||
.status {
|
||||
composes: label from 'Components/Label.css';
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import { kinds, sizes } from 'Helpers/Props';
|
||||
import Label from 'Components/Label';
|
||||
|
||||
function PageSidebarStatus({ count, errors, warnings }) {
|
||||
if (!count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let kind = kinds.INFO;
|
||||
|
||||
if (errors) {
|
||||
kind = kinds.DANGER;
|
||||
} else if (warnings) {
|
||||
kind = kinds.WARNING;
|
||||
}
|
||||
|
||||
return (
|
||||
<Label
|
||||
kind={kind}
|
||||
size={sizes.MEDIUM}
|
||||
>
|
||||
{count}
|
||||
</Label>
|
||||
);
|
||||
}
|
||||
|
||||
PageSidebarStatus.propTypes = {
|
||||
count: PropTypes.number,
|
||||
errors: PropTypes.bool,
|
||||
warnings: PropTypes.bool
|
||||
};
|
||||
|
||||
export default PageSidebarStatus;
|
||||
@@ -0,0 +1,16 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
flex: 0 0 auto;
|
||||
padding: 0 20px;
|
||||
height: $toolbarHeight;
|
||||
background-color: $toolbarBackgroundColor;
|
||||
color: $toolbarColor;
|
||||
line-height: 60px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.toolbar {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import styles from './PageToolbar.css';
|
||||
|
||||
class PageToolbar extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
className,
|
||||
children
|
||||
} = this.props;
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PageToolbar.propTypes = {
|
||||
className: PropTypes.string,
|
||||
children: PropTypes.node.isRequired
|
||||
};
|
||||
|
||||
PageToolbar.defaultProps = {
|
||||
className: styles.toolbar
|
||||
};
|
||||
|
||||
export default PageToolbar;
|
||||
@@ -0,0 +1,32 @@
|
||||
.toolbarButton {
|
||||
composes: link from 'Components/Link/Link.css';
|
||||
|
||||
width: $toolbarButtonWidth;
|
||||
text-align: center;
|
||||
|
||||
&:hover {
|
||||
color: $toobarButtonHoverColor;
|
||||
}
|
||||
|
||||
&.isDisabled {
|
||||
color: $disabledColor;
|
||||
}
|
||||
}
|
||||
|
||||
.isDisabled {
|
||||
color: $disabledColor;
|
||||
}
|
||||
|
||||
.labelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 16px;
|
||||
}
|
||||
|
||||
.label {
|
||||
padding: 0 3px;
|
||||
color: $toolbarLabelColor;
|
||||
font-size: $extraSmallFontSize;
|
||||
line-height: calc($extraSmallFontSize + 1px);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { icons } from 'Helpers/Props';
|
||||
import Icon from 'Components/Icon';
|
||||
import Link from 'Components/Link/Link';
|
||||
import styles from './PageToolbarButton.css';
|
||||
|
||||
function PageToolbarButton(props) {
|
||||
const {
|
||||
label,
|
||||
iconName,
|
||||
spinningName,
|
||||
isDisabled,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
return (
|
||||
<Link
|
||||
className={classNames(
|
||||
styles.toolbarButton,
|
||||
isDisabled && styles.isDisabled
|
||||
)}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
>
|
||||
<Icon
|
||||
name={isSpinning ? (spinningName || iconName) : iconName}
|
||||
isSpinning={isSpinning}
|
||||
size={21}
|
||||
/>
|
||||
|
||||
<div className={styles.labelContainer}>
|
||||
<div className={styles.label}>
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
PageToolbarButton.propTypes = {
|
||||
label: PropTypes.string.isRequired,
|
||||
iconName: PropTypes.object.isRequired,
|
||||
spinningName: PropTypes.object,
|
||||
isSpinning: PropTypes.bool,
|
||||
isDisabled: PropTypes.bool
|
||||
};
|
||||
|
||||
PageToolbarButton.defaultProps = {
|
||||
spinningName: icons.SPINNER,
|
||||
isDisabled: false,
|
||||
isSpinning: false
|
||||
};
|
||||
|
||||
export default PageToolbarButton;
|
||||
@@ -0,0 +1,40 @@
|
||||
.sectionContainer {
|
||||
display: flex;
|
||||
flex: 1 1 10%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
.left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.overflowMenuButton {
|
||||
composes: menuButton from 'Components/Menu/ToolbarMenuButton.css';
|
||||
}
|
||||
|
||||
.overflowMenuItemIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.overflowMenuButton {
|
||||
&::after {
|
||||
margin-left: 0;
|
||||
content: '\25BE';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import PropTypes from 'prop-types';
|
||||
import React, { Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import { forEach } from 'Helpers/elementChildren';
|
||||
import { align, icons } from 'Helpers/Props';
|
||||
import dimensions from 'Styles/Variables/dimensions';
|
||||
import SpinnerIcon from 'Components/SpinnerIcon';
|
||||
import Measure from 'Components/Measure';
|
||||
import Menu from 'Components/Menu/Menu';
|
||||
import MenuContent from 'Components/Menu/MenuContent';
|
||||
import MenuItem from 'Components/Menu/MenuItem';
|
||||
import ToolbarMenuButton from 'Components/Menu/ToolbarMenuButton';
|
||||
import styles from './PageToolbarSection.css';
|
||||
|
||||
const BUTTON_WIDTH = parseInt(dimensions.toolbarButtonWidth);
|
||||
const SEPARATOR_MARGIN = parseInt(dimensions.toolbarSeparatorMargin);
|
||||
const SEPARATOR_WIDTH = 2 * SEPARATOR_MARGIN + 1;
|
||||
const SEPARATOR_NAME = 'PageToolbarSeparator';
|
||||
|
||||
function calculateOverflowItems(children, isMeasured, width, collapseButtons) {
|
||||
let buttonCount = 0;
|
||||
let separatorCount = 0;
|
||||
const validChildren = [];
|
||||
|
||||
forEach(children, (child) => {
|
||||
const name = child.type.name;
|
||||
|
||||
if (name === SEPARATOR_NAME) {
|
||||
separatorCount++;
|
||||
} else {
|
||||
buttonCount++;
|
||||
}
|
||||
|
||||
validChildren.push(child);
|
||||
});
|
||||
|
||||
const buttonsWidth = buttonCount * BUTTON_WIDTH;
|
||||
const separatorsWidth = separatorCount + SEPARATOR_WIDTH;
|
||||
const totalWidth = buttonsWidth + separatorsWidth;
|
||||
|
||||
// If the width of buttons and separators is less than
|
||||
// the available width return all valid children.
|
||||
|
||||
if (
|
||||
!isMeasured ||
|
||||
!collapseButtons ||
|
||||
totalWidth < width
|
||||
) {
|
||||
return {
|
||||
buttons: validChildren,
|
||||
buttonCount,
|
||||
overflowItems: []
|
||||
};
|
||||
}
|
||||
|
||||
const maxButtons = Math.max(Math.floor((width - separatorsWidth) / BUTTON_WIDTH), 1);
|
||||
const buttons = [];
|
||||
const overflowItems = [];
|
||||
let actualButtons = 0;
|
||||
|
||||
// Return all buttons if only one is being pushed to the overflow menu.
|
||||
if (buttonCount - 1 === maxButtons) {
|
||||
return {
|
||||
buttons: validChildren,
|
||||
buttonCount,
|
||||
overflowItems: []
|
||||
};
|
||||
}
|
||||
|
||||
validChildren.forEach((child, index) => {
|
||||
if (actualButtons < maxButtons) {
|
||||
if (child.type.name !== SEPARATOR_NAME) {
|
||||
buttons.push(child);
|
||||
actualButtons++;
|
||||
}
|
||||
} else if (child.type.name !== SEPARATOR_NAME) {
|
||||
overflowItems.push(child.props);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
buttons,
|
||||
buttonCount,
|
||||
overflowItems
|
||||
};
|
||||
}
|
||||
|
||||
class PageToolbarSection extends Component {
|
||||
|
||||
//
|
||||
// Lifecycle
|
||||
|
||||
constructor(props, context) {
|
||||
super(props, context);
|
||||
|
||||
this.state = {
|
||||
isMeasured: false,
|
||||
width: 0,
|
||||
buttons: [],
|
||||
overflowItems: []
|
||||
};
|
||||
}
|
||||
|
||||
//
|
||||
// Listeners
|
||||
|
||||
onMeasure = ({ width }) => {
|
||||
this.setState({
|
||||
isMeasured: true,
|
||||
width
|
||||
});
|
||||
}
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
const {
|
||||
children,
|
||||
alignContent,
|
||||
collapseButtons
|
||||
} = this.props;
|
||||
|
||||
const {
|
||||
isMeasured,
|
||||
width
|
||||
} = this.state;
|
||||
|
||||
const {
|
||||
buttons,
|
||||
buttonCount,
|
||||
overflowItems
|
||||
} = calculateOverflowItems(children, isMeasured, width, collapseButtons);
|
||||
|
||||
return (
|
||||
<Measure
|
||||
whitelist={['width']}
|
||||
onMeasure={this.onMeasure}
|
||||
>
|
||||
<div
|
||||
className={styles.sectionContainer}
|
||||
style={{
|
||||
flexGrow: buttonCount
|
||||
}}
|
||||
>
|
||||
{
|
||||
isMeasured ?
|
||||
<div className={classNames(
|
||||
styles.section,
|
||||
styles[alignContent]
|
||||
)}
|
||||
>
|
||||
{
|
||||
buttons.map((button) => {
|
||||
return button;
|
||||
})
|
||||
}
|
||||
|
||||
{
|
||||
!!overflowItems.length &&
|
||||
<Menu>
|
||||
<ToolbarMenuButton
|
||||
className={styles.overflowMenuButton}
|
||||
iconName={icons.OVERFLOW}
|
||||
text="More"
|
||||
/>
|
||||
|
||||
<MenuContent>
|
||||
{
|
||||
overflowItems.map((item) => {
|
||||
const {
|
||||
iconName,
|
||||
spinningName,
|
||||
label,
|
||||
isDisabled,
|
||||
isSpinning,
|
||||
...otherProps
|
||||
} = item;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={label}
|
||||
isDisabled={isDisabled || isSpinning}
|
||||
{...otherProps}
|
||||
>
|
||||
<SpinnerIcon
|
||||
className={styles.overflowMenuItemIcon}
|
||||
name={iconName}
|
||||
spinningName={spinningName}
|
||||
isSpinning={isSpinning}
|
||||
/>
|
||||
{label}
|
||||
</MenuItem>
|
||||
);
|
||||
})
|
||||
}
|
||||
</MenuContent>
|
||||
</Menu>
|
||||
}
|
||||
</div> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
</Measure>
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
PageToolbarSection.propTypes = {
|
||||
children: PropTypes.node,
|
||||
alignContent: PropTypes.oneOf([align.LEFT, align.CENTER, align.RIGHT]),
|
||||
collapseButtons: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
PageToolbarSection.defaultProps = {
|
||||
alignContent: align.LEFT,
|
||||
collapseButtons: true
|
||||
};
|
||||
|
||||
export default PageToolbarSection;
|
||||
@@ -0,0 +1,12 @@
|
||||
.separator {
|
||||
margin: 10px $toolbarSeparatorMargin;
|
||||
height: 40px;
|
||||
border-right: 1px solid #e5e5e5;
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
@media only screen and (max-width: $breakpointSmall) {
|
||||
.separator {
|
||||
margin: 10px 5px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import React, { Component } from 'react';
|
||||
import styles from './PageToolbarSeparator.css';
|
||||
|
||||
class PageToolbarSeparator extends Component {
|
||||
|
||||
//
|
||||
// Render
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={styles.separator} />
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default PageToolbarSeparator;
|
||||
Reference in New Issue
Block a user