Merge branch 'mod-queue-load-more' of github.com:coralproject/talk into mod-queue-load-more
@@ -12,3 +12,5 @@ dump.rdb
|
||||
*.cfg
|
||||
.idea/
|
||||
coverage/
|
||||
.tags
|
||||
.tags1
|
||||
|
||||
@@ -34,6 +34,7 @@ app.use(helmet({
|
||||
}));
|
||||
app.use(bodyParser.json());
|
||||
app.use('/client', express.static(path.join(__dirname, 'dist')));
|
||||
app.use('/public', express.static(path.join(__dirname, 'public')));
|
||||
app.set('views', path.join(__dirname, 'views'));
|
||||
app.set('view engine', 'ejs');
|
||||
|
||||
|
||||
@@ -5,10 +5,13 @@ import Streams from 'containers/Streams/Streams';
|
||||
import Configure from 'containers/Configure/Configure';
|
||||
import LayoutContainer from 'containers/LayoutContainer';
|
||||
import InstallContainer from 'containers/Install/InstallContainer';
|
||||
|
||||
import CommunityLayout from 'containers/Community/CommunityLayout';
|
||||
import CommunityContainer from 'containers/Community/CommunityContainer';
|
||||
|
||||
import ModerationLayout from 'containers/ModerationQueue/ModerationLayout';
|
||||
import ModerationContainer from 'containers/ModerationQueue/ModerationContainer';
|
||||
|
||||
import Dashboard from 'containers/Dashboard/Dashboard';
|
||||
|
||||
const routes = (
|
||||
@@ -21,6 +24,18 @@ const routes = (
|
||||
<Route path='streams' component={Streams} />
|
||||
<Route path='dashboard' component={Dashboard} />
|
||||
|
||||
{/* Community Routes */}
|
||||
|
||||
<Route path='community' component={CommunityLayout}>
|
||||
<Route path='flagged' components={CommunityContainer}>
|
||||
<Route path=':id' components={CommunityContainer} />
|
||||
</Route>
|
||||
<Route path='people' components={CommunityContainer}>
|
||||
<Route path=':id' components={CommunityContainer} />
|
||||
</Route>
|
||||
<IndexRedirect to='flagged' />
|
||||
</Route>
|
||||
|
||||
{/* Moderation Routes */}
|
||||
|
||||
<Route path='moderate' component={ModerationLayout}>
|
||||
|
||||
@@ -7,28 +7,33 @@ import {
|
||||
SORT_UPDATE,
|
||||
COMMENTERS_NEW_PAGE,
|
||||
SET_ROLE,
|
||||
SET_COMMENTER_STATUS
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
import coralApi from '../../../coral-framework/helpers/response';
|
||||
|
||||
export const fetchCommenters = (query = {}) => dispatch => {
|
||||
dispatch(requestFetchCommenters());
|
||||
export const fetchAccounts = (query = {}) => dispatch => {
|
||||
|
||||
dispatch(requestFetchAccounts());
|
||||
coralApi(`/users?${qs.stringify(query)}`)
|
||||
.then(({result, page, count, limit, totalPages}) =>
|
||||
.then(({result, page, count, limit, totalPages}) =>{
|
||||
dispatch({
|
||||
type: FETCH_COMMENTERS_SUCCESS,
|
||||
commenters: result,
|
||||
accounts: result,
|
||||
page,
|
||||
count,
|
||||
limit,
|
||||
totalPages
|
||||
})
|
||||
)
|
||||
});
|
||||
})
|
||||
.catch(error => dispatch({type: FETCH_COMMENTERS_FAILURE, error}));
|
||||
};
|
||||
|
||||
const requestFetchCommenters = () => ({
|
||||
const requestFetchAccounts = () => ({
|
||||
type: FETCH_COMMENTERS_REQUEST
|
||||
});
|
||||
|
||||
@@ -55,3 +60,11 @@ export const setCommenterStatus = (id, status) => (dispatch) => {
|
||||
return dispatch({type: SET_COMMENTER_STATUS, id, status});
|
||||
});
|
||||
};
|
||||
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user) => ({type: SHOW_BANUSER_DIALOG, user});
|
||||
export const hideBanUserDialog = () => ({type: HIDE_BANUSER_DIALOG});
|
||||
|
||||
// Suspend User Dialog
|
||||
export const showSuspendUserDialog = (user) => ({type: SHOW_SUSPENDUSER_DIALOG, user});
|
||||
export const hideSuspendUserDialog = () => ({type: HIDE_SUSPENDUSER_DIALOG});
|
||||
|
||||
@@ -8,7 +8,14 @@ import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
const onBanClick = (userId, commentId, handleBanUser, rejectComment, handleClose) => (e) => {
|
||||
e.preventDefault();
|
||||
handleBanUser({userId})
|
||||
.then(handleClose)
|
||||
.then(() => rejectComment({commentId}));
|
||||
};
|
||||
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, rejectComment, user, commentId}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
@@ -29,7 +36,7 @@ const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
|
||||
{lang.t('bandialog.cancel')}
|
||||
</Button>
|
||||
<Button cStyle="black" className={styles.ban} onClick={() => handleBanUser({userId: user.id})} raised>
|
||||
<Button cStyle="black" className={styles.ban} onClick={onBanClick(user.id, commentId, handleBanUser, rejectComment, handleClose)} raised>
|
||||
{lang.t('bandialog.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
import React from 'react';
|
||||
import {Button, Icon} from 'react-mdl';
|
||||
import styles from './Modal.css';
|
||||
|
||||
@@ -1,106 +0,0 @@
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import Modal from 'components/Modal';
|
||||
import styles from './SuspendUserModal.css';
|
||||
import {Button} from 'coral-ui';
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_0',
|
||||
description: 'suspenduser.description_0',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_1',
|
||||
description: 'suspenduser.description_1',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class SuspendUserModal extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
actionType: PropTypes.string,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
const about = lang.t('suspenduser.username');
|
||||
this.setState({email: lang.t('suspenduser.email', about)});
|
||||
}
|
||||
|
||||
/*
|
||||
* When an admin clicks to suspend a user a dialog is shown, this function
|
||||
* handles the possible actions for that dialog.
|
||||
*/
|
||||
onActionClick = (stage, menuOption) => () => {
|
||||
const {suspendUser, action} = this.props;
|
||||
const {stage, email} = this.state;
|
||||
const cancel = this.props.onClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => suspendUser(action.item_id, lang.t('suspenduser.email_subject'), email)
|
||||
.then(this.props.onClose);
|
||||
const suspendModalActions = [
|
||||
[ cancel, next ],
|
||||
[ cancel, suspend ]
|
||||
];
|
||||
return suspendModalActions[stage][menuOption]();
|
||||
}
|
||||
|
||||
onEmailChange = (e) => this.setState({email: e.target.value})
|
||||
|
||||
render () {
|
||||
const {action, onClose} = this.props;
|
||||
|
||||
if (!action) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const {stage} = this.state;
|
||||
const actionType = action.actionType;
|
||||
const about = actionType === 'flag_bio' ? lang.t('suspenduser.bio') : lang.t('suspenduser.username');
|
||||
return <Modal open={true} onClose={onClose}>
|
||||
<div className={styles.title}>{lang.t(stages[stage].title, about)}</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{lang.t(stages[stage].description, about)}
|
||||
</div>
|
||||
{
|
||||
stage === 1 &&
|
||||
<div className={styles.writeContainer}>
|
||||
<div className={styles.emailMessage}>{lang.t('suspenduser.write_message')}</div>
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={styles.emailInput}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.modalButtons}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
{lang.t(stages[stage].options[key], about)}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserModal;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -1,50 +0,0 @@
|
||||
import React from 'react';
|
||||
import styles from './ModerationList.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../translations.json';
|
||||
|
||||
import {Icon} from 'react-mdl';
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
// Render a single comment for the list
|
||||
const User = props => {
|
||||
const {action, user} = props;
|
||||
let userStatus = user.status;
|
||||
|
||||
// Do not display unless the user status is 'pending' or 'banned'.
|
||||
// This means that they have already been reviewed and approved.
|
||||
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
|
||||
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<div className={styles.author}>
|
||||
<span>{user.username}</span>
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{props.modActions.map(
|
||||
(action, i) =>
|
||||
<ActionButton
|
||||
type={action.toUpperCase()}
|
||||
key={i}
|
||||
user={user}
|
||||
menuOptionsMap={props.menuOptionsMap}
|
||||
onClickAction={props.onClickAction}
|
||||
onClickShowBanDialog={props.onClickShowBanDialog}/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
{userStatus === 'banned' ?
|
||||
<span className={styles.banned}><Icon name='error_outline'/> {lang.t('comment.banned_user')}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.flagCount}>
|
||||
{`${action.count} ${action.action_type === 'flag_bio' ? lang.t('user.bio_flags') : lang.t('user.username_flags')}`}
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default User;
|
||||
|
||||
const lang = new I18n(translations);
|
||||
@@ -1,6 +1,5 @@
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_REQUEST = 'COMMENTS_MODERATION_QUEUE_FETCH_REQUEST';
|
||||
export const COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS = 'COMMENTS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
export const COMMENT_CREATE_SUCCESS = 'COMMENT_CREATE_SUCCESS';
|
||||
|
||||
@@ -5,3 +5,13 @@ export const SORT_UPDATE = 'SORT_UPDATE';
|
||||
export const COMMENTERS_NEW_PAGE = 'COMMENTERS_NEW_PAGE';
|
||||
export const SET_ROLE = 'SET_ROLE';
|
||||
export const SET_COMMENTER_STATUS = 'SET_COMMENTER_STATUS';
|
||||
|
||||
export const FETCH_FLAGGED_COMMENTERS_REQUEST = 'FETCH_FLAGGED_COMMENTERS_REQUEST';
|
||||
export const FETCH_FLAGGED_COMMENTERS_SUCCESS = 'FETCH_FLAGGED_COMMENTERS_SUCCESS';
|
||||
export const FETCH_FLAGGED_COMMENTERS_FAILURE = 'FETCH_FLAGGED_COMMENTERS_FAILURE';
|
||||
|
||||
export const SHOW_BANUSER_DIALOG = 'SHOW_BANUSER_DIALOG';
|
||||
export const HIDE_BANUSER_DIALOG = 'HIDE_BANUSER_DIALOG';
|
||||
|
||||
export const SHOW_SUSPENDUSER_DIALOG = 'SHOW_SUSPENDUSER_DIALOG';
|
||||
export const HIDE_SUSPENDUSER_DIALOG = 'HIDE_SUSPENDUSER_DIALOG';
|
||||
|
||||
@@ -3,4 +3,3 @@ export const UPDATE_STATUS_SUCCESS = 'UPDATE_STATUS_SUCCESS';
|
||||
export const UPDATE_STATUS_FAILURE = 'UPDATE_STATUS_FAILURE';
|
||||
export const USER_EMAIL_FAILURE = 'USER_EMAIL_FAILURE';
|
||||
export const USERNAME_ENABLE_FAILURE = 'USERNAME_ENABLE_FAILURE';
|
||||
export const USERS_MODERATION_QUEUE_FETCH_SUCCESS = 'USERS_MODERATION_QUEUE_FETCH_SUCCESS';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.container {
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
@@ -15,6 +17,12 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.mainFlaggedContent {
|
||||
width: 100%;
|
||||
padding: 34px 14px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.roleButton {
|
||||
display: block;
|
||||
}
|
||||
@@ -94,3 +102,188 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 14px;
|
||||
position: relative;
|
||||
transition: box-shadow 200ms;
|
||||
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: inline;
|
||||
|
||||
.author {
|
||||
font-size: 24px;
|
||||
min-width: 50px;
|
||||
align-items: left;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
.itemBody {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.created {
|
||||
color: #666;
|
||||
font-size: 13px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 20px;
|
||||
flex: 1;
|
||||
font-size: 0.88em;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
color: rgba(255, 0, 0, .5);
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flagCount{
|
||||
font-size: 12px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hasLinks {
|
||||
color: #f00;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.banned {
|
||||
color: #f00;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ban {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.banButton {
|
||||
width: 114px;
|
||||
letter-spacing: 1px;
|
||||
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-right: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.flaggedByCount {
|
||||
display: block;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.flaggedBy {
|
||||
display: inline;
|
||||
padding: 3px;
|
||||
}
|
||||
|
||||
.flaggedByLabel {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,29 @@
|
||||
import React, {Component} from 'react';
|
||||
import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
|
||||
import {modUserFlaggedQuery} from 'coral-admin/src/graphql/queries';
|
||||
import {banUser, setUserStatus, suspendUser} from '../../graphql/mutations';
|
||||
|
||||
import {
|
||||
fetchCommenters,
|
||||
fetchAccounts,
|
||||
updateSorting,
|
||||
newPage,
|
||||
showBanUserDialog,
|
||||
hideBanUserDialog,
|
||||
showSuspendUserDialog,
|
||||
hideSuspendUserDialog
|
||||
} from '../../actions/community';
|
||||
|
||||
import Community from './Community';
|
||||
import CommunityMenu from './components/CommunityMenu';
|
||||
import BanUserDialog from './components/BanUserDialog';
|
||||
import SuspendUserDialog from './components/SuspendUserDialog';
|
||||
|
||||
import People from './People';
|
||||
import FlaggedAccounts from './FlaggedAccounts';
|
||||
|
||||
class CommunityContainer extends Component {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
@@ -22,6 +37,10 @@ class CommunityContainer extends Component {
|
||||
this.onNewPageHandler = this.onNewPageHandler.bind(this);
|
||||
}
|
||||
|
||||
componentWillMount() {
|
||||
this.props.fetchAccounts({});
|
||||
}
|
||||
|
||||
onKeyDownHandler(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
@@ -38,16 +57,13 @@ class CommunityContainer extends Component {
|
||||
search(query = {}) {
|
||||
const {community} = this.props;
|
||||
|
||||
this.props.dispatch(fetchCommenters({
|
||||
this.props.fetchAccounts({
|
||||
value: this.state.searchValue,
|
||||
field: community.field,
|
||||
asc: community.asc,
|
||||
field: community.fieldPeople,
|
||||
asc: community.ascPeople,
|
||||
...query
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
componentDidMount() {
|
||||
this.search();
|
||||
}
|
||||
|
||||
onHeaderClickHandler(sort) {
|
||||
@@ -60,19 +76,64 @@ class CommunityContainer extends Component {
|
||||
this.search({page});
|
||||
}
|
||||
|
||||
getTabContent(searchValue, props) {
|
||||
const {community, data} = props;
|
||||
const activeTab = props.route.path === ':id' ? 'flagged' : props.route.path;
|
||||
|
||||
if (activeTab === 'people') {
|
||||
return (
|
||||
<People
|
||||
isFetching={community.isFetchingPeople}
|
||||
commenters={community.accounts}
|
||||
searchValue={searchValue}
|
||||
error={community.errorPeople}
|
||||
totalPages={community.totalPagesPeople}
|
||||
page={community.pagePeople}
|
||||
{...this}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<FlaggedAccounts
|
||||
commenters={data.users}
|
||||
isFetching={data.loading}
|
||||
error={data.error}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
{...this}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={community.banDialog}
|
||||
user={community.user}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
/>
|
||||
<SuspendUserDialog
|
||||
open={community.suspendDialog}
|
||||
handleClose={props.hideSuspendUserDialog}
|
||||
user={community.user}
|
||||
suspendUser={props.suspendUser}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {searchValue} = this.state;
|
||||
const {community} = this.props;
|
||||
|
||||
const tab = this.getTabContent(searchValue, this.props);
|
||||
|
||||
return (
|
||||
<Community
|
||||
searchValue={searchValue}
|
||||
commenters={community.commenters}
|
||||
isFetching={community.isFetching}
|
||||
error={community.error}
|
||||
totalPages={community.totalPages}
|
||||
page={community.page}
|
||||
{...this}
|
||||
/>
|
||||
<div>
|
||||
<CommunityMenu />
|
||||
<div>
|
||||
{ tab }
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -81,4 +142,18 @@ const mapStateToProps = state => ({
|
||||
community: state.community.toJS()
|
||||
});
|
||||
|
||||
export default connect(mapStateToProps)(CommunityContainer);
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
fetchAccounts: query => dispatch(fetchAccounts(query)),
|
||||
showBanUserDialog: (user) => dispatch(showBanUserDialog(user)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
showSuspendUserDialog: (user) => dispatch(showSuspendUserDialog(user)),
|
||||
hideSuspendUserDialog: () => dispatch(hideSuspendUserDialog())
|
||||
});
|
||||
|
||||
export default compose(
|
||||
connect(mapStateToProps, mapDispatchToProps),
|
||||
modUserFlaggedQuery,
|
||||
banUser,
|
||||
setUserStatus,
|
||||
suspendUser
|
||||
)(CommunityContainer);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import React from 'react';
|
||||
|
||||
const CommunityLayout = props => (
|
||||
<div>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export default CommunityLayout;
|
||||
@@ -0,0 +1,45 @@
|
||||
import React from 'react';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
import styles from './Community.css';
|
||||
|
||||
import Loading from './Loading';
|
||||
import EmptyCard from '../../components/EmptyCard';
|
||||
import User from './components/User';
|
||||
|
||||
const FlaggedAccounts = ({...props}) => {
|
||||
const {commenters, isFetching} = props;
|
||||
const hasResults = !isFetching && commenters && !!commenters.length;
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.mainFlaggedContent}>
|
||||
{ isFetching && <Loading /> }
|
||||
{
|
||||
hasResults
|
||||
? commenters.map((commenter, index) => {
|
||||
if (commenter.status === 'PENDING' && commenter.actions.length > 0) {
|
||||
return <User
|
||||
user={commenter}
|
||||
key={index}
|
||||
index={index}
|
||||
modActionButtons={['REJECT', 'APPROVE']}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
/>;
|
||||
}
|
||||
return null;
|
||||
})
|
||||
: <EmptyCard>{lang.t('community.no-flagged-accounts')}</EmptyCard>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default FlaggedAccounts;
|
||||
@@ -29,7 +29,7 @@ const tableHeaders = [
|
||||
}
|
||||
];
|
||||
|
||||
const Community = ({isFetching, commenters, ...props}) => {
|
||||
const People = ({isFetching, commenters, ...props}) => {
|
||||
const hasResults = !isFetching && !!commenters.length;
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
@@ -58,7 +58,7 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
hasResults
|
||||
? <Table
|
||||
headers={tableHeaders}
|
||||
data={commenters}
|
||||
commenters={commenters}
|
||||
onHeaderClickHandler={props.onHeaderClickHandler}
|
||||
/>
|
||||
: <EmptyCard>{lang.t('community.no-results')}</EmptyCard>
|
||||
@@ -73,4 +73,4 @@ const Community = ({isFetching, commenters, ...props}) => {
|
||||
);
|
||||
};
|
||||
|
||||
export default Community;
|
||||
export default People;
|
||||
@@ -77,4 +77,4 @@ class Table extends Component {
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(state => ({commenters: state.community.get('commenters')}))(Table);
|
||||
export default connect(state => ({commenters: state.community.get('accounts')}))(Table);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import React from 'react';
|
||||
import styles from '../Community.css';
|
||||
import BanUserButton from '../../../components/BanUserButton';
|
||||
import {FabButton} from 'coral-ui';
|
||||
import {menuActionsMap} from '../../../containers/ModerationQueue/helpers/moderationQueueActionsMap';
|
||||
|
||||
const ActionButton = ({type = '', user, ...props}) => {
|
||||
if (type === 'BAN') {
|
||||
return <BanUserButton icon='not interested' user={user} onClick={() => props.showBanUserDialog(user)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FabButton
|
||||
className={`${type.toLowerCase()} ${styles.actionButton}`}
|
||||
cStyle={type.toLowerCase()}
|
||||
icon={menuActionsMap[type].icon}
|
||||
onClick={() => {
|
||||
type === 'APPROVE' ? props.approveUser({userId: user.id}) : props.showSuspendUserDialog({user: user});
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionButton;
|
||||
@@ -0,0 +1,164 @@
|
||||
.dialog {
|
||||
border: none;
|
||||
box-shadow: 0 9px 46px 8px rgba(0, 0, 0, 0.14), 0 11px 15px -7px rgba(0, 0, 0, 0.12), 0 24px 38px 3px rgba(0, 0, 0, 0.2);
|
||||
width: 500px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
height: 184px;
|
||||
padding: 20px;
|
||||
|
||||
h2 {
|
||||
color: black;
|
||||
font-size: 1.76em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
h3 {
|
||||
color: black;
|
||||
font-size: 1.4em;
|
||||
font-weight: 500;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.textField {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.textField label {
|
||||
font-size: 1.08em;
|
||||
font-weight: bold;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.textField input {
|
||||
width: 100%;
|
||||
display: block;
|
||||
border: none;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 10px 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin: 20px auto 10px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.footer span {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.footer a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.socialConnections {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.signInButton {
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.close {
|
||||
font-size: 20px;
|
||||
line-height: 14px;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
position: absolute;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: #363636;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: #6b6b6b;
|
||||
}
|
||||
|
||||
input.error{
|
||||
border: solid 2px #f44336;
|
||||
}
|
||||
|
||||
.errorMsg, .hint {
|
||||
color: grey;
|
||||
font-weight: 600;
|
||||
padding: 3px 0 16px;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.alert--success {
|
||||
border: solid 1px #1ec00e;
|
||||
background: #cbf1b8;
|
||||
color: #006900;
|
||||
}
|
||||
|
||||
.alert--error {
|
||||
background: #FFEBEE;
|
||||
color: #B71C1C;
|
||||
}
|
||||
|
||||
.userBox a {
|
||||
color: #2c69b6;
|
||||
cursor: pointer;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
.attention {
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
background: #B71C1C;
|
||||
color: #FFEBEE;
|
||||
font-weight: bolder;
|
||||
padding: 4px;
|
||||
vertical-align: middle;
|
||||
border-radius: 20px;
|
||||
box-sizing: border-box;
|
||||
font-size: 9px;
|
||||
line-height: 7px;
|
||||
text-align: center;
|
||||
margin-right: 5px;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.passwordRequestSuccess {
|
||||
border: 1px solid green;
|
||||
background-color: lightgreen;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.passwordRequestFailure {
|
||||
border: 1px solid orange;
|
||||
background-color: 1px solid coral;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cancel {
|
||||
margin-right: 10px;
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.ban {
|
||||
width: 47%;
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin: 20px 0;
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Dialog} from 'coral-ui';
|
||||
import styles from './BanUserDialog.css';
|
||||
|
||||
import Button from 'coral-ui/components/Button';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../../translations';
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('community.ban_user')}>
|
||||
<span className={styles.close} onClick={handleClose}>×</span>
|
||||
<div>
|
||||
<div className={styles.header}>
|
||||
<h2>{lang.t('community.ban_user')}</h2>
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{lang.t('community.are_you_sure', user.username)}</h3>
|
||||
<i>{lang.t('community.note')}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<Button cStyle="cancel" className={styles.cancel} onClick={handleClose} raised>
|
||||
{lang.t('community.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
cStyle="black" className={styles.ban}
|
||||
onClick={() => {
|
||||
handleBanUser({userId: user.id}).then(() => {
|
||||
handleClose();
|
||||
});
|
||||
}}
|
||||
raised>
|
||||
{lang.t('community.yes_ban_user')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
BanUserDialog.propTypes = {
|
||||
handleBanUser: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
user: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export default BanUserDialog;
|
||||
@@ -0,0 +1,31 @@
|
||||
import React from 'react';
|
||||
|
||||
import styles from './styles.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations.json';
|
||||
|
||||
import {Link} from 'react-router';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const CommunityMenu = () => {
|
||||
const flaggedPath = '/admin/community/flagged';
|
||||
const peoplePath = '/admin/community/people';
|
||||
return (
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div>
|
||||
<Link to={flaggedPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('community.flaggedaccounts')}
|
||||
</Link>
|
||||
<Link to={peoplePath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('community.people')}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CommunityMenu;
|
||||
@@ -0,0 +1,115 @@
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
|
||||
import {Dialog, Button} from 'coral-ui';
|
||||
import styles from './SuspendUserDialog.css';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../../translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
const stages = [
|
||||
{
|
||||
title: 'suspenduser.title_0',
|
||||
description: 'suspenduser.description_0',
|
||||
options: {
|
||||
'j': 'suspenduser.no_cancel',
|
||||
'k': 'suspenduser.yes_suspend'
|
||||
}
|
||||
},
|
||||
{
|
||||
title: 'suspenduser.title_1',
|
||||
description: 'suspenduser.description_1',
|
||||
options: {
|
||||
'j': 'bandialog.cancel',
|
||||
'k': 'suspenduser.send'
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
class SuspendUserDialog extends Component {
|
||||
|
||||
state = {email: '', stage: 0}
|
||||
|
||||
static propTypes = {
|
||||
stage: PropTypes.number,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
suspendUser: PropTypes.func.isRequired
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({email: lang.t('suspenduser.email'), about: lang.t('suspenduser.username')});
|
||||
}
|
||||
|
||||
/*
|
||||
* When an admin clicks to suspend a user a dialog is shown, this function
|
||||
* handles the possible actions for that dialog.
|
||||
*/
|
||||
onActionClick = (stage, menuOption) => () => {
|
||||
const {suspendUser, user} = this.props;
|
||||
const {stage} = this.state;
|
||||
|
||||
const cancel = this.props.onClose;
|
||||
const next = () => this.setState({stage: stage + 1});
|
||||
const suspend = () => {
|
||||
suspendUser({userId: user.user.id})
|
||||
.then(() => {
|
||||
this.props.handleClose();
|
||||
});
|
||||
};
|
||||
|
||||
const suspendModalActions = [
|
||||
[ cancel, next ],
|
||||
[ cancel, suspend ]
|
||||
];
|
||||
return suspendModalActions[stage][menuOption]();
|
||||
}
|
||||
|
||||
onEmailChange = (e) => {
|
||||
this.setState({email: e.target.value});
|
||||
}
|
||||
|
||||
render () {
|
||||
const {open, handleClose} = this.props;
|
||||
const {stage} = this.state;
|
||||
|
||||
return <Dialog
|
||||
className={styles.suspendDialog}
|
||||
id="suspendUserDialog"
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onCancel={handleClose}
|
||||
title={lang.t('suspenduser.title')}>
|
||||
<div className={styles.title}>
|
||||
{lang.t(stages[stage].title, lang.t('suspenduser.username'))}
|
||||
</div>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.description}>
|
||||
{lang.t(stages[stage].description, lang.t('suspenduser.username'))}
|
||||
</div>
|
||||
{
|
||||
stage === 1 &&
|
||||
<div className={styles.writeContainer}>
|
||||
<div className={styles.emailMessage}>{lang.t('suspenduser.write_message')}</div>
|
||||
<div className={styles.emailContainer}>
|
||||
<textarea
|
||||
rows={5}
|
||||
className={styles.emailInput}
|
||||
value={this.state.email}
|
||||
onChange={this.onEmailChange}/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<div className={styles.modalButtons}>
|
||||
{Object.keys(stages[stage].options).map((key, i) => (
|
||||
<Button key={i} onClick={this.onActionClick(stage, i)}>
|
||||
{lang.t(stages[stage].options[key], lang.t('suspenduser.username'))}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>;
|
||||
}
|
||||
}
|
||||
|
||||
export default SuspendUserDialog;
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import styles from '../Community.css';
|
||||
|
||||
import ActionButton from './ActionButton';
|
||||
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../../translations.json';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
// Render a single user for the list
|
||||
const User = props => {
|
||||
const {user, modActionButtons} = props;
|
||||
let userStatus = user.status;
|
||||
|
||||
// Do not display unless the user status is 'pending' or 'banned'.
|
||||
// This means that they have already been reviewed and approved.
|
||||
return (userStatus === 'PENDING' || userStatus === 'BANNED') &&
|
||||
<li tabIndex={props.index} className={`mdl-card mdl-shadow--2dp ${styles.listItem} ${props.isActive && !props.hideActive ? styles.activeItem : ''}`}>
|
||||
<div className={styles.itemHeader}>
|
||||
<span className={styles.author}>{user.username}</span>
|
||||
<ActionButton
|
||||
className={styles.banButton}
|
||||
type='BAN'
|
||||
user={user}
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.itemBody}>
|
||||
<div className={styles.flaggedByCount}>
|
||||
<i className="material-icons">flag</i><span className={styles.flaggedByLabel}>Flags({ user.actions.length })</span>:
|
||||
{ user.action_summaries.map(
|
||||
(action, i ) => {
|
||||
return <span className={styles.flaggedBy} key={i}>
|
||||
{lang.t(`community.${action.reason}`)} ({action.count})
|
||||
</span>;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.flaggedReasons}>
|
||||
{user.actions.map(
|
||||
(action, i) => {
|
||||
return <span key={i}>
|
||||
{action.reason}
|
||||
{/* action.user.username */}
|
||||
</span>;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.sideActions}>
|
||||
<div className={`actions ${styles.actions}`}>
|
||||
{modActionButtons.map(
|
||||
(action, i) => {
|
||||
return <ActionButton
|
||||
type={action.toUpperCase()}
|
||||
key={i}
|
||||
user={user}
|
||||
approveUser={props.approveUser}
|
||||
suspendUser={props.suspendUser}
|
||||
showSuspendUserDialog={props.showSuspendUserDialog}
|
||||
/>;
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>;
|
||||
};
|
||||
|
||||
export default User;
|
||||
@@ -0,0 +1,336 @@
|
||||
@custom-media --big-viewport (min-width: 780px);
|
||||
|
||||
.listContainer {
|
||||
max-width: 860px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.tabBar {
|
||||
background-color: rgba(44, 44, 44, 0.89);
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: border-bottom 200ms;
|
||||
}
|
||||
|
||||
.active {
|
||||
color: white;
|
||||
box-sizing: border-box;
|
||||
border-bottom: solid 5px #F36451;
|
||||
}
|
||||
|
||||
.active > span {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.active:after {
|
||||
background: transparent !important;
|
||||
}
|
||||
|
||||
.showShortcuts {
|
||||
position: absolute;
|
||||
right: 130px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
|
||||
span {
|
||||
margin-left: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (--big-viewport) {
|
||||
.tab {
|
||||
flex: none;
|
||||
}
|
||||
}
|
||||
|
||||
.notFound {
|
||||
position: relative;
|
||||
margin: 20px auto;
|
||||
text-align: center;
|
||||
padding: 68px 45px;
|
||||
vertical-align: middle;
|
||||
min-width: 500px;
|
||||
|
||||
a {
|
||||
color: rgb(244, 126, 107);
|
||||
font-weight: 500;
|
||||
|
||||
&.goToStreams {
|
||||
position: absolute;
|
||||
right: 10px;
|
||||
bottom: 10px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #2c2c2c;
|
||||
color: white;
|
||||
margin-bottom: -1px;
|
||||
|
||||
.settingsButton {
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
}
|
||||
|
||||
.moderateAsset {
|
||||
a {
|
||||
-webkit-box-flex: 1;
|
||||
-ms-flex: 1;
|
||||
flex: 1;
|
||||
color: white;
|
||||
text-transform: capitalize;
|
||||
font-weight: 500;
|
||||
font-size: 15px;
|
||||
letter-spacing: 1px;
|
||||
transition: opacity 200ms;
|
||||
opacity: 1;
|
||||
|
||||
&:hover {
|
||||
opacity: .8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@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;
|
||||
font-size: 16px;
|
||||
width: 100%;
|
||||
max-width: 660px;
|
||||
min-width: 400px;
|
||||
margin: 0 auto;
|
||||
padding: 16px 14px;
|
||||
position: relative;
|
||||
transition: box-shadow 200ms;
|
||||
margin-top: 0;
|
||||
|
||||
|
||||
&:hover {
|
||||
box-shadow: 0 3px 6px rgba(0,0,0,0.16), 0 3px 6px rgba(0,0,0,0.23);
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.context {
|
||||
a {
|
||||
color: #f36451;
|
||||
text-decoration: underline;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
|
||||
.sideActions {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
padding: 40px 18px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.itemHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.author {
|
||||
min-width: 230px;
|
||||
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: 13px;
|
||||
margin-left: 40px;
|
||||
}
|
||||
|
||||
.actionButton {
|
||||
transform: scale(.8);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.body {
|
||||
margin-top: 0px;
|
||||
flex: 1;
|
||||
font-size: 0.88em;
|
||||
color: black;
|
||||
max-width: 500px;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.flagged {
|
||||
color: rgba(255, 0, 0, .5);
|
||||
padding-top: 15px;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.flagCount{
|
||||
font-size: 12px;
|
||||
color: #d32f2f;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.hasLinks {
|
||||
color: #f00;
|
||||
text-align: right;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.banned {
|
||||
color: #f00;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
i {
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
.ban {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.Comment {
|
||||
.moderateArticle {
|
||||
font-size: 12px;
|
||||
a {
|
||||
display: inline-block;
|
||||
color: #679af3;
|
||||
text-decoration: none;
|
||||
font-size: 1em;
|
||||
font-weight: 400;
|
||||
letter-spacing: .5px;
|
||||
font-size: 12px;
|
||||
margin-left: 10px;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
opacity: .9;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.flagBox {
|
||||
max-width: 480px;
|
||||
border-top: 1px solid rgba(66, 66, 66, 0.12);
|
||||
h3 {
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
@@ -24,10 +24,30 @@
|
||||
margin-bottom: 20px;
|
||||
align-items: flex-start;
|
||||
min-height: 100px;
|
||||
max-width: 600px;
|
||||
|
||||
h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
|
||||
.copiedText {
|
||||
display: inline-block;
|
||||
color: #00796b;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
float: right;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: inline-block;
|
||||
width: 200px;
|
||||
float: right;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.settingsError {
|
||||
@@ -41,7 +61,9 @@
|
||||
|
||||
.settingsHeader {
|
||||
margin-top: 3px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 7px;
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.disabledSettingText {
|
||||
@@ -58,11 +80,6 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.configSettingInfoBox p {
|
||||
font-size: 12px;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.configSettingEmbed {
|
||||
border: 1px solid #ccc;
|
||||
border-radius: 4px;
|
||||
@@ -81,6 +98,8 @@
|
||||
border-color: #ccc;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.charCountTexfieldEnabled {
|
||||
@@ -96,29 +115,18 @@
|
||||
color: white;
|
||||
}
|
||||
|
||||
.copiedText {
|
||||
color: #00796b;
|
||||
float: right;
|
||||
padding: 12px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
float: right;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.embedInput {
|
||||
border-radius: 3px;
|
||||
border: 1px solid #ccc;
|
||||
width: 100%;
|
||||
display: block;
|
||||
width: 90%;
|
||||
vertical-align: middle;
|
||||
margin-bottom: 10px;
|
||||
color: #555;
|
||||
padding: 14px;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
min-height: 175px;
|
||||
font-size: 14px;
|
||||
letter-spacing: 0.03em;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
#bannedWordlist, #suspectWordlist {
|
||||
@@ -170,3 +178,24 @@
|
||||
padding-left: 30px;
|
||||
}
|
||||
}
|
||||
|
||||
.Configure {
|
||||
|
||||
p {
|
||||
line-height: 1.2;
|
||||
max-width: 550px;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
width: 550px;
|
||||
}
|
||||
|
||||
.descriptionBox {
|
||||
margin-top: 15px;
|
||||
max-width: 550px;
|
||||
|
||||
input {
|
||||
height: 150px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,15 +10,19 @@ const lang = new I18n(translations);
|
||||
const Domainlist = ({domains, onChangeDomainlist}) => {
|
||||
return (
|
||||
<Card id={styles.domainlist} className={styles.configSetting}>
|
||||
<h3>{lang.t('configure.domain-list-title')}</h3>
|
||||
<p className={styles.domainlistDesc}>{lang.t('configure.domain-list-text')}</p>
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeDomainlist('whitelist', tags)}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.domain-list-title')}</div>
|
||||
<p className={styles.domainlistDesc}>{lang.t('configure.domain-list-text')}</p>
|
||||
<div className={styles.wrapper}>
|
||||
<TagsInput
|
||||
value={domains}
|
||||
inputProps={{placeholder: 'URL'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeDomainlist('whitelist', tags)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -45,13 +45,19 @@ class EmbedLink extends Component {
|
||||
`.trim();
|
||||
return (
|
||||
<Card shadow="2" className={styles.configSetting}>
|
||||
<h3>Embed Comment Stream</h3>
|
||||
<p>{lang.t('configure.copy-and-paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
|
||||
{lang.t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>{this.state.copied && 'Copied!'}</div>
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>Embed Comment Stream</div>
|
||||
<p>{lang.t('configure.copy-and-paste')}</p>
|
||||
<textarea rows={5} type='text' className={styles.embedInput} value={embedText} readOnly={true}/>
|
||||
<div className={styles.actions}>
|
||||
<Button raised className={styles.copyButton} onClick={this.copyToClipBoard} cStyle="black">
|
||||
{lang.t('embedlink.copy')}
|
||||
</Button>
|
||||
<div className={styles.copiedText}>
|
||||
{this.state.copied && 'Copied!'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ const ModerationSettings = ({settings, updateSettings, onChangeWordlist}) => {
|
||||
const off = styles.disabledSetting;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.Configure}>
|
||||
<Card className={`${styles.configSetting} ${settings.requireEmailConfirmation ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
|
||||
@@ -3,8 +3,8 @@ import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from '../../translations.json';
|
||||
import styles from './Configure.css';
|
||||
import {Textfield, Checkbox} from 'react-mdl';
|
||||
import {Card, Icon} from 'coral-ui';
|
||||
import {Checkbox, Textfield} from 'react-mdl';
|
||||
import {Card, Icon, TextArea} from 'coral-ui';
|
||||
|
||||
const TIMESTAMPS = {
|
||||
weeks: 60 * 60 * 24 * 7,
|
||||
@@ -32,6 +32,11 @@ const updateInfoBoxEnable = (updateSettings, infoBox) => () => {
|
||||
updateSettings({infoBoxEnable});
|
||||
};
|
||||
|
||||
const updatePremodLinksEnable = (updateSettings, premodLinks) => () => {
|
||||
const premodLinksEnable = !premodLinks;
|
||||
updateSettings({premodLinksEnable});
|
||||
};
|
||||
|
||||
const updateInfoBoxContent = (updateSettings) => (event) => {
|
||||
const infoBoxContent = event.target.value;
|
||||
updateSettings({infoBoxContent});
|
||||
@@ -64,7 +69,7 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
const off = styles.disabledSetting;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.Configure}>
|
||||
<Card className={`${styles.configSetting} ${settings.charCountEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
@@ -79,7 +84,9 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
className={`${styles.charCountTexfield} ${settings.charCountEnable && styles.charCountTexfieldEnabled}`}
|
||||
htmlFor='charCount'
|
||||
onChange={updateCharCount(updateSettings, settingsError)}
|
||||
value={settings.charCount}/>
|
||||
value={settings.charCount}
|
||||
disabled={settings.charCountEnable ? '' : 'disabled'}
|
||||
/>
|
||||
<span>{lang.t('configure.comment-count-text-post')}</span>
|
||||
{
|
||||
errors.charCount &&
|
||||
@@ -92,41 +99,56 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? on : off}`}>
|
||||
<Card className={`${styles.configSetting} ${settings.premodLinksEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updatePremodLinksEnable(updateSettings, settings.premodLinksEnable)}
|
||||
checked={settings.premodLinksEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.enable-premod-links')}</div>
|
||||
<p>
|
||||
{lang.t('configure.enable-premod-links-text')}
|
||||
</p>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox} ${settings.infoBoxEnable ? on : off}`}>
|
||||
<div className={styles.action}>
|
||||
<Checkbox
|
||||
onChange={updateInfoBoxEnable(updateSettings, settings.infoBoxEnable)}
|
||||
checked={settings.infoBoxEnable} />
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.include-comment-stream')}
|
||||
<p>
|
||||
<div className={styles.settingsHeader}>
|
||||
{lang.t('configure.include-comment-stream')}
|
||||
</div>
|
||||
<p className={settings.infoBoxEnable ? '' : styles.disabledSettingText}>
|
||||
{lang.t('configure.include-comment-stream-desc')}
|
||||
</p>
|
||||
<div className={`${styles.configSettingInfoBox} ${settings.infoBoxEnable ? null : styles.hidden}`} >
|
||||
<div className={styles.content}>
|
||||
<Textfield
|
||||
<div>
|
||||
<TextArea
|
||||
className={styles.descriptionBox}
|
||||
onChange={updateInfoBoxContent(updateSettings)}
|
||||
value={settings.infoBoxContent}
|
||||
label={lang.t('configure.include-text')}
|
||||
rows={3}/>
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.configSettingInfoBox}>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.closed-comments-desc')}
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.closed-stream-settings')}</div>
|
||||
<div className={styles.wrapper}>
|
||||
<p>{lang.t('configure.closed-comments-desc')}</p>
|
||||
<div>
|
||||
<Textfield
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
label={lang.t('configure.closed-comments-label')}
|
||||
rows={3}/>
|
||||
<TextArea className={styles.descriptionBox}
|
||||
onChange={updateClosedMessage(updateSettings)}
|
||||
value={settings.closedMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
<Card className={styles.configSettingInfoBox}>
|
||||
<Card className={`${styles.configSetting} ${styles.configSettingInfoBox}`}>
|
||||
<div className={styles.content}>
|
||||
{lang.t('configure.close-after')}
|
||||
<br />
|
||||
|
||||
@@ -14,19 +14,20 @@ const updateCustomCssUrl = (updateSettings) => (event) => {
|
||||
|
||||
const TechSettings = ({settings, onChangeDomainlist, updateSettings}) => {
|
||||
return (
|
||||
<div>
|
||||
<div className={styles.Configure}>
|
||||
<Domainlist
|
||||
domains={settings.domains.whitelist}
|
||||
onChangeDomainlist={onChangeDomainlist} />
|
||||
<EmbedLink />
|
||||
<Card className={styles.configSetting}>
|
||||
<h3>{lang.t('configure.custom-css-url')}</h3>
|
||||
<p>{lang.t('configure.custom-css-url-desc')}</p>
|
||||
<br />
|
||||
<input
|
||||
className={styles.customCSSInput}
|
||||
value={settings.customCssUrl}
|
||||
onChange={updateCustomCssUrl(updateSettings)} />
|
||||
<div className={styles.wrapper}>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.custom-css-url')}</div>
|
||||
<p>{lang.t('configure.custom-css-url-desc')}</p>
|
||||
<input
|
||||
className={styles.customCSSInput}
|
||||
value={settings.customCssUrl}
|
||||
onChange={updateCustomCssUrl(updateSettings)} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -8,25 +8,29 @@ import {Card} from 'coral-ui';
|
||||
const Wordlist = ({suspectWords, bannedWords, onChangeWordlist}) => (
|
||||
<div>
|
||||
<Card id={styles.bannedWordlist} className={styles.configSetting}>
|
||||
<h3>{lang.t('configure.banned-words-title')}</h3>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.banned-words-title')}</div>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.banned-word-text')}</p>
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
<div className={styles.wrapper}>
|
||||
<TagsInput
|
||||
value={bannedWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('banned', tags)}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
<Card id={styles.suspectWordlist} className={styles.configSetting}>
|
||||
<h3>{lang.t('configure.suspect-words-title')}</h3>
|
||||
<div className={styles.settingsHeader}>{lang.t('configure.suspect-words-title')}</div>
|
||||
<p className={styles.wordlistDesc}>{lang.t('configure.suspect-word-text')}</p>
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('suspect', tags)} />
|
||||
<div className={styles.wrapper}>
|
||||
<TagsInput
|
||||
value={suspectWords}
|
||||
inputProps={{placeholder: 'word or phrase'}}
|
||||
addOnPaste={true}
|
||||
pasteSplit={data => data.split(',').map(d => d.trim())}
|
||||
onChange={tags => onChangeWordlist('suspect', tags)} />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -16,7 +16,7 @@ const FlagWidget = (props) => {
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('dashboard.flags')}</th>
|
||||
<th colSpan='2'>{lang.t('dashboard.flags')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -27,20 +27,21 @@ const FlagWidget = (props) => {
|
||||
return (
|
||||
<tr className={styles.rowLinkify} key={asset.id}>
|
||||
<td>
|
||||
<Link className={styles.linkToAsset} to={`/admin/moderate/flagged/${asset.id}`}>
|
||||
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Link className={styles.linkToAsset} to={`/admin/moderate/flagged/${asset.id}`}>
|
||||
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
|
||||
</Link>
|
||||
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
|
||||
</td>
|
||||
<td>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr className={styles.rowLinkify}><td colSpan="2">{lang.t('dashboard.no_flags')}</td></tr>
|
||||
: <tr className={styles.rowLinkify}><td colSpan="3">{lang.t('dashboard.no_flags')}</td></tr>
|
||||
}
|
||||
{ /* rows in a table with a fixed height will expand and ignore height.
|
||||
this extra row will expand to fill the extra space. */
|
||||
|
||||
@@ -17,7 +17,7 @@ const LikeWidget = (props) => {
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('modqueue.likes')}</th>
|
||||
<th colSpan='2'>{lang.t('modqueue.likes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -28,20 +28,21 @@ const LikeWidget = (props) => {
|
||||
return (
|
||||
<tr className={styles.rowLinkify} key={asset.id}>
|
||||
<td>
|
||||
<Link className={styles.linkToAsset} to={`/admin/moderate/flagged/${asset.id}`}>
|
||||
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</Link>
|
||||
</td>
|
||||
<td>
|
||||
<Link className={styles.linkToAsset} to={`/admin/moderate/flagged/${asset.id}`}>
|
||||
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
|
||||
</Link>
|
||||
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
|
||||
</td>
|
||||
<td>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr className={styles.rowLinkify}><td colSpan="2">{lang.t('dashboard.no_likes')}</td></tr>
|
||||
: <tr className={styles.rowLinkify}><td colSpan="3">{lang.t('dashboard.no_likes')}</td></tr>
|
||||
}
|
||||
{ /* rows in a table with a fixed height will expand and ignore height.
|
||||
this extra row will expand to fill the extra space. */
|
||||
|
||||
@@ -55,11 +55,26 @@
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.widgetTable tbody td:last-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.linkToAsset {
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linkToModerate {
|
||||
background-color: #e0e0e0;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
|
||||
.linkToModerate:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.lede {
|
||||
font-size: 0.9em;
|
||||
color: #aaa;
|
||||
|
||||
@@ -27,23 +27,21 @@ class ModerationContainer extends Component {
|
||||
|
||||
componentWillMount() {
|
||||
const {toggleModal, singleView} = this.props;
|
||||
const {selectedIndex} = this.state;
|
||||
|
||||
this.props.fetchSettings();
|
||||
key('s', () => singleView());
|
||||
key('shift+/', () => toggleModal(true));
|
||||
key('esc', () => toggleModal(false));
|
||||
key('j', () => this.setState({selectedIndex: selectedIndex + 1}));
|
||||
key('k', () => this.setState({selectedIndex: selectedIndex > 0 ? selectedIndex + 1 : selectedIndex}));
|
||||
key('r', () => this.moderate(false));
|
||||
key('t', () => this.moderate(true));
|
||||
key('j', this.select(true));
|
||||
key('k', this.select(false));
|
||||
key('r', this.moderate(false));
|
||||
key('t', this.moderate(true));
|
||||
}
|
||||
|
||||
moderate = (accept) => {
|
||||
const {data, route, acceptComment, rejectComment} = this.props;
|
||||
moderate = (accept) => () => {
|
||||
const {acceptComment, rejectComment} = this.props;
|
||||
const {selectedIndex} = this.state;
|
||||
const activeTab = route.path === ':id' ? 'premod' : route.path;
|
||||
const comments = data[activeTab];
|
||||
const comments = this.getComments();
|
||||
const commentId = {commentId: comments[selectedIndex].id};
|
||||
|
||||
if (accept) {
|
||||
@@ -51,7 +49,30 @@ class ModerationContainer extends Component {
|
||||
} else {
|
||||
rejectComment(commentId);
|
||||
}
|
||||
}
|
||||
|
||||
getComments = () => {
|
||||
const {data, route} = this.props;
|
||||
const activeTab = route.path === ':id' ? 'premod' : route.path;
|
||||
return data[activeTab];
|
||||
}
|
||||
|
||||
select = (next) => () => {
|
||||
if (next) {
|
||||
this.setState(prevState =>
|
||||
({
|
||||
...prevState,
|
||||
selectedIndex: prevState.selectedIndex < this.getComments().length - 1
|
||||
? prevState.selectedIndex + 1 : prevState.selectedIndex
|
||||
}));
|
||||
} else {
|
||||
this.setState(prevState =>
|
||||
({
|
||||
...prevState,
|
||||
selectedIndex: prevState.selectedIndex > 0 ?
|
||||
prevState.selectedIndex - 1 : prevState.selectedIndex
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
selectSort = (sort) => {
|
||||
@@ -142,8 +163,10 @@ class ModerationContainer extends Component {
|
||||
<BanUserDialog
|
||||
open={moderation.banDialog}
|
||||
user={moderation.user}
|
||||
commentId={moderation.commentId}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
rejectComment={props.rejectComment}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
open={moderation.modalOpen}
|
||||
|
||||
@@ -10,9 +10,9 @@ const ModerationHeader = props => (
|
||||
props.asset ?
|
||||
<div className={`mdl-tabs__tab-bar ${styles.moderateAsset}`}>
|
||||
<Link className="mdl-tabs__tab" to="/admin/moderate">All Streams</Link>
|
||||
<a className="mdl-tabs__tab">
|
||||
{props.asset.title}
|
||||
<a href={props.asset.url} className={styles.settingsButton}><Icon name="settings"/></a>
|
||||
<a className="mdl-tabs__tab" href={props.asset.url}>
|
||||
<span>{props.asset.title}</span>
|
||||
<Icon className={styles.settingsButton} name="open_in_new"/>
|
||||
</a>
|
||||
<Link className="mdl-tabs__tab" to="/admin/streams">Select Stream</Link>
|
||||
</div>
|
||||
|
||||
@@ -84,11 +84,10 @@ span {
|
||||
margin-bottom: -1px;
|
||||
|
||||
.settingsButton {
|
||||
i {
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
margin-top: -4px;
|
||||
}
|
||||
vertical-align: middle;
|
||||
margin-left: 10px;
|
||||
margin-top: -4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.moderateAsset {
|
||||
@@ -114,7 +113,15 @@ span {
|
||||
}
|
||||
|
||||
&:nth-child(2) {
|
||||
text-align: center;
|
||||
span {
|
||||
text-align: center;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
max-width: 344px;
|
||||
display: inline-block;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
import SET_USER_STATUS from './setUserStatus.graphql';
|
||||
import SET_COMMENT_STATUS from './setCommentStatus.graphql';
|
||||
import SUSPEND_USER from './suspendUser.graphql';
|
||||
|
||||
export const banUser = graphql(SET_USER_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
@@ -9,11 +10,39 @@ export const banUser = graphql(SET_USER_STATUS, {
|
||||
variables: {
|
||||
userId,
|
||||
status: 'BANNED'
|
||||
}
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}}),
|
||||
});
|
||||
|
||||
export const setUserStatus = graphql(SET_USER_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
approveUser: ({userId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
userId,
|
||||
status: 'APPROVED'
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const suspendUser = graphql(SUSPEND_USER, {
|
||||
props: ({mutate}) => ({
|
||||
suspendUser: ({userId}) => {
|
||||
return mutate({
|
||||
variables: {
|
||||
userId
|
||||
},
|
||||
refetchQueries: ['Users']
|
||||
});
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
|
||||
props: ({mutate}) => ({
|
||||
acceptComment: ({commentId}) => {
|
||||
@@ -22,7 +51,26 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
|
||||
commentId,
|
||||
status: 'ACCEPTED'
|
||||
},
|
||||
refetchQueries: ['ModQueue']
|
||||
updateQueries: {
|
||||
ModQueue: (oldData) => {
|
||||
const premod = oldData.premod.filter(c => c.id !== commentId);
|
||||
const flagged = oldData.flagged.filter(c => c.id !== commentId);
|
||||
const rejected = oldData.rejected.filter(c => c.id !== commentId);
|
||||
const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount;
|
||||
const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount;
|
||||
const rejectedCount = rejected.length < oldData.rejected.length ? oldData.rejectedCount - 1 : oldData.rejectedCount;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
premodCount,
|
||||
flaggedCount,
|
||||
rejectedCount,
|
||||
premod,
|
||||
flagged,
|
||||
rejected,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
rejectComment: ({commentId}) => {
|
||||
@@ -31,7 +79,27 @@ export const setCommentStatus = graphql(SET_COMMENT_STATUS, {
|
||||
commentId,
|
||||
status: 'REJECTED'
|
||||
},
|
||||
refetchQueries: ['ModQueue']
|
||||
updateQueries: {
|
||||
ModQueue: (oldData) => {
|
||||
const comment = oldData.premod.concat(oldData.flagged).filter(c => c.id === commentId)[0];
|
||||
const rejected = [comment].concat(oldData.rejected);
|
||||
const premod = oldData.premod.filter(c => c.id !== commentId);
|
||||
const flagged = oldData.flagged.filter(c => c.id !== commentId);
|
||||
const premodCount = premod.length < oldData.premod.length ? oldData.premodCount - 1 : oldData.premodCount;
|
||||
const flaggedCount = flagged.length < oldData.flagged.length ? oldData.flaggedCount - 1 : oldData.flaggedCount;
|
||||
const rejectedCount = oldData.rejectedCount + 1;
|
||||
|
||||
return {
|
||||
...oldData,
|
||||
premodCount,
|
||||
flaggedCount,
|
||||
rejectedCount,
|
||||
premod,
|
||||
flagged,
|
||||
rejected
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
mutation suspendUser($userId: ID!) {
|
||||
suspendUser(id: $userId) {
|
||||
errors {
|
||||
translation_key
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import {graphql} from 'react-apollo';
|
||||
|
||||
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
|
||||
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
|
||||
import MOD_USER_FLAGGED_QUERY from './modUserFlaggedQuery.graphql';
|
||||
import METRICS from './metricsQuery.graphql';
|
||||
|
||||
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
@@ -66,6 +67,8 @@ export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) =>
|
||||
});
|
||||
};
|
||||
|
||||
export const modUserFlaggedQuery = graphql(MOD_USER_FLAGGED_QUERY);
|
||||
|
||||
export const modQueueResort = (id, fetchMore) => (sort) => {
|
||||
return fetchMore({
|
||||
query: MOD_QUEUE_QUERY,
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
query Users ($n: ACTION_TYPE) {
|
||||
users (query:{action_type: $n}){
|
||||
id
|
||||
username
|
||||
status
|
||||
roles
|
||||
actions{
|
||||
id
|
||||
created_at
|
||||
... on FlagAction {
|
||||
reason
|
||||
}
|
||||
user {
|
||||
username
|
||||
}
|
||||
}
|
||||
action_summaries {
|
||||
count
|
||||
reason
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,58 +6,88 @@ import {
|
||||
FETCH_COMMENTERS_SUCCESS,
|
||||
SORT_UPDATE,
|
||||
SET_ROLE,
|
||||
SET_COMMENTER_STATUS
|
||||
SET_COMMENTER_STATUS,
|
||||
SHOW_BANUSER_DIALOG,
|
||||
HIDE_BANUSER_DIALOG,
|
||||
SHOW_SUSPENDUSER_DIALOG,
|
||||
HIDE_SUSPENDUSER_DIALOG
|
||||
} from '../constants/community';
|
||||
|
||||
const initialState = Map({
|
||||
community: Map(),
|
||||
isFetching: false,
|
||||
error: '',
|
||||
commenters: [],
|
||||
field: 'created_at',
|
||||
asc: false,
|
||||
totalPages: 0,
|
||||
page: 0
|
||||
isFetchingPeople: false,
|
||||
errorPeople: '',
|
||||
accounts: [],
|
||||
fieldPeople: 'created_at',
|
||||
ascPeople: false,
|
||||
totalPagesPeople: 0,
|
||||
pagePeople: 0,
|
||||
user: Map({}),
|
||||
banDialog: false,
|
||||
suspendDialog: false
|
||||
});
|
||||
|
||||
export default function community (state = initialState, action) {
|
||||
switch (action.type) {
|
||||
case FETCH_COMMENTERS_REQUEST :
|
||||
return state
|
||||
.set('isFetching', true);
|
||||
.set('isFetchingPeople', true);
|
||||
case FETCH_COMMENTERS_FAILURE :
|
||||
return state
|
||||
.set('isFetching', false)
|
||||
.set('error', action.error);
|
||||
.set('isFetchingPeople', false)
|
||||
.set('errorPeople', action.error);
|
||||
|
||||
case FETCH_COMMENTERS_SUCCESS : {
|
||||
const {commenters, type, ...rest} = action; // eslint-disable-line
|
||||
const {accounts, type, page, count, limit, totalPages, ...rest} = action; // eslint-disable-line
|
||||
return state
|
||||
.merge({
|
||||
isFetching: false,
|
||||
error: '',
|
||||
isFetchingPeople: false,
|
||||
errorPeople: '',
|
||||
pagePeople: page,
|
||||
countPeople: count,
|
||||
limitPeople: limit,
|
||||
totalPagesPeople: totalPages,
|
||||
...rest
|
||||
})
|
||||
.set('commenters', commenters); // Sets to normal array
|
||||
.set('accounts', accounts); // Sets to normal array
|
||||
}
|
||||
case SET_ROLE : {
|
||||
const commenters = state.get('commenters');
|
||||
const commenters = state.get('accounts');
|
||||
const idx = commenters.findIndex(el => el.id === action.id);
|
||||
|
||||
commenters[idx].roles[0] = action.role;
|
||||
return state.set('commenters', commenters.map(id => id));
|
||||
return state.set('accounts', commenters.map(id => id));
|
||||
}
|
||||
case SET_COMMENTER_STATUS: {
|
||||
const commenters = state.get('commenters');
|
||||
const commenters = state.get('accounts');
|
||||
const idx = commenters.findIndex(el => el.id === action.id);
|
||||
|
||||
commenters[idx].status = action.status;
|
||||
return state.set('commenters', commenters.map(id => id));
|
||||
return state.set('accounts', commenters.map(id => id));
|
||||
|
||||
}
|
||||
case SORT_UPDATE :
|
||||
return state
|
||||
.set('field', action.sort.field)
|
||||
.set('asc', !state.get('asc'));
|
||||
.set('fieldPeople', action.sort.field)
|
||||
.set('ascPeople', !state.get('ascPeople'));
|
||||
case HIDE_BANUSER_DIALOG:
|
||||
return state
|
||||
.set('banDialog', false);
|
||||
case SHOW_BANUSER_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
banDialog: true
|
||||
});
|
||||
case HIDE_SUSPENDUSER_DIALOG:
|
||||
return state
|
||||
.set('suspendDialog', false);
|
||||
case SHOW_SUSPENDUSER_DIALOG:
|
||||
return state
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
suspendDialog: true
|
||||
});
|
||||
default :
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,20 @@
|
||||
"active": "Active",
|
||||
"banned": "Banned",
|
||||
"banned-user": "Banned User",
|
||||
"loading": "Loading results"
|
||||
"loading": "Loading results",
|
||||
"flaggedaccounts": "Flagged Usernames",
|
||||
"people": "People",
|
||||
"no-flagged-accounts": "The Account Flags queue is currently empty.",
|
||||
"I don't like this username": "I don't like this username",
|
||||
"This user is impersonating": "Impersonation",
|
||||
"This looks like an ad/marketing": "Spam/Ads",
|
||||
"This username is offensive": "Offensive",
|
||||
"Other": "Other",
|
||||
"ban_user": "Ban User?",
|
||||
"are_you_sure": "Are you sure you would like to ban {0}?",
|
||||
"note": "Note: Banning this user will not let them edit, comment or remove anything.",
|
||||
"cancel": "Cancel",
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"modqueue": {
|
||||
"likes": "likes",
|
||||
@@ -53,6 +66,7 @@
|
||||
"copy": "Copy to Clipboard"
|
||||
},
|
||||
"configure": {
|
||||
"closed-stream-settings": "Closed Stream Message",
|
||||
"stream-settings": "Stream Settings",
|
||||
"moderation-settings": "Moderation Settings",
|
||||
"tech-settings": "Tech Settings",
|
||||
@@ -63,9 +77,11 @@
|
||||
"enable-pre-moderation-text": "Moderators must approve any comment before it is published.",
|
||||
"require-email-verification": "Require Email Verification",
|
||||
"require-email-verification-text": "New Users must verify their email before commenting",
|
||||
"include-comment-stream": "Include Comment Stream Description for Readers.",
|
||||
"include-comment-stream": "Include Comment Stream Description for Readers",
|
||||
"include-comment-stream-desc": "Write a message to be added to the top of your comment stream. Pose a topic, include community guidelines, etc.",
|
||||
"include-text": "Include your text here.",
|
||||
"enable-premod-links": "Pre-Moderate Comments Containing Links",
|
||||
"enable-premod-links-text": "Moderators must approve any comment containing a link before its published.",
|
||||
"comment-settings": "Settings",
|
||||
"embed-comment-stream": "Embed Stream",
|
||||
"banned-word-text": "Comments which contain these words or phrases (not case-sensitive) will be automatically removed from the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
@@ -79,7 +95,7 @@
|
||||
"configure": "Configure",
|
||||
"community": "Community",
|
||||
"streams": "Streams",
|
||||
"closed-comments-desc": "Write a message for closed threads",
|
||||
"closed-comments-desc": "Write a message to be displayed when when your comment stream is closed and no longer accepting comments.",
|
||||
"closed-comments-label": "Write a message...",
|
||||
"hours": "Hours",
|
||||
"days": "Days",
|
||||
@@ -87,7 +103,7 @@
|
||||
"close-after": "Close comments after",
|
||||
"comment-count-header": "Limit Comment Length",
|
||||
"comment-count-text-pre": "Comments will be limited to ",
|
||||
"comment-count-text-post": " characters.",
|
||||
"comment-count-text-post": " characters",
|
||||
"comment-count-error": "Please enter a valid number.",
|
||||
"domain-list-title": "Permitted Domains",
|
||||
"domain-list-text": "Enter the domains you would like to permit for Talk, e.g. your local, staging and production environments (ex. localhost:3000, staging.domain.com, domain.com)."
|
||||
@@ -100,7 +116,8 @@
|
||||
"yes_ban_user": "Yes, Ban User"
|
||||
},
|
||||
"suspenduser": {
|
||||
"title_0": "We noticed you rejected a {0}",
|
||||
"title": "Suspend a user",
|
||||
"title_0": "We noticed you rejected a username",
|
||||
"description_0": "Would you like to temporarily ban this user becuase of their {0}? Doing so will temporarily hide their comments until they rewrite their {0}.",
|
||||
"title_1": "Notify the user of their temporary suspension",
|
||||
"description_1": "Suspending this user will temporarily disable their account and hide all of their comments on the site.",
|
||||
@@ -110,7 +127,7 @@
|
||||
"bio": "bio",
|
||||
"username": "username",
|
||||
"email_subject": "Your account has been suspended",
|
||||
"email": "Another member of the community recently flagged your {0} for review. Because of its content your {0} was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
|
||||
"email": "Another member of the community recently flagged your username for review. Because of its content your user was rejected. This means you can no longer comment, like, or flag content until you rewrite your {0}. Please e-mail moderator@newsorg.com if you have any questions or concerns.",
|
||||
"write_message": "Write a message"
|
||||
},
|
||||
"dashboard": {
|
||||
@@ -154,7 +171,34 @@
|
||||
"active": "Activa",
|
||||
"banned": "Suspendido",
|
||||
"banned-user": "Usuario Suspendido",
|
||||
"loading": "Cargando resultados"
|
||||
"loading": "Cargando resultados",
|
||||
"flaggedaccounts": "Nombres de Usuario Reportados",
|
||||
"people": "Gente",
|
||||
"no-flagged-accounts": "No hay ninguna cuenta reportada.",
|
||||
"I don't like this username": "No me gusta ese nombre de usuario",
|
||||
"This user is impersonating": "Suplantación",
|
||||
"This looks like an ad/marketing": "Spam/Propaganda",
|
||||
"This username is offensive": "Ofensivo",
|
||||
"Other": "Otros",
|
||||
"ban_user": "Quieres suspender el Usuario?",
|
||||
"are_you_sure": "Estas segura que quieres suspender a {0}?",
|
||||
"note": "Nota: Suspender a este usuario no le va a permitir borrar ni editar ni comentar.",
|
||||
"cancel": "Cancelar",
|
||||
"yes_ban_user": "Si, Suspendan el usuario"
|
||||
},
|
||||
"suspenduser": {
|
||||
"title": "Suspendiendo un usuario",
|
||||
"title_0": "Esta queriendo suspender un usuario?",
|
||||
"description_0": "Le gustaria suspender a esta usuaria temporarianmente por su nombre de usuario? Si lo hace sus comentarios serán escondidos temporariamente hasta que puedan reescribir su nombre de usuario.",
|
||||
"title_1": "Enviarle una nota al usuario sobre su cuenta suspendida",
|
||||
"description_1": "Si suspende a este usuario, su cuenta va a ser deshabilitada y todos sus comentarios escondidos del sitio.",
|
||||
"no_cancel": "No, cancelar",
|
||||
"yes_suspend": "Si, suspender",
|
||||
"send": "Enviar",
|
||||
"username": "nombre de usuario",
|
||||
"email_subject": "Su cuenta ha sido suspendida temporariamente",
|
||||
"email": "Otra persona de la comunidad recientemente marcó su nombre de usuario para ser revisado. Por su contenido, el nombre de usuario ha sido rechazado. Esto quiere decir que no puede comentar, gustar o marcar contenido hasta que modifique su nombre de usuario. Por favor, envienos un correo a moderator@newsorg.com si tiene alguna pregunta o preocupación",
|
||||
"write_message": "Escribir un mensaje"
|
||||
},
|
||||
"modqueue": {
|
||||
"likes": "gustos",
|
||||
@@ -178,6 +222,7 @@
|
||||
"username_flags": ""
|
||||
},
|
||||
"configure": {
|
||||
"closed-stream-settings": "Mensaje cuando los comentarios están cerrados en el artículo",
|
||||
"stream-settings": "Configuración de Comentarios",
|
||||
"moderation-settings": "Configuración de Moderación",
|
||||
"tech-settings": "Configuración Technical",
|
||||
@@ -193,6 +238,8 @@
|
||||
"include-text": "Incluir tu texto aqui.",
|
||||
"comment-settings": "Configuración de Comentarios",
|
||||
"embed-comment-stream": "Colocar Hilo de Comentarios",
|
||||
"enable-premod-links": "Pre-Moderar Commentarios que contienen Links",
|
||||
"enable-premod-links-text": "Los y las Moderadoras deben probar cualquier comentario que contengan links antes de su publicación.",
|
||||
"wordlist": "Palabras Suspendidas y Suspechosas",
|
||||
"banned-word-text": "Comentarios que contengan estas palabras o frases, no separadas por comas y en mayusculas o minusuculas, serán automaticamente separadas de los comentarios publicados.",
|
||||
"suspect-word-text": "Comments which contain these words or phrases (not case-sensitive) will be highlighted in the comment stream. Type a word and press Enter or Tab to add. Optionally paste a comma-separated list.",
|
||||
@@ -204,7 +251,7 @@
|
||||
"configure": "Configurar",
|
||||
"community": "Comunidad",
|
||||
"streams": "Streams",
|
||||
"closed-comments-desc": "Escribe un mensaje para cuando los comentarios se encuentran cerrados",
|
||||
"closed-comments-desc": "Escribe un mensaje que será mostrado cuando los comentarios estén cerrados y no se acepten más comentarios.",
|
||||
"closed-comments-label": "Escribe un mensaje...",
|
||||
"never": "Nunca",
|
||||
"hours": "Horas",
|
||||
|
||||
@@ -34,6 +34,18 @@ export default ({handleChange, handleApply, changed, updateQuestionBoxContent, .
|
||||
description: lang.t('configureCommentStream.enablePremodDescription')
|
||||
}} />
|
||||
</li>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
cStyle={changed ? 'green' : 'darkGrey'}
|
||||
name="premodLinks"
|
||||
onChange={handleChange}
|
||||
defaultChecked={props.premodLinks}
|
||||
info={{
|
||||
title: lang.t('configureCommentStream.enablePremodLinks'),
|
||||
description: lang.t('configureCommentStream.enablePremodLinksDescription')
|
||||
}} />
|
||||
</li>
|
||||
<li>
|
||||
<Checkbox
|
||||
className={styles.checkbox}
|
||||
|
||||
@@ -31,13 +31,14 @@ class ConfigureStreamContainer extends Component {
|
||||
const questionBoxEnable = elements.qboxenable.checked;
|
||||
const questionBoxContent = elements.qboxcontent.value;
|
||||
|
||||
// const premodLinks = elements.premodLinks.checked;
|
||||
const premodLinksEnable = elements.premodLinks.checked;
|
||||
const {changed} = this.state;
|
||||
|
||||
const newConfig = {
|
||||
moderation: premod ? 'PRE' : 'POST',
|
||||
questionBoxEnable,
|
||||
questionBoxContent
|
||||
questionBoxContent,
|
||||
premodLinksEnable
|
||||
};
|
||||
|
||||
if (changed) {
|
||||
@@ -77,10 +78,9 @@ class ConfigureStreamContainer extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const status = this.props.asset.closedAt === null ? 'open' : 'closed';
|
||||
const premod = this.props.asset.settings.moderation === 'PRE';
|
||||
const questionBoxEnable = this.props.asset.settings.questionBoxEnable;
|
||||
const questionBoxContent = this.props.asset.settings.questionBoxContent;
|
||||
const {settings, closedAt} = this.props.asset;
|
||||
const status = closedAt === null ? 'open' : 'closed';
|
||||
const premod = settings.moderation === 'PRE';
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -88,11 +88,11 @@ class ConfigureStreamContainer extends Component {
|
||||
handleChange={this.handleChange}
|
||||
handleApply={this.handleApply}
|
||||
changed={this.state.changed}
|
||||
premodLinks={false}
|
||||
premodLinks={settings.premodLinks}
|
||||
premod={premod}
|
||||
updateQuestionBoxContent={this.updateQuestionBoxContent}
|
||||
questionBoxEnable={questionBoxEnable}
|
||||
questionBoxContent={questionBoxContent}
|
||||
questionBoxEnable={settings.questionBoxEnable}
|
||||
questionBoxContent={settings.questionBoxContent}
|
||||
/>
|
||||
<hr />
|
||||
<h3>{status === 'open' ? 'Close' : 'Open'} Comment Stream</h3>
|
||||
|
||||
@@ -3,5 +3,10 @@
|
||||
}
|
||||
|
||||
.Comment {
|
||||
|
||||
|
||||
}
|
||||
|
||||
.pendingComment {
|
||||
filter: blur(2px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -116,6 +116,7 @@ class Comment extends React.Component {
|
||||
const dontagree = getActionSummary('DontAgreeActionSummary', comment);
|
||||
let commentClass = parentId ? `reply ${styles.Reply}` : `comment ${styles.Comment}`;
|
||||
commentClass += highlighted === comment.id ? ' highlighted-comment' : '';
|
||||
commentClass += comment.id === 'pending' ? ` ${styles.pendingComment}` : '';
|
||||
|
||||
// call a function, and if it errors, call addNotification('error', ...) (e.g. to show user a snackbar)
|
||||
const notifyOnError = (fn, errorToMessage) => async () => {
|
||||
|
||||
@@ -16,7 +16,7 @@ import {queryStream} from 'coral-framework/graphql/queries';
|
||||
import {postComment, postFlag, postLike, postDontAgree, deleteAction, addCommentTag, removeCommentTag} from 'coral-framework/graphql/mutations';
|
||||
import {editName} from 'coral-framework/actions/user';
|
||||
import {updateCountCache} from 'coral-framework/actions/asset';
|
||||
import {Notification, notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
import {notificationActions, authActions, assetActions, pym} from 'coral-framework';
|
||||
|
||||
import Stream from './Stream';
|
||||
import InfoBox from 'coral-plugin-infobox/InfoBox';
|
||||
@@ -176,7 +176,7 @@ class Embed extends Component {
|
||||
refetch={refetch}
|
||||
setActiveReplyBox={this.setActiveReplyBox}
|
||||
activeReplyBox={this.state.activeReplyBox}
|
||||
addNotification={addNotification}
|
||||
addNotification={this.props.addNotification}
|
||||
depth={0}
|
||||
postItem={this.props.postItem}
|
||||
asset={asset}
|
||||
@@ -220,11 +220,6 @@ class Embed extends Component {
|
||||
showSignInDialog={this.props.showSignInDialog}
|
||||
comments={asset.comments} />
|
||||
</div>
|
||||
<Notification
|
||||
notifLength={4500}
|
||||
clearNotification={this.props.clearNotification}
|
||||
notification={{text: null}}
|
||||
/>
|
||||
<LoadMore
|
||||
assetId={asset.id}
|
||||
comments={asset.comments}
|
||||
@@ -246,11 +241,6 @@ class Embed extends Component {
|
||||
/>
|
||||
</RestrictedContent>
|
||||
</TabContent>
|
||||
<Notification
|
||||
notifLength={4500}
|
||||
clearNotification={this.props.clearNotification}
|
||||
notification={this.props.notification}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -258,7 +248,6 @@ class Embed extends Component {
|
||||
}
|
||||
|
||||
const mapStateToProps = state => ({
|
||||
notification: state.notification.toJS(),
|
||||
auth: state.auth.toJS(),
|
||||
userData: state.user.toJS(),
|
||||
asset: state.asset.toJS()
|
||||
@@ -267,13 +256,7 @@ const mapStateToProps = state => ({
|
||||
const mapDispatchToProps = dispatch => ({
|
||||
requestConfirmEmail: () => dispatch(requestConfirmEmail()),
|
||||
loadAsset: (asset) => dispatch(fetchAssetSuccess(asset)),
|
||||
addNotification: (type, text) => {
|
||||
pym.sendMessage('getPosition');
|
||||
|
||||
pym.onMessage('position', position => {
|
||||
dispatch(addNotification(type, text, position));
|
||||
});
|
||||
},
|
||||
addNotification: (type, text) => dispatch(addNotification(type, text)),
|
||||
clearNotification: () => dispatch(clearNotification()),
|
||||
editName: (username) => dispatch(editName(username)),
|
||||
showSignInDialog: (offset) => dispatch(showSignInDialog(offset)),
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
* {
|
||||
font-weight: inherit;
|
||||
font-family: inherit;
|
||||
font-style: inherit;
|
||||
font-size: 100%;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width:auto;
|
||||
height:auto;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Open Sans', sans-serif;
|
||||
font-family: 'Lato', sans-serif;
|
||||
width: 100%;
|
||||
font-size: 14px;
|
||||
@@ -80,6 +86,16 @@ hr {
|
||||
padding: 10px;
|
||||
margin-bottom: 10px;
|
||||
display: block;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
|
||||
.commentStream .material-icons {
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
font-size: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Question Box Styles */
|
||||
@@ -95,15 +111,41 @@ hr {
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
.coral-plugin-questionbox-icon {
|
||||
.coral-plugin-questionbox-icon.bubble{
|
||||
position: absolute;
|
||||
top: 11px;
|
||||
left: 15px;
|
||||
color: #949393;
|
||||
font-size: 20px;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.coral-plugin-questionbox-icon.person{
|
||||
z-index: 2;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
position: absolute;
|
||||
font-size: 24px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.coral-plugin-questionbox-box {
|
||||
position: relative;
|
||||
border: 0;
|
||||
background: black;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
margin-left: 0px !important;
|
||||
margin-right: 10px;
|
||||
display: inline-block;
|
||||
width: 15px;
|
||||
height: 100%;
|
||||
padding: 3px 20px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
@@ -127,6 +169,8 @@ hr {
|
||||
flex: 1;
|
||||
padding: 5px;
|
||||
min-height: 100px;
|
||||
margin-top: 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.coral-plugin-commentbox-button-container {
|
||||
@@ -220,7 +264,7 @@ hr {
|
||||
|
||||
.comment__action-container .material-icons {
|
||||
font-size: 12px;
|
||||
margin-left: 3px;
|
||||
margin-left: 3px;
|
||||
}
|
||||
|
||||
button.comment__action-button,
|
||||
@@ -237,13 +281,6 @@ button.comment__action-button[disabled],
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.commentStream .material-icons {
|
||||
vertical-align: middle;
|
||||
width: 1em;
|
||||
font-size: 1em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.likedButton {
|
||||
color: rgb(0,134,227);
|
||||
}
|
||||
@@ -323,14 +360,14 @@ button.comment__action-button[disabled],
|
||||
}
|
||||
|
||||
.coral-plugin-flags-popup-counter {
|
||||
float: left;
|
||||
margin-top: 21px;
|
||||
color: #999;
|
||||
float: left;
|
||||
margin-top: 21px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.coral-plugin-flags-popup-button {
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
float: right;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.coral-plugin-flags-reason-text {
|
||||
@@ -386,6 +423,8 @@ button.coral-load-more {
|
||||
color: #FFF;
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
padding: 10px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
button.coral-load-more:hover {
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
import pym from 'pym.js';
|
||||
|
||||
const snackbarStyles = {
|
||||
position: 'fixed',
|
||||
cursor: 'default',
|
||||
userSelect: 'none',
|
||||
backgroundColor: '#323232',
|
||||
zIndex: 3,
|
||||
willChange: 'transform, opacity',
|
||||
transition: 'transform .35s cubic-bezier(.55,0,.1,1), opacity .35s',
|
||||
pointerEvents: 'none',
|
||||
padding: '12px 18px',
|
||||
color: '#fff',
|
||||
borderRadius: '3px 3px 0 0',
|
||||
textAlign: 'center',
|
||||
maxWidth: '300px',
|
||||
left: '50%',
|
||||
opacity: 0,
|
||||
transform: 'translate(-50%, 20px)',
|
||||
bottom: 0,
|
||||
boxSizing: 'border-box'
|
||||
};
|
||||
|
||||
// This function should return value of window.Coral
|
||||
const Coral = {};
|
||||
const Talk = Coral.Talk = {};
|
||||
@@ -32,6 +53,14 @@ function configurePymParent(pymParent, asset_url) {
|
||||
let notificationOffset = 200;
|
||||
let ready = false;
|
||||
let cachedHeight;
|
||||
const snackbar = document.createElement('div');
|
||||
snackbar.id = 'coral-notif';
|
||||
|
||||
for (let key in snackbarStyles) {
|
||||
snackbar.style[key] = snackbarStyles[key];
|
||||
}
|
||||
|
||||
window.document.body.appendChild(snackbar);
|
||||
|
||||
// Resize parent iframe height when child height changes
|
||||
pymParent.onMessage('height', function(height) {
|
||||
@@ -41,6 +70,27 @@ function configurePymParent(pymParent, asset_url) {
|
||||
}
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-clear-notification', function () {
|
||||
snackbar.style.opacity = 0;
|
||||
});
|
||||
|
||||
pymParent.onMessage('coral-alert', function (message) {
|
||||
const [type, text] = message.split('|');
|
||||
snackbar.style.transform = 'translate(-50%, 20px)';
|
||||
snackbar.style.opacity = 0;
|
||||
snackbar.className = `coral-notif-${type}`;
|
||||
snackbar.textContent = text;
|
||||
|
||||
setTimeout(() => {
|
||||
snackbar.style.transform = 'translate(-50%, 0)';
|
||||
snackbar.style.opacity = 1;
|
||||
}, 0);
|
||||
|
||||
setTimeout(() => {
|
||||
snackbar.style.opacity = 0;
|
||||
}, 5000);
|
||||
});
|
||||
|
||||
// Helps child show notifications at the right scrollTop
|
||||
pymParent.onMessage('getPosition', function() {
|
||||
let position = viewport().height + document.body.scrollTop;
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
export const ADD_NOTIFICATION = 'ADD_NOTIFICATION';
|
||||
export const CLEAR_NOTIFICATION = 'CLEAR_NOTIFICATION';
|
||||
import {pym} from 'coral-framework';
|
||||
|
||||
export const addNotification = (notifType, text, position) => {
|
||||
return {
|
||||
type: ADD_NOTIFICATION,
|
||||
notifType,
|
||||
text,
|
||||
position
|
||||
};
|
||||
export const addNotification = (notifType, text) => {
|
||||
pym.sendMessage('coral-alert', `${notifType}|${text}`);
|
||||
};
|
||||
|
||||
export const clearNotification = () => {
|
||||
return {
|
||||
type: CLEAR_NOTIFICATION
|
||||
};
|
||||
pym.sendMessage('coral-clear-notification');
|
||||
};
|
||||
|
||||
@@ -35,14 +35,14 @@ export const postComment = graphql(POST_COMMENT, {
|
||||
action_summaries: [],
|
||||
tags: [],
|
||||
status: null,
|
||||
id: `${Date.now()}_temp_id`
|
||||
id: 'pending'
|
||||
}
|
||||
}
|
||||
},
|
||||
updateQueries: {
|
||||
AssetQuery: (oldData, {mutationResult:{data:{createComment:{comment}}}}) => {
|
||||
|
||||
if (oldData.asset.settings.moderation === 'PRE') {
|
||||
if (oldData.asset.settings.moderation === 'PRE' || comment.status === 'PREMOD' || comment.status === 'REJECTED') {
|
||||
return oldData;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ import I18n from './modules/i18n/i18n';
|
||||
import * as authActions from './actions/auth';
|
||||
import * as assetActions from './actions/asset';
|
||||
import * as notificationActions from './actions/notification';
|
||||
import Notification from './modules/notification/Notification';
|
||||
|
||||
export {
|
||||
pym,
|
||||
@@ -12,6 +11,5 @@ export {
|
||||
store,
|
||||
authActions,
|
||||
assetActions,
|
||||
Notification,
|
||||
notificationActions
|
||||
};
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
import React from 'react';
|
||||
import {SnackBar} from 'coral-ui';
|
||||
|
||||
const Notification = (props) => {
|
||||
if (props.notification.text) {
|
||||
setTimeout(() => {
|
||||
props.clearNotification();
|
||||
}, props.notifLength);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
{
|
||||
props.notification.text &&
|
||||
<SnackBar id='coral-notif' className={`coral-notif-${props.notification.type}`} position={props.notification.position}>
|
||||
{props.notification.text}
|
||||
</SnackBar>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Notification;
|
||||
@@ -1,11 +1,9 @@
|
||||
import auth from './auth';
|
||||
import user from './user';
|
||||
import asset from './asset';
|
||||
import notification from './notification';
|
||||
|
||||
export default {
|
||||
auth,
|
||||
user,
|
||||
asset,
|
||||
notification
|
||||
};
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
import * as actions from '../actions/notification';
|
||||
import {Map} from 'immutable';
|
||||
|
||||
const initialState = Map({
|
||||
text: '',
|
||||
type: '',
|
||||
position: 400
|
||||
});
|
||||
|
||||
export default (state = initialState, action) => {
|
||||
switch (action.type) {
|
||||
case actions.ADD_NOTIFICATION:
|
||||
return state
|
||||
.merge({
|
||||
type: action.notifType,
|
||||
text: action.text,
|
||||
position: action.position
|
||||
});
|
||||
case actions.CLEAR_NOTIFICATION:
|
||||
return initialState;
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
"post": "Post",
|
||||
"cancel": "Cancel",
|
||||
"reply": "Reply",
|
||||
"comment": "Comment",
|
||||
"comment": "Post a Comment",
|
||||
"name": "Name",
|
||||
"comment-post-notif": "Your comment has been posted.",
|
||||
"comment-post-notif-premod": "Thank you for posting. Our moderation team will review your comment shortly.",
|
||||
@@ -14,7 +14,7 @@
|
||||
"post": "Publicar",
|
||||
"cancel": "Cancelar",
|
||||
"reply": "Respuesta",
|
||||
"comment": "Comentario",
|
||||
"comment": "Escribe un Comentario",
|
||||
"name": "Nombre",
|
||||
"comment-post-notif": "Tu comentario ha sido publicado.",
|
||||
"comment-post-notif-premod": "Gracias por comentar. Nuestro equipo de moderación va a revisarlo muy pronto.",
|
||||
|
||||
@@ -32,18 +32,30 @@ class FlagButton extends Component {
|
||||
if (flagged) {
|
||||
this.setState((prev) => prev.localPost ? {...prev, localPost: null, step: 0} : {...prev, localDelete: true});
|
||||
deleteAction(localPost || flag.current_user.id);
|
||||
} else if (this.state.showMenu){
|
||||
this.closeMenu();
|
||||
} else {
|
||||
this.setState({showMenu: !this.state.showMenu});
|
||||
this.setState({showMenu: true});
|
||||
}
|
||||
}
|
||||
|
||||
closeMenu = () => {
|
||||
this.setState({
|
||||
showMenu: false,
|
||||
itemType: '',
|
||||
reason: '',
|
||||
message: '',
|
||||
step: 0
|
||||
});
|
||||
}
|
||||
|
||||
onPopupContinue = () => {
|
||||
const {postFlag, postDontAgree, id, author_id} = this.props;
|
||||
const {itemType, reason, step, posted, message} = this.state;
|
||||
|
||||
// Proceed to the next step or close the menu if we've reached the end
|
||||
if (step + 1 >= this.props.getPopupMenu.length) {
|
||||
this.setState({showMenu: false});
|
||||
this.closeMenu();
|
||||
} else {
|
||||
this.setState({step: step + 1});
|
||||
}
|
||||
@@ -114,7 +126,7 @@ class FlagButton extends Component {
|
||||
}
|
||||
|
||||
handleClickOutside () {
|
||||
this.setState({showMenu: false});
|
||||
this.closeMenu();
|
||||
}
|
||||
|
||||
render () {
|
||||
|
||||
@@ -23,14 +23,14 @@ const getPopupMenu = [
|
||||
{val: 'This comment is offensive', text: lang.t('comment-offensive')},
|
||||
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
|
||||
{val: 'I don\'t agree with this comment', text: lang.t('no-agree-comment')},
|
||||
{val: 'other', text: lang.t('other')}
|
||||
{val: 'Other', text: lang.t('other')}
|
||||
]
|
||||
: [
|
||||
{val: 'This username is offensive', text: lang.t('username-offensive')},
|
||||
{val: 'I don\'t like this username', text: lang.t('no-like-username')},
|
||||
{val: 'This user is impersonating', text: lang.t('user-impersonating')},
|
||||
{val: 'This looks like an ad/marketing', text: lang.t('marketing')},
|
||||
{val: 'other', text: lang.t('other')}
|
||||
{val: 'Other', text: lang.t('other')}
|
||||
];
|
||||
return {
|
||||
header: lang.t('step-2-header'),
|
||||
|
||||
@@ -2,9 +2,11 @@ import React from 'react';
|
||||
const packagename = 'coral-plugin-questionbox';
|
||||
|
||||
const QuestionBox = ({enable, content}) =>
|
||||
<div
|
||||
className={`${packagename}-info ${enable ? null : 'hidden'}` }>
|
||||
<i className={`${packagename}-icon material-icons`}>chat_bubble person</i>
|
||||
<div className={`${packagename}-info ${enable ? null : 'hidden'}` }>
|
||||
<div className={`${packagename}-box`}>
|
||||
<i className={`${packagename}-icon material-icons bubble`}>chat_bubble</i>
|
||||
<i className={`${packagename}-icon material-icons person`}>person</i>
|
||||
</div>
|
||||
{content}
|
||||
</div>;
|
||||
|
||||
|
||||
@@ -1,21 +1,29 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import React, {Component, PropTypes} from 'react';
|
||||
import CommentBox from '../coral-plugin-commentbox/CommentBox';
|
||||
|
||||
const name = 'coral-plugin-replies';
|
||||
|
||||
const ReplyBox = ({styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler, setActiveReplyBox}) => (
|
||||
<div className={`${name}-textarea`} style={styles && styles.container}>
|
||||
<CommentBox
|
||||
commentPostedHandler={commentPostedHandler}
|
||||
parentId={parentId}
|
||||
cancelButtonClicked={setActiveReplyBox}
|
||||
addNotification={addNotification}
|
||||
authorId={authorId}
|
||||
assetId={assetId}
|
||||
postItem={postItem}
|
||||
isReply={true} />
|
||||
</div>
|
||||
);
|
||||
class ReplyBox extends Component {
|
||||
|
||||
componentDidMount() {
|
||||
document.getElementById('replyText').focus();
|
||||
}
|
||||
|
||||
render() {
|
||||
const {styles, postItem, assetId, authorId, addNotification, parentId, commentPostedHandler, setActiveReplyBox} = this.props;
|
||||
return <div className={`${name}-textarea`} style={styles && styles.container}>
|
||||
<CommentBox
|
||||
commentPostedHandler={commentPostedHandler}
|
||||
parentId={parentId}
|
||||
cancelButtonClicked={setActiveReplyBox}
|
||||
addNotification={addNotification}
|
||||
authorId={authorId}
|
||||
assetId={assetId}
|
||||
postItem={postItem}
|
||||
isReply={true} />
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
ReplyBox.propTypes = {
|
||||
setActiveReplyBox: PropTypes.func.isRequired,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
min-width: 64px;
|
||||
padding: 0 8px;
|
||||
display: inline-block;
|
||||
font-family: 'Roboto','Helvetica','Arial',sans-serif;
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
overflow: hidden;
|
||||
will-change: box-shadow,transform;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import {Icon as IconMDL} from 'react-mdl';
|
||||
|
||||
const Icon = ({className, name}) => (
|
||||
const Icon = ({className = '', name}) => (
|
||||
<IconMDL className={className} name={name} />
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
.textArea {
|
||||
textarea {
|
||||
width: 100%;
|
||||
display: block;
|
||||
outline: none;
|
||||
border: 1px solid rgba(0,0,0,.12);
|
||||
padding: 6px;
|
||||
box-sizing: border-box;
|
||||
border-radius: 2px;
|
||||
margin: 5px auto;
|
||||
min-height: 175px;
|
||||
font-size: 14px;
|
||||
resize: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import styles from './TextArea.css';
|
||||
|
||||
const TextArea = ({className, value = '', ...props}) => (
|
||||
<div className={`${styles.textArea} ${className ? className : ''}`}>
|
||||
<textarea value={value} {...props}/>
|
||||
</div>
|
||||
);
|
||||
|
||||
TextArea.propTypes = {
|
||||
onChange: PropTypes.func,
|
||||
};
|
||||
|
||||
export default TextArea;
|
||||
@@ -22,3 +22,4 @@ export {default as WizardNav} from './components/WizardNav';
|
||||
export {default as Select} from './components/Select';
|
||||
export {default as Option} from './components/Option';
|
||||
export {default as SnackBar} from './components/SnackBar';
|
||||
export {default as TextArea} from './components/TextArea';
|
||||
|
||||
@@ -3,11 +3,51 @@ const DataLoader = require('dataloader');
|
||||
const util = require('./util');
|
||||
|
||||
const UsersService = require('../../services/users');
|
||||
const UserModel = require('../../models/user');
|
||||
|
||||
const genUserByIDs = (context, ids) => UsersService
|
||||
.findByIdArray(ids)
|
||||
.then(util.singleJoinBy(ids, 'id'));
|
||||
|
||||
/**
|
||||
* Retrieves users based on the passed in query that is filtered by the
|
||||
* current used passed in via the context.
|
||||
* @param {Object} context graph context
|
||||
* @param {Object} query query terms to apply to the users query
|
||||
*/
|
||||
const getUsersByQuery = ({user}, {ids, limit, cursor, sort}) => {
|
||||
|
||||
let users = UserModel.find();
|
||||
|
||||
if (ids) {
|
||||
users = users.find({
|
||||
id: {
|
||||
$in: ids
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (cursor) {
|
||||
if (sort === 'REVERSE_CHRONOLOGICAL') {
|
||||
users = users.where({
|
||||
created_at: {
|
||||
$lt: cursor
|
||||
}
|
||||
});
|
||||
} else {
|
||||
users = users.where({
|
||||
created_at: {
|
||||
$gt: cursor
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return users
|
||||
.sort({created_at: sort === 'REVERSE_CHRONOLOGICAL' ? -1 : 1})
|
||||
.limit(limit);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a set of loaders based on a GraphQL context.
|
||||
* @param {Object} context the context of the GraphQL request
|
||||
@@ -15,6 +55,7 @@ const genUserByIDs = (context, ids) => UsersService
|
||||
*/
|
||||
module.exports = (context) => ({
|
||||
Users: {
|
||||
getByQuery: (query) => getUsersByQuery(context, query),
|
||||
getByID: new DataLoader((ids) => genUserByIDs(context, ids))
|
||||
}
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ const errors = require('../../errors');
|
||||
const AssetsService = require('../../services/assets');
|
||||
const ActionsService = require('../../services/actions');
|
||||
const CommentsService = require('../../services/comments');
|
||||
const linkify = require('linkify-it')();
|
||||
|
||||
const Wordlist = require('../../services/wordlist');
|
||||
|
||||
@@ -54,13 +55,16 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id =
|
||||
* @param {String} body body of a comment
|
||||
* @return {Object} resolves to the wordlist results
|
||||
*/
|
||||
const filterNewComment = (context, {body}) => {
|
||||
const filterNewComment = (context, {body, asset_id}) => {
|
||||
|
||||
// Create a new instance of the Wordlist.
|
||||
const wl = new Wordlist();
|
||||
|
||||
// Load the wordlist and filter the comment content.
|
||||
return wl.load().then(() => wl.scan('body', body));
|
||||
return Promise.all([
|
||||
wl.load().then(() => wl.scan('body', body)),
|
||||
AssetsService.rectifySettings(AssetsService.findById(asset_id))
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -72,7 +76,7 @@ const filterNewComment = (context, {body}) => {
|
||||
* @param {Object} [wordlist={}] the results of the wordlist scan
|
||||
* @return {Promise} resolves to the comment's status
|
||||
*/
|
||||
const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => {
|
||||
const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}, settings) => {
|
||||
|
||||
// Decide the status based on whether or not the current asset/settings
|
||||
// has pre-mod enabled or not. If the comment was rejected based on the
|
||||
@@ -82,6 +86,8 @@ const resolveNewCommentStatus = (context, {asset_id, body}, wordlist = {}) => {
|
||||
|
||||
if (wordlist.banned) {
|
||||
status = Promise.resolve('REJECTED');
|
||||
} else if (settings.premodLinksEnable && linkify.test(body)) {
|
||||
status = Promise.resolve('PREMOD');
|
||||
} else {
|
||||
status = AssetsService
|
||||
.rectifySettings(AssetsService.findById(asset_id).then((asset) => {
|
||||
@@ -131,13 +137,13 @@ const createPublicComment = (context, commentInput) => {
|
||||
// We then take the wordlist and the comment into consideration when
|
||||
// considering what status to assign the new comment, and resolve the new
|
||||
// status to set the comment to.
|
||||
.then((wordlist) => resolveNewCommentStatus(context, commentInput, wordlist)
|
||||
.then(([wordlist, settings]) => resolveNewCommentStatus(context, commentInput, wordlist, settings)
|
||||
|
||||
// Then we actually create the comment with the new status.
|
||||
.then((status) => createComment(context, commentInput, status))
|
||||
.then((comment) => {
|
||||
|
||||
// If the comment was flagged as being suspect, we need to add a
|
||||
// If the comment has a suspect word or a link, we need to add a
|
||||
// flag to it to indicate that it needs to be looked at.
|
||||
// Otherwise just return the new comment.
|
||||
|
||||
|
||||
@@ -6,18 +6,26 @@ const setUserStatus = ({user}, {id, status}) => {
|
||||
.then(res => res);
|
||||
};
|
||||
|
||||
module.exports = (context) => {
|
||||
if (context.user && context.user.can('mutation:setUserStatus')) {
|
||||
return {
|
||||
User: {
|
||||
setUserStatus: (action) => setUserStatus(context, action)
|
||||
}
|
||||
};
|
||||
}
|
||||
const suspendUser = ({user}, {id}) => {
|
||||
return UsersService.suspendUser(id)
|
||||
.then(res => res);
|
||||
};
|
||||
|
||||
return {
|
||||
module.exports = (context) => {
|
||||
let mutators = {
|
||||
User: {
|
||||
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized)
|
||||
setUserStatus: () => Promise.reject(errors.ErrNotAuthorized),
|
||||
suspendUser: () => Promise.reject(errors.ErrNotAuthorized)
|
||||
}
|
||||
};
|
||||
|
||||
if (context.user && context.user.can('mutation:setUserStatus')) {
|
||||
mutators.User.setUserStatus = (action) => setUserStatus(context, action);
|
||||
}
|
||||
|
||||
if (context.user && context.user.can('mutation:suspendUser')) {
|
||||
mutators.User.suspendUser = (action) => suspendUser(context, action);
|
||||
}
|
||||
|
||||
return mutators;
|
||||
};
|
||||
|
||||
@@ -47,6 +47,9 @@ const RootMutation = {
|
||||
setUserStatus(_, {id, status}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.setUserStatus({id, status}));
|
||||
},
|
||||
suspendUser(_, {id}, {mutators: {User}}) {
|
||||
return wrapResponse(null)(User.suspendUser({id}));
|
||||
},
|
||||
setCommentStatus(_, {id, status}, {mutators: {Comment}}) {
|
||||
return wrapResponse(null)(Comment.setCommentStatus({id, status}));
|
||||
},
|
||||
|
||||
@@ -82,6 +82,28 @@ const RootQuery = {
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
// This endpoint is used for loading the user moderation queues (users whose username has been flagged),
|
||||
// so hide it in the event that we aren't an admin.
|
||||
users(_, {query: {action_type, limit, cursor, sort}}, {user, loaders: {Users, Actions}}) {
|
||||
|
||||
if (user == null || !user.hasRoles('ADMIN')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const query = {limit, cursor, sort};
|
||||
|
||||
if (action_type) {
|
||||
return Actions.getByTypes({action_type, item_type: 'USERS'})
|
||||
.then((ids) => {
|
||||
|
||||
// Perform the query using the available resolver.
|
||||
return Users.getByQuery({ids, limit, cursor, sort});
|
||||
});
|
||||
}
|
||||
|
||||
return Users.getByQuery(query);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ type User {
|
||||
username: String!
|
||||
|
||||
# Action summaries against the user.
|
||||
action_summaries: [ActionSummary]
|
||||
action_summaries: [FlagActionSummary]
|
||||
|
||||
# Actions completed on the parent.
|
||||
actions: [Action]
|
||||
@@ -45,6 +45,9 @@ type User {
|
||||
# returns all comments based on a query.
|
||||
comments(query: CommentsQuery): [Comment]
|
||||
|
||||
# returns all users based on a query.
|
||||
users(query: UsersQuery): [User]
|
||||
|
||||
# returns user status
|
||||
status: USER_STATUS
|
||||
}
|
||||
@@ -60,6 +63,20 @@ type Tag {
|
||||
created_at: Date!
|
||||
}
|
||||
|
||||
# UsersQuery allows the ability to query users by a specific fields.
|
||||
input UsersQuery {
|
||||
action_type: ACTION_TYPE
|
||||
|
||||
# Limit the number of results to be returned.
|
||||
limit: Int = 10
|
||||
|
||||
# Skip results from the last created_at timestamp.
|
||||
cursor: Date
|
||||
|
||||
# Sort the results by created_at.
|
||||
sort: SORT_ORDER = REVERSE_CHRONOLOGICAL
|
||||
}
|
||||
|
||||
################################################################################
|
||||
## Comments
|
||||
################################################################################
|
||||
@@ -370,6 +387,7 @@ type Settings {
|
||||
|
||||
infoBoxEnable: Boolean
|
||||
infoBoxContent: String
|
||||
premodLinksEnable: Boolean
|
||||
questionBoxEnable: Boolean
|
||||
questionBoxContent: String
|
||||
closeTimeout: Int
|
||||
@@ -500,6 +518,9 @@ type RootQuery {
|
||||
# role.
|
||||
me: User
|
||||
|
||||
# Users returned based on a query.
|
||||
users(query: UsersQuery): [User]
|
||||
|
||||
# Asset metrics related to user actions are saturated into the assets
|
||||
# returned.
|
||||
assetMetrics(from: Date!, to: Date!, sort: ACTION_TYPE!, limit: Int = 10): [Asset!]
|
||||
@@ -633,6 +654,14 @@ type SetUserStatusResponse implements Response {
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# SuspendUserResponse is the response returned with possibly some errors
|
||||
# relating to the suspend action attempt.
|
||||
type SuspendUserResponse implements Response {
|
||||
|
||||
# An array of errors relating to the mutation that occurred.
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# SetCommentStatusResponse is the response returned with possibly some errors
|
||||
# relating to the delete action attempt.
|
||||
type SetCommentStatusResponse implements Response {
|
||||
@@ -645,14 +674,14 @@ type SetCommentStatusResponse implements Response {
|
||||
type AddCommentTagResponse implements Response {
|
||||
# An array of errors relating to the mutation that occured.
|
||||
comment: Comment
|
||||
errors: [UserError]
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# Response to removeCommentTag mutation
|
||||
type RemoveCommentTagResponse implements Response {
|
||||
# An array of errors relating to the mutation that occured.
|
||||
comment: Comment
|
||||
errors: [UserError]
|
||||
errors: [UserError]
|
||||
}
|
||||
|
||||
# All mutations for the application are defined on this object.
|
||||
@@ -676,6 +705,9 @@ type RootMutation {
|
||||
# Sets User status. Requires the `ADMIN` role.
|
||||
setUserStatus(id: ID!, status: USER_STATUS!): SetUserStatusResponse
|
||||
|
||||
# Sets User status to BANNED and canEditName to true. Requires the `ADMIN` role.
|
||||
suspendUser(id: ID!): SuspendUserResponse
|
||||
|
||||
# Sets Comment status. Requires the `ADMIN` role.
|
||||
setCommentStatus(id: ID!, status: COMMENT_STATUS!): SetCommentStatusResponse
|
||||
|
||||
|
||||
@@ -40,6 +40,10 @@ const SettingSchema = new Schema({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
premodLinksEnable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
organizationName: {
|
||||
type: String
|
||||
},
|
||||
|
||||
@@ -156,9 +156,10 @@ const USER_GRAPH_OPERATIONS = [
|
||||
'mutation:deleteAction',
|
||||
'mutation:editName',
|
||||
'mutation:setUserStatus',
|
||||
'mutation:suspendUser',
|
||||
'mutation:setCommentStatus',
|
||||
'mutation:addCommentTag',
|
||||
'mutation:removeCommentTag',
|
||||
'mutation:removeCommentTag'
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -174,7 +175,7 @@ UserSchema.method('can', function(...actions) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
|
||||
if (actions.some((action) => action === 'mutation:setUserStatus' || action === 'mutation:suspendUser' || action === 'mutation:setCommentStatus') && !this.hasRoles('ADMIN')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"inquirer": "^3.0.1",
|
||||
"jsonwebtoken": "^7.1.9",
|
||||
"kue": "^0.11.5",
|
||||
"linkify-it": "^2.0.3",
|
||||
"lodash": "^4.16.6",
|
||||
"metascraper": "^1.0.6",
|
||||
"minimist": "^1.2.0",
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 3.3 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 9.1 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 8.7 KiB |