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