Moderation page (#575)
* Fixes #541 - Moderation page * code cleanup * changed how moderation page shows and works by default * code improvement * thumbnails on moderation page should not be hyperlinks & show moderation navlink * extracted ModerationPanel and Details to their own files * changed border style * thinner border
This commit is contained in:
Родитель
909dc101cf
Коммит
9d6cd8cdbc
|
@ -52,7 +52,7 @@ The port number you are using. e.g., `PORT=3000`
|
|||
|
||||
Default: `https://network-pulse-api-staging.herokuapp.com/`
|
||||
|
||||
URL to your local Pulse API instance (if you have one set up). e.g., `PULSE_API=http://test.example.com:8000`
|
||||
URL to your local Pulse API instance (if you have one set up). e.g., `PULSE_API=http://test.example.com:8000/api/pulse`
|
||||
|
||||
To set up a local instance of Pulse API, follow instructions on https://github.com/mozilla/network-pulse-api/blob/master/README.md.
|
||||
|
||||
|
|
|
@ -0,0 +1,31 @@
|
|||
import React from 'react';
|
||||
import ReactGA from 'react-ga';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class Details extends React.Component {
|
||||
handleVisitBtnClick() {
|
||||
ReactGA.event(this.props.createGaEventConfig(`Visit button`, `Clicked`, `beacon`));
|
||||
}
|
||||
|
||||
handleGetInvolvedLinkClick() {
|
||||
ReactGA.event(this.props.createGaEventConfig(`Get involved`, `Clicked`, `beacon`));
|
||||
}
|
||||
|
||||
render() {
|
||||
let props = this.props;
|
||||
let getInvolvedText = props.getInvolved ? props.getInvolved : null;
|
||||
let getInvolvedLink = props.getInvolvedUrl ? ( <a href={props.getInvolvedUrl} target="_blank" onClick={this.handleGetInvolvedLinkClick}>Get Involved</a>) : null;
|
||||
|
||||
return props.onDetailView || props.onModerationMode ?
|
||||
(<div>
|
||||
{ props.interest ? <p className="interest">{props.interest}</p> : null }
|
||||
{ getInvolvedText || getInvolvedLink ? <p className="get-involved">{getInvolvedText} {getInvolvedLink}</p> : null }
|
||||
{ props.contentUrl ? <a href={props.contentUrl} target="_blank" className="btn btn-block btn-outline-info mb-3" onClick={this.handleVisitBtnClick}>Visit</a> : null }
|
||||
</div>) : null;
|
||||
}
|
||||
}
|
||||
Details.propTypes = {
|
||||
createGaEventConfig: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
export default Details;
|
|
@ -0,0 +1,85 @@
|
|||
import React from 'react';
|
||||
import Select from 'react-select';
|
||||
import Service from '../js/service.js';
|
||||
|
||||
class ModerationPanel extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
||||
this.state = {
|
||||
moderationState: this.props.moderationState
|
||||
};
|
||||
}
|
||||
|
||||
getModerationStates(input, callback) {
|
||||
Service.moderationStates
|
||||
.get()
|
||||
.then((data) => {
|
||||
let options = data.map((option) => {
|
||||
return { value: option.id, label: option.name };
|
||||
});
|
||||
|
||||
callback(null, {options});
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.error(reason);
|
||||
});
|
||||
}
|
||||
|
||||
getNonce(callback) {
|
||||
Service.nonce()
|
||||
.then((nonce) => {
|
||||
callback(false, nonce);
|
||||
})
|
||||
.catch((reason) => {
|
||||
callback(new Error(`Could not retrieve data from /nonce. Reason: ${reason}`));
|
||||
});
|
||||
}
|
||||
|
||||
handleModerationStateChange(selected) {
|
||||
this.getNonce((error, nonce) => {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
let formattedNonce = {
|
||||
nonce: nonce.nonce,
|
||||
csrfmiddlewaretoken: nonce.csrf_token
|
||||
};
|
||||
|
||||
Service.entry
|
||||
.put.moderationState(this.props.id, selected.value, formattedNonce)
|
||||
.then(() => {
|
||||
this.setState({ moderationState: selected });
|
||||
})
|
||||
.catch(reason => {
|
||||
this.setState({
|
||||
serverError: true
|
||||
});
|
||||
console.error(reason);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
return <div className="moderation-panel p-3">
|
||||
<Select.Async
|
||||
name="form-field-name"
|
||||
value={this.state.moderationState}
|
||||
className="d-block text-left"
|
||||
searchable={false}
|
||||
loadOptions={(input, callback) => this.getModerationStates(input, callback)}
|
||||
onChange={(selected) => this.handleModerationStateChange(selected)}
|
||||
clearable={false}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
||||
ModerationPanel.defaultProps = {
|
||||
id: ``,
|
||||
moderationState: ``
|
||||
};
|
||||
|
||||
export default ModerationPanel;
|
|
@ -1,6 +1,7 @@
|
|||
import React from 'react';
|
||||
import { IndexLink } from 'react-router';
|
||||
import NavLink from '../nav-link/nav-link.jsx';
|
||||
import user from '../../js/app-user';
|
||||
|
||||
class NavListItem extends React.Component {
|
||||
render() {
|
||||
|
@ -11,13 +12,39 @@ class NavListItem extends React.Component {
|
|||
}
|
||||
|
||||
class NavBar extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = { user };
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
user.addListener(this);
|
||||
user.verify();
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
user.removeListener(this);
|
||||
}
|
||||
|
||||
updateUser(event) {
|
||||
// this updateUser method is called by "user" after changes in the user state happened
|
||||
if (event === `verified` ) {
|
||||
this.setState({ user });
|
||||
}
|
||||
}
|
||||
|
||||
renderModeratorLink() {
|
||||
if (!user.moderator) return null;
|
||||
|
||||
return <NavListItem><NavLink to="/moderation" className="text-nav-link bookmarks">Moderation</NavLink></NavListItem>;
|
||||
}
|
||||
|
||||
render() {
|
||||
// We have renamed all non user facing "favorites" related variables and text (e.g., favs, faved, etc) to "bookmarks".
|
||||
// This is because we want client side code to match what Pulse API uses (i.e., bookmarks)
|
||||
// For user facing bits like UI labels and URL path we want them to stay as "favorites".
|
||||
// That's why a link like <NavLink to="/favs" className="text-nav-link bookmarks">Favs</NavLink> is seen here.
|
||||
// For more info see: https://github.com/mozilla/network-pulse/issues/326
|
||||
|
||||
return (
|
||||
<div className="navbar">
|
||||
<div className="container">
|
||||
|
@ -30,6 +57,7 @@ class NavBar extends React.Component {
|
|||
<NavListItem><NavLink to="/latest" className="text-nav-link">Latest</NavLink></NavListItem>
|
||||
<NavListItem><NavLink to="/issues" className="text-nav-link">Issues</NavLink></NavListItem>
|
||||
<NavListItem><NavLink to="/favs" className="text-nav-link bookmarks">Favs</NavLink></NavListItem>
|
||||
{ this.renderModeratorLink() }
|
||||
<NavListItem><NavLink to="/search" className="btn-search"><i className="fa fa-search"/><span className="sr-only">Search</span></NavLink></NavListItem>
|
||||
<NavListItem><NavLink to="/add" className="btn-add"><img src="/assets/svg/icon-plus.svg" /></NavLink></NavListItem>
|
||||
</ul>
|
||||
|
|
|
@ -4,35 +4,11 @@ import { Link } from 'react-router';
|
|||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import moment from 'moment';
|
||||
import Details from '../details.jsx';
|
||||
import ModerationPanel from '../moderation-panel.jsx';
|
||||
import { getBookmarks, saveBookmarks } from '../../js/bookmarks-manager';
|
||||
import Utility from '../../js/utility.js';
|
||||
|
||||
class Details extends React.Component {
|
||||
handleVisitBtnClick() {
|
||||
ReactGA.event(this.props.createGaEventConfig(`Visit button`, `Clicked`, `beacon`));
|
||||
}
|
||||
|
||||
handleGetInvolvedLinkClick() {
|
||||
ReactGA.event(this.props.createGaEventConfig(`Get involved`, `Clicked`, `beacon`));
|
||||
}
|
||||
|
||||
render() {
|
||||
let props = this.props;
|
||||
let getInvolvedText = props.getInvolved ? props.getInvolved : null;
|
||||
let getInvolvedLink = props.getInvolvedUrl ? ( <a href={props.getInvolvedUrl} target="_blank" onClick={this.handleGetInvolvedLinkClick}>Get Involved</a>) : null;
|
||||
|
||||
return props.onDetailView ?
|
||||
(<div>
|
||||
{ props.interest ? <p className="interest">{props.interest}</p> : null }
|
||||
{ getInvolvedText || getInvolvedLink ? <p className="get-involved">{getInvolvedText} {getInvolvedLink}</p> : null }
|
||||
{ props.contentUrl ? <a href={props.contentUrl} target="_blank" className="btn btn-block btn-outline-info mb-3" onClick={this.handleVisitBtnClick}>Visit</a> : null }
|
||||
</div>) : null;
|
||||
}
|
||||
}
|
||||
Details.propTypes = {
|
||||
createGaEventConfig: PropTypes.func.isRequired
|
||||
};
|
||||
|
||||
class ProjectCard extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
|
@ -145,7 +121,7 @@ class ProjectCard extends React.Component {
|
|||
renderTitle(detailViewLink) {
|
||||
let title = this.props.title;
|
||||
|
||||
if (!this.props.onDetailView) {
|
||||
if (!this.props.onDetailView && !this.props.onModerationMode) {
|
||||
title = <Link to={detailViewLink} onClick={this.handleTitleClick}>{title}</Link>;
|
||||
}
|
||||
|
||||
|
@ -153,15 +129,18 @@ class ProjectCard extends React.Component {
|
|||
}
|
||||
|
||||
renderThumbnail(detailViewLink) {
|
||||
let thumbnailSource = this.props.thumbnail;
|
||||
if (!this.props.thumbnail) return null;
|
||||
|
||||
if (!thumbnailSource) return null;
|
||||
let classnames = `thumbnail`;
|
||||
let thumbnail = <div className="img-container"><img src={this.props.thumbnail} /></div>;
|
||||
|
||||
if (this.props.onDetailView) {
|
||||
return <div className={classnames}>{thumbnail}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<Link to={detailViewLink} onClick={this.handleThumbnailClick} className="thumbnail">
|
||||
<div className="img-container">
|
||||
<img src={thumbnailSource} />
|
||||
</div>
|
||||
<Link to={detailViewLink} onClick={this.handleThumbnailClick} className={classnames}>
|
||||
{thumbnail}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
@ -169,14 +148,14 @@ class ProjectCard extends React.Component {
|
|||
renderActionPanel(detailViewLink) {
|
||||
let actionPanel = null;
|
||||
|
||||
if (this.props.onDetailView) {
|
||||
if (this.props.onDetailView || this.props.onModerationMode) {
|
||||
let twitterUrl = `https://twitter.com/intent/tweet?text=${encodeURIComponent(this.props.title)}&url=${encodeURIComponent(window.location.href)}`;
|
||||
actionPanel = (
|
||||
<div className="d-flex share">
|
||||
<a href={twitterUrl} onClick={evt => this.handleTwitterShareClick(evt) } className="btn twitter-share d-inline-block align-self-center mr-3"></a>
|
||||
<div className="reveal-url">
|
||||
<a className="btn" onClick={evt => this.handleShareBtnClick(evt)} ref={(btn) => { this.shareBtn = btn; }}></a>
|
||||
<input creadOnly type="text" ref={(input) => { this.urlToShare = input; }} className="form-control px-2" />
|
||||
<input readOnly type="text" ref={(input) => { this.urlToShare = input; }} className="form-control px-2" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
@ -217,11 +196,15 @@ class ProjectCard extends React.Component {
|
|||
}
|
||||
|
||||
renderDescription() {
|
||||
return this.props.description.split(`\n`).map(paragraph => <p>{paragraph}</p>);
|
||||
return this.props.description.split(`\n`).map((paragraph) => {
|
||||
if (!paragraph) return null;
|
||||
|
||||
return <p key={paragraph}>{paragraph}</p>;
|
||||
});
|
||||
}
|
||||
|
||||
renderIssuesAndTags() {
|
||||
if (!this.props.onDetailView) return null;
|
||||
if (!this.props.onDetailView && !this.props.onModerationMode) return null;
|
||||
|
||||
let issues = this.props.issues.map(issue => {
|
||||
return <Link to={`/issues/${Utility.getUriPathFromIssueName(issue)}`} className="btn btn-xs btn-tag" key={issue}>{issue}</Link>;
|
||||
|
@ -234,6 +217,12 @@ class ProjectCard extends React.Component {
|
|||
return <div>{issues}{tags}</div>;
|
||||
}
|
||||
|
||||
renderModerationPanel() {
|
||||
if (!this.props.onModerationMode) return null;
|
||||
|
||||
return <ModerationPanel id={this.props.id} moderationState={this.props.moderationState} />;
|
||||
}
|
||||
|
||||
render() {
|
||||
let wrapperClassnames = classNames({
|
||||
"col-md-6": !this.props.onDetailView,
|
||||
|
@ -244,6 +233,7 @@ class ProjectCard extends React.Component {
|
|||
let classnames = classNames({
|
||||
"project-card": true,
|
||||
"detail-view": this.props.onDetailView,
|
||||
"moderation-mode": this.props.onModerationMode,
|
||||
"bookmarked": this.state.bookmarked
|
||||
});
|
||||
|
||||
|
@ -252,6 +242,7 @@ class ProjectCard extends React.Component {
|
|||
return (
|
||||
<div className={wrapperClassnames}>
|
||||
<div className={classnames}>
|
||||
{ this.renderModerationPanel() }
|
||||
<div className="main-content">
|
||||
{this.renderThumbnail(detailViewLink)}
|
||||
<div className="content m-3">
|
||||
|
@ -294,7 +285,8 @@ ProjectCard.propTypes = {
|
|||
};
|
||||
|
||||
ProjectCard.defaultProps = {
|
||||
onDetailView: false
|
||||
onDetailView: false,
|
||||
moderationState: undefined // id of the moderation
|
||||
};
|
||||
|
||||
export default ProjectCard;
|
||||
|
|
|
@ -8,13 +8,9 @@
|
|||
|
||||
&.detail-view {
|
||||
margin: 0 auto;
|
||||
|
||||
h2 {
|
||||
color: $body-color;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(.detail-view) {
|
||||
&:not(.detail-view):not(.moderation-mode) {
|
||||
.main-content {
|
||||
position: relative;
|
||||
height: 350px;
|
||||
|
@ -31,6 +27,7 @@
|
|||
}
|
||||
|
||||
h2 {
|
||||
color: $body-color;
|
||||
font-size: 30px;
|
||||
line-height: 1;
|
||||
margin: 0 0 10px;
|
||||
|
@ -150,6 +147,10 @@
|
|||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.moderation-panel {
|
||||
background: $gray-lightest;
|
||||
}
|
||||
}
|
||||
|
||||
// >= 768px
|
||||
|
|
|
@ -31,7 +31,7 @@ class ProjectList extends React.Component {
|
|||
|
||||
renderProjectCards() {
|
||||
return this.props.entries.map(project => {
|
||||
return <ProjectCard key={project.id} {...Utility.processEntryData(project)} />;
|
||||
return <ProjectCard key={project.id} onModerationMode={this.props.onModerationMode} {...Utility.processEntryData(project)} />;
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -68,9 +68,9 @@ ProjectList.propTypes = {
|
|||
entries: PropTypes.array.isRequired,
|
||||
loadingData: PropTypes.bool.isRequired,
|
||||
moreEntriesToFetch: PropTypes.bool.isRequired,
|
||||
fetchData: PropTypes.func.isRequired
|
||||
fetchData: PropTypes.func.isRequired,
|
||||
};
|
||||
ProjectCard.defaultProps = {
|
||||
ProjectList.defaultProps = {
|
||||
entries: []
|
||||
};
|
||||
|
||||
|
|
|
@ -37,6 +37,7 @@ export default React.createClass({
|
|||
let combinedParams = Object.assign({},params);
|
||||
|
||||
if (combinedParams.ids) {
|
||||
// The `ids` query param is only used on the bookmarks(favs) page
|
||||
// We want to display bookmarked projects by the time they were bookmarked.
|
||||
// There are a few steps to make this happen:
|
||||
// 1) first we fetch projects from Pulse API in a batch of size PROJECT_BATCH_SIZE.
|
||||
|
@ -54,6 +55,13 @@ export default React.createClass({
|
|||
return Object.assign(combinedParams, { page: 1 });
|
||||
}
|
||||
|
||||
if (combinedParams.moderationState) {
|
||||
// "moderationstate" is the query param the API understands (case sensitive)
|
||||
// and its value should just be the name of the moderation state
|
||||
combinedParams.moderationstate = combinedParams.moderationState.label;
|
||||
delete combinedParams.moderationState;
|
||||
}
|
||||
|
||||
return Object.assign(combinedParams, { page: this.state.nextBatchIndex });
|
||||
},
|
||||
fetchData(params = this.props) {
|
||||
|
@ -120,34 +128,27 @@ export default React.createClass({
|
|||
|
||||
return <div><p>Discover & collaborate on projects for a healthy internet. <a href="https://www.mozillapulse.org/entry/120">Learn more</a>.</p></div>;
|
||||
},
|
||||
renderSearchResult() {
|
||||
if (!this.props.search || this.state.loadingData) return null;
|
||||
|
||||
let total = this.state.totalMatched,
|
||||
plural = (total === 0 || total > 1), // because "0 results"
|
||||
term = this.props.search,
|
||||
searchResultNotice = `${total} result${plural ? `s` : ``} found for ‘${term}’`;
|
||||
|
||||
return <p>{searchResultNotice}</p>;
|
||||
},
|
||||
renderEntryCounter() {
|
||||
if (this.state.loadingData) return null;
|
||||
if (!this.props.issue && !this.props.tag) return null;
|
||||
if (!this.props.search && !this.props.moderationState && !this.props.issue && !this.props.tag) return null;
|
||||
|
||||
return <p>{this.state.totalMatched} result{this.state.totalMatched > 0 ? `s` : ``} found</p>;
|
||||
let counterText = `${this.state.totalMatched} result${this.state.totalMatched > 0 ? `s` : ``} found`;
|
||||
let searchKeyword = this.props.search;
|
||||
|
||||
return <p>{`${counterText}${searchKeyword ? ` for ‘${searchKeyword}’` : ``}`}</p>;
|
||||
},
|
||||
render() {
|
||||
return (
|
||||
<div>
|
||||
{ this.renderTagHeader() }
|
||||
{ this.renderLearnMoreNotice()}
|
||||
{ this.renderSearchResult() }
|
||||
{ this.renderEntryCounter() }
|
||||
<ProjectList entries={this.state.entries}
|
||||
loadingData={this.state.loadingData}
|
||||
moreEntriesToFetch={this.state.moreEntriesToFetch}
|
||||
fetchData={this.fetchData}
|
||||
restoreScrollPosition={pageSettings.shouldRestore} />
|
||||
restoreScrollPosition={pageSettings.shouldRestore}
|
||||
onModerationMode={!!this.props.moderationState} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
HOST=https://pulse-react.herokuapp.com
|
||||
PULSE_API=https://network-pulse-api-staging.herokuapp.com
|
||||
PULSE_API=https://network-pulse-api-staging.herokuapp.com/api/pulse
|
||||
|
|
|
@ -39,7 +39,7 @@ const Login = {
|
|||
// verify user's logged in status with Pulse API
|
||||
Service.userstatus()
|
||||
.then(response => {
|
||||
setUserData(false, response.username);
|
||||
setUserData(false, response.username, response.moderator);
|
||||
})
|
||||
.catch(reason => {
|
||||
console.error(reason);
|
||||
|
@ -83,6 +83,7 @@ class User {
|
|||
resetUser() {
|
||||
this.username = undefined;
|
||||
this.loggedin = false;
|
||||
this.moderator = false;
|
||||
this.failedLogin = false;
|
||||
// We do not touch the "attemptingLogin" value in localStorage.
|
||||
// It is up to the login/verify/logout/update functions to
|
||||
|
@ -119,8 +120,8 @@ class User {
|
|||
}
|
||||
|
||||
verify(location) {
|
||||
Login.isLoggedIn(location, (error, username) => {
|
||||
this.update(error, username);
|
||||
Login.isLoggedIn(location, (error, username, moderator) => {
|
||||
this.update(error, username, moderator);
|
||||
});
|
||||
}
|
||||
|
||||
|
@ -135,7 +136,7 @@ class User {
|
|||
});
|
||||
}
|
||||
|
||||
update(error, username=false) {
|
||||
update(error, username=false, moderator=false) {
|
||||
if (error) {
|
||||
console.log(`login error:`, error);
|
||||
}
|
||||
|
@ -150,6 +151,7 @@ class User {
|
|||
|
||||
// bind the user values
|
||||
this.loggedin = !!username;
|
||||
this.moderator = !!moderator;
|
||||
this.username = username;
|
||||
|
||||
// notify listeners that this user logged in state has been verified
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import url from 'url';
|
||||
import env from "../config/env.generated.json";
|
||||
|
||||
export default {
|
||||
|
@ -28,7 +29,7 @@ export default {
|
|||
],
|
||||
connectSrc: [
|
||||
`'self'`,
|
||||
env.PULSE_API || `https://network-pulse-api-staging.herokuapp.com/`
|
||||
url.parse(env.PULSE_API).host || `https://network-pulse-api-staging.herokuapp.com/`
|
||||
],
|
||||
childSrc: [
|
||||
`'none'`
|
||||
|
|
|
@ -121,21 +121,24 @@ function callURL(route) {
|
|||
}
|
||||
|
||||
/**
|
||||
* Make an POST XHR request and return a promise to resolve it.
|
||||
* Make a POST or PUT XHR request and return a promise to resolve it.
|
||||
* @param {Object} params A key-value object to be posted
|
||||
* @returns {Promise} A promise to resolve an XHR request
|
||||
*/
|
||||
function postEntry(entryData) {
|
||||
function updateEntry(requestType = ``, endpointRoute, entryData) {
|
||||
let dataToSend = requestType === `POST` ? JSON.stringify(entryData) : ``;
|
||||
let request = new XMLHttpRequest();
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
request.open(`POST`, `${pulseAPI}/entries/`, true);
|
||||
request.open(requestType, endpointRoute, true);
|
||||
|
||||
request.withCredentials = true;
|
||||
request.onload = (event) => {
|
||||
let result = event.currentTarget;
|
||||
|
||||
if (result.status >= 200 && result.status < 400) {
|
||||
if (result.response.length === 0) resolve();
|
||||
|
||||
let data;
|
||||
|
||||
try {
|
||||
|
@ -157,10 +160,11 @@ function postEntry(entryData) {
|
|||
|
||||
request.setRequestHeader(`X-CSRFToken`, entryData.csrfmiddlewaretoken);
|
||||
request.setRequestHeader(`Content-Type`,`application/json`);
|
||||
request.send(JSON.stringify(entryData));
|
||||
request.send(dataToSend);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
entries: {
|
||||
get: function(params,token) {
|
||||
|
@ -171,12 +175,22 @@ export default {
|
|||
return getDataFromURL(`${pulseAPI}/entries/`, Object.assign(params, defaultParams), token);
|
||||
},
|
||||
post: function(entryData) {
|
||||
return postEntry(entryData);
|
||||
return updateEntry(`POST`,`${pulseAPI}/entries/`, entryData);
|
||||
}
|
||||
},
|
||||
entry: {
|
||||
get: function(entryId) {
|
||||
return getDataFromURL(`${pulseAPI}/entries/${entryId}/`);
|
||||
},
|
||||
put: {
|
||||
moderationState: function(entryId, stateId, nonce) {
|
||||
return updateEntry(`PUT`,`${pulseAPI}/entries/${entryId}/moderate/${stateId}`, nonce);
|
||||
}
|
||||
}
|
||||
},
|
||||
moderationStates: {
|
||||
get: function() {
|
||||
return getDataFromURL(`${pulseAPI}/entries/moderation-states/`);
|
||||
}
|
||||
},
|
||||
issues: {
|
||||
|
|
|
@ -3550,11 +3550,21 @@
|
|||
"resolved": "https://registry.npmjs.org/react-helmet/-/react-helmet-5.1.3.tgz",
|
||||
"integrity": "sha1-zUBiZZOinuz2hLbTjXEfRMSBiK8="
|
||||
},
|
||||
"react-input-autosize": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/react-input-autosize/-/react-input-autosize-1.1.4.tgz",
|
||||
"integrity": "sha1-y8RQctQITdxXgG2447NOZEuDZqw="
|
||||
},
|
||||
"react-router": {
|
||||
"version": "3.0.5",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-3.0.5.tgz",
|
||||
"integrity": "sha1-w7eHN1gEWou8lWKu9P9LyMznwTY="
|
||||
},
|
||||
"react-select": {
|
||||
"version": "1.0.0-rc.5",
|
||||
"resolved": "https://registry.npmjs.org/react-select/-/react-select-1.0.0-rc.5.tgz",
|
||||
"integrity": "sha1-nTFvJSsa3Dct21zfHxGca3z9tdY="
|
||||
},
|
||||
"react-side-effect": {
|
||||
"version": "1.1.3",
|
||||
"resolved": "https://registry.npmjs.org/react-side-effect/-/react-side-effect-1.1.3.tgz",
|
||||
|
|
|
@ -9,11 +9,11 @@
|
|||
"build": "run-s bootstrap build:**",
|
||||
"build:config": "node js/build-env-config.js > config/env.generated.json",
|
||||
"build:client": "run-s build-client:**",
|
||||
"build-client:js": "webpack --config webpack.config.js --optimize-minimize --display-error-details --colors",
|
||||
"build-client:js": "webpack --config webpack.config.js --display-error-details --colors",
|
||||
"build-client:scss": "node-sass scss/main.scss dist/css/main.css",
|
||||
"build-client:prefix:main": "postcss --use autoprefixer -o dist/css/main.css dist/css/main.css",
|
||||
"build-client:static": "run-s copy:**",
|
||||
"build:server": "webpack --config webpack.server.config.js --optimize-minimize --display-error-details --colors",
|
||||
"build:server": "webpack --config webpack.server.config.js --display-error-details --colors",
|
||||
"copy:styling-dependency": "run-s bootstrap:clean bootstrap:fontawesome bootstrap:prefix",
|
||||
"bootstrap:clean": "shx cp -r node_modules/mofo-bootstrap/dest/css dist",
|
||||
"bootstrap:fontawesome": "shx cp -r node_modules/font-awesome/css/font-awesome.min.css dist/css && shx cp -r node_modules/font-awesome/fonts dist",
|
||||
|
@ -91,6 +91,7 @@
|
|||
"react-ga": "^2.1.2",
|
||||
"react-helmet": "^5.0.3",
|
||||
"react-router": "^3.0.0",
|
||||
"react-select": "^1.0.0-rc.5",
|
||||
"react-tag-autocomplete": "^5.4.0",
|
||||
"shx": "^0.2.1",
|
||||
"superagent": "^3.3.0",
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
import React from 'react';
|
||||
import Search from './search/search.jsx';
|
||||
import NotFound from './not-found.jsx';
|
||||
import user from '../js/app-user';
|
||||
|
||||
class Moderation extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = {
|
||||
user
|
||||
};
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
user.addListener(this);
|
||||
user.verify(this.props.router.location);
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
user.removeListener(this);
|
||||
}
|
||||
|
||||
updateUser(event) {
|
||||
// this updateUser method is called by "user" after changes in the user state happened
|
||||
if (event === `verified` ) {
|
||||
this.setState({ user });
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!user.moderator) return <NotFound />;
|
||||
|
||||
return <Search moderation={true} {...this.props} />;
|
||||
}
|
||||
}
|
||||
|
||||
export default Moderation;
|
|
@ -3,40 +3,59 @@ import { browserHistory } from 'react-router';
|
|||
import { Helmet } from "react-helmet";
|
||||
import classNames from 'classnames';
|
||||
import DebounceInput from 'react-debounce-input';
|
||||
import Select from 'react-select';
|
||||
import ReactGA from 'react-ga';
|
||||
import Service from '../../js/service.js';
|
||||
import ProjectLoader from '../../components/project-loader/project-loader.jsx';
|
||||
|
||||
export default React.createClass({
|
||||
getInitialState() {
|
||||
return {
|
||||
keywordSearched: ``
|
||||
};
|
||||
},
|
||||
const DEFALUT_MODERATION_FILTER = `Pending`;
|
||||
|
||||
class Search extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.state = this.getSearchCriteria(props);
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps) {
|
||||
// when window.history.back() or windows.history.forward() is triggered
|
||||
// (e.g., clicking on browser's back / forward button)
|
||||
// we want to make sure search result gets updated accordingly
|
||||
this.setState({ keywordSearched: nextProps.location.query.keyword });
|
||||
},
|
||||
this.setState(this.getSearchCriteria(nextProps));
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.setState({ keywordSearched: this.props.location.query.keyword });
|
||||
// The focus() function of <input /> isn't exposed by <DebounceInput />
|
||||
// Ticket filed on the 'react-debounce-input' repo https://github.com/nkbt/react-debounce-input/issues/65
|
||||
// In the meanwhile, we have to rely on document.querySelector(`#search-box`) to trigger input's focus() function
|
||||
document.querySelector(`#search-box`).focus();
|
||||
},
|
||||
}
|
||||
|
||||
getSearchCriteria(theProps) {
|
||||
return {
|
||||
keywordSearched: theProps.location.query.keyword,
|
||||
moderationState: { value: ``, label: theProps.location.query.moderationstate || DEFALUT_MODERATION_FILTER }
|
||||
};
|
||||
}
|
||||
|
||||
updateBrowserHistory() {
|
||||
let keywordSearched = this.state.keywordSearched;
|
||||
let location = {
|
||||
pathname: this.props.router.location.pathname
|
||||
};
|
||||
let moderationState = this.state.moderationState;
|
||||
let location = { pathname: this.props.router.location.pathname };
|
||||
let query = {};
|
||||
|
||||
|
||||
if ( keywordSearched ) {
|
||||
location[`query`] = { keyword: keywordSearched };
|
||||
query.keyword = keywordSearched;
|
||||
}
|
||||
|
||||
if ( moderationState ) {
|
||||
query.moderationstate = moderationState.label;
|
||||
}
|
||||
|
||||
location.query = query;
|
||||
browserHistory.push(location);
|
||||
},
|
||||
}
|
||||
|
||||
handleInputChange(event) {
|
||||
let keywordsEntered = event.target.value;
|
||||
|
||||
|
@ -49,7 +68,8 @@ export default React.createClass({
|
|||
this.setState({ keywordSearched: keywordsEntered }, () => {
|
||||
this.updateBrowserHistory();
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
handleDismissBtnClick() {
|
||||
this.setState({ keywordSearched: `` }, () => {
|
||||
this.updateBrowserHistory();
|
||||
|
@ -58,22 +78,103 @@ export default React.createClass({
|
|||
// In the meanwhile, we have to rely on document.querySelector(`#search-box`) to trigger input's focus() function
|
||||
document.querySelector(`#search-box`).focus();
|
||||
});
|
||||
},
|
||||
}
|
||||
|
||||
renderSearchBar() {
|
||||
let classnames = classNames({activated: true, 'search-bar': true, 'w-100': true, 'mb-0': true});
|
||||
let label;
|
||||
|
||||
if (this.props.moderation) {
|
||||
label = <div className="mr-2">Keywords:</div>;
|
||||
}
|
||||
|
||||
return <div className="d-flex align-items-center">
|
||||
{label}
|
||||
<div className={classnames}>
|
||||
<DebounceInput id="search-box"
|
||||
value={this.state.keywordSearched}
|
||||
debounceTimeout={300}
|
||||
type="search"
|
||||
onChange={(event) => this.handleInputChange(event)}
|
||||
placeholder="Search keywords, people, tags..." />
|
||||
<button className="btn dismiss" onClick={() => this.handleDismissBtnClick()}>×</button>
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
getModerationStates(input, callback) {
|
||||
Service.moderationStates
|
||||
.get()
|
||||
.then((mStates) => {
|
||||
let options = mStates.map((mState) => {
|
||||
return { value: mState.id, label: mState.name };
|
||||
});
|
||||
|
||||
callback(null, {options});
|
||||
})
|
||||
.catch((reason) => {
|
||||
console.error(reason);
|
||||
});
|
||||
}
|
||||
|
||||
handleFilterChange(selected) {
|
||||
this.setState({ moderationState: selected }, () => {
|
||||
this.updateBrowserHistory();
|
||||
});
|
||||
}
|
||||
|
||||
renderStateFilter() {
|
||||
return <div className="d-flex align-items-center mb-3">
|
||||
<div className="mr-2">Filter by moderation state:</div>
|
||||
<Select.Async
|
||||
name="form-field-name"
|
||||
value={this.state.moderationState}
|
||||
className="state-filter d-inline-block text-left"
|
||||
searchable={false}
|
||||
clearable={false}
|
||||
cache={false}
|
||||
placeholder="Moderation state"
|
||||
loadOptions={(input, callback) => this.getModerationStates(input, callback)}
|
||||
onChange={(selected) => this.handleFilterChange(selected)}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
||||
renderSearchControls() {
|
||||
if (this.props.moderation) {
|
||||
return <div className="moderation-search-controls mb-4 pb-4">
|
||||
<h2>Moderation</h2>
|
||||
<div>
|
||||
{ this.renderStateFilter() }
|
||||
{ this.renderSearchBar() }
|
||||
</div>
|
||||
</div>;
|
||||
}
|
||||
|
||||
return <div>{ this.renderSearchBar() }</div>;
|
||||
}
|
||||
|
||||
renderProjects() {
|
||||
if (!this.state.keywordSearched && !this.props.moderation) return null;
|
||||
|
||||
if (this.props.moderation) {
|
||||
return <ProjectLoader search={this.state.keywordSearched} moderationState={this.state.moderationState} />;
|
||||
}
|
||||
|
||||
if (this.state.keywordSearched) {
|
||||
return <ProjectLoader search={this.state.keywordSearched} />;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className="search-page">
|
||||
<Helmet><title>{this.state.keywordSearched}</title></Helmet>
|
||||
<div className={classNames({activated: true, 'search-bar': true})}>
|
||||
<DebounceInput id="search-box"
|
||||
value={this.state.keywordSearched}
|
||||
debounceTimeout={300}
|
||||
type="search"
|
||||
onChange={this.handleInputChange}
|
||||
placeholder="Search keywords, people, tags..." />
|
||||
<button className="btn dismiss" onClick={this.handleDismissBtnClick}>×</button>
|
||||
</div>
|
||||
{ this.state.keywordSearched ? <ProjectLoader search={this.state.keywordSearched} /> : null }
|
||||
{ this.renderSearchControls() }
|
||||
{ this.renderProjects() }
|
||||
</div>
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export default Search;
|
||||
|
|
|
@ -1,3 +1,7 @@
|
|||
.moderation-search-controls {
|
||||
border-bottom: 3px solid $gray-lighter;
|
||||
}
|
||||
|
||||
.search-bar {
|
||||
position: relative;
|
||||
|
||||
|
@ -40,3 +44,16 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
|
||||
.state-filter {
|
||||
width: 100%;
|
||||
|
||||
@media screen and (min-width: $bp-md) {
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.Select-control {
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,6 @@
|
|||
import React from 'react';
|
||||
import { Route, IndexRoute, IndexRedirect } from 'react-router';
|
||||
import { Helmet } from "react-helmet";
|
||||
import localstorage from './js/localstorage.js';
|
||||
import pageSettings from './js/app-page-settings';
|
||||
|
||||
import ProjectLoader from './components/project-loader/project-loader.jsx';
|
||||
|
@ -12,6 +11,7 @@ import Entry from './pages/entry.jsx';
|
|||
import Add from './pages/add/add.jsx';
|
||||
import Submitted from './pages/add/submitted.jsx';
|
||||
import Search from './pages/search/search.jsx';
|
||||
import Moderation from './pages/moderation.jsx';
|
||||
import NotFound from './pages/not-found.jsx';
|
||||
|
||||
import Navbar from './components/navbar/navbar.jsx';
|
||||
|
@ -83,6 +83,7 @@ module.exports = (
|
|||
<IndexRedirect to="/latest" />
|
||||
<Route path=":tag" component={Tag} onEnter={evt => pageSettings.setCurrentPathname(evt.location.pathname)} />
|
||||
</Route>
|
||||
<Route path="moderation" component={Moderation} onEnter={evt => pageSettings.setCurrentPathname(evt.location.pathname)} />
|
||||
<Route path="*" component={NotFound}/>
|
||||
</Route>
|
||||
);
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
@import '../node_modules/mofo-bootstrap/src/scss/overrides/mixins.scss';
|
||||
@import '../node_modules/bootstrap/scss/buttons.scss';
|
||||
@import '../node_modules/bootstrap/scss/forms.scss';
|
||||
@import '../node_modules/react-select/scss/default.scss';
|
||||
|
||||
// mixins
|
||||
|
||||
|
|
Загрузка…
Ссылка в новой задаче