mirror of
https://github.com/wassname/talk.git
synced 2026-07-04 09:20:31 +08:00
@@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
public/bundle.js
|
||||
public/embed/comment-stream
|
||||
.DS_Store
|
||||
npm-debug.log
|
||||
config.json
|
||||
yarn.lock
|
||||
@@ -0,0 +1,23 @@
|
||||
|
||||
# Coral Admin
|
||||
|
||||
This app handles moderation for Talk (and maybe more later on)
|
||||
|
||||
## Installation
|
||||
|
||||
$ npm install
|
||||
$ cp config.sample.json config.json
|
||||
|
||||
Then change `config.json` to adjust it to your project
|
||||
|
||||
## Building for production
|
||||
|
||||
$ npm run build
|
||||
|
||||
The public folder has everything you need for deployment. You can just copy that folder to your favorite static web server
|
||||
|
||||
## Development
|
||||
|
||||
$ npm start
|
||||
|
||||
A development server will be running at http://localhost:4132
|
||||
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"basePath": "http://localhost:3142",
|
||||
"talkHost": "http://localhost:16180",
|
||||
"xeniaHost": "http://localhost:16180"
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<title>Talk - Coral Admin</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<style media="screen">
|
||||
body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="<%= basePath %>/bundle.js" charset="utf-8"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"name": "coral-admin",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"build": "./node_modules/.bin/webpack --config webpack.config.js",
|
||||
"start": "./node_modules/.bin/webpack-dev-server --config webpack.config.dev.js --inline --hot --content-base public --port 3142"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"coral-framework": "0.0.1",
|
||||
"hammerjs": "2.0.8",
|
||||
"immutable": "3.8.1",
|
||||
"keymaster": "1.6.2",
|
||||
"material-design-lite": "1.2.1",
|
||||
"react": "^15.3.2",
|
||||
"react-dom": "^15.3.2",
|
||||
"react-mdl": "^1.7.2",
|
||||
"react-redux": "^4.4.5",
|
||||
"react-router": "^3.0.0",
|
||||
"redux": "3.6.0",
|
||||
"redux-thunk": "2.1.0",
|
||||
"timeago.js": "2.0.2",
|
||||
"xenia-driver": "0.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"autoprefixer": "6.5.0",
|
||||
"buble": "0.13.0",
|
||||
"buble-loader": "0.3.0",
|
||||
"copy-webpack-plugin": "3.0.1",
|
||||
"css-loader": "0.25.0",
|
||||
"json-loader": "0.5.4",
|
||||
"postcss-loader": "0.13.0",
|
||||
"postcss-modules": "0.5.2",
|
||||
"precss": "1.4.0",
|
||||
"standard": "8.2.0",
|
||||
"style-loader": "0.13.1",
|
||||
"webpack": "2.1.0-beta.25",
|
||||
"webpack-dev-server": "1.16.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<title>Talk - Coral Admin</title>
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
<style media="screen">
|
||||
body, #root {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
background: #fff;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="http://localhost:3142/bundle.js" charset="utf-8"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"short_name": "Talk",
|
||||
"name": "Talk",
|
||||
"icons": [
|
||||
{
|
||||
"src": "https://coralproject.net/images/icon-coral-white.svg",
|
||||
"sizes": "150x150"
|
||||
}
|
||||
],
|
||||
"start_url": "./",
|
||||
"display": "standalone"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"modqueue": {
|
||||
"pending": "pending",
|
||||
"rejected": "rejected",
|
||||
"flagged": "flagged",
|
||||
"shortcuts": "Shortcuts",
|
||||
"close": "Close",
|
||||
"actions": "Actions",
|
||||
"navigation": "Navigation",
|
||||
"approve": "Approve comment",
|
||||
"reject": "Reject comment",
|
||||
"nextcomment": "Go to the next comment",
|
||||
"prevcomment": "Go to the previous comment",
|
||||
"singleview": "Toggle single comment edit view",
|
||||
"thismenu": "Open this menu"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "flagged",
|
||||
"anon": "Anonymous"
|
||||
},
|
||||
"embedlink": {
|
||||
"copy": "Copy to Clipboard"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"modqueue": {
|
||||
"pending": "pendiente",
|
||||
"rejected": "rechazado",
|
||||
"flagged": "marcado",
|
||||
"shortcuts": "Atajos de teclado",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "marcado",
|
||||
"anon": "Anónimo"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
/**
|
||||
* Action disptacher related to comments
|
||||
*/
|
||||
|
||||
export const updateStatus = (status, id) => (dispatch, getState) => {
|
||||
dispatch({ type: 'COMMENT_STATUS_UPDATE', id, status })
|
||||
dispatch({ type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id) })
|
||||
}
|
||||
|
||||
export const flagComment = id => (dispatch, getState) => {
|
||||
dispatch({ type: 'COMMENT_FLAG', id })
|
||||
dispatch({ type: 'COMMENT_UPDATE', comment: getState().comments.get('byId').get(id) })
|
||||
}
|
||||
|
||||
export const createComment = (name, body) => dispatch => {
|
||||
dispatch({ type: 'COMMENT_CREATE', name, body })
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
import React from 'react'
|
||||
import { Provider } from 'react-redux'
|
||||
import { Layout, Content } from 'react-mdl'
|
||||
import 'material-design-lite'
|
||||
import { Router, Route, browserHistory } from 'react-router'
|
||||
import ModerationQueue from 'containers/ModerationQueue'
|
||||
import Header from 'components/Header'
|
||||
import store from 'services/store'
|
||||
import CommentStream from 'containers/CommentStream'
|
||||
import EmbedLink from 'components/EmbedLink'
|
||||
import Configure from 'containers/Configure'
|
||||
|
||||
export default class App extends React.Component {
|
||||
render (props) {
|
||||
return (
|
||||
<Provider store={store}>
|
||||
<Layout>
|
||||
<Header>
|
||||
<div>
|
||||
<Router history={browserHistory}>
|
||||
<Route path='/' component={ModerationQueue} />
|
||||
<Route path='embed' component={CommentStream} />
|
||||
<Route path='embedlink' compoent={EmbedLink} />
|
||||
<Route path='configure' component={Configure} />
|
||||
</Router>
|
||||
</div>
|
||||
</Header>
|
||||
</Layout>
|
||||
</Provider>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
|
||||
import React from 'react'
|
||||
import { Button, Icon } from 'react-mdl'
|
||||
import timeago from 'timeago.js'
|
||||
import styles from './CommentList.css'
|
||||
import I18n from 'coral-framework/i18n/i18n'
|
||||
import translations from '../translations'
|
||||
|
||||
// Render a single comment for the list
|
||||
export default props => (
|
||||
<li tabindex={props.index} className={`${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<i className={`material-icons ${styles.avatar}`}>person</i>
|
||||
<span>{props.comment.get('data').get('name') || lang.t('comment.anon')}</span>
|
||||
<span className={styles.created}>{timeago().format(props.comment.get('data').get('createdAt') || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}</span>
|
||||
{props.comment.get('data').get('flagged') ? <p className={styles.flagged}>{lang.t('comment.flagged')}</p> : null}
|
||||
</div>
|
||||
<div className={styles.actions}>
|
||||
{props.actions.map(action => canShowAction(action, props.comment) ? (
|
||||
<Button className={styles.actionButton}
|
||||
onClick={() => props.onClickAction(props.actionsMap[action].status, props.comment.get('item_id'))}
|
||||
fab colored>
|
||||
<Icon name={props.actionsMap[action].icon} />
|
||||
</Button>
|
||||
) : null)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<span className={styles.body}>{props.comment.get('data').get('body')}</span>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
// Check if an action can be performed over a comment
|
||||
const canShowAction = (action, comment) => {
|
||||
const status = comment.get('data').get('status')
|
||||
const flagged = comment.get('data').get('flagged')
|
||||
if (action === 'flag' && (status !== 'Untouched' || flagged === true)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const lang = new I18n(translations)
|
||||
@@ -0,0 +1,4 @@
|
||||
|
||||
.textareaContainer {
|
||||
width: 100%;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
|
||||
import React from 'react'
|
||||
import styles from './CommentBox.css'
|
||||
import { Button } from 'react-mdl'
|
||||
|
||||
// Renders a comment box for creating a new comment
|
||||
export default class CommentBox extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
this.state = { name: '', body: '' }
|
||||
this.onSubmit = this.onSubmit.bind(this)
|
||||
}
|
||||
|
||||
onSubmit () {
|
||||
const { name, body } = this.state
|
||||
this.props.onSubmit({ name, body })
|
||||
this.setState({ body: '', name: '' })
|
||||
}
|
||||
|
||||
render (props, { name, body }) {
|
||||
return (
|
||||
<div>
|
||||
<div class={`${styles.textareaContainer} mdl-textfield mdl-js-textfield`}>
|
||||
<input type='text' value={name} onInput={this.linkState('name')} class='mdl-textfield__input' id='name' />
|
||||
<label class='mdl-textfield__label' for='name'>Your name</label>
|
||||
</div>
|
||||
<div class={`${styles.textareaContainer} mdl-textfield mdl-js-textfield`}>
|
||||
<textarea value={body} onInput={this.linkState('body')} class='mdl-textfield__input' type='text' rows='5' id='comment' />
|
||||
<label class='mdl-textfield__label' for='comment'>Write your comment</label>
|
||||
</div>
|
||||
<Button onClick={this.onSubmit} raised>Post</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.list {
|
||||
padding: 8px 0;
|
||||
list-style: none;
|
||||
display: block;
|
||||
|
||||
&.singleView .listItem {
|
||||
display: none;
|
||||
}
|
||||
|
||||
&.singleView .listItem.activeItem {
|
||||
display: block;
|
||||
height: 100%;
|
||||
font-size: 1.5em;
|
||||
line-height: 1.5em;
|
||||
border: none;
|
||||
|
||||
.actions {
|
||||
position: fixed;
|
||||
bottom: 60px;
|
||||
left: 25%;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
width: 50%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listItem {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
padding: 16px;
|
||||
font-size: 16px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.author {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
margin-right: 16px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: 50%;
|
||||
background-color: #757575;
|
||||
font-size: 40px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.created {
|
||||
color: #666;
|
||||
font-size: 10px;
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.7);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 20px;
|
||||
flex: 1;
|
||||
font-size: 1em;
|
||||
color: rgba(0,0,0,.54);
|
||||
}
|
||||
|
||||
.actions {
|
||||
margin-left: 10px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
color: rgba(255, 0, 0, .5);
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.empty {
|
||||
color: #444;
|
||||
margin-top: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
|
||||
@media (--big-viewport) {
|
||||
.listItem {
|
||||
border: 1px solid #e0e0e0;
|
||||
margin-bottom: 30px;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 1px solid #e0e0e0;
|
||||
}
|
||||
|
||||
&.activeItem {
|
||||
border: 2px solid #333;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
|
||||
import React from 'react'
|
||||
import styles from './CommentList.css'
|
||||
import key from 'keymaster'
|
||||
import Hammer from 'hammerjs'
|
||||
import Comment from 'components/Comment'
|
||||
|
||||
// Each action has different meaning and configuration
|
||||
const actions = {
|
||||
'reject': { status: 'Rejected', icon: 'close', key: 'r' },
|
||||
'approve': { status: 'Approved', icon: 'done', key: 't' },
|
||||
'flag': { status: 'flagged', icon: 'flag', filter: 'Untouched' }
|
||||
}
|
||||
|
||||
// Renders a comment list and allow performing actions
|
||||
export default class CommentList extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
this.state = { active: null }
|
||||
this.onClickAction = this.onClickAction.bind(this)
|
||||
}
|
||||
|
||||
// remove key handlers before leaving
|
||||
componentWillUnmount () {
|
||||
this.unbindKeyHandlers()
|
||||
}
|
||||
|
||||
// add key handlers and gestures
|
||||
componentDidMount () {
|
||||
this.bindKeyHandlers()
|
||||
// this.bindGestures() // need to check whether we're on a mobile device or this throws an Error
|
||||
}
|
||||
|
||||
// If entering to singleview and no active, active is the first eleement
|
||||
componentWillReceiveProps (nextProps) {
|
||||
if (nextProps.singleView && !this.state.active) {
|
||||
this.setState({ active: nextProps.commentIds.get(0) })
|
||||
}
|
||||
}
|
||||
|
||||
// Add swipe to approve or reject
|
||||
bindGestures () {
|
||||
const { actions } = this.props
|
||||
this._hammer = new Hammer(this.base)
|
||||
this._hammer.get('swipe').set({ direction: Hammer.DIRECTION_HORIZONTAL })
|
||||
|
||||
if (actions.indexOf('reject') !== -1) {
|
||||
this._hammer.on('swipeleft', () => this.props.singleView && this.actionKeyHandler('Rejected'))
|
||||
}
|
||||
if (actions.indexOf('approve') !== -1) {
|
||||
this._hammer.on('swiperight', () => this.props.singleView && this.actionKeyHandler('Approved'))
|
||||
}
|
||||
}
|
||||
|
||||
// Add key handlers. Each action has one and added j/k for moving around
|
||||
bindKeyHandlers () {
|
||||
this.props.actions.filter(action => actions[action].key).forEach(action => {
|
||||
key(actions[action].key, 'commentList', () => this.props.isActive && this.actionKeyHandler(actions[action].status))
|
||||
})
|
||||
key('j', 'commentList', () => this.props.isActive && this.moveKeyHandler('down'))
|
||||
key('k', 'commentList', () => this.props.isActive && this.moveKeyHandler('up'))
|
||||
key.setScope('commentList')
|
||||
}
|
||||
|
||||
// Perform an action using the keys only if the comment is active
|
||||
actionKeyHandler (action) {
|
||||
if (this.props.isActive && this.state.active) {
|
||||
this.onClickAction(action, this.state.active)
|
||||
}
|
||||
}
|
||||
|
||||
// move around with j/k
|
||||
moveKeyHandler (direction) {
|
||||
if (!this.props.isActive) {
|
||||
return
|
||||
}
|
||||
|
||||
const { commentIds } = this.props
|
||||
const { active } = this.state
|
||||
// check boundaries
|
||||
if (active == null || !commentIds.size) {
|
||||
this.setState({ active: commentIds.get(0) })
|
||||
} else if (direction === 'up' && active !== commentIds.first()) {
|
||||
this.setState({ active: commentIds.get(commentIds.indexOf(active) - 1) })
|
||||
} else if (direction === 'down' && active !== commentIds.last()) {
|
||||
this.setState({ active: commentIds.get(commentIds.indexOf(active) + 1) })
|
||||
}
|
||||
|
||||
// scroll to the position
|
||||
const index = Math.max(commentIds.indexOf(this.state.active), 0)
|
||||
this.base.childNodes[index] && this.base.childNodes[index].focus()
|
||||
}
|
||||
|
||||
unbindKeyHandlers () {
|
||||
key.deleteScope('commentList')
|
||||
}
|
||||
|
||||
// If we are performing an action over a comment (aka removing from the list) we need to select a new active.
|
||||
// TODO: In the future this can be improved and look at the actual state to
|
||||
// resolve since the content of the list could change externally. For now it works as expected
|
||||
onClickAction (action, id) {
|
||||
if (id === this.state.active) {
|
||||
const { commentIds } = this.props
|
||||
if (commentIds.last() === this.state.active) {
|
||||
this.setState({ active: commentIds.get(commentIds.size - 2) })
|
||||
} else {
|
||||
this.setState({ active: commentIds.get(Math.min(commentIds.indexOf(this.state.active) + 1, commentIds.size - 1)) })
|
||||
}
|
||||
}
|
||||
this.props.onClickAction(action, id)
|
||||
}
|
||||
|
||||
render () {
|
||||
const {singleView, actions, commentIds, comments, hideActive} = this.props
|
||||
const {active} = this.state
|
||||
return (
|
||||
<ul className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
|
||||
{commentIds.map((commentId, index) => (
|
||||
<Comment comment={comments.get(commentId)}
|
||||
ref={el => { if (el && commentId === active) { this._active = el } }}
|
||||
key={index}
|
||||
index={index}
|
||||
onClickAction={this.onClickAction}
|
||||
actions={actions}
|
||||
actionsMap={actions}
|
||||
isActive={commentId === active}
|
||||
hideActive={hideActive} />
|
||||
)).toArray()}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
#embedLink {
|
||||
width:400px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.embedTextarea {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
margin-top: 20px;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import React from 'react'
|
||||
import styles from './EmbedLink.css'
|
||||
import I18n from 'coral-framework/i18n/i18n'
|
||||
import translations from '../translations'
|
||||
import { Button } from 'react-mdl'
|
||||
|
||||
const embedText =
|
||||
`<div id='coralStreamEmbed'></div><script type='text/javascript' src='http://pym.nprapps.org/pym.v1.min.js'></script><script>var pymParent = new pym.Parent('coralStreamEmbed', '${window.location.protocol}//${window.location.host}/embedScript/index.html', {});</script>`
|
||||
|
||||
const copyToClipBoard = event => {
|
||||
const copyTextarea = document.querySelector('.' + styles.embedTextarea)
|
||||
copyTextarea.select()
|
||||
|
||||
try {
|
||||
document.execCommand('copy')
|
||||
} catch (err) {
|
||||
console.error('Unable to copy')
|
||||
}
|
||||
}
|
||||
|
||||
const EmbedLink = () => <div id={styles.embedLink}>
|
||||
<h3>Embed Comment Stream</h3>
|
||||
<textarea
|
||||
className={styles.embedTextarea}
|
||||
onClick={copyToClipBoard}
|
||||
rows={4}
|
||||
value={embedText} />
|
||||
<div className={styles.copyButton}>
|
||||
<Button onClick={copyToClipBoard} raised>
|
||||
{lang.t('embedlink.copy')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
export default EmbedLink
|
||||
|
||||
const lang = new I18n(translations)
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react'
|
||||
import { Layout, Navigation, Drawer, Header } from 'react-mdl'
|
||||
import styles from './Header.css'
|
||||
|
||||
// App header. If we add a navbar it should be here
|
||||
export default (props) => (
|
||||
<Layout fixedDrawer>
|
||||
<Header title='Talk'>
|
||||
<Navigation>
|
||||
<a className={styles.navLink} href='/'>Moderate</a>
|
||||
<a className={styles.navLink} href='/configure'>Configure</a>
|
||||
</Navigation>
|
||||
</Header>
|
||||
<Drawer>
|
||||
<Navigation>
|
||||
<a className={styles.navLink} href='/'>Moderate</a>
|
||||
<a className={styles.navLink} href='/configure'>Configure</a>
|
||||
</Navigation>
|
||||
</Drawer>
|
||||
{props.children}
|
||||
</Layout>
|
||||
)
|
||||
@@ -0,0 +1,43 @@
|
||||
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
|
||||
.container {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
z-index: 999;
|
||||
background-color: rgba(0, 0, 0, .4);
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
left: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.inner {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background-color: #fff;
|
||||
box-shadow: 2px 2px 5px 0px rgba(153,153,153,1);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.close {
|
||||
float: right;
|
||||
}
|
||||
|
||||
.hide {
|
||||
display: none;
|
||||
}
|
||||
|
||||
|
||||
@media (--big-viewport) {
|
||||
.inner {
|
||||
width: 600px;
|
||||
height: 50vh;
|
||||
border-radius: 4px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
import React from 'react'
|
||||
import { Button, Icon } from 'react-mdl'
|
||||
import styles from './Modal.css'
|
||||
|
||||
export default ({ open, children, onClose }) => (
|
||||
<div className={`${styles.container} ${!open ? styles.hide : ''}`}>
|
||||
<div className={styles.inner}>
|
||||
<Button className={styles.close} onClick={onClose}><Icon name='close' /></Button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -0,0 +1,20 @@
|
||||
|
||||
.container {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
|
||||
.shortcut {
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
.key {
|
||||
background: #eee;
|
||||
padding: 3px;
|
||||
min-width: 15px;
|
||||
min-height: 8px;
|
||||
text-align: center;
|
||||
display: inline-block;
|
||||
border-radius: 4px;
|
||||
border: 1px solid #999;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import I18n from 'coral-framework/i18n/i18n'
|
||||
import translations from '../translations'
|
||||
import React from 'react'
|
||||
import Modal from 'components/Modal'
|
||||
import styles from './ModerationKeysModal.css'
|
||||
import { Map } from 'immutable'
|
||||
|
||||
const shortcuts = [
|
||||
{
|
||||
title: 'modqueue.navigation',
|
||||
shortcuts: {
|
||||
'j': 'modqueue.nextcomment',
|
||||
'k': 'modqueue.prevcomment',
|
||||
's': 'modqueue.singleview',
|
||||
'?': 'modqueue.thismenu'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'modqueue.actions',
|
||||
shortcuts: {
|
||||
't': 'modqueue.approve',
|
||||
'r': 'modqueue.reject'
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
export default ({ open, onClose }) => (
|
||||
<Modal open={open} onClose={onClose}>
|
||||
<h3>{lang.t('modqueue.shortcuts')}</h3>
|
||||
<div className={styles.container}>
|
||||
{shortcuts.map(shortcut => (
|
||||
<table className={styles.table}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{lang.t(shortcut.title)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{Object.keys(shortcut.shortcuts).map(key => (
|
||||
<tr>
|
||||
<td className={styles.shortcut}><span className={styles.key}>{key}</span></td>
|
||||
<td>{lang.t(shortcut.shortcuts[key])}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
|
||||
const lang = new I18n(translations)
|
||||
@@ -0,0 +1,13 @@
|
||||
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.container {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
|
||||
import React from 'react'
|
||||
import styles from './CommentStream.css'
|
||||
import { Snackbar } from 'react-mdl'
|
||||
import { connect } from 'react-redux'
|
||||
import { createComment, flagComment } from 'actions/comments'
|
||||
import CommentList from 'components/CommentList'
|
||||
import CommentBox from 'components/CommentBox'
|
||||
|
||||
/**
|
||||
* Renders a comment stream using a CommentList component
|
||||
* and adds a box for adding a new comment
|
||||
*/
|
||||
|
||||
class CommentStream extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
this.state = { snackbar: false, snackbarMsg: '' }
|
||||
this.onSubmit = this.onSubmit.bind(this)
|
||||
this.onClickAction = this.onClickAction.bind(this)
|
||||
}
|
||||
|
||||
// Fetch the comments before mounting
|
||||
componentWillMount () {
|
||||
this.props.dispatch({ type: 'COMMENT_STREAM_FETCH' })
|
||||
}
|
||||
|
||||
// Submit the new comment
|
||||
onSubmit (comment) {
|
||||
this.props.dispatch(createComment(comment.name, comment.body))
|
||||
}
|
||||
|
||||
// The only action for now is flagging
|
||||
onClickAction (action, id) {
|
||||
if (action === 'flagged') {
|
||||
this.props.dispatch(flagComment(id))
|
||||
clearTimeout(this._snackTimeout)
|
||||
this.setState({ snackbar: true, snackbarMsg: 'Thank you for reporting this comment. Our moderation team has been notified and will review it shortly.' })
|
||||
this._snackTimeout = setTimeout(() => this.setState({ snackbar: false }), 30000)
|
||||
}
|
||||
}
|
||||
|
||||
// Render the comment box along with the CommentList
|
||||
render ({ comments }, { snackbar, snackbarMsg }) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<CommentBox onSubmit={this.onSubmit} />
|
||||
<CommentList isActive hideActive
|
||||
singleView={false}
|
||||
commentIds={comments.get('ids')}
|
||||
comments={comments.get('byId')}
|
||||
onClickAction={this.onClickAction}
|
||||
actions={['flag']}
|
||||
loading={comments.loading} />
|
||||
<Snackbar active={snackbar}>{snackbarMsg}</Snackbar>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(({ comments }) => ({ comments }))(CommentStream)
|
||||
@@ -0,0 +1,42 @@
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.leftColumn {
|
||||
width: 300px;
|
||||
}
|
||||
|
||||
.mainContent {
|
||||
width: calc(70% - 300px)
|
||||
}
|
||||
|
||||
.settingOption {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.configSetting {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
height: 90px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.configSettingEmbed {
|
||||
composes: configSetting;
|
||||
display: block;
|
||||
height: 170px;
|
||||
}
|
||||
|
||||
.embedInput {
|
||||
border-radius: 3px;
|
||||
border: 1px solid #ccc;
|
||||
display: block;
|
||||
width: 90%;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 10px;
|
||||
color: #555;
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import React from 'react'
|
||||
import {connect} from 'react-redux'
|
||||
import {
|
||||
List,
|
||||
ListItem,
|
||||
ListItemContent,
|
||||
ListItemAction,
|
||||
Textfield,
|
||||
Checkbox,
|
||||
Button,
|
||||
Icon
|
||||
} from 'react-mdl'
|
||||
import styles from './Configure.css'
|
||||
|
||||
class Configure extends React.Component {
|
||||
constructor (props) {
|
||||
super(props)
|
||||
this.state = {activeSection: 'comments'}
|
||||
}
|
||||
|
||||
getCommentSettings () {
|
||||
return <List>
|
||||
<ListItem className={styles.configSetting}>
|
||||
<ListItemAction><Checkbox /></ListItemAction>
|
||||
Enable pre-moderation
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSetting}>
|
||||
<ListItemAction><Checkbox /></ListItemAction>
|
||||
Include Comment Stream Description for Readers
|
||||
</ListItem>
|
||||
<ListItem className={styles.configSetting}>
|
||||
<ListItemAction><Checkbox /></ListItemAction>
|
||||
Limit Comment Length
|
||||
<Textfield
|
||||
pattern='-?[0-9]*(\.[0-9]+)?'
|
||||
error='Input is not a number!'
|
||||
label='Maximum Characters' />
|
||||
</ListItem>
|
||||
</List>
|
||||
}
|
||||
|
||||
getEmbed () {
|
||||
return <List>
|
||||
<ListItem className={styles.configSettingEmbed}>
|
||||
<p>Copy and paste code below into your CMS to embed your comment box in your articles</p>
|
||||
<input type='text' className={styles.embedInput} />
|
||||
<Button raised colored>Copy</Button>
|
||||
</ListItem>
|
||||
</List>
|
||||
}
|
||||
|
||||
changeSection (activeSection) {
|
||||
this.setState({activeSection})
|
||||
}
|
||||
|
||||
render () {
|
||||
const pageTitle = this.state.activeSection === 'comments'
|
||||
? 'Comment Settings'
|
||||
: 'Embed Comment Stream'
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.leftColumn}>
|
||||
<List>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection.bind(this, 'comments')}
|
||||
icon='settings'>Comment Settings</ListItemContent>
|
||||
</ListItem>
|
||||
<ListItem className={styles.settingOption}>
|
||||
<ListItemContent
|
||||
onClick={this.changeSection.bind(this, 'embed')}
|
||||
icon='code'>Embed Comment Stream</ListItemContent>
|
||||
</ListItem>
|
||||
</List>
|
||||
<Button raised colored>
|
||||
<Icon name='save' /> Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
<div className={styles.mainContent}>
|
||||
<h1>{pageTitle}</h1>
|
||||
{
|
||||
this.state.activeSection === 'comments'
|
||||
? this.getCommentSettings()
|
||||
: this.getEmbed()
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(x => x)(Configure)
|
||||
@@ -0,0 +1,17 @@
|
||||
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.listContainer {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
|
||||
import React from 'react'
|
||||
import { connect } from 'react-redux'
|
||||
import ModerationKeysModal from 'components/ModerationKeysModal'
|
||||
import CommentList from 'components/CommentList'
|
||||
import { updateStatus } from 'actions/comments'
|
||||
import styles from './ModerationQueue.css'
|
||||
import key from 'keymaster'
|
||||
import I18n from 'coral-framework/i18n/i18n'
|
||||
import translations from '../translations'
|
||||
|
||||
/*
|
||||
* Renders the moderation queue as a tabbed layout with 3 moderation
|
||||
* queues filtered by status (Untouched, Rejected and Approved)
|
||||
*/
|
||||
|
||||
class ModerationQueue extends React.Component {
|
||||
|
||||
constructor (props) {
|
||||
super(props)
|
||||
|
||||
console.log('ModerationQueue', props)
|
||||
|
||||
this.state = { activeTab: 'pending', singleView: false, modalOpen: false }
|
||||
}
|
||||
|
||||
// Fetch comments and bind singleView key before render
|
||||
componentWillMount () {
|
||||
this.props.dispatch({ type: 'COMMENTS_MODERATION_QUEUE_FETCH' })
|
||||
key('s', () => this.setState({ singleView: !this.state.singleView }))
|
||||
key('shift+/', () => this.setState({ modalOpen: true }))
|
||||
key('esc', () => this.setState({ modalOpen: false }))
|
||||
}
|
||||
|
||||
// Unbind singleView key before unmount
|
||||
componentWillUnmount () {
|
||||
key.unbind('s')
|
||||
key.unbind('shift+/')
|
||||
key.unbind('esc')
|
||||
}
|
||||
|
||||
// Hack for dynamic mdl tabs
|
||||
componentDidMount () {
|
||||
if (typeof componentHandler !== 'undefined') {
|
||||
componentHandler.upgradeAllRegistered()
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch the update status action
|
||||
onCommentAction (status, id) {
|
||||
this.props.dispatch(updateStatus(status, id))
|
||||
}
|
||||
|
||||
onTabClick (activeTab) {
|
||||
this.setState({ activeTab })
|
||||
}
|
||||
|
||||
// Render the tabbed lists moderation queues
|
||||
render () {
|
||||
const { comments } = this.props
|
||||
const { activeTab, singleView, modalOpen } = this.state
|
||||
|
||||
return (
|
||||
<div className='mdl-tabs mdl-js-tabs mdl-js-ripple-effect'>
|
||||
<div className='mdl-tabs__tab-bar'>
|
||||
<a href='#pending' onClick={() => this.onTabClick('pending')}
|
||||
className={`mdl-tabs__tab is-active ${styles.tab}`}>{lang.t('modqueue.pending')}</a>
|
||||
<a href='#rejected' onClick={() => this.onTabClick('rejected')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.rejected')}</a>
|
||||
<a href='#flagged' onClick={() => this.onTabClick('flagged')}
|
||||
className={`mdl-tabs__tab ${styles.tab}`}>{lang.t('modqueue.flagged')}</a>
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel is-active ${styles.listContainer}`} id='pending'>
|
||||
<CommentList
|
||||
isActive={activeTab === 'pending'}
|
||||
singleView={singleView}
|
||||
commentIds={comments.get('ids').filter(id => comments.get('byId').get(id).get('data').get('status') === 'Untouched')}
|
||||
comments={comments.get('byId')}
|
||||
onClickAction={(action, id) => this.onCommentAction(action, id)}
|
||||
actions={['reject', 'approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='rejected'>
|
||||
<CommentList
|
||||
isActive={activeTab === 'rejected'}
|
||||
singleView={singleView}
|
||||
commentIds={comments.get('ids').filter(id => comments.get('byId').get(id).get('data').get('status') === 'Rejected')}
|
||||
comments={comments.get('byId')}
|
||||
onClickAction={(action, id) => this.onCommentAction(action, id)}
|
||||
actions={['approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<div className={`mdl-tabs__panel ${styles.listContainer}`} id='flagged'>
|
||||
<CommentList
|
||||
isActive={activeTab === 'rejected'}
|
||||
singleView={singleView}
|
||||
commentIds={comments.get('ids').filter(id => {
|
||||
const data = comments.get('byId').get(id).get('data')
|
||||
return data.get('status') === 'Untouched' && data.get('flagged') === true
|
||||
})}
|
||||
comments={comments.get('byId')}
|
||||
onClickAction={(action, id) => this.onCommentAction(action, id)}
|
||||
actions={['reject', 'approve']}
|
||||
loading={comments.loading} />
|
||||
</div>
|
||||
<ModerationKeysModal open={modalOpen}
|
||||
onClose={() => this.setState({ modalOpen: false })} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(({ comments }) => ({ comments }))(ModerationQueue)
|
||||
|
||||
const lang = new I18n(translations)
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom'
|
||||
import App from 'components/App'
|
||||
|
||||
// Render the application into the DOM
|
||||
ReactDOM.render(<App />, document.querySelector('#root'))
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
import { Map, List, fromJS } from 'immutable'
|
||||
|
||||
/**
|
||||
* Comments state is stored using 2 structures:
|
||||
* - byId is a Map holding the comments using the item_id property as keys
|
||||
* - ids is a List of item_id, this allows us to order and iterate easily
|
||||
* since maps are unordered and some times we just need a list of things
|
||||
*/
|
||||
|
||||
const initialState = Map({
|
||||
byId: Map(),
|
||||
ids: List(),
|
||||
loading: false
|
||||
})
|
||||
|
||||
// Handle the comment actions
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case 'COMMENTS_MODERATION_QUEUE_FETCH': return state.set('loading', true)
|
||||
case 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS': return replaceComments(action, state)
|
||||
case 'COMMENTS_MODERATION_QUEUE_FAILED': return state.set('loading', false)
|
||||
case 'COMMENT_STATUS_UPDATE': return updateStatus(state, action)
|
||||
case 'COMMENT_FLAG': return flag(state, action)
|
||||
case 'COMMENT_CREATE_SUCCESS': return addComment(state, action)
|
||||
case 'COMMENT_STREAM_FETCH_SUCCESS': return replaceComments(action, state)
|
||||
default: return state
|
||||
}
|
||||
}
|
||||
|
||||
// Update a comment status
|
||||
const updateStatus = (state, action) => {
|
||||
const byId = state.get('byId')
|
||||
const data = byId.get(action.id).get('data').set('status', action.status)
|
||||
const comment = byId.get(action.id).set('data', data)
|
||||
return state.set('byId', byId.set(action.id, comment))
|
||||
}
|
||||
|
||||
// Flag a comment
|
||||
const flag = (state, action) => {
|
||||
const byId = state.get('byId')
|
||||
const data = byId.get(action.id).get('data').set('flagged', true)
|
||||
const comment = byId.get(action.id).set('data', data)
|
||||
return state.set('byId', byId.set(action.id, comment))
|
||||
}
|
||||
|
||||
// Replace the comment list with a new one
|
||||
const replaceComments = (action, state) => {
|
||||
const comments = fromJS(action.comments.reduce((prev, curr) => { prev[curr.item_id] = curr; return prev }, {}))
|
||||
return state.set('byId', comments).set('loading', false)
|
||||
.set('ids', List(comments.keys()))
|
||||
}
|
||||
|
||||
// Add a new comment
|
||||
const addComment = (state, action) => {
|
||||
const comment = fromJS(action.comment)
|
||||
return state.set('byId', state.get('byId').set(comment.get('item_id'), comment))
|
||||
.set('ids', state.get('ids').unshift(comment.get('item_id')))
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
|
||||
import { combineReducers } from 'redux'
|
||||
import comments from 'reducers/comments'
|
||||
|
||||
// Combine all reducers into a main one
|
||||
export default combineReducers({
|
||||
comments
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
|
||||
/**
|
||||
* Just load the root config file and throw an
|
||||
* error message if not present
|
||||
*/
|
||||
|
||||
try {
|
||||
module.exports = require('../../config.json')
|
||||
} catch (error) {
|
||||
const message = `The config.json file under the root directory is missing
|
||||
or invalid Please add one to use this app. You can use config.sample.json as a guide.`
|
||||
window.alert(message)
|
||||
throw new Error(message)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
|
||||
import { createStore, applyMiddleware } from 'redux'
|
||||
import thunk from 'redux-thunk'
|
||||
import mainReducer from 'reducers'
|
||||
import talkAdapter from 'services/talk-adapter'
|
||||
|
||||
/**
|
||||
* Create the store by merging the app reducers with
|
||||
* the talk adapter. The talk adapter is the wire between
|
||||
* this client and the coral backend. The idea is we can
|
||||
* write different adapters for other platforms if we want
|
||||
*/
|
||||
|
||||
export default createStore(
|
||||
mainReducer,
|
||||
window.devToolsExtension && window.devToolsExtension(),
|
||||
applyMiddleware(thunk, talkAdapter)
|
||||
)
|
||||
@@ -0,0 +1,93 @@
|
||||
|
||||
/**
|
||||
* The adapter is a redux middleware that interecepts the actions that need
|
||||
* to interface with the backend, do the job and return the results.
|
||||
* The idea is that if we expose the required actions to handle to devs, the
|
||||
* moderation app can be platform agnostic. This same client could work not only
|
||||
* for the coral but also for wordpress comments, disqus and many more.
|
||||
*/
|
||||
|
||||
import { talkHost, xeniaHost } from 'services/config'
|
||||
import XeniaDriver from 'xenia-driver'
|
||||
|
||||
// Intercept redux actions and act over the ones we are interested
|
||||
export default store => next => action => {
|
||||
switch (action.type) {
|
||||
case 'COMMENTS_MODERATION_QUEUE_FETCH':
|
||||
fetchModerationQueueComments(store)
|
||||
break
|
||||
case 'COMMENT_STREAM_FETCH':
|
||||
fetchCommentStream(store)
|
||||
break
|
||||
case 'COMMENT_UPDATE':
|
||||
updateComment(store, action.comment)
|
||||
break
|
||||
case 'COMMENT_CREATE':
|
||||
createComment(store, action.name, action.body)
|
||||
break
|
||||
}
|
||||
|
||||
next(action)
|
||||
}
|
||||
|
||||
// Setup xenia driver
|
||||
const xenia = XeniaDriver(`${xeniaHost}/v1`, {username: 'user', password: 'pass'})
|
||||
|
||||
// Get comments to fill each of the three lists on the mod queue
|
||||
const fetchModerationQueueComments = store => xenia()
|
||||
.collection('items')
|
||||
.match({type: 'comment', 'data.status': 'Untouched', 'data.createdAt': { $exists: true }})
|
||||
.sort(['data.createdAt', 1])
|
||||
.skip(0).limit(50)
|
||||
.addQuery().collection('items')
|
||||
.match({type: 'comment', 'data.status': 'Rejected', 'data.createdAt': { $exists: true }})
|
||||
.sort(['data.createdAt', 1])
|
||||
.skip(0).limit(50)
|
||||
.addQuery().collection('items')
|
||||
.match({type: 'comment', 'data.status': 'Untouched', 'data.flagged': true, 'data.createdAt': { $exists: true }})
|
||||
.sort(['data.createdAt', 1])
|
||||
.skip(0).limit(50)
|
||||
.exec()
|
||||
.then(res => store.dispatch({ type: 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS',
|
||||
comments: res.results.map(res => res.Docs).reduce((p, c) => p.concat(c), []) }))
|
||||
.catch(error => store.dispatch({ type: 'COMMENTS_MODERATION_QUEUE_FETCH_FAILED', error }))
|
||||
|
||||
// Update a comment. Now to update a comment we need to send back the whole object
|
||||
const updateComment = (store, comment) =>
|
||||
fetch(`${talkHost}/v1/item`, {
|
||||
method: 'PUT',
|
||||
mode: 'cors',
|
||||
body: JSON.stringify(comment)
|
||||
})
|
||||
.then(res => store.dispatch({ type: 'COMMENT_UPDATE_SUCCESS', res }))
|
||||
.catch(error => store.dispatch({ type: 'COMMENT_UPDATE_FAILED', error }))
|
||||
|
||||
// Create a new comment
|
||||
const createComment = (store, name, comment) =>
|
||||
fetch(`${talkHost}/v1/item`, {
|
||||
method: 'POST',
|
||||
mode: 'cors',
|
||||
body: JSON.stringify({
|
||||
type: 'comment',
|
||||
version: 1,
|
||||
data: {
|
||||
status: 'Untouched',
|
||||
body: comment,
|
||||
name: name,
|
||||
createdAt: Date.now()
|
||||
}
|
||||
})
|
||||
}).then(res => res.json())
|
||||
.then(res => store.dispatch({ type: 'COMMENT_CREATE_SUCCESS', comment: res }))
|
||||
.catch(error => store.dispatch({ type: 'COMMENT_CREATE_FAILED', error }))
|
||||
|
||||
// Get a comment stream. Now we don't have the concept of assets, this should
|
||||
// be adapted to retrieve the current asset when the backend supports it
|
||||
const fetchCommentStream = store => xenia()
|
||||
.collection('items')
|
||||
.match({type: 'comment', 'data.status': { $ne: 'Rejected' }, 'data.createdAt': { $exists: true }})
|
||||
.sort(['data.createdAt', 1])
|
||||
.skip(0).limit(100)
|
||||
.exec()
|
||||
.then(res => store.dispatch({ type: 'COMMENT_STREAM_FETCH_SUCCESS', comments: res.results[0].Docs }))
|
||||
.catch(error => store.dispatch({ type: 'COMMENT_STREAM_FETCH_FAILED', error }))
|
||||
@@ -0,0 +1,39 @@
|
||||
export default {
|
||||
en: {
|
||||
"modqueue": {
|
||||
"pending": "pending",
|
||||
"rejected": "rejected",
|
||||
"flagged": "flagged",
|
||||
"shortcuts": "Shortcuts",
|
||||
"close": "Close",
|
||||
"actions": "Actions",
|
||||
"navigation": "Navigation",
|
||||
"approve": "Approve comment",
|
||||
"reject": "Reject comment",
|
||||
"nextcomment": "Go to the next comment",
|
||||
"prevcomment": "Go to the previous comment",
|
||||
"singleview": "Toggle single comment edit view",
|
||||
"thismenu": "Open this menu"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "flagged",
|
||||
"anon": "Anonymous"
|
||||
},
|
||||
"embedlink": {
|
||||
"copy": "Copy to Clipboard"
|
||||
}
|
||||
},
|
||||
es: {
|
||||
"modqueue": {
|
||||
"pending": "pendiente",
|
||||
"rejected": "rechazado",
|
||||
"flagged": "marcado",
|
||||
"shortcuts": "Atajos de teclado",
|
||||
"close": "Cerrar"
|
||||
},
|
||||
"comment": {
|
||||
"flagged": "marcado",
|
||||
"anon": "Anónimo"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const webpack = require('webpack')
|
||||
const autoprefixer = require('autoprefixer')
|
||||
const precss = require('precss')
|
||||
const config = require('./config.json')
|
||||
|
||||
// doing a string replace here because I spent a day trying to do it the "webpack" way
|
||||
// ond nothing works. just trying to replace a string in an index.html file is
|
||||
// apparently something no one has ever done in the js community.
|
||||
let templateString = fs.readFileSync('./index.ejs').toString()
|
||||
templateString = templateString.replace('<%= basePath %>', config.basePath)
|
||||
fs.writeFileSync('./public/index.html', templateString)
|
||||
|
||||
console.log(templateString)
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
'bundle': path.join(__dirname, 'src', 'index')
|
||||
},
|
||||
output: {
|
||||
path: path.join(__dirname, 'public'),
|
||||
filename: '[name].js'
|
||||
},
|
||||
module: {
|
||||
loaders: [
|
||||
{ test: /.js$/, loaders: 'buble', include: path.join(__dirname, 'src') },
|
||||
{ test: /.json$/, loaders: 'json', include: __dirname, exclude: /node_modules/ },
|
||||
{ test: /\.css$/, loader: 'style-loader!css-loader?modules&importLoaders=1!postcss-loader' }
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
options: {
|
||||
context: __dirname,
|
||||
postcss: [autoprefixer, precss]
|
||||
}
|
||||
})
|
||||
],
|
||||
resolve: {
|
||||
modules: [
|
||||
path.resolve('./src'),
|
||||
'node_modules'
|
||||
]
|
||||
},
|
||||
devServer: {
|
||||
historyApiFallback: {
|
||||
index: '/'
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
const path = require('path')
|
||||
const fs = require('fs')
|
||||
const devConfig = require('./webpack.config.dev')
|
||||
const autoprefixer = require('autoprefixer')
|
||||
const precss = require('precss')
|
||||
const Copy = require('copy-webpack-plugin')
|
||||
const webpack = require('webpack')
|
||||
const config = require('./config.json')
|
||||
|
||||
// doing a string replace here because I spent a day trying to do it the "webpack" way
|
||||
// ond nothing works. just trying to replace a string in an index.html file is
|
||||
// apparently something no one has ever done in the js community.
|
||||
let templateString = fs.readFileSync('./index.ejs').toString()
|
||||
templateString = templateString.replace('<%= basePath %>', config.basePath)
|
||||
fs.writeFileSync('./public/index.html', templateString)
|
||||
|
||||
module.exports = Object.assign({}, devConfig, {
|
||||
module: {
|
||||
loaders: [
|
||||
{ test: /.js$/, loaders: 'buble', include: [path.join(__dirname, 'src'), path.join(__dirname, '../', 'coral-framework')] },
|
||||
{ test: /.json$/, loaders: 'json', include: __dirname, exclude: /node_modules/ },
|
||||
{ test: /\.css$/, loader: 'style-loader!css-loader?modules&importLoaders=1!postcss-loader' }
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new Copy([{
|
||||
from: path.join(__dirname, '..', 'coral-embed-stream', 'dist'),
|
||||
to: './embed/comment-stream'
|
||||
}]),
|
||||
new webpack.LoaderOptionsPlugin({
|
||||
options: {
|
||||
context: __dirname,
|
||||
postcss: [autoprefixer, precss],
|
||||
minimize: true,
|
||||
debug: false
|
||||
}
|
||||
}),
|
||||
new webpack.optimize.UglifyJsPlugin()
|
||||
]
|
||||
})
|
||||
Reference in New Issue
Block a user