New: Project Aphrodite

This commit is contained in:
Qstick
2018-11-23 02:04:42 -05:00
parent 65efa15551
commit 8430cb40ab
1080 changed files with 73015 additions and 0 deletions
@@ -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;
}
+56
View File
@@ -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;
+18
View File
@@ -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;
}
}
+136
View File
@@ -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;
}
}
+140
View File
@@ -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;