Merge pull request #30 from EbookFoundation/add-push-address-page

Add push address page
pull/24/head
Theodore Kluge 2018-11-15 18:37:50 -05:00 committed by GitHub
commit 666cbccea0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
24 changed files with 506 additions and 13 deletions

View File

@ -39,6 +39,7 @@ module.exports = {
throw new HttpError(500, err.message)
}
await Book.update({ id: result.id }, { storage: uploaded[0].fd })
sendUpdatesAsync(result.id)
return res.json({
...result
})
@ -70,3 +71,11 @@ module.exports = {
}
}
}
async function sendUpdatesAsync (id) {
const book = await Book.find({ id })
const targets = await TargetUrl.find()
for (const i in targets) {
sails.log('sending ' + book.id + ' info to ' + targets[i].url)
}
}

View File

@ -1,8 +1,55 @@
'use strict'
const HttpError = require('../errors/HttpError')
module.exports = {
show: function (req, res) {
res.view('pages/temp', {
res.view('pages/targets', {
email: req.user.email
})
},
create: async function (req, res) {
try {
const url = await TargetUrl.create({
user: req.user.id
}).fetch()
return res.json(url)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
edit: async function (req, res) {
try {
const id = req.param('id')
const value = req.param('url')
if (value.length) {
const url = await TargetUrl.update({ id, user: req.user.id }, { url: value }).fetch()
return res.json(url)
} else {
await TargetUrl.destroyOne({ id })
return res.status(204).send()
}
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
delete: async function (req, res) {
try {
const id = +req.param('id')
await TargetUrl.destroyOne({ id })
return res.status(204).send()
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
},
list: async function (req, res) {
try {
const urls = await TargetUrl.find({
user: req.user.id
})
return res.json(urls)
} catch (e) {
return (new HttpError(500, e.message)).send(res)
}
}
}

16
api/models/TargetUrl.js Normal file
View File

@ -0,0 +1,16 @@
module.exports = {
attributes: {
id: {
type: 'number',
unique: true,
autoIncrement: true
},
user: {
model: 'User',
required: true
},
url: {
type: 'string'
}
}
}

View File

@ -9,6 +9,6 @@ module.exports = function (req, res, next) {
if (req.session.authenticated) {
return next()
}
res.status(403).json({ error: 'You are not permitted to perform this action.' })
// res.redirect('/login')
// res.status(403).json({ error: 'You are not permitted to perform this action.' })
res.redirect('/login')
}

View File

@ -0,0 +1,110 @@
'use strict'
import Ajax from '../lib/Ajax'
const ACTIONS = {
set_working: 'set_working',
add_url: 'add_url',
edit_url: 'edit_url',
delete_url: 'delete_url',
list_url: 'list_url',
error: 'error'
}
export default ACTIONS
export const setWorking = working => ({
type: ACTIONS.set_working,
data: working
})
export const setUrls = (urls) => ({
type: ACTIONS.list_url,
data: urls
})
export const addUrl = url => ({
type: ACTIONS.add_url,
data: url
})
export const changeUrlField = (id, value) => ({
type: ACTIONS.edit_url,
data: {
id,
value
}
})
export const removeUrl = id => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.delete({
url: '/api/targets/' + id
})
dispatch({
type: ACTIONS.delete_url,
data: id
})
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const fetchUrls = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.get({
url: '/api/targets'
})
dispatch(setUrls(data))
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const createNewUrl = () => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
const { data } = await Ajax.post({
url: '/api/targets'
})
dispatch(addUrl(data))
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}
export const setUrl = (id, value) => async (dispatch, getState) => {
dispatch(setWorking(true))
try {
await Ajax.patch({
url: '/api/targets/' + id,
data: {
url: value
}
})
} catch (e) {
dispatch({
type: ACTIONS.error,
data: e
})
} finally {
dispatch(setWorking(false))
}
}

View File

@ -0,0 +1,19 @@
'use strict'
import React from 'react'
import '../../styles/shared/iconbutton.scss'
function getSVG (icon) {
switch (icon) {
case 'delete': return '<svg viewBox="0 0 24 24"><path d="M19,4H15.5L14.5,3H9.5L8.5,4H5V6H19M6,19A2,2 0 0,0 8,21H16A2,2 0 0,0 18,19V7H6V19Z" /></svg>'
default: return icon || 'missing icon prop'
}
}
const IconButton = props => {
return (
<button className='button icon' onClick={props.onClick} dangerouslySetInnerHTML={{ __html: getSVG(props.icon) }} />
)
}
export default IconButton

View File

@ -5,14 +5,17 @@ import React from 'react'
import '../../styles/shared/underlineinput.scss'
const UnderlineInput = props => (
<div className='underlined-input'>
<div className={'underlined-input ' + (props.className ? props.className : '')}>
<input
type={props.type}
name={props.name}
value={props.value}
className={(props.value.length ? 'has-content' : '')}
className={(props.value.length ? 'has-content' : '') + (props.pattern
? (props.value.length && !props.pattern.test(props.value) ? ' invalid' : '')
: '')}
autoComplete='nothing'
onChange={props.onChange} />
onChange={props.onChange}
onBlur={props.onBlur} />
<div className='reacts-to'>
<label className='placeholder'>{props.placeholder}</label>
<div className='underline' />

View File

@ -0,0 +1,30 @@
'use strict'
import React from 'react'
import IconButton from '../components/IconButton'
import UnderlineInput from '../components/UnderlineInput'
import '../../styles/shared/urilistitem.scss'
import { changeUrlField, setUrl, removeUrl } from '../actions/targets'
const uriRegex = /(.+:\/\/)?(.+\.)*(.+\.).{1,}(:\d+)?(.+)?/i
class UriListItem extends React.Component {
render () {
return (
<li className='uri-list-item flex-container'>
<UnderlineInput
className='uri flex'
type='text'
name={'url-' + this.props.id}
placeholder='Destination URL'
value={'' + this.props.url}
pattern={uriRegex}
onChange={(e) => this.props.dispatch(changeUrlField(this.props.id, e.target.value))}
onBlur={(e) => this.props.dispatch(setUrl(this.props.id, e.target.value))} />
<IconButton icon='delete' onClick={() => this.props.dispatch(removeUrl(this.props.id))} />
</li>
)
}
}
export default UriListItem

View File

@ -81,8 +81,11 @@ export default class Ajax {
}
xhr.onload = () => {
if (xhr.status !== 200) { return xhr.onerror() }
if (!('' + xhr.status).startsWith('2')) { return xhr.onerror() }
var data = xhr.response
try {
data = JSON.parse(data)
} catch (e) {}
resolve({
data,
xhr

View File

@ -0,0 +1,41 @@
'use strict'
import Actions from '../actions/targets'
const reducer = (state = {}, action) => {
const { type, data } = action
let urls
switch (type) {
case Actions.set_working:
return {
working: data
}
case Actions.list_url:
return {
urls: data || []
}
case Actions.add_url:
return {
urls: state.urls.concat(data),
error: ''
}
case Actions.delete_url:
return {
urls: state.urls.filter(x => x.id !== data),
error: ''
}
case Actions.edit_url:
urls = state.urls
urls.find(x => x.id === data.id).url = data.value
return {
urls: urls
}
case Actions.error:
return {
error: data.message
}
default: return {}
}
}
export default reducer

81
assets/js/targets.js Normal file
View File

@ -0,0 +1,81 @@
'use strict'
import React from 'react'
import ReactDOM from 'react-dom'
import Progress from './components/Progress'
import UriListItem from './containers/UriListItem'
import reducer from './reducers/targets'
import { fetchUrls, createNewUrl } from './actions/targets'
import '../styles/targets.scss'
class App extends React.Component {
constructor () {
super()
this.state = {
error: '',
user: {
email: '',
password: ''
},
urls: [{
id: 1,
url: 'http'
}],
working: false
}
this.dispatch = this.dispatch.bind(this)
this.getRegisteredUris = this.getRegisteredUris.bind(this)
}
dispatch (action) {
if (!action) throw new Error('dispatch: missing action')
if (action instanceof Function) {
action(this.dispatch, () => this.state)
} else {
const changes = reducer(this.state, action)
if (!changes || !Object.keys(changes).length) return
this.setState({
...changes
})
}
}
componentDidMount () {
this.dispatch(fetchUrls())
}
getRegisteredUris () {
return this.state.urls.map((item, i) => {
return (<UriListItem
key={i}
dispatch={this.dispatch}
id={item.id}
url={item.url} />)
})
}
render () {
return (
<div className='root-container flex-container'>
<aside className='nav nav-left'>
<header>
<h1>RoE</h1>
</header>
</aside>
<section className={'content flex' + (this.state.working ? ' working' : '')}>
<Progress bound />
{this.state.error && <div className='error-box'>{this.state.error}</div>}
<header className='flex-container'>
<div className='flex'>
<h1>Push URIs</h1>
<h2>Newly published books will be sent to these addresses.</h2>
</div>
<button className='btn' onClick={() => this.dispatch(createNewUrl())}>New address</button>
</header>
<ul className='list'>
{this.getRegisteredUris()}
</ul>
</section>
</div>
)
}
}
ReactDOM.render(<App />, document.getElementById('root'))

View File

@ -16,6 +16,12 @@ body,
box-sizing: border-box;
}
h1 {
margin: 0;
padding: 0;
font-weight: normal;
}
.flex-container {
display: flex;

View File

@ -21,6 +21,8 @@ $background-2: white;
$text-dark-1: $black-1;
$text-dark-2: $black-2;
$text-light-1: white;
$text-light-2: rgba(255,255,255,.75);
$accent-1: #731212;
$accent-2: #9a834d;

View File

@ -0,0 +1,23 @@
@import '../lib/vars';
.button.icon {
height: 40px;
line-height: 40px;
width: 40px;
padding: 5px;
background: transparent;
border: none;
outline: none;
border-radius: 50%;
transition: background 0.2s $transition,
box-shadow 0.2s $transition;
cursor: pointer;
&:hover {
background: $black-4;
box-shadow: $shadow-1;
}
path {
fill: $black-2;
}
}

View File

@ -0,0 +1,7 @@
.nav-left {
min-width: 300px;
height: 100%;
background: $accent-1;
color: $text-light-1;
box-shadow: $shadow-1;
}

View File

@ -71,6 +71,9 @@
left: 0;
}
}
&.invalid {
color: $red;
}
&.invalid:focus + .reacts-to,
&.invalid:active + .reacts-to,
&.invalid.has-content + .reacts-to {
@ -79,7 +82,7 @@
}
}
&.invalid + .reacts-to {
.underline {
.underline:before {
background: $red;
}
}

View File

@ -0,0 +1,12 @@
@import '../lib/vars';
.uri-list-item {
height: 60px;
line-height: 60px;
background: $background-1;
box-shadow: $shadow-0;
& > .button.icon {
margin: 7px 5px 3px 5px;
}
}

View File

@ -0,0 +1,51 @@
@import 'lib/default';
@import 'shared/twopanels';
.nav {
header {
height: 50px;
line-height: 50px;
padding: 0 14px;
}
}
.content {
padding: 14px 0 42px 0;
position: relative;
.error-box {
height: 30px;
line-height: 30px;
background: $red;
color: white;
padding: 0 14px;
margin: -14px 0 8px 0;
}
header {
padding: 0 14px;
h1 {
text-shadow: 1px 1px 2px $black-3;
}
h2 {
margin: 0;
padding: 0;
font-weight: normal;
font-size: 16px;
margin-top: 4px;
color: $text-dark-2;
text-shadow: 1px 1px 2px $black-4;
}
}
.list {
margin: 14px;
padding: 0;
list-style: none;
overflow: hidden;
}
&.working {
& > .progress {
top: 0;
height: 4px;
}
}
}

View File

@ -0,0 +1,17 @@
<% var key, item %>
<% htmlWebpackPlugin.options.links = htmlWebpackPlugin.options.links || [] %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>RoE - Push Targets</title>
<% for (item of htmlWebpackPlugin.options.links) {
if (typeof item === 'string' || item instanceof String) { item = { href: item, rel: 'stylesheet' } } %>
<link<% for (key in item) { %> <%= key %>="<%= item[key] %>"<% } %> /><%
} %>
<meta name="viewport" content="initial-scale=1, width=device-width, maximum-scale=1, minimum-scale=1, user-scalable=no" />
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -66,7 +66,12 @@ module.exports.routes = {
'POST /api/publish': 'BooksController.publish',
'GET /api/books': 'BooksController.list',
'GET /api/me': 'UserController.me'
'GET /api/me': 'UserController.me',
'POST /api/targets': 'TargetController.create',
'PATCH /api/targets/:id': 'TargetController.edit',
'DELETE /api/targets/:id': 'TargetController.delete',
'GET /api/targets': 'TargetController.list'
// ╦ ╦╔═╗╔╗ ╦ ╦╔═╗╔═╗╦╔═╔═╗
// ║║║║╣ ╠╩╗╠═╣║ ║║ ║╠╩╗╚═╗

View File

@ -82,6 +82,7 @@
"User",
"Book",
"Passport",
"TargetUrl",
"_"
],
"parser": "babel-eslint"

1
views/pages/targets.ejs Normal file
View File

@ -0,0 +1 @@
<%- partial('../../.tmp/public/targets.html') %>

View File

@ -1,2 +0,0 @@
authed: <%- email %><br />
<a href="/logout">Logout</a>

View File

@ -9,7 +9,8 @@ module.exports = (env, argv) => {
return {
mode: mode || 'development',
entry: {
login: './assets/js/login.js'
login: './assets/js/login.js',
targets: './assets/js/targets.js'
},
output: {
path: path.join(__dirname, '/.tmp/public'),
@ -36,7 +37,14 @@ module.exports = (env, argv) => {
new HtmlWebpackPlugin({
template: 'assets/templates/login.html',
links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'login.css' }] : [],
filename: path.join(__dirname, '/.tmp/public/login.html')
filename: path.join(__dirname, '/.tmp/public/login.html'),
chunks: ['login']
}),
new HtmlWebpackPlugin({
template: 'assets/templates/targets.html',
links: mode === 'production' ? [{ rel: 'stylesheet', type: 'text/css', href: 'targets.css' }] : [],
filename: path.join(__dirname, '/.tmp/public/targets.html'),
chunks: ['targets']
}),
new MiniCssExtractPlugin({
filename: '[name].css'