Preferences page rehaul: use React components, UI improvements

This commit is contained in:
Feross Aboukhadijeh
2016-08-22 00:27:05 -07:00
parent aa150b76a5
commit 173d8444d7
14 changed files with 458 additions and 458 deletions

View File

@@ -18,6 +18,7 @@
"application-config": "^1.0.0",
"bitfield": "^1.0.2",
"chromecasts": "^1.8.0",
"classnames": "^2.2.5",
"create-torrent": "^3.24.5",
"deep-equal": "^1.0.1",
"dlnacasts": "^0.1.0",
@@ -47,6 +48,8 @@
"devDependencies": {
"babel-cli": "^6.11.4",
"babel-plugin-syntax-jsx": "^6.13.0",
"babel-plugin-transform-es2015-destructuring": "^6.9.0",
"babel-plugin-transform-object-rest-spread": "^6.8.0",
"babel-plugin-transform-react-jsx": "^6.8.0",
"cross-zip": "^2.0.1",
"electron-osx-sign": "^0.3.0",

View File

@@ -1,6 +1,8 @@
{
"plugins": [
"syntax-jsx",
"transform-es2015-destructuring",
"transform-object-rest-spread",
"transform-react-jsx"
]
}

View File

@@ -0,0 +1,40 @@
const c = require('classnames')
const React = require('react')
class Button extends React.Component {
static get propTypes () {
return {
className: React.PropTypes.string,
onClick: React.PropTypes.func,
theme: React.PropTypes.oneOf(['light', 'dark']),
type: React.PropTypes.oneOf(['default', 'flat', 'raised'])
}
}
static get defaultProps () {
return {
theme: 'light',
type: 'default'
}
}
render () {
const { theme, type, className, ...other } = this.props
return (
<button
{...other}
className={c(
'Button',
theme,
type,
className
)}
onClick={this.props.onClick}
>
{this.props.children}
</button>
)
}
}
module.exports = Button

View File

@@ -0,0 +1,70 @@
const c = require('classnames')
const electron = require('electron')
const React = require('react')
const remote = electron.remote
const Button = require('./Button')
const TextInput = require('./TextInput')
class PathSelector extends React.Component {
static get propTypes () {
return {
className: React.PropTypes.string,
defaultValue: React.PropTypes.string.isRequired,
dialog: React.PropTypes.object,
label: React.PropTypes.string.isRequired,
onChange: React.PropTypes.func
}
}
constructor (props) {
super(props)
this.state = {
value: props.defaultValue
}
this.handleClick = this.handleClick.bind(this)
}
handleClick () {
var opts = Object.assign({
defaultPath: this.state.value,
properties: [ 'openFile', 'openDirectory' ],
title: this.props.label
}, this.props.dialog)
remote.dialog.showOpenDialog(
remote.getCurrentWindow(),
opts,
(filenames) => {
if (!Array.isArray(filenames)) return
this.setState({value: filenames[0]})
this.props.onChange && this.props.onChange(filenames[0])
}
)
}
render () {
return (
<div className={c('PathSelector', this.props.className)}>
<div className='label'>{this.props.label}:</div>
<TextInput
className='input'
disabled
value={this.state.value}
/>
<Button
className='button'
theme='dark'
onClick={this.handleClick}
>
Change
</Button>
</div>
)
}
}
module.exports = PathSelector

View File

@@ -0,0 +1,166 @@
const React = require('react')
const path = require('path')
const PathSelector = require('./PathSelector')
const {dispatch} = require('../lib/dispatcher')
class PreferencesPage extends React.Component {
render () {
var state = this.props.state
return (
<div className='PreferencesPage'>
<PreferencesSection title='Downloads'>
<Preference>
{DownloadPathSelector(state)}
</Preference>
</PreferencesSection>
<PreferencesSection title='Playback'>
<Preference>
{ExternalPlayerPathSelector(state)}
</Preference>
</PreferencesSection>
</div>
)
}
}
// {ExternalPlayerCheckbox(state)}
// {DefaultAppCheckbox(state)}
class PreferencesSection extends React.Component {
static get propTypes () {
return {
title: React.PropTypes.string
}
}
render () {
return (
<div className='PreferencesSection'>
<h2 className='title'>{this.props.title}</h2>
{this.props.children}
</div>
)
}
}
class Preference extends React.Component {
render () {
return (
<div className='Preference'>
{this.props.children}
</div>
)
}
}
function DownloadPathSelector (state) {
return (
<PathSelector
className='download-path'
label='Download location'
dialog={{
title: 'Select download directory',
properties: [ 'openDirectory' ]
}}
defaultValue={state.unsaved.prefs.downloadPath}
onChange={handleChange}
/>
)
function handleChange (filePath) {
dispatch('updatePreferences', 'downloadPath', filePath)
}
}
function ExternalPlayerPathSelector (state) {
return (
<PathSelector
className='download-path'
label='Player app location'
dialog={{
title: 'Select media player app',
properties: [ 'openFile' ]
}}
defaultValue={state.unsaved.prefs.externalPlayerPath || '<VLC>'}
onChange={handleChange}
/>
)
function handleChange (filePath) {
if (path.extname(filePath) === '.app') {
// Get executable in packaged mac app
var name = path.basename(filePath, '.app')
filePath += '/Contents/MacOS/' + name
}
dispatch('updatePreferences', 'externalPlayerPath', filePath)
}
}
// function ExternalPlayerCheckbox (state) {
// return renderCheckbox({
// label: 'Play in External Player',
// description: 'Media will play in external player',
// property: 'openExternalPlayer',
// value: state.saved.prefs.openExternalPlayer
// },
// state.unsaved.prefs.openExternalPlayer,
// function (value) {
// dispatch('updatePreferences', 'openExternalPlayer', value)
// })
// }
// function renderCheckbox (definition, value, callback) {
// var iconClass = 'icon clickable'
// if (value) iconClass += ' enabled'
// return (
// <div key='{definition.key}' className='control-group'>
// <div className='controls'>
// <label className='control-label'>
// <div className='preference-title'>{definition.label}</div>
// </label>
// <div className='controls'>
// <label className='clickable' onClick={handleClick}>
// <i
// className={iconClass}
// id='{definition.property}'
// >
// check_circle
// </i>
// <span className='checkbox-label'>{definition.description}</span>
// </label>
// </div>
// </div>
// </div>
// )
// function handleClick () {
// callback(!value)
// }
// }
// function DefaultAppCheckbox (state) {
// var definition = {
// key: 'file-handlers',
// label: 'Handle Torrent Files'
// }
// var buttonText = state.unsaved.prefs.isFileHandler
// ? 'Remove default app for torrent files'
// : 'Make WebTorrent the default app for torrent files'
// var controls = [(
// <button key='toggle-handlers'
// className='btn'
// onClick={toggleFileHandlers}>
// {buttonText}
// </button>
// )]
// return renderControlGroup(definition, controls)
// function toggleFileHandlers () {
// var isFileHandler = state.unsaved.prefs.isFileHandler
// dispatch('updatePreferences', 'isFileHandler', !isFileHandler)
// }
// }
module.exports = PreferencesPage

View File

@@ -0,0 +1,34 @@
const c = require('classnames')
const React = require('react')
class TextInput extends React.Component {
static get propTypes () {
return {
theme: React.PropTypes.oneOf('light', 'dark'),
className: React.PropTypes.string
}
}
static get defaultProps () {
return {
theme: 'light'
}
}
render () {
const { className, theme, ...other } = this.props
return (
<input
{...other}
className={c(
'TextInput',
theme,
className
)}
type='text'
/>
)
}
}
module.exports = TextInput

View File

@@ -6,7 +6,7 @@ const Views = {
'home': require('./torrent-list'),
'player': require('./player'),
'create-torrent': require('./create-torrent'),
'preferences': require('./preferences')
'preferences': require('./PreferencesPage')
}
const Modals = {

View File

@@ -1,217 +0,0 @@
const React = require('react')
const remote = require('electron').remote
const dialog = remote.dialog
const path = require('path')
const {dispatch} = require('../lib/dispatcher')
module.exports = class Preferences extends React.Component {
render () {
var state = this.props.state
return (
<div className='preferences'>
{renderGeneralSection(state)}
{renderPlaybackSection(state)}
</div>
)
}
}
function renderGeneralSection (state) {
return renderSection({
key: 'general',
title: 'General',
description: '',
icon: 'settings'
}, [
renderDownloadPathSelector(state),
renderFileHandlers(state)
])
}
function renderPlaybackSection (state) {
return renderSection({
title: 'Playback',
description: '',
icon: 'settings'
}, [
renderOpenExternalPlayerSelector(state),
renderExternalPlayerSelector(state)
])
}
function renderDownloadPathSelector (state) {
return renderFileSelector({
key: 'download-path',
label: 'Download Path',
description: 'Data from torrents will be saved here',
property: 'downloadPath',
options: {
title: 'Select download directory',
properties: [ 'openDirectory' ]
}
},
state.unsaved.prefs.downloadPath,
function (filePath) {
dispatch('updatePreferences', 'downloadPath', filePath)
})
}
function renderFileHandlers (state) {
var definition = {
key: 'file-handlers',
label: 'Handle Torrent Files'
}
var buttonText = state.unsaved.prefs.isFileHandler
? 'Remove default app for torrent files'
: 'Make WebTorrent the default app for torrent files'
var controls = [(
<button key='toggle-handlers'
className='btn'
onClick={toggleFileHandlers}>
{buttonText}
</button>
)]
return renderControlGroup(definition, controls)
function toggleFileHandlers () {
var isFileHandler = state.unsaved.prefs.isFileHandler
dispatch('updatePreferences', 'isFileHandler', !isFileHandler)
}
}
function renderExternalPlayerSelector (state) {
return renderFileSelector({
label: 'External Media Player',
description: 'Progam that will be used to play media externally',
property: 'externalPlayerPath',
options: {
title: 'Select media player executable',
properties: [ 'openFile' ]
}
},
state.unsaved.prefs.externalPlayerPath || '<VLC>',
function (filePath) {
if (path.extname(filePath) === '.app') {
// Get executable in packaged mac app
var name = path.basename(filePath, '.app')
filePath += '/Contents/MacOS/' + name
}
dispatch('updatePreferences', 'externalPlayerPath', filePath)
})
}
function renderOpenExternalPlayerSelector (state) {
return renderCheckbox({
key: 'open-external-player',
label: 'Play in External Player',
description: 'Media will play in external player',
property: 'openExternalPlayer',
value: state.saved.prefs.openExternalPlayer
},
state.unsaved.prefs.openExternalPlayer,
function (value) {
dispatch('updatePreferences', 'openExternalPlayer', value)
})
}
// Renders a prefs section.
// - definition should be {icon, title, description}
// - controls should be an array of vdom elements
function renderSection (definition, controls) {
var helpElem = !definition.description ? null : (
<div key='help' className='help text'>
<i className='icon'>help_outline</i>{definition.description}
</div>
)
return (
<section key={definition.key} className='section preferences-panel'>
<div className='section-container'>
<div key='heading' className='section-heading'>
<i className='icon'>{definition.icon}</i>{definition.title}
</div>
{helpElem}
<div key='body' className='section-body'>
{controls}
</div>
</div>
</section>
)
}
function renderCheckbox (definition, value, callback) {
var iconClass = 'icon clickable'
if (value) iconClass += ' enabled'
return (
<div key='{definition.key}' className='control-group'>
<div className='controls'>
<label className='control-label'>
<div className='preference-title'>{definition.label}</div>
</label>
<div className='controls'>
<label className='clickable' onClick={handleClick}>
<i
className={iconClass}
id='{definition.property}'
>
check_circle
</i>
<span className='checkbox-label'>{definition.description}</span>
</label>
</div>
</div>
</div>
)
function handleClick () {
callback(!value)
}
}
// Creates a file chooser
// - defition should be {label, description, options}
// options are passed to dialog.showOpenDialog
// - value should be the current pref, a file or folder path
// - callback takes a new file or folder path
function renderFileSelector (definition, value, callback) {
var controls = [(
<input
type='text'
className='file-picker-text'
key={definition.property}
id={definition.property}
disabled='disabled'
value={value} />
), (
<button
key={definition.property + '-btn'}
className='btn'
onClick={handleClick}>
<i className='icon'>folder_open</i>
</button>
)]
return renderControlGroup(definition, controls)
function handleClick () {
dialog.showOpenDialog(remote.getCurrentWindow(), definition.options, function (filenames) {
if (!Array.isArray(filenames)) return
callback(filenames[0])
})
}
}
function renderControlGroup (definition, controls) {
return (
<div key={definition.key} className='control-group'>
<div className='controls'>
<label className='control-label'>
<div className='preference-title'>{definition.label}</div>
<div className='preference-description'>{definition.description}</div>
</label>
<div className='controls'>
{controls}
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,76 @@
.Button {
-webkit-app-region: no-drag;
background: transparent;
border-radius: 3px;
border: none;
color: #aaa;
cursor: default;
font-size: 14px;
font-weight: bold;
margin-left: 10px;
outline: none;
padding: 0;
padding: 7px 18px;
text-transform: uppercase;
}
/**
* Default button
*/
.Button.default {
color: #222;
background-color: rgba(153, 153, 153, 0.1);
}
.Button.default.dark {
color: #eee;
}
.Button.default:hover,
.Button.default:focus { /* Material design: focused */
background-color: rgba(153, 153, 153, 0.2);
}
.Button.default:active { /* Material design: pressed */
background-color: rgba(153, 153, 153, 0.4);
}
/**
* Flat button
*/
.Button.flat {
color: #222;
}
.Button.flat.dark {
color: #eee;
}
.Button.flat:hover,
.Button.flat:focus {
background-color: rgba(153, 153, 153, 0.2);
}
.Button.flat:active {
background-color: rgba(153, 153, 153, 0.4);
}
/**
* Raised button
*/
.Button.raised {
background-color: #2196f3;
color: #eee;
}
.Button.raised:hover,
.Button.raised:focus {
background-color: #38a0f5;
}
.Button.raised:active {
background-color: #51abf6;
}

View File

@@ -0,0 +1,17 @@
.PathSelector {
align-items: center;
display: flex;
width: 100%;
}
.PathSelector .label {
margin-right: 10px;
flex: 1 1 auto;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.PathSelector .input {
flex: 5 5 200px;
}

View File

@@ -0,0 +1,18 @@
.TextInput {
background: rgba(255, 255, 255, 0.8);
border-radius: 3px;
font-size: 14px;
outline: none;
padding: 3px 6px;
width: 200px;
-webkit-app-region: no-drag;
cursor: default;
}
.TextInput.light {
border: 1px solid rgba(0, 0, 0, 0.4);
}
.TextInput.dark {
border: 1px solid rgba(0, 0, 0, 0.8);
}

View File

@@ -75,12 +75,12 @@ table {
height: 100%;
display: flex;
flex-flow: column;
background: rgb(40, 40, 40);
background: rgb(30, 30, 30);
animation: fadein 0.5s;
}
.app:not(.is-focused) {
background: rgb(50, 50, 50);
background: rgb(40, 40, 40);
}
/*
@@ -93,7 +93,7 @@ table {
font-weight: 400;
src: local('Material Icons'),
local('MaterialIcons-Regular'),
url(MaterialIcons-Regular.woff2) format('woff2');
url('../MaterialIcons-Regular.woff2') format('woff2');
}
.icon {
@@ -170,7 +170,7 @@ table {
.header {
background: rgb(40, 40, 40);
border-bottom: 1px solid rgb(20, 20, 20);
border-bottom: 1px solid rgb(30, 30, 30);
height: 38px; /* vertically center OS menu buttons (OS X) */
padding-top: 7px;
overflow: hidden;
@@ -247,7 +247,7 @@ table {
overflow-x: hidden;
overflow-y: overlay;
flex: 1 1 auto;
margin-top: 37px;
margin-top: 38px;
}
.app.view-player .content {
@@ -369,64 +369,10 @@ i:not(.disabled):hover { /* Show they're clickable without pointer: cursor */
text-align: center;
}
button { /* Rectangular text buttons */
background: transparent;
margin-left: 10px;
padding: 0;
border: none;
border-radius: 3px;
font-size: 14px;
font-weight: bold;
color: #aaa;
outline: none;
}
button.button-flat {
color: #222;
padding: 7px 18px;
}
button.button-flat.light {
color: #eee;
}
button.button-flat:hover,
button.button-flat:focus { /* Material design: focused */
background-color: rgba(153, 153, 153, 0.2);
}
button.button-flat:active { /* Material design: pressed */
background-color: rgba(153, 153, 153, 0.4);
}
button.button-raised {
background-color: #2196f3;
color: #eee;
padding: 7px 18px;
}
button.button-raised:hover,
button.button-raised:focus {
background-color: #38a0f5;
}
button.button-raised:active {
background-color: #51abf6;
}
/*
* OTHER FORM ELEMENT DEFAULTS
*/
input[type='text'] {
background: transparent;
width: 300px;
padding: 6px;
border: 1px solid #bbb;
border-radius: 3px;
outline: none;
}
/*
* TORRENT LIST
*/
@@ -438,7 +384,7 @@ input[type='text'] {
background-position: center;
transition: -webkit-filter 0.1s ease-out;
position: relative;
animation: fadein .4s;
animation: fadein 0.5s;
}
.torrent,
@@ -921,185 +867,6 @@ video::-webkit-media-text-track-container {
font-weight: bold;
}
/*
* Preferences page, based on Atom settings style
*/
.preferences {
font-size: 12px;
line-height: calc(10/7);
}
.preferences .text {
color: #a8a8a8;
}
.preferences .icon {
color: rgba(170, 170, 170, 0.6);
font-size: 16px;
margin-right: 0.2em;
}
.preferences .icon.enabled {
color: yellow;
}
.preferences .btn {
display: inline-block;
-webkit-appearance: button;
margin: 0;
font-weight: normal;
text-align: center;
vertical-align: middle;
border-color: #cccccc;
border-radius: 3px;
color: #9da5b4;
text-shadow: none;
border: 1px solid #181a1f;
background-color: #3d3d3d;
white-space: initial;
font-size: 0.889em;
line-height: 1;
padding: 0.5em 0.75em;
}
.preferences .btn .icon {
margin: 0;
color: #a8a8a8;
}
.preferences .help .icon {
vertical-align: sub;
}
.preferences .preferences-panel .control-group + .control-group {
margin-top: 1.5em;
}
.preferences .section {
padding: 20px;
border-top: 1px solid #181a1f;
}
.preferences .section:first {
border-top: 0px;
}
.preferences .section:first-child,
.preferences .section:last-child {
padding: 20px;
}
.preferences .section.section:empty {
padding: 0;
border-top: none;
}
.preferences .section-container {
width: 100%;
max-width: 800px;
}
.preferences section .section-heading,
.preferences .section .section-heading {
margin-bottom: 10px;
color: #dcdcdc;
font-size: 1.75em;
font-weight: bold;
line-height: 1;
-webkit-user-select: none;
cursor: default;
}
.preferences .sub-section-heading.icon:before,
.preferences .section-heading.icon:before {
margin-right: 8px;
}
.preferences .section-heading-count {
margin-left: .5em;
}
.preferences .section-body {
margin-top: 20px;
}
.preferences .sub-section {
margin: 20px 0;
}
.preferences .sub-section .sub-section-heading {
color: #dcdcdc;
font-size: 1.4em;
font-weight: bold;
line-height: 1;
-webkit-user-select: none;
}
.preferences .preferences-panel label {
color: #a8a8a8;
}
.preferences .preferences-panel .control-group + .control-group {
margin-top: 1.5em;
}
.preferences .preferences-panel .control-group .editor-container {
margin: 0;
}
.preferences .preference-title {
font-size: 1.2em;
-webkit-user-select: none;
display: inline-block;
}
.preferences .preference-description {
color: rgba(170, 170, 170, 0.6);
-webkit-user-select: none;
cursor: default;
}
.preferences input {
font-size: 1.1em;
line-height: 1.15em;
max-height: none;
width: 100%;
padding-left: 0.5em;
border-radius: 3px;
color: #a8a8a8;
border: 1px solid #181a1f;
background-color: #1b1d23;
}
.preferences input::-webkit-input-placeholder {
color: rgba(170, 170, 170, 0.6);
}
.preferences .control-group input {
margin-top: 0.2em;
}
.preferences .control-group input.file-picker-text {
width: calc(100% - 40px);
}
.preferences .control-group .checkbox .icon {
font-size: 1.5em;
margin: 0;
vertical-align: text-bottom;
}
.preferences .checkbox {
width: auto;
}
.checkbox-label {
vertical-align: top;
}
/*
* MEDIA OVERLAY / AUDIO DETAILS
*/

View File

@@ -0,0 +1,17 @@
.PreferencesPage {
color: rgba(255, 255, 255, 0.6);
margin: 0 20px;
}
.PreferencesSection:not(:last-child) {
margin-top: 20px;
margin-bottom: 40px;
}
.PreferencesSection .title {
color: rgba(255, 255, 255, 0.9);
}
.Preference:not(:last-child) {
margin-bottom: 10px;
}

View File

@@ -3,8 +3,15 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>WebTorrent Desktop</title>
<link rel="stylesheet" href="main.css">
<link rel="stylesheet" href="css/components/Button.css">
<link rel="stylesheet" href="css/components/PathSelector.css">
<link rel="stylesheet" href="css/components/TextInput.css">
<link rel="stylesheet" href="css/pages/PreferencesPage.css">
<link rel="stylesheet" href="css/main.css">
</head>
<body>
<!-- React prints a warning if you render to <body> directly -->