diff --git a/client/coral-admin/.gitignore b/client/coral-admin/.gitignore
new file mode 100644
index 000000000..c9c7cb580
--- /dev/null
+++ b/client/coral-admin/.gitignore
@@ -0,0 +1,7 @@
+node_modules
+public/bundle.js
+public/embed/comment-stream
+.DS_Store
+npm-debug.log
+config.json
+yarn.lock
diff --git a/client/coral-admin/README.md b/client/coral-admin/README.md
new file mode 100644
index 000000000..067a3cac9
--- /dev/null
+++ b/client/coral-admin/README.md
@@ -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
diff --git a/client/coral-admin/config.sample.json b/client/coral-admin/config.sample.json
new file mode 100644
index 000000000..5d23e2576
--- /dev/null
+++ b/client/coral-admin/config.sample.json
@@ -0,0 +1,5 @@
+{
+ "basePath": "http://localhost:3142",
+ "talkHost": "http://localhost:16180",
+ "xeniaHost": "http://localhost:16180"
+}
diff --git a/client/coral-admin/index.ejs b/client/coral-admin/index.ejs
new file mode 100644
index 000000000..9b5fe84bf
--- /dev/null
+++ b/client/coral-admin/index.ejs
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Talk - Coral Admin
+
+
+
+
+
+
+
+
+
diff --git a/client/coral-admin/package.json b/client/coral-admin/package.json
new file mode 100644
index 000000000..c5a2d4c45
--- /dev/null
+++ b/client/coral-admin/package.json
@@ -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"
+ }
+}
diff --git a/client/coral-admin/public/index.html b/client/coral-admin/public/index.html
new file mode 100644
index 000000000..707b82ebd
--- /dev/null
+++ b/client/coral-admin/public/index.html
@@ -0,0 +1,22 @@
+
+
+
+
+
+ Talk - Coral Admin
+
+
+
+
+
+
+
+
+
diff --git a/client/coral-admin/public/manifest.json b/client/coral-admin/public/manifest.json
new file mode 100644
index 000000000..5da846121
--- /dev/null
+++ b/client/coral-admin/public/manifest.json
@@ -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"
+}
diff --git a/client/coral-admin/public/translations/en.json b/client/coral-admin/public/translations/en.json
new file mode 100644
index 000000000..591de1754
--- /dev/null
+++ b/client/coral-admin/public/translations/en.json
@@ -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"
+ }
+}
diff --git a/client/coral-admin/public/translations/es.json b/client/coral-admin/public/translations/es.json
new file mode 100644
index 000000000..d151cf13c
--- /dev/null
+++ b/client/coral-admin/public/translations/es.json
@@ -0,0 +1,13 @@
+{
+ "modqueue": {
+ "pending": "pendiente",
+ "rejected": "rechazado",
+ "flagged": "marcado",
+ "shortcuts": "Atajos de teclado",
+ "close": "Cerrar"
+ },
+ "comment": {
+ "flagged": "marcado",
+ "anon": "AnĂ³nimo"
+ }
+}
diff --git a/client/coral-admin/src/actions/comments.js b/client/coral-admin/src/actions/comments.js
new file mode 100644
index 000000000..04751276e
--- /dev/null
+++ b/client/coral-admin/src/actions/comments.js
@@ -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 })
+}
diff --git a/client/coral-admin/src/components/App.js b/client/coral-admin/src/components/App.js
new file mode 100644
index 000000000..044d00989
--- /dev/null
+++ b/client/coral-admin/src/components/App.js
@@ -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 (
+
+
+
+
+
+ )
+ }
+}
diff --git a/client/coral-admin/src/components/Comment.js b/client/coral-admin/src/components/Comment.js
new file mode 100644
index 000000000..ce4f6686e
--- /dev/null
+++ b/client/coral-admin/src/components/Comment.js
@@ -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 => (
+
+
+
+
person
+
{props.comment.get('data').get('name') || lang.t('comment.anon')}
+
{timeago().format(props.comment.get('data').get('createdAt') || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
+ {props.comment.get('data').get('flagged') ?
{lang.t('comment.flagged')}
: null}
+
+
+ {props.actions.map(action => canShowAction(action, props.comment) ? (
+ props.onClickAction(props.actionsMap[action].status, props.comment.get('item_id'))}
+ fab colored>
+
+
+ ) : null)}
+
+
+
+ {props.comment.get('data').get('body')}
+
+
+)
+
+// 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)
diff --git a/client/coral-admin/src/components/CommentBox.css b/client/coral-admin/src/components/CommentBox.css
new file mode 100644
index 000000000..34c7ca6ef
--- /dev/null
+++ b/client/coral-admin/src/components/CommentBox.css
@@ -0,0 +1,4 @@
+
+.textareaContainer {
+ width: 100%;
+}
diff --git a/client/coral-admin/src/components/CommentBox.js b/client/coral-admin/src/components/CommentBox.js
new file mode 100644
index 000000000..f0f0adbf2
--- /dev/null
+++ b/client/coral-admin/src/components/CommentBox.js
@@ -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 (
+
+
+
+ Your name
+
+
+
+ Write your comment
+
+
Post
+
+ )
+ }
+}
diff --git a/client/coral-admin/src/components/CommentList.css b/client/coral-admin/src/components/CommentList.css
new file mode 100644
index 000000000..f683ca36b
--- /dev/null
+++ b/client/coral-admin/src/components/CommentList.css
@@ -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;
+ }
+ }
+
+}
diff --git a/client/coral-admin/src/components/CommentList.js b/client/coral-admin/src/components/CommentList.js
new file mode 100644
index 000000000..b9a1ba944
--- /dev/null
+++ b/client/coral-admin/src/components/CommentList.js
@@ -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 (
+
+ {commentIds.map((commentId, index) => (
+ { if (el && commentId === active) { this._active = el } }}
+ key={index}
+ index={index}
+ onClickAction={this.onClickAction}
+ actions={actions}
+ actionsMap={actions}
+ isActive={commentId === active}
+ hideActive={hideActive} />
+ )).toArray()}
+
+ )
+ }
+}
diff --git a/client/coral-admin/src/components/EmbedLink.css b/client/coral-admin/src/components/EmbedLink.css
new file mode 100644
index 000000000..15583414a
--- /dev/null
+++ b/client/coral-admin/src/components/EmbedLink.css
@@ -0,0 +1,13 @@
+#embedLink {
+ width:400px;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.embedTextarea {
+ width: 100%;
+}
+
+.copyButton {
+ margin-top: 20px;
+}
diff --git a/client/coral-admin/src/components/EmbedLink.js b/client/coral-admin/src/components/EmbedLink.js
new file mode 100644
index 000000000..21fe52f3f
--- /dev/null
+++ b/client/coral-admin/src/components/EmbedLink.js
@@ -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 =
+`
`
+
+const copyToClipBoard = event => {
+ const copyTextarea = document.querySelector('.' + styles.embedTextarea)
+ copyTextarea.select()
+
+ try {
+ document.execCommand('copy')
+ } catch (err) {
+ console.error('Unable to copy')
+ }
+}
+
+const EmbedLink = () =>
+
Embed Comment Stream
+
+
+
+ {lang.t('embedlink.copy')}
+
+
+
+
+export default EmbedLink
+
+const lang = new I18n(translations)
diff --git a/client/coral-admin/src/components/Header.css b/client/coral-admin/src/components/Header.css
new file mode 100644
index 000000000..e69de29bb
diff --git a/client/coral-admin/src/components/Header.js b/client/coral-admin/src/components/Header.js
new file mode 100644
index 000000000..10f414108
--- /dev/null
+++ b/client/coral-admin/src/components/Header.js
@@ -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) => (
+
+
+
+
+ Moderate
+ Configure
+
+
+ {props.children}
+
+)
diff --git a/client/coral-admin/src/components/Modal.css b/client/coral-admin/src/components/Modal.css
new file mode 100644
index 000000000..8a3d2ad00
--- /dev/null
+++ b/client/coral-admin/src/components/Modal.css
@@ -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;
+ }
+}
diff --git a/client/coral-admin/src/components/Modal.js b/client/coral-admin/src/components/Modal.js
new file mode 100644
index 000000000..44535eda7
--- /dev/null
+++ b/client/coral-admin/src/components/Modal.js
@@ -0,0 +1,13 @@
+
+import React from 'react'
+import { Button, Icon } from 'react-mdl'
+import styles from './Modal.css'
+
+export default ({ open, children, onClose }) => (
+
+)
diff --git a/client/coral-admin/src/components/ModerationKeysModal.css b/client/coral-admin/src/components/ModerationKeysModal.css
new file mode 100644
index 000000000..a030b6efa
--- /dev/null
+++ b/client/coral-admin/src/components/ModerationKeysModal.css
@@ -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;
+}
diff --git a/client/coral-admin/src/components/ModerationKeysModal.js b/client/coral-admin/src/components/ModerationKeysModal.js
new file mode 100644
index 000000000..c28534417
--- /dev/null
+++ b/client/coral-admin/src/components/ModerationKeysModal.js
@@ -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 }) => (
+
+ {lang.t('modqueue.shortcuts')}
+
+ {shortcuts.map(shortcut => (
+
+
+
+ {lang.t(shortcut.title)}
+
+
+
+ {Object.keys(shortcut.shortcuts).map(key => (
+
+ {key}
+ {lang.t(shortcut.shortcuts[key])}
+
+ ))}
+
+
+ ))}
+
+
+)
+
+const lang = new I18n(translations)
diff --git a/client/coral-admin/src/containers/CommentStream.css b/client/coral-admin/src/containers/CommentStream.css
new file mode 100644
index 000000000..9247183e5
--- /dev/null
+++ b/client/coral-admin/src/containers/CommentStream.css
@@ -0,0 +1,13 @@
+
+@custom-media --big-viewport (min-width: 780px);
+
+.container {
+ max-width: 860px;
+ margin: 0 auto;
+}
+
+@media (--big-viewport) {
+ .tab {
+ flex: none;
+ }
+}
diff --git a/client/coral-admin/src/containers/CommentStream.js b/client/coral-admin/src/containers/CommentStream.js
new file mode 100644
index 000000000..33963f422
--- /dev/null
+++ b/client/coral-admin/src/containers/CommentStream.js
@@ -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 (
+
+
+
+ {snackbarMsg}
+
+ )
+ }
+}
+
+export default connect(({ comments }) => ({ comments }))(CommentStream)
diff --git a/client/coral-admin/src/containers/Configure.css b/client/coral-admin/src/containers/Configure.css
new file mode 100644
index 000000000..2f98a8b99
--- /dev/null
+++ b/client/coral-admin/src/containers/Configure.css
@@ -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;
+}
diff --git a/client/coral-admin/src/containers/Configure.js b/client/coral-admin/src/containers/Configure.js
new file mode 100644
index 000000000..02862cb8b
--- /dev/null
+++ b/client/coral-admin/src/containers/Configure.js
@@ -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
+
+
+ Enable pre-moderation
+
+
+
+ Include Comment Stream Description for Readers
+
+
+
+ Limit Comment Length
+
+
+
+ }
+
+ getEmbed () {
+ return
+
+ Copy and paste code below into your CMS to embed your comment box in your articles
+
+ Copy
+
+
+ }
+
+ changeSection (activeSection) {
+ this.setState({activeSection})
+ }
+
+ render () {
+ const pageTitle = this.state.activeSection === 'comments'
+ ? 'Comment Settings'
+ : 'Embed Comment Stream'
+
+ return (
+
+
+
+
+ Comment Settings
+
+
+ Embed Comment Stream
+
+
+
+ Save Changes
+
+
+
+
{pageTitle}
+ {
+ this.state.activeSection === 'comments'
+ ? this.getCommentSettings()
+ : this.getEmbed()
+ }
+
+
+ )
+ }
+}
+
+export default connect(x => x)(Configure)
diff --git a/client/coral-admin/src/containers/ModerationQueue.css b/client/coral-admin/src/containers/ModerationQueue.css
new file mode 100644
index 000000000..02f9eb9e9
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue.css
@@ -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;
+ }
+}
diff --git a/client/coral-admin/src/containers/ModerationQueue.js b/client/coral-admin/src/containers/ModerationQueue.js
new file mode 100644
index 000000000..a52ef25eb
--- /dev/null
+++ b/client/coral-admin/src/containers/ModerationQueue.js
@@ -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 (
+
+
+
+ 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} />
+
+
+ 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} />
+
+
+ {
+ 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} />
+
+
this.setState({ modalOpen: false })} />
+
+ )
+ }
+}
+
+export default connect(({ comments }) => ({ comments }))(ModerationQueue)
+
+const lang = new I18n(translations)
diff --git a/client/coral-admin/src/index.js b/client/coral-admin/src/index.js
new file mode 100644
index 000000000..509473891
--- /dev/null
+++ b/client/coral-admin/src/index.js
@@ -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( , document.querySelector('#root'))
diff --git a/client/coral-admin/src/reducers/comments.js b/client/coral-admin/src/reducers/comments.js
new file mode 100644
index 000000000..f3b465735
--- /dev/null
+++ b/client/coral-admin/src/reducers/comments.js
@@ -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')))
+}
diff --git a/client/coral-admin/src/reducers/index.js b/client/coral-admin/src/reducers/index.js
new file mode 100644
index 000000000..5c99e3bb2
--- /dev/null
+++ b/client/coral-admin/src/reducers/index.js
@@ -0,0 +1,8 @@
+
+import { combineReducers } from 'redux'
+import comments from 'reducers/comments'
+
+// Combine all reducers into a main one
+export default combineReducers({
+ comments
+})
diff --git a/client/coral-admin/src/services/config.js b/client/coral-admin/src/services/config.js
new file mode 100644
index 000000000..56e96222f
--- /dev/null
+++ b/client/coral-admin/src/services/config.js
@@ -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)
+}
diff --git a/client/coral-admin/src/services/store.js b/client/coral-admin/src/services/store.js
new file mode 100644
index 000000000..c11728de0
--- /dev/null
+++ b/client/coral-admin/src/services/store.js
@@ -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)
+)
diff --git a/client/coral-admin/src/services/talk-adapter.js b/client/coral-admin/src/services/talk-adapter.js
new file mode 100644
index 000000000..72e121388
--- /dev/null
+++ b/client/coral-admin/src/services/talk-adapter.js
@@ -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 }))
diff --git a/client/coral-admin/src/translations.js b/client/coral-admin/src/translations.js
new file mode 100644
index 000000000..ce5bf7718
--- /dev/null
+++ b/client/coral-admin/src/translations.js
@@ -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"
+ }
+ }
+}
diff --git a/client/coral-admin/webpack.config.dev.js b/client/coral-admin/webpack.config.dev.js
new file mode 100644
index 000000000..010f0c04b
--- /dev/null
+++ b/client/coral-admin/webpack.config.dev.js
@@ -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: '/'
+ }
+ }
+}
diff --git a/client/coral-admin/webpack.config.js b/client/coral-admin/webpack.config.js
new file mode 100644
index 000000000..67d5aef22
--- /dev/null
+++ b/client/coral-admin/webpack.config.js
@@ -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()
+ ]
+})