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

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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);