Merge branch 'master' into recaptcha-support
@@ -1,4 +1,4 @@
|
||||
FROM node:7.6
|
||||
FROM node:7
|
||||
|
||||
# Create app directory
|
||||
RUN mkdir -p /usr/src/app
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -4,5 +4,5 @@ export const toggleModal = open => ({type: actions.TOGGLE_MODAL, open});
|
||||
export const singleView = () => ({type: actions.SINGLE_VIEW});
|
||||
|
||||
// Ban User Dialog
|
||||
export const showBanUserDialog = (user, commentId) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId});
|
||||
export const showBanUserDialog = (user, commentId, showRejectedNote) => ({type: actions.SHOW_BANUSER_DIALOG, user, commentId, showRejectedNote});
|
||||
export const hideBanUserDialog = (showDialog) => ({type: actions.HIDE_BANUSER_DIALOG, showDialog});
|
||||
|
||||
@@ -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, showRejectedNote}) => (
|
||||
<Dialog
|
||||
className={styles.dialog}
|
||||
id="banuserDialog"
|
||||
@@ -23,13 +30,13 @@ const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
</div>
|
||||
<div className={styles.separator}>
|
||||
<h3>{lang.t('bandialog.are_you_sure', user.name)}</h3>
|
||||
<i>{lang.t('bandialog.note')}</i>
|
||||
<i>{showRejectedNote && lang.t('bandialog.note')}</i>
|
||||
</div>
|
||||
<div className={styles.buttons}>
|
||||
<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>
|
||||
@@ -40,6 +47,8 @@ const BanUserDialog = ({open, handleClose, handleBanUser, user}) => (
|
||||
BanUserDialog.propTypes = {
|
||||
handleBanUser: PropTypes.func.isRequired,
|
||||
handleClose: PropTypes.func.isRequired,
|
||||
rejectComment: PropTypes.func.isRequired,
|
||||
commentId: PropTypes.string,
|
||||
user: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -73,6 +90,11 @@
|
||||
.configTimeoutSelect {
|
||||
display: inline-block;
|
||||
margin-left: 20px;
|
||||
|
||||
i { /* fix for firefox and react-mdl-selectfield@0.2.0 */
|
||||
padding: 20px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
}
|
||||
|
||||
.charCountTexfield {
|
||||
@@ -81,6 +103,8 @@
|
||||
border-color: #ccc;
|
||||
border-style: solid;
|
||||
border-width: 0px 0px 1px 0px;
|
||||
font-size: 14px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.charCountTexfieldEnabled {
|
||||
@@ -96,29 +120,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 +183,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 />
|
||||
@@ -149,6 +171,7 @@ const StreamSettings = ({updateSettings, settingsError, settings, errors}) => {
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
{/* the above card should be the last one if at all possible because of z-index issues with the selects */}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -7,3 +7,28 @@
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.autoUpdate {
|
||||
background-color: #d5d5d5;
|
||||
padding: 3px 10px 10px 10px;
|
||||
margin-bottom: 0;
|
||||
|
||||
i {
|
||||
position: relative;
|
||||
top: 7px;
|
||||
}
|
||||
|
||||
b {
|
||||
float: right;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
background-color: #c0c0c0;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
text-align: center;
|
||||
top: 4px;
|
||||
position: relative;
|
||||
line-height: 1.7em;
|
||||
font-size: 1.3em;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,11 +6,41 @@ import {getMetrics} from 'coral-admin/src/graphql/queries';
|
||||
import FlagWidget from './FlagWidget';
|
||||
import LikeWidget from './LikeWidget';
|
||||
import {showBanUserDialog, hideBanUserDialog} from 'coral-admin/src/actions/moderation';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
import {Spinner, Icon} from 'coral-ui';
|
||||
|
||||
import {Spinner} from 'coral-ui';
|
||||
const lang = new I18n(translations);
|
||||
const refreshIntervalSeconds = 60 * 5;
|
||||
|
||||
class Dashboard extends React.Component {
|
||||
|
||||
state = {
|
||||
noteHidden: false,
|
||||
secondsUntilRefresh: refreshIntervalSeconds
|
||||
}
|
||||
|
||||
componentWillMount () {
|
||||
setInterval(() => { // the countdown timer
|
||||
let nextCount = this.state.secondsUntilRefresh - 1;
|
||||
if (nextCount < 0) {
|
||||
nextCount = refreshIntervalSeconds;
|
||||
this.props.data.refetch();
|
||||
}
|
||||
this.setState({secondsUntilRefresh: nextCount});
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
formatTime = () => {
|
||||
const minutes = Math.floor(this.state.secondsUntilRefresh / 60);
|
||||
let seconds = (this.state.secondsUntilRefresh % 60).toString();
|
||||
if (seconds.length < 2) {
|
||||
seconds = `0${seconds}`;
|
||||
}
|
||||
|
||||
return `${minutes}:${seconds}`;
|
||||
}
|
||||
|
||||
render () {
|
||||
|
||||
if (this.props.data && this.props.data.loading) {
|
||||
@@ -20,9 +50,18 @@ class Dashboard extends React.Component {
|
||||
const {data: {assetsByLike, assetsByFlag}} = this.props;
|
||||
|
||||
return (
|
||||
<div className={styles.Dashboard}>
|
||||
<FlagWidget assets={assetsByFlag} />
|
||||
<LikeWidget assets={assetsByLike} />
|
||||
<div>
|
||||
<p
|
||||
style={{display: this.state.noteHidden ? 'none' : 'block'}}
|
||||
className={styles.autoUpdate}
|
||||
onClick={() => this.setState({noteHidden: true})}>
|
||||
<b>×</b>
|
||||
<Icon name='timer' /> <strong>{lang.t('dashboard.next-update', this.formatTime())}</strong> {lang.t('dashboard.auto-update')}
|
||||
</p>
|
||||
<div className={styles.Dashboard}>
|
||||
<FlagWidget assets={assetsByFlag} />
|
||||
<LikeWidget assets={assetsByLike} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,42 +12,29 @@ const FlagWidget = (props) => {
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most flags</h2>
|
||||
<table className={styles.widgetTable}>
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('dashboard.flags')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
assets.length
|
||||
? assets.map(asset => {
|
||||
const flagSummary = asset.action_summaries.find(s => s.type === 'FlagAssetActionSummary');
|
||||
return (
|
||||
<tr className={styles.rowLinkify} key={asset.id}>
|
||||
<td>
|
||||
<Link className={styles.linkToAsset} to={`/admin/moderate/flagged/${asset.id}`}>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr className={styles.rowLinkify}><td colSpan="2">{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. */
|
||||
assets.length < 10 ? <tr></tr> : null
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{lang.t('streams.article')}</p>
|
||||
<p>{lang.t('dashboard.flags')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map(asset => {
|
||||
const flagSummary = asset.action_summaries.find(s => s.type === 'FlagAssetActionSummary');
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{flagSummary ? flagSummary.actionCount : 0}</p>
|
||||
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</Link>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{lang.t('dashboard.no_flags')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -13,42 +13,29 @@ const LikeWidget = (props) => {
|
||||
return (
|
||||
<div className={styles.widget}>
|
||||
<h2 className={styles.heading}>Articles with the most likes</h2>
|
||||
<table className={styles.widgetTable}>
|
||||
<thead className={styles.widgetHead}>
|
||||
<tr>
|
||||
<th>{lang.t('streams.article')}</th>
|
||||
<th>{lang.t('modqueue.likes')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{
|
||||
assets.length
|
||||
? assets.map(asset => {
|
||||
const likeSummary = asset.action_summaries.find(s => s.type === 'LikeAssetActionSummary');
|
||||
return (
|
||||
<tr className={styles.rowLinkify} key={asset.id}>
|
||||
<td>
|
||||
<Link className={styles.linkToAsset} to={`/admin/moderate/flagged/${asset.id}`}>
|
||||
<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>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
: <tr className={styles.rowLinkify}><td colSpan="2">{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. */
|
||||
assets.length < 10 ? <tr></tr> : null
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
<div className={styles.widgetHead}>
|
||||
<p>{lang.t('streams.article')}</p>
|
||||
<p>{lang.t('modqueue.likes')}</p>
|
||||
</div>
|
||||
<div className={styles.widgetTable}>
|
||||
{
|
||||
assets.length
|
||||
? assets.map(asset => {
|
||||
const likeSummary = asset.action_summaries.find(s => s.type === 'LikeAssetActionSummary');
|
||||
return (
|
||||
<div className={styles.rowLinkify} key={asset.id}>
|
||||
<Link className={styles.linkToModerate} to={`/admin/moderate/flagged/${asset.id}`}>Moderate</Link>
|
||||
<p className={styles.widgetCount}>{likeSummary ? likeSummary.actionCount : 0}</p>
|
||||
<Link className={styles.linkToAsset} to={`${asset.url}#coralStreamEmbed_iframe`} target="_blank">
|
||||
<p className={styles.assetTitle}>{asset.title}</p>
|
||||
</Link>
|
||||
<p className={styles.lede}>{asset.author} — Published: {new Date(asset.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
: <div className={styles.rowLinkify}>{lang.t('dashboard.no_likes')}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
:root {
|
||||
--row-height: 80px;
|
||||
--row-height: 60px;
|
||||
}
|
||||
|
||||
.widget {
|
||||
box-sizing: border-box;
|
||||
margin: 10px 5px 5px 5px;
|
||||
box-shadow: 0px 0px 5px 0px rgba(0,0,0,0.2);
|
||||
padding: 15px;
|
||||
flex: 1;
|
||||
background-color: white;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.widget * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.heading {
|
||||
@@ -16,24 +19,32 @@
|
||||
padding-left: 10px;
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
background-color: #e0e0e0;
|
||||
}
|
||||
|
||||
.widgetTable {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
user-select: none;
|
||||
height: calc(var(--row-height) * 10);
|
||||
}
|
||||
|
||||
.widgetTable thead th {
|
||||
.widgetTable + div:after {
|
||||
content: '';
|
||||
clear: both;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.widgetHead p {
|
||||
color: rgb(35, 102, 223);
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
text-transform: capitalize;
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.widgetTable thead th:last-child {
|
||||
width: 10%;
|
||||
.widgetHead p:last-child {
|
||||
float: right;
|
||||
margin-right: 100px;
|
||||
}
|
||||
|
||||
.rowLinkify {
|
||||
@@ -41,6 +52,7 @@
|
||||
border-bottom: 1px solid lightgrey;
|
||||
color: #555;
|
||||
height: var(--row-height);
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.rowLinkify:last-child {
|
||||
@@ -51,13 +63,22 @@
|
||||
background-color: #f8f8f8;
|
||||
}
|
||||
|
||||
.widgetTable tbody td {
|
||||
padding: 10px;
|
||||
.linkToAsset {
|
||||
display: inline-block;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.linkToAsset {
|
||||
display: block;
|
||||
.linkToModerate {
|
||||
background-color: #e0e0e0;
|
||||
padding: 10px 14px;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
float: right;
|
||||
margin-left: 15px;
|
||||
}
|
||||
|
||||
.linkToModerate:hover {
|
||||
background-color: #ccc;
|
||||
}
|
||||
|
||||
.lede {
|
||||
@@ -81,4 +102,6 @@
|
||||
color: #555;
|
||||
font-size: 1.3em;
|
||||
font-weight: 400;
|
||||
float: right;
|
||||
margin-top: 7px;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import {connect} from 'react-redux';
|
||||
import {compose} from 'react-apollo';
|
||||
import key from 'keymaster';
|
||||
import isEqual from 'lodash/isEqual';
|
||||
import styles from './components/styles.css';
|
||||
|
||||
import {modQueueQuery} from '../../graphql/queries';
|
||||
import {banUser, setCommentStatus} from '../../graphql/mutations';
|
||||
@@ -21,28 +22,27 @@ import ModerationKeysModal from '../../components/ModerationKeysModal';
|
||||
|
||||
class ModerationContainer extends Component {
|
||||
state = {
|
||||
selectedIndex: 0
|
||||
selectedIndex: 0,
|
||||
sort: 'REVERSE_CHRONOLOGICAL'
|
||||
}
|
||||
|
||||
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) {
|
||||
@@ -50,7 +50,35 @@ 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) => {
|
||||
this.setState({sort});
|
||||
this.props.modQueueResort(sort);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
@@ -63,6 +91,17 @@ class ModerationContainer extends Component {
|
||||
key.unbind('t');
|
||||
}
|
||||
|
||||
componentDidUpdate(_, prevState) {
|
||||
|
||||
// If paging through using keybaord shortcuts, scroll the page to keep the selected
|
||||
// comment in view.
|
||||
if (prevState.selectedIndex !== this.state.selectedIndex) {
|
||||
|
||||
// the 'smooth' flag only works in FF as of March 2017
|
||||
document.querySelector(`.${styles.selected}`).scrollIntoView({behavior: 'smooth'});
|
||||
}
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
const {updateAssets} = this.props;
|
||||
if(!isEqual(nextProps.data.assets, this.props.data.assets)) {
|
||||
@@ -71,7 +110,7 @@ class ModerationContainer extends Component {
|
||||
}
|
||||
|
||||
render () {
|
||||
const {data, moderation, settings, assets, modQueueResort, onClose, ...props} = this.props;
|
||||
const {data, moderation, settings, assets, onClose, ...props} = this.props;
|
||||
const providedAssetId = this.props.params.id;
|
||||
const activeTab = this.props.route.path === ':id' ? 'premod' : this.props.route.path;
|
||||
|
||||
@@ -94,6 +133,18 @@ class ModerationContainer extends Component {
|
||||
}
|
||||
|
||||
const comments = data[activeTab];
|
||||
let activeTabCount;
|
||||
switch(activeTab) {
|
||||
case 'premod':
|
||||
activeTabCount = data.premodCount;
|
||||
break;
|
||||
case 'flagged':
|
||||
activeTabCount = data.flaggedCount;
|
||||
break;
|
||||
case 'rejected':
|
||||
activeTabCount = data.rejectedCount;
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -103,7 +154,8 @@ class ModerationContainer extends Component {
|
||||
premodCount={data.premodCount}
|
||||
rejectedCount={data.rejectedCount}
|
||||
flaggedCount={data.flaggedCount}
|
||||
modQueueResort={modQueueResort}
|
||||
selectSort={this.selectSort}
|
||||
sort={this.state.sort}
|
||||
/>
|
||||
<ModerationQueue
|
||||
currentAsset={asset}
|
||||
@@ -115,12 +167,19 @@ class ModerationContainer extends Component {
|
||||
showBanUserDialog={props.showBanUserDialog}
|
||||
acceptComment={props.acceptComment}
|
||||
rejectComment={props.rejectComment}
|
||||
loadMore={props.loadMore}
|
||||
assetId={providedAssetId}
|
||||
sort={this.state.sort}
|
||||
commentCount={activeTabCount}
|
||||
/>
|
||||
<BanUserDialog
|
||||
open={moderation.banDialog}
|
||||
user={moderation.user}
|
||||
commentId={moderation.commentId}
|
||||
handleClose={props.hideBanUserDialog}
|
||||
handleBanUser={props.banUser}
|
||||
showRejectedNote={moderation.showRejectedNote}
|
||||
rejectComment={props.rejectComment}
|
||||
/>
|
||||
<ModerationKeysModal
|
||||
open={moderation.modalOpen}
|
||||
@@ -142,7 +201,7 @@ const mapDispatchToProps = dispatch => ({
|
||||
singleView: () => dispatch(singleView()),
|
||||
updateAssets: assets => dispatch(updateAssets(assets)),
|
||||
fetchSettings: () => dispatch(fetchSettings()),
|
||||
showBanUserDialog: (user, commentId) => dispatch(showBanUserDialog(user, commentId)),
|
||||
showBanUserDialog: (user, commentId, showRejectedNote) => dispatch(showBanUserDialog(user, commentId, showRejectedNote)),
|
||||
hideBanUserDialog: () => dispatch(hideBanUserDialog(false)),
|
||||
});
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import EmptyCard from '../../components/EmptyCard';
|
||||
import {actionsMap} from './helpers/moderationQueueActionsMap';
|
||||
import I18n from 'coral-framework/modules/i18n/i18n';
|
||||
import translations from 'coral-admin/src/translations';
|
||||
import LoadMore from './components/LoadMore';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
const ModerationQueue = ({comments, selectedIndex, singleView, ...props}) => {
|
||||
const ModerationQueue = ({comments, selectedIndex, commentCount, singleView, loadMore, activeTab, sort, ...props}) => {
|
||||
return (
|
||||
<div id="moderationList" className={`${styles.list} ${singleView ? styles.singleView : ''}`}>
|
||||
<ul style={{paddingLeft: 0}}>
|
||||
@@ -20,7 +21,7 @@ const ModerationQueue = ({comments, selectedIndex, singleView, ...props}) => {
|
||||
key={i}
|
||||
index={i}
|
||||
comment={comment}
|
||||
commentType={props.activeTab}
|
||||
commentType={activeTab}
|
||||
selected={i === selectedIndex}
|
||||
suspectWords={props.suspectWords}
|
||||
actions={actionsMap[status]}
|
||||
@@ -33,6 +34,14 @@ const ModerationQueue = ({comments, selectedIndex, singleView, ...props}) => {
|
||||
: <EmptyCard>{lang.t('modqueue.emptyqueue')}</EmptyCard>
|
||||
}
|
||||
</ul>
|
||||
<LoadMore
|
||||
comments={comments}
|
||||
loadMore={loadMore}
|
||||
sort={sort}
|
||||
tab={activeTab}
|
||||
showLoadMore={comments.length < commentCount}
|
||||
assetId={props.assetId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,7 +31,7 @@ const Comment = ({actions = [], ...props}) => {
|
||||
<span className={styles.created}>
|
||||
{timeago().format(props.comment.created_at || (Date.now() - props.index * 60 * 1000), lang.getLocale().replace('-', '_'))}
|
||||
</span>
|
||||
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id)} />
|
||||
<BanUserButton user={props.comment.user} onClick={() => props.showBanUserDialog(props.comment.user, props.comment.id, props.comment.status !== 'REJECTED')} />
|
||||
<CommentType type={props.commentType} />
|
||||
</div>
|
||||
{props.comment.user.status === 'banned' ?
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import React, {PropTypes} from 'react';
|
||||
import {Button} from 'coral-ui';
|
||||
import styles from './styles.css';
|
||||
|
||||
const LoadMore = ({comments, loadMore, sort, tab, assetId, showLoadMore}) =>
|
||||
<div className={styles.loadMoreContainer}>
|
||||
{
|
||||
showLoadMore && <Button
|
||||
className={styles.loadMore}
|
||||
onClick={() =>
|
||||
loadMore({
|
||||
cursor: comments[comments.length - 1].created_at,
|
||||
sort,
|
||||
tab,
|
||||
asset_id: assetId
|
||||
})}>
|
||||
Load More
|
||||
</Button>
|
||||
}
|
||||
</div>;
|
||||
|
||||
LoadMore.propTypes = {
|
||||
comments: PropTypes.array.isRequired,
|
||||
loadMore: PropTypes.func.isRequired,
|
||||
sort: PropTypes.oneOf(['CHRONOLOGICAL', 'REVERSE_CHRONOLOGICAL']).isRequired,
|
||||
tab: PropTypes.oneOf(['rejected', 'premod', 'flagged']).isRequired,
|
||||
assetId: PropTypes.string,
|
||||
showLoadMore: PropTypes.bool.isRequired
|
||||
};
|
||||
|
||||
export default LoadMore;
|
||||
@@ -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>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {PropTypes, Component} from 'react';
|
||||
import React, {PropTypes} from 'react';
|
||||
import CommentCount from './CommentCount';
|
||||
import styles from './styles.css';
|
||||
import {SelectField, Option} from 'react-mdl-selectfield';
|
||||
@@ -8,57 +8,45 @@ import {Link} from 'react-router';
|
||||
|
||||
const lang = new I18n(translations);
|
||||
|
||||
class ModerationMenu extends Component {
|
||||
state = {
|
||||
sort: 'REVERSE_CHRONOLOGICAL',
|
||||
}
|
||||
|
||||
static propTypes = {
|
||||
premodCount: PropTypes.number.isRequired,
|
||||
rejectedCount: PropTypes.number.isRequired,
|
||||
flaggedCount: PropTypes.number.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
}
|
||||
|
||||
selectSort = (sort) => {
|
||||
this.setState({sort});
|
||||
this.props.modQueueResort(sort);
|
||||
}
|
||||
|
||||
render() {
|
||||
const {asset, premodCount, rejectedCount, flaggedCount} = this.props;
|
||||
const premodPath = asset ? `/admin/moderate/premod/${asset.id}` : '/admin/moderate/premod';
|
||||
const rejectPath = asset ? `/admin/moderate/rejected/${asset.id}` : '/admin/moderate/rejected';
|
||||
const flagPath = asset ? `/admin/moderate/flagged/${asset.id}` : '/admin/moderate/flagged';
|
||||
return (
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div className={styles.tabBarPadding}/>
|
||||
<div>
|
||||
<Link to={premodPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
</Link>
|
||||
<Link to={rejectPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
</Link>
|
||||
<Link to={flagPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
</Link>
|
||||
</div>
|
||||
<SelectField
|
||||
className={styles.selectField}
|
||||
label='Sort'
|
||||
value={this.state.sort}
|
||||
onChange={sort => this.selectSort(sort)}>
|
||||
<Option value={'REVERSE_CHRONOLOGICAL'}>Newest First</Option>
|
||||
<Option value={'CHRONOLOGICAL'}>Oldest First</Option>
|
||||
</SelectField>
|
||||
const ModerationMenu = ({asset, premodCount, rejectedCount, flaggedCount, selectSort, sort}) => {
|
||||
const premodPath = asset ? `/admin/moderate/premod/${asset.id}` : '/admin/moderate/premod';
|
||||
const rejectPath = asset ? `/admin/moderate/rejected/${asset.id}` : '/admin/moderate/rejected';
|
||||
const flagPath = asset ? `/admin/moderate/flagged/${asset.id}` : '/admin/moderate/flagged';
|
||||
return (
|
||||
<div className='mdl-tabs'>
|
||||
<div className={`mdl-tabs__tab-bar ${styles.tabBar}`}>
|
||||
<div className={styles.tabBarPadding}/>
|
||||
<div>
|
||||
<Link to={premodPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.premod')} <CommentCount count={premodCount} />
|
||||
</Link>
|
||||
<Link to={rejectPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.rejected')} <CommentCount count={rejectedCount} />
|
||||
</Link>
|
||||
<Link to={flagPath} className={`mdl-tabs__tab ${styles.tab}`} activeClassName={styles.active}>
|
||||
{lang.t('modqueue.flagged')} <CommentCount count={flaggedCount} />
|
||||
</Link>
|
||||
</div>
|
||||
<SelectField
|
||||
className={styles.selectField}
|
||||
label='Sort'
|
||||
value={sort}
|
||||
onChange={sort => selectSort(sort)}>
|
||||
<Option value={'REVERSE_CHRONOLOGICAL'}>Newest First</Option>
|
||||
<Option value={'CHRONOLOGICAL'}>Oldest First</Option>
|
||||
</SelectField>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
ModerationMenu.propTypes = {
|
||||
premodCount: PropTypes.number.isRequired,
|
||||
rejectedCount: PropTypes.number.isRequired,
|
||||
flaggedCount: PropTypes.number.isRequired,
|
||||
asset: PropTypes.shape({
|
||||
id: PropTypes.string
|
||||
})
|
||||
};
|
||||
|
||||
export default ModerationMenu;
|
||||
|
||||
@@ -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 {
|
||||
@@ -387,3 +394,23 @@ span {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.loadMoreContainer {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
};
|
||||
|
||||
.loadMore {
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
color: #FFF;
|
||||
max-width: 660px;
|
||||
margin-bottom: 30px;
|
||||
background-color: #2376D8;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.loadMore:hover {
|
||||
background-color: #4399FF;
|
||||
}
|
||||
|
||||
@@ -80,8 +80,8 @@ class Streams extends Component {
|
||||
<div
|
||||
className={closed ? styles.statusMenuClosed : styles.statusMenuOpen}
|
||||
onClick={this.onStatusClick(closed, id, statusMenuOpen)}>
|
||||
{closed ? lang.t('streams.closed') : lang.t('streams.open')}
|
||||
{!statusMenuOpen && <Icon className={styles.statusMenuIcon} name='keyboard_arrow_down'/>}
|
||||
{closed ? lang.t('streams.closed') : lang.t('streams.open')}
|
||||
</div>
|
||||
{
|
||||
statusMenuOpen &&
|
||||
|
||||
@@ -22,7 +22,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 +50,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
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {graphql} from 'react-apollo';
|
||||
|
||||
import MOD_QUEUE_QUERY from './modQueueQuery.graphql';
|
||||
import MOD_QUEUE_LOAD_MORE from './loadMore.graphql';
|
||||
import METRICS from './metricsQuery.graphql';
|
||||
|
||||
export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
@@ -14,7 +15,8 @@ export const modQueueQuery = graphql(MOD_QUEUE_QUERY, {
|
||||
},
|
||||
props: ({ownProps: {params: {id = null}}, data}) => ({
|
||||
data,
|
||||
modQueueResort: modQueueResort(id, data.fetchMore)
|
||||
modQueueResort: modQueueResort(id, data.fetchMore),
|
||||
loadMore: loadMore(data.fetchMore)
|
||||
})
|
||||
});
|
||||
|
||||
@@ -30,6 +32,40 @@ export const getMetrics = graphql(METRICS, {
|
||||
}
|
||||
});
|
||||
|
||||
export const loadMore = (fetchMore) => ({limit, cursor, sort, tab, asset_id}) => {
|
||||
let statuses;
|
||||
switch(tab) {
|
||||
case 'premod':
|
||||
statuses = ['PREMOD'];
|
||||
break;
|
||||
case 'flagged':
|
||||
statuses = ['NONE', 'PREMOD'];
|
||||
break;
|
||||
case 'rejected':
|
||||
statuses = ['REJECTED'];
|
||||
break;
|
||||
}
|
||||
return fetchMore({
|
||||
query: MOD_QUEUE_LOAD_MORE,
|
||||
variables: {
|
||||
limit,
|
||||
cursor,
|
||||
sort,
|
||||
statuses,
|
||||
asset_id
|
||||
},
|
||||
updateQuery: (oldData, {fetchMoreResult:{data:{comments}}}) => {
|
||||
return {
|
||||
...oldData,
|
||||
[tab]: [
|
||||
...oldData[tab],
|
||||
...comments
|
||||
]
|
||||
};
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const modQueueResort = (id, fetchMore) => (sort) => {
|
||||
return fetchMore({
|
||||
query: MOD_QUEUE_QUERY,
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#import "../fragments/commentView.graphql"
|
||||
|
||||
query LoadMoreModQueue($limit: Int = 10, $cursor: Date, $sort: SORT_ORDER, $asset_id: ID, $statuses:[COMMENT_STATUS!]) {
|
||||
comments(query: {limit: $limit, cursor: $cursor, asset_id: $asset_id, statuses: $statuses, sort: $sort}) {
|
||||
...commentView
|
||||
action_summaries {
|
||||
count
|
||||
... on FlagActionSummary {
|
||||
reason
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ export default function moderation (state = initialState, action) {
|
||||
.merge({
|
||||
user: Map(action.user),
|
||||
commentId: action.commentId,
|
||||
showRejectedNote: action.showRejectedNote,
|
||||
banDialog: true
|
||||
});
|
||||
case actions.SET_ACTIVE_TAB:
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
"copy": "Copy to Clipboard"
|
||||
},
|
||||
"configure": {
|
||||
"closed-stream-settings": "Closed Stream Message",
|
||||
"stream-settings": "Stream Settings",
|
||||
"moderation-settings": "Moderation Settings",
|
||||
"tech-settings": "Tech Settings",
|
||||
@@ -64,9 +65,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.",
|
||||
@@ -80,7 +83,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",
|
||||
@@ -88,7 +91,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)."
|
||||
@@ -115,6 +118,8 @@
|
||||
"write_message": "Write a message"
|
||||
},
|
||||
"dashboard": {
|
||||
"next-update": "{0} minutes until next update.",
|
||||
"auto-update": "Data automatically updates every five minutes or when you Reload.",
|
||||
"no_flags": "There have been no flags in the last 5 minutes! Hooray!",
|
||||
"no_likes": "There have been no likes in the last 5 minutes. All quiet.",
|
||||
"flags": "Flags",
|
||||
@@ -180,6 +185,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",
|
||||
@@ -195,6 +201,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.",
|
||||
@@ -206,7 +214,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",
|
||||
@@ -231,6 +239,8 @@
|
||||
"yes_ban_user": "Si, Suspendan el usuario"
|
||||
},
|
||||
"dashbord": {
|
||||
"next-update": "{0} minutos hasta la siguiente actualización.",
|
||||
"auto-update": "Los datos se actualizan automaticamente cada 5 minutos o cuando Recargas.",
|
||||
"no_flags": "¡Nadie ha marcado nada en los últimos 5 minutos! ¡Bravo!",
|
||||
"no_likes": "A nadie le ha gustado algún comentario en los últimos 5 minutos. Todo tranquilo.",
|
||||
"flags": "Marcados",
|
||||
|
||||
@@ -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) => 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: '400px',
|
||||
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 () {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
const util = require('./util');
|
||||
const {
|
||||
SharedCounterDataLoader,
|
||||
singleJoinBy,
|
||||
arrayJoinBy
|
||||
} = require('./util');
|
||||
const DataLoader = require('dataloader');
|
||||
|
||||
const CommentModel = require('../../models/comment');
|
||||
@@ -11,6 +15,38 @@ const CommentModel = require('../../models/comment');
|
||||
* comments that we want to get
|
||||
*/
|
||||
const getCountsByAssetID = (context, asset_ids) => {
|
||||
return CommentModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
asset_id: {
|
||||
$in: asset_ids
|
||||
},
|
||||
status: {
|
||||
$in: ['NONE', 'ACCEPTED']
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
$group: {
|
||||
_id: '$asset_id',
|
||||
count: {
|
||||
$sum: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
])
|
||||
.then(singleJoinBy(asset_ids, '_id'))
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the comment count for all comments that are public based on their
|
||||
* asset ids.
|
||||
* @param {Object} context graph context
|
||||
* @param {Array<String>} asset_ids the ids of assets for which there are
|
||||
* comments that we want to get
|
||||
*/
|
||||
const getParentCountsByAssetID = (context, asset_ids) => {
|
||||
return CommentModel.aggregate([
|
||||
{
|
||||
$match: {
|
||||
@@ -32,7 +68,7 @@ const getCountsByAssetID = (context, asset_ids) => {
|
||||
}
|
||||
}
|
||||
])
|
||||
.then(util.singleJoinBy(asset_ids, '_id'))
|
||||
.then(singleJoinBy(asset_ids, '_id'))
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
@@ -64,7 +100,7 @@ const getCountsByParentID = (context, parent_ids) => {
|
||||
}
|
||||
}
|
||||
])
|
||||
.then(util.singleJoinBy(parent_ids, '_id'))
|
||||
.then(singleJoinBy(parent_ids, '_id'))
|
||||
.then((results) => results.map((result) => result ? result.count : 0));
|
||||
};
|
||||
|
||||
@@ -216,7 +252,7 @@ const genRecentReplies = (context, ids) => {
|
||||
|
||||
])
|
||||
.then((replies) => replies.map((reply) => reply.replies))
|
||||
.then(util.arrayJoinBy(ids, 'parent_id'));
|
||||
.then(arrayJoinBy(ids, 'parent_id'));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -267,7 +303,7 @@ const genRecentComments = (_, ids) => {
|
||||
|
||||
])
|
||||
.then((replies) => replies.map((reply) => reply.comments))
|
||||
.then(util.arrayJoinBy(ids, 'asset_id'));
|
||||
.then(arrayJoinBy(ids, 'asset_id'));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -294,7 +330,7 @@ const genComments = ({user}, ids) => {
|
||||
}
|
||||
});
|
||||
}
|
||||
return comments.then(util.singleJoinBy(ids, 'id'));
|
||||
return comments.then(singleJoinBy(ids, 'id'));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -307,8 +343,9 @@ module.exports = (context) => ({
|
||||
get: new DataLoader((ids) => genComments(context, ids)),
|
||||
getByQuery: (query) => getCommentsByQuery(context, query),
|
||||
getCountByQuery: (query) => getCommentCountByQuery(context, query),
|
||||
countByAssetID: new util.SharedCacheDataLoader('Comments.countByAssetID', 3600, (ids) => getCountsByAssetID(context, ids)),
|
||||
countByParentID: new util.SharedCacheDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
|
||||
countByAssetID: new SharedCounterDataLoader('Comments.totalCommentCount', 3600, (ids) => getCountsByAssetID(context, ids)),
|
||||
parentCountByAssetID: new SharedCounterDataLoader('Comments.countByAssetID', 3600, (ids) => getParentCountsByAssetID(context, ids)),
|
||||
countByParentID: new SharedCounterDataLoader('Comments.countByParentID', 3600, (ids) => getCountsByParentID(context, ids)),
|
||||
genRecentReplies: new DataLoader((ids) => genRecentReplies(context, ids)),
|
||||
genRecentComments: new DataLoader((ids) => genRecentComments(context, ids))
|
||||
}
|
||||
|
||||
@@ -121,6 +121,29 @@ class SharedCacheDataLoader extends DataLoader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* SharedCounterDataLoader is identical to SharedCacheDataLoader with the
|
||||
* exception in that it is designed to work with numerical cached data.
|
||||
*/
|
||||
class SharedCounterDataLoader extends SharedCacheDataLoader {
|
||||
|
||||
/**
|
||||
* Increments the key in the cache if it already exists in the cache, if not
|
||||
* it does nothing.
|
||||
*/
|
||||
incr(key) {
|
||||
return cache.incr(key, this._expiry, this._keyFunc);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrements the key in the cache if it already exists in the cache, if not
|
||||
* it does nothing.
|
||||
*/
|
||||
decr(key) {
|
||||
return cache.decr(key, this._expiry, this._keyFunc);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps an object's paths to a string that can be used as a cache key.
|
||||
* @param {Array} paths paths on the object to be used to generate the cache
|
||||
@@ -145,5 +168,6 @@ module.exports = {
|
||||
objectCacheKeyFn,
|
||||
arrayCacheKeyFn,
|
||||
SingletonResolver,
|
||||
SharedCacheDataLoader
|
||||
SharedCacheDataLoader,
|
||||
SharedCounterDataLoader
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -32,16 +33,17 @@ const createComment = ({user, loaders: {Comments}}, {body, asset_id, parent_id =
|
||||
})
|
||||
.then((comment) => {
|
||||
|
||||
// TODO: explore using an `INCR` operation to update the counts here
|
||||
|
||||
// If the loaders are present, clear the caches for these values because we
|
||||
// just added a new comment, hence the counts should be updated.
|
||||
if (Comments && Comments.countByAssetID && Comments.countByParentID) {
|
||||
// just added a new comment, hence the counts should be updated. We should
|
||||
// perform these increments in the event that we do have a new comment that
|
||||
// is approved or without a comment.
|
||||
if (status === 'NONE' || status === 'APPROVED') {
|
||||
if (parent_id != null) {
|
||||
Comments.countByParentID.clear(parent_id);
|
||||
Comments.countByParentID.incr(parent_id);
|
||||
} else {
|
||||
Comments.countByAssetID.clear(asset_id);
|
||||
Comments.parentCountByAssetID.incr(asset_id);
|
||||
}
|
||||
Comments.countByAssetID.incr(asset_id);
|
||||
}
|
||||
|
||||
return comment;
|
||||
@@ -54,13 +56,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 +77,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 +87,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 +138,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.
|
||||
|
||||
@@ -176,15 +183,18 @@ const setCommentStatus = ({loaders: {Comments}}, {id, status}) => {
|
||||
.then((comment) => {
|
||||
|
||||
// If the loaders are present, clear the caches for these values because we
|
||||
// just added a new comment, hence the counts should be updated.
|
||||
if (Comments && Comments.countByAssetID && Comments.countByParentID) {
|
||||
if (comment.parent_id != null) {
|
||||
Comments.countByParentID.clear(comment.parent_id);
|
||||
} else {
|
||||
Comments.countByAssetID.clear(comment.asset_id);
|
||||
}
|
||||
// just added a new comment, hence the counts should be updated. It would
|
||||
// be nice if we could decrement the counters here, but that would result
|
||||
// in us having to know the initial state of the comment, which would
|
||||
// require another database query.
|
||||
if (comment.parent_id != null) {
|
||||
Comments.countByParentID.clear(comment.parent_id);
|
||||
} else {
|
||||
Comments.parentCountByAssetID.clear(comment.asset_id);
|
||||
}
|
||||
|
||||
Comments.countByAssetID.clear(comment.asset_id);
|
||||
|
||||
return comment;
|
||||
});
|
||||
};
|
||||
|
||||
@@ -10,7 +10,18 @@ const Asset = {
|
||||
parent_id: null
|
||||
});
|
||||
},
|
||||
commentCount({id}, _, {loaders: {Comments}}) {
|
||||
commentCount({id, commentCount}, _, {loaders: {Comments}}) {
|
||||
if (commentCount != null) {
|
||||
return commentCount;
|
||||
}
|
||||
|
||||
return Comments.parentCountByAssetID.load(id);
|
||||
},
|
||||
totalCommentCount({id, totalCommentCount}, _, {loaders: {Comments}}) {
|
||||
if (totalCommentCount != null) {
|
||||
return totalCommentCount;
|
||||
}
|
||||
|
||||
return Comments.countByAssetID.load(id);
|
||||
},
|
||||
settings({settings = null}, _, {loaders: {Settings}}) {
|
||||
|
||||
@@ -370,6 +370,7 @@ type Settings {
|
||||
|
||||
infoBoxEnable: Boolean
|
||||
infoBoxContent: String
|
||||
premodLinksEnable: Boolean
|
||||
questionBoxEnable: Boolean
|
||||
questionBoxContent: String
|
||||
closeTimeout: Int
|
||||
@@ -404,6 +405,9 @@ type Asset {
|
||||
# The count of top level comments on the asset.
|
||||
commentCount: Int
|
||||
|
||||
# The total count of all comments made on the asset.
|
||||
totalCommentCount: Int
|
||||
|
||||
# The settings (rectified with the global settings) that should be applied to
|
||||
# this asset.
|
||||
settings: Settings!
|
||||
@@ -645,14 +649,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.
|
||||
|
||||
@@ -40,6 +40,10 @@ const SettingSchema = new Schema({
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
premodLinksEnable: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
organizationName: {
|
||||
type: String
|
||||
},
|
||||
|
||||
@@ -71,6 +71,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",
|
||||
@@ -84,7 +85,7 @@
|
||||
"passport-facebook": "^2.1.1",
|
||||
"passport-local": "^1.0.0",
|
||||
"react-apollo": "^0.10.0",
|
||||
"redis": "^2.6.3",
|
||||
"redis": "^2.6.5",
|
||||
"uuid": "^2.0.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -167,6 +168,6 @@
|
||||
"webpack": "^2.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "~7.6.0"
|
||||
"node": "^7.7.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 |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 872 B |
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "App",
|
||||
"icons": [
|
||||
{
|
||||
"src": "\/img\/android-icon-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image\/png",
|
||||
"density": "0.75"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/android-icon-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image\/png",
|
||||
"density": "1.0"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/android-icon-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image\/png",
|
||||
"density": "1.5"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/android-icon-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image\/png",
|
||||
"density": "2.0"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/android-icon-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image\/png",
|
||||
"density": "3.0"
|
||||
},
|
||||
{
|
||||
"src": "\/img\/android-icon-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image\/png",
|
||||
"density": "4.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,7 +12,7 @@ router.get('/password-reset', (req, res) => {
|
||||
|
||||
// TODO: store the redirect uri in the token or something fancy.
|
||||
// admins and regular users should probably be redirected to different places.
|
||||
res.render('password-reset', {redirectUri: process.env.TALK_ROOT_URL});
|
||||
res.render('admin/password-reset', {redirectUri: process.env.TALK_ROOT_URL});
|
||||
});
|
||||
|
||||
router.get('*', (req, res) => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const redis = require('./redis');
|
||||
const debug = require('debug')('talk:cache');
|
||||
const crypto = require('crypto');
|
||||
|
||||
const cache = module.exports = {
|
||||
client: redis.createClient()
|
||||
@@ -60,6 +61,157 @@ cache.wrap = (key, expiry, work, kf = keyfunc) => {
|
||||
});
|
||||
};
|
||||
|
||||
// This is designed to increment a key and add an expiry iff the key already
|
||||
// exists.
|
||||
const INCR_SCRIPT = `
|
||||
if redis.call('GET', KEYS[1]) ~= false then
|
||||
redis.call('INCR', KEYS[1])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
`;
|
||||
|
||||
// Stores the SHA1 hash of INCR_SCRIPT, used for executing via EVALSHA.
|
||||
let INCR_SCRIPT_HASH;
|
||||
|
||||
// This is designed to decrement a key and add an expiry iff the key already
|
||||
// exists.
|
||||
const DECR_SCRIPT = `
|
||||
if redis.call('GET', KEYS[1]) ~= false then
|
||||
redis.call('DECR', KEYS[1])
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
`;
|
||||
|
||||
// Stores the SHA1 hash of DECR_SCRIPT, used for executing via EVALSHA.
|
||||
let DECR_SCRIPT_HASH;
|
||||
|
||||
// Load the script into redis and track the script hash that we will use to exec
|
||||
// increments on.
|
||||
const loadScript = (name, script) => new Promise((resolve, reject) => {
|
||||
|
||||
let shasum = crypto.createHash('sha1');
|
||||
shasum.update(script);
|
||||
|
||||
let hash = shasum.digest('hex');
|
||||
|
||||
cache.client
|
||||
.script('EXISTS', hash, (err, [exists]) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
if (exists) {
|
||||
debug(`already loaded ${name} as SHA[${hash}], not loading again`);
|
||||
|
||||
return resolve(hash);
|
||||
}
|
||||
|
||||
debug(`${name} not loaded as SHA[${hash}], loading`);
|
||||
|
||||
cache.client
|
||||
.script('load', script, (err, hash) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
debug(`loaded ${name} as SHA[${hash}]`);
|
||||
|
||||
resolve(hash);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Load the INCR_SCRIPT and DECR_SCRIPT into Redis.
|
||||
Promise.all([
|
||||
loadScript('INCR_SCRIPT', INCR_SCRIPT),
|
||||
loadScript('DECR_SCRIPT', DECR_SCRIPT)
|
||||
])
|
||||
.then(([incrScriptHash, decrScriptHash]) => {
|
||||
INCR_SCRIPT_HASH = incrScriptHash;
|
||||
DECR_SCRIPT_HASH = decrScriptHash;
|
||||
})
|
||||
.catch((err) => {
|
||||
throw err;
|
||||
});
|
||||
|
||||
/**
|
||||
* This will increment a key in redis and update the expiry iff it already
|
||||
* exists, otherwise it will do nothing.
|
||||
*/
|
||||
cache.incr = (key, expiry, kf = keyfunc) => new Promise((resolve, reject) => {
|
||||
cache.client
|
||||
.evalsha(INCR_SCRIPT_HASH, 1, kf(key), expiry, (err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This will decrement a key in redis and update the expiry iff it already
|
||||
* exists, otherwise it will do nothing.
|
||||
*/
|
||||
cache.decr = (key, expiry, kf = keyfunc) => new Promise((resolve, reject) => {
|
||||
cache.client
|
||||
.evalsha(DECR_SCRIPT_HASH, 1, kf(key), expiry, (err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
return resolve();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* This will increment many keys in redis and update the expiry iff it already
|
||||
* exists, otherwise it will do nothing.
|
||||
*/
|
||||
cache.incrMany = (keys, expiry, kf = keyfunc) => {
|
||||
let multi = cache.client.multi();
|
||||
|
||||
keys.forEach((key) => {
|
||||
|
||||
// Queue up the evalsha command.
|
||||
multi.evalsha(INCR_SCRIPT_HASH, 1, kf(key), expiry);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
multi.exec((err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* This will decrement many keys in redis and update the expiry iff it already
|
||||
* exists, otherwise it will do nothing.
|
||||
*/
|
||||
cache.decrMany = (keys, expiry, kf = keyfunc) => {
|
||||
let multi = cache.client.multi();
|
||||
|
||||
keys.forEach((key) => {
|
||||
|
||||
// Queue up the evalsha command.
|
||||
multi.evalsha(DECR_SCRIPT_HASH, 1, kf(key), expiry);
|
||||
});
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
multi.exec((err) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* [wrapMany description]
|
||||
* @param {Array<String>} keys Either an array of objects represening
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import {Map} from 'immutable';
|
||||
import {expect} from 'chai';
|
||||
import notificationReducer from '../../../../client/coral-framework/reducers/notification';
|
||||
import * as actions from '../../../../client/coral-framework/actions/notification';
|
||||
|
||||
describe ('notificationsReducer', () => {
|
||||
describe('ADD_NOTIFICATION', () => {
|
||||
it('should add a notification', () => {
|
||||
const action = {
|
||||
type: actions.ADD_NOTIFICATION,
|
||||
text: 'Test notification',
|
||||
notifType: 'test'
|
||||
};
|
||||
const store = new Map({});
|
||||
const result = notificationReducer(store, action);
|
||||
expect(result.get('text')).to.equal(action.text);
|
||||
expect(result.get('type')).to.equal(action.notifType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLEAR_NOTIFICATION', () => {
|
||||
it('should clear a notification', () => {
|
||||
const action = {
|
||||
type: actions.CLEAR_NOTIFICATION
|
||||
};
|
||||
const store = new Map({
|
||||
text: 'Test notification',
|
||||
type: 'test'
|
||||
});
|
||||
const result = notificationReducer(store, action);
|
||||
expect(result.get('text')).to.equal('');
|
||||
expect(result.get('type')).to.equal('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,35 +0,0 @@
|
||||
import {Map} from 'immutable';
|
||||
import {expect} from 'chai';
|
||||
import notificationReducer from '../../../../client/coral-framework/reducers/notification';
|
||||
import * as actions from '../../../../client/coral-framework/actions/notification';
|
||||
|
||||
describe ('notificationsReducer', () => {
|
||||
describe('ADD_NOTIFICATION', () => {
|
||||
it('should add a notification', () => {
|
||||
const action = {
|
||||
type: actions.ADD_NOTIFICATION,
|
||||
text: 'Test notification',
|
||||
notifType: 'test'
|
||||
};
|
||||
const store = new Map({});
|
||||
const result = notificationReducer(store, action);
|
||||
expect(result.get('text')).to.equal(action.text);
|
||||
expect(result.get('type')).to.equal(action.notifType);
|
||||
});
|
||||
});
|
||||
|
||||
describe('CLEAR_NOTIFICATION', () => {
|
||||
it('should clear a notification', () => {
|
||||
const action = {
|
||||
type: actions.CLEAR_NOTIFICATION
|
||||
};
|
||||
const store = new Map({
|
||||
text: 'Test notification',
|
||||
type: 'test'
|
||||
});
|
||||
const result = notificationReducer(store, action);
|
||||
expect(result.get('text')).to.equal('');
|
||||
expect(result.get('type')).to.equal('');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,20 @@
|
||||
<meta name="viewport" content="initial-scale=1, maximum-scale=1">
|
||||
<meta property="csrf" content="<%= csrfToken %>">
|
||||
<title>Talk - Coral Admin</title>
|
||||
<link rel="apple-touch-icon" sizes="57x57" href="/public/img/apple-icon-57x57.png">
|
||||
<link rel="apple-touch-icon" sizes="60x60" href="/public/img/apple-icon-60x60.png">
|
||||
<link rel="apple-touch-icon" sizes="72x72" href="/public/img/apple-icon-72x72.png">
|
||||
<link rel="apple-touch-icon" sizes="76x76" href="/public/img/apple-icon-76x76.png">
|
||||
<link rel="apple-touch-icon" sizes="114x114" href="/public/img/apple-icon-114x114.png">
|
||||
<link rel="apple-touch-icon" sizes="120x120" href="/public/img/apple-icon-120x120.png">
|
||||
<link rel="apple-touch-icon" sizes="144x144" href="/public/img/apple-icon-144x144.png">
|
||||
<link rel="apple-touch-icon" sizes="152x152" href="/public/img/apple-icon-152x152.png">
|
||||
<link rel="apple-touch-icon" sizes="180x180" href="/public/img/apple-icon-180x180.png">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/public/img/favicon-32x32.png">
|
||||
<link rel="icon" type="image/png" sizes="96x96" href="/public/img/favicon-96x96.png">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/public/img/favicon-16x16.png">
|
||||
<link rel="manifest" href="/public/manifest.json">
|
||||
<meta name="msapplication-TileColor" content="#ffffff">
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons">
|
||||
<link rel="stylesheet" href="https://code.getmdl.io/1.2.1/material.indigo-pink.min.css">
|
||||
|
||||
@@ -4408,6 +4408,12 @@ linkify-it@^1.2.0:
|
||||
dependencies:
|
||||
uc.micro "^1.0.1"
|
||||
|
||||
linkify-it@^2.0.3:
|
||||
version "2.0.3"
|
||||
resolved "https://registry.yarnpkg.com/linkify-it/-/linkify-it-2.0.3.tgz#d94a4648f9b1c179d64fa97291268bdb6ce9434f"
|
||||
dependencies:
|
||||
uc.micro "^1.0.1"
|
||||
|
||||
load-json-file@^1.0.0:
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
|
||||
@@ -6598,7 +6604,7 @@ redis@^0.12.1:
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/redis/-/redis-0.12.1.tgz#64df76ad0fc8acebaebd2a0645e8a48fac49185e"
|
||||
|
||||
redis@^2.1.0, redis@^2.6.3, redis@~2.6.0-2:
|
||||
redis@^2.1.0, redis@^2.6.5, redis@~2.6.0-2:
|
||||
version "2.6.5"
|
||||
resolved "https://registry.yarnpkg.com/redis/-/redis-2.6.5.tgz#87c1eff4a489f94b70871f3d08b6988f23a95687"
|
||||
dependencies:
|
||||
|
||||