mirror of
https://github.com/vale981/Vulcan
synced 2025-03-06 01:51:40 -05:00
Merge branch 'example-forum' into devel
This commit is contained in:
commit
d1a98463cc
186 changed files with 8089 additions and 614 deletions
|
@ -14,11 +14,13 @@ accounts-password@1.4.0
|
|||
|
||||
############ Your Packages ############
|
||||
|
||||
example-simple
|
||||
# example-simple
|
||||
# example-movies
|
||||
# example-instagram
|
||||
# example-forum
|
||||
example-forum
|
||||
# example-customization
|
||||
# example-permissions
|
||||
# example-membership
|
||||
# example-interfaces
|
||||
|
||||
vulcan:debug
|
|
@ -24,7 +24,7 @@ ecmascript-runtime-client@0.4.3
|
|||
ecmascript-runtime-server@0.4.1
|
||||
ejson@1.0.13
|
||||
email@1.2.3
|
||||
example-simple@0.0.0
|
||||
example-forum@1.7.0
|
||||
fourseven:scss@4.5.4
|
||||
geojson-utils@1.0.10
|
||||
hot-code-push@1.0.4
|
||||
|
@ -33,14 +33,14 @@ id-map@1.0.9
|
|||
livedata@1.0.18
|
||||
localstorage@1.1.1
|
||||
logging@1.1.17
|
||||
meteor@1.7.0
|
||||
meteor@1.7.1
|
||||
meteor-base@1.1.0
|
||||
meteorhacks:inject-initial@1.0.4
|
||||
meteorhacks:picker@1.0.3
|
||||
minifier-css@1.2.16
|
||||
minifier-js@2.1.1
|
||||
minimongo@1.2.1
|
||||
modules@0.9.2
|
||||
modules@0.9.4
|
||||
modules-runtime@0.8.0
|
||||
mongo@1.1.22
|
||||
mongo-id@1.0.6
|
||||
|
@ -69,11 +69,17 @@ underscore@1.0.10
|
|||
url@1.1.0
|
||||
vulcan:accounts@1.7.0
|
||||
vulcan:core@1.7.0
|
||||
vulcan:debug@1.7.0
|
||||
vulcan:email@1.7.0
|
||||
vulcan:embedly@1.7.0
|
||||
vulcan:events@1.7.0
|
||||
vulcan:forms@1.7.0
|
||||
vulcan:i18n@1.7.0
|
||||
vulcan:i18n-en-us@1.7.0
|
||||
vulcan:lib@1.7.0
|
||||
vulcan:newsletter@1.7.0
|
||||
vulcan:routing@1.7.0
|
||||
vulcan:users@1.7.0
|
||||
vulcan:voting@1.7.0
|
||||
webapp@1.3.17
|
||||
webapp-hashing@1.0.9
|
||||
|
|
9
packages/example-forum/lib/assets/content/customizing.md
Normal file
9
packages/example-forum/lib/assets/content/customizing.md
Normal file
|
@ -0,0 +1,9 @@
|
|||
If you want to learn how to customize Vulcan, we suggest checking out the [docs](http://docs.vulcanjs.org).
|
||||
|
||||
The first things you'll want to do are probably create a `settings.json` file to hold all your settings, and then taking a look at the sample custom package by uncommenting `customization-demo` in `.meteor/packages`.
|
||||
|
||||
Here are two tutorials to get further:
|
||||
* [Understanding the Vulcan Framework](http://docs.vulcanjs.org/tutorial-framework.html )
|
||||
* [Customizing & Extending Vulcan](http://docs.vulcanjs.org/tutorial-customizing.html)
|
||||
|
||||
Happy hacking!
|
7
packages/example-forum/lib/assets/content/deploying.md
Normal file
7
packages/example-forum/lib/assets/content/deploying.md
Normal file
|
@ -0,0 +1,7 @@
|
|||
Once you've played around with Vulcan, you might want to deploy your app for the whole world to see.
|
||||
|
||||
We recommend using [Meteor Up](https://github.com/kadirahq/meteor-up) to deploy to a [Digital Ocean](http://digitalocean.com) server, along with [Compose](http://compose.io) to host your database.
|
||||
|
||||
Other good alternatives include [Galaxy](http://galaxy.meteor.com/) and [Scalingo](http://scalingo.com).
|
||||
|
||||
Learn more in the [Vulcan docs](http://docs.vulcanjs.org/deployment.html).
|
|
@ -0,0 +1,7 @@
|
|||
### Slack Chatroom
|
||||
|
||||
If you have a question, the best place to ask is the [Slack chatroom](http://slack.vulcanjs.org) to get help. Or you can also drop to just say hello!
|
||||
|
||||
### GitHub Issues
|
||||
|
||||
If you've found a bug in Telescope, then please [leave an issue on GitHub](https://github.com/VulcanJS/Vulcan/issues).
|
15
packages/example-forum/lib/assets/content/read_this_first.md
Normal file
15
packages/example-forum/lib/assets/content/read_this_first.md
Normal file
|
@ -0,0 +1,15 @@
|
|||
### Welcome to Vulcan!
|
||||
|
||||
If you're reading this, it means you've successfully got Vulcan to run.
|
||||
|
||||
To make your first run a bit easier, we've taken the liberty of preloading your brand new app with a few posts that will walk you through your first steps with Vulcan.
|
||||
|
||||
### Creating An Account
|
||||
|
||||
The first thing you'll need to do is create your account. Since this will be the first ever account created in this app, it will automatically be assigned admin rights, and you'll then be able to access Vulcan's settings panel.
|
||||
|
||||
Click the “Log In” link in the top menu and come back here once you're done!
|
||||
|
||||
### Start Posting!
|
||||
|
||||
You're now all set to start using Vulcan. Check out the other posts for more information, or just start posting!
|
|
@ -0,0 +1,11 @@
|
|||
### Removing Getting Started Posts
|
||||
|
||||
If you're ready to insert your own content and want to delete these getting started posts, comments, and users, you can do so with a single command.
|
||||
|
||||
Open a new [Meteor shell](https://docs.meteor.com/commandline.html#meteorshell) and type:
|
||||
|
||||
```js
|
||||
Vulcan.removeGettingStartedContent()
|
||||
```
|
||||
|
||||
See you on the other side!
|
BIN
packages/example-forum/lib/assets/images/stackoverflow.png
Normal file
BIN
packages/example-forum/lib/assets/images/stackoverflow.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 4.5 KiB |
BIN
packages/example-forum/lib/assets/images/telescope.png
Normal file
BIN
packages/example-forum/lib/assets/images/telescope.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 25 KiB |
1
packages/example-forum/lib/client/main.js
Normal file
1
packages/example-forum/lib/client/main.js
Normal file
|
@ -0,0 +1 @@
|
|||
export * from '../modules/index.js';
|
|
@ -0,0 +1,12 @@
|
|||
import React from 'react';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const AdminUsersPosts = ({ document: user }) =>
|
||||
<ul>
|
||||
{user.posts && user.posts.map(post =>
|
||||
<li key={post._id}><Link to={Posts.getLink(post)}>{post.title}</Link></li>
|
||||
)}
|
||||
</ul>
|
||||
|
||||
export default AdminUsersPosts;
|
|
@ -0,0 +1,43 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { intlShape } from 'meteor/vulcan:i18n';
|
||||
import { Components, registerComponent, getFragment, withMessages } from 'meteor/vulcan:core';
|
||||
import Categories from '../../modules/categories/index.js';
|
||||
|
||||
const CategoriesEditForm = (props, context) => {
|
||||
|
||||
return (
|
||||
<div className="categories-edit-form">
|
||||
<div className="categories-edit-form-admin">
|
||||
<div className="categories-edit-form-id">ID: {props.category._id}</div>
|
||||
</div>
|
||||
<Components.SmartForm
|
||||
collection={Categories}
|
||||
documentId={props.category._id}
|
||||
mutationFragment={getFragment('CategoriesList')}
|
||||
successCallback={category => {
|
||||
props.closeModal();
|
||||
props.flash(context.intl.formatMessage({ id: 'categories.edit_success' }, { name: category.name }), 'success');
|
||||
}}
|
||||
removeSuccessCallback={({ documentId, documentTitle }) => {
|
||||
props.closeModal();
|
||||
props.flash(context.intl.formatMessage({ id: 'categories.delete_success' }, { name: documentTitle }), 'success');
|
||||
// context.events.track("category deleted", {_id: documentId});
|
||||
}}
|
||||
showRemove={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
CategoriesEditForm.propTypes = {
|
||||
category: PropTypes.object.isRequired,
|
||||
closeModal: PropTypes.func,
|
||||
flash: PropTypes.func,
|
||||
};
|
||||
|
||||
CategoriesEditForm.contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
registerComponent('CategoriesEditForm', CategoriesEditForm, withMessages);
|
|
@ -0,0 +1,123 @@
|
|||
import { ModalTrigger, Components, registerComponent, withList, Utils } from "meteor/vulcan:core";
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import Button from 'react-bootstrap/lib/Button';
|
||||
import DropdownButton from 'react-bootstrap/lib/DropdownButton';
|
||||
import MenuItem from 'react-bootstrap/lib/MenuItem';
|
||||
import { withRouter } from 'react-router'
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import Categories from '../../modules/categories/index.js';
|
||||
import { withApollo } from 'react-apollo';
|
||||
|
||||
class CategoriesList extends PureComponent {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.getCurrentCategoriesArray = this.getCurrentCategoriesArray.bind(this);
|
||||
this.getCategoryLink = this.getCategoryLink.bind(this);
|
||||
}
|
||||
|
||||
getCurrentCategoriesArray() {
|
||||
const currentCategories = _.clone(this.props.location.query.cat);
|
||||
if (currentCategories) {
|
||||
return Array.isArray(currentCategories) ? currentCategories : [currentCategories]
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
getCategoryLink(slug) {
|
||||
const categories = this.getCurrentCategoriesArray();
|
||||
return {
|
||||
pathname: '/',
|
||||
query: {
|
||||
...this.props.location.query,
|
||||
cat: categories.includes(slug) ? _.without(categories, slug) : categories.concat([slug])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
getNestedCategories() {
|
||||
const categories = this.props.results;
|
||||
|
||||
// check if a category is currently active in the route
|
||||
const currentCategorySlug = this.props.router.location.query && this.props.router.location.query.cat;
|
||||
const currentCategory = Categories.findOneInStore(this.props.client.store, {slug: currentCategorySlug});
|
||||
const parentCategories = Categories.getParents(currentCategory, this.props.client.store);
|
||||
|
||||
// decorate categories with active and expanded properties
|
||||
const categoriesClone = _.map(categories, category => {
|
||||
return {
|
||||
...category, // we don't want to modify the objects we got from props
|
||||
active: currentCategory && category.slug === currentCategory.slug,
|
||||
expanded: parentCategories && _.contains(_.pluck(parentCategories, 'slug'), category.slug)
|
||||
};
|
||||
});
|
||||
|
||||
const nestedCategories = Utils.unflatten(categoriesClone, {idProperty: '_id', parentIdProperty: 'parentId'});
|
||||
|
||||
return nestedCategories;
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const allCategoriesQuery = _.clone(this.props.router.location.query);
|
||||
delete allCategoriesQuery.cat;
|
||||
const nestedCategories = this.getNestedCategories();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropdownButton
|
||||
bsStyle="default"
|
||||
className="categories-list btn-secondary"
|
||||
title={<FormattedMessage id="categories"/>}
|
||||
id="categories-dropdown"
|
||||
>
|
||||
<div className="category-menu-item category-menu-item-all dropdown-item">
|
||||
<LinkContainer className="category-menu-item-title" to={{pathname:"/", query: allCategoriesQuery}}>
|
||||
<MenuItem eventKey={0}>
|
||||
<FormattedMessage id="categories.all"/>
|
||||
</MenuItem>
|
||||
</LinkContainer>
|
||||
</div>
|
||||
{
|
||||
// categories data are loaded
|
||||
!this.props.loading ?
|
||||
// there are currently categories
|
||||
nestedCategories && nestedCategories.length > 0 ?
|
||||
nestedCategories.map((category, index) => <Components.CategoriesNode key={index} category={category} index={index} openModal={this.openCategoryEditModal}/>)
|
||||
// not any category found
|
||||
: null
|
||||
// categories are loading
|
||||
: <div className="dropdown-item"><MenuItem><Components.Loading /></MenuItem></div>
|
||||
}
|
||||
<Components.ShowIf check={Categories.options.mutations.new.check}>
|
||||
<div className="categories-new-button category-menu-item dropdown-item">
|
||||
<ModalTrigger title={<FormattedMessage id="categories.new"/>} component={<Button bsStyle="primary"><FormattedMessage id="categories.new"/></Button>}>
|
||||
<Components.CategoriesNewForm/>
|
||||
</ModalTrigger>
|
||||
</div>
|
||||
</Components.ShowIf>
|
||||
</DropdownButton>
|
||||
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
CategoriesList.propTypes = {
|
||||
results: PropTypes.array,
|
||||
};
|
||||
|
||||
|
||||
const options = {
|
||||
collection: Categories,
|
||||
queryName: 'categoriesListQuery',
|
||||
fragmentName: 'CategoriesList',
|
||||
limit: 0,
|
||||
pollInterval: 0,
|
||||
};
|
||||
|
||||
registerComponent('CategoriesList', CategoriesList, withRouter, withApollo, [withList, options]);
|
|
@ -0,0 +1,34 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { intlShape } from 'meteor/vulcan:i18n';
|
||||
import { Components, registerComponent, getFragment, withMessages } from 'meteor/vulcan:core';
|
||||
import Categories from '../../modules/categories/index.js';
|
||||
|
||||
const CategoriesNewForm = (props, context) => {
|
||||
|
||||
return (
|
||||
<div className="categories-new-form">
|
||||
<Components.SmartForm
|
||||
collection={Categories}
|
||||
mutationFragment={getFragment('CategoriesList')}
|
||||
successCallback={category => {
|
||||
props.closeModal();
|
||||
props.flash(context.intl.formatMessage({id: 'categories.new_success'}, {name: category.name}), "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CategoriesNewForm.displayName = "CategoriesNewForm";
|
||||
|
||||
CategoriesNewForm.propTypes = {
|
||||
closeCallback: PropTypes.func,
|
||||
flash: PropTypes.func,
|
||||
};
|
||||
|
||||
CategoriesNewForm.contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
registerComponent('CategoriesNewForm', CategoriesNewForm, withMessages);
|
|
@ -0,0 +1,40 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
class CategoriesNode extends PureComponent {
|
||||
|
||||
renderCategory(category) {
|
||||
return (
|
||||
<Components.Category category={category} key={category._id} openModal={this.props.openModal} />
|
||||
)
|
||||
}
|
||||
|
||||
renderChildren(children) {
|
||||
return (
|
||||
<div className="categories-children">
|
||||
{children.map(category => <CategoriesNode category={category} key={category._id} />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const category = this.props.category;
|
||||
const children = this.props.category.childrenResults;
|
||||
|
||||
return (
|
||||
<div className="categories-node">
|
||||
{this.renderCategory(category)}
|
||||
{children ? this.renderChildren(children) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
CategoriesNode.propTypes = {
|
||||
category: PropTypes.object.isRequired, // the current category
|
||||
};
|
||||
|
||||
registerComponent('CategoriesNode', CategoriesNode);
|
|
@ -0,0 +1,52 @@
|
|||
import { ModalTrigger, Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import MenuItem from 'react-bootstrap/lib/MenuItem'
|
||||
import { withRouter } from 'react-router'
|
||||
import Categories from '../../modules/categories/index.js';
|
||||
|
||||
class Category extends PureComponent {
|
||||
|
||||
renderEdit() {
|
||||
return (
|
||||
<ModalTrigger title="Edit Category" component={<a className="edit-category-link"><Components.Icon name="edit"/></a>}>
|
||||
<Components.CategoriesEditForm category={this.props.category}/>
|
||||
</ModalTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const {category, index, router} = this.props;
|
||||
|
||||
// const currentQuery = router.location.query;
|
||||
const currentCategorySlug = router.location.query.cat;
|
||||
const newQuery = _.clone(router.location.query);
|
||||
newQuery.cat = category.slug;
|
||||
|
||||
return (
|
||||
<div className="category-menu-item dropdown-item">
|
||||
<LinkContainer to={{pathname:"/", query: newQuery}}>
|
||||
<MenuItem
|
||||
eventKey={index+1}
|
||||
key={category._id}
|
||||
>
|
||||
{currentCategorySlug === category.slug ? <Components.Icon name="voted"/> : null}
|
||||
{category.name}
|
||||
</MenuItem>
|
||||
</LinkContainer>
|
||||
<Components.ShowIf check={Categories.options.mutations.edit.check} document={category}>{this.renderEdit()}</Components.ShowIf>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Category.propTypes = {
|
||||
category: PropTypes.object,
|
||||
index: PropTypes.number,
|
||||
currentCategorySlug: PropTypes.string,
|
||||
openModal: PropTypes.func
|
||||
};
|
||||
|
||||
registerComponent('Category', Category, withRouter);
|
|
@ -0,0 +1,29 @@
|
|||
import { Components, registerComponent, getFragment, withMessages } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Comments from '../../modules/comments/index.js';
|
||||
|
||||
const CommentsEditForm = (props, context) => {
|
||||
return (
|
||||
<div className="comments-edit-form">
|
||||
<Components.SmartForm
|
||||
layout="elementOnly"
|
||||
collection={Comments}
|
||||
documentId={props.comment._id}
|
||||
successCallback={props.successCallback}
|
||||
cancelCallback={props.cancelCallback}
|
||||
removeSuccessCallback={props.removeSuccessCallback}
|
||||
showRemove={true}
|
||||
mutationFragment={getFragment('CommentsList')}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
CommentsEditForm.propTypes = {
|
||||
comment: PropTypes.object.isRequired,
|
||||
successCallback: PropTypes.func,
|
||||
cancelCallback: PropTypes.func
|
||||
};
|
||||
|
||||
registerComponent('CommentsEditForm', CommentsEditForm, withMessages);
|
135
packages/example-forum/lib/components/comments/CommentsItem.jsx
Normal file
135
packages/example-forum/lib/components/comments/CommentsItem.jsx
Normal file
|
@ -0,0 +1,135 @@
|
|||
import { Components, registerComponent, withMessages } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { intlShape, FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import Comments from '../../modules/comments/index.js';
|
||||
import moment from 'moment';
|
||||
|
||||
class CommentsItem extends PureComponent {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
['showReply', 'replyCancelCallback', 'replySuccessCallback', 'showEdit', 'editCancelCallback', 'editSuccessCallback', 'removeSuccessCallback'].forEach(methodName => {this[methodName] = this[methodName].bind(this)});
|
||||
this.state = {
|
||||
showReply: false,
|
||||
showEdit: false
|
||||
};
|
||||
}
|
||||
|
||||
showReply(event) {
|
||||
event.preventDefault();
|
||||
this.setState({showReply: true});
|
||||
}
|
||||
|
||||
replyCancelCallback(event) {
|
||||
event.preventDefault();
|
||||
this.setState({showReply: false});
|
||||
}
|
||||
|
||||
replySuccessCallback() {
|
||||
this.setState({showReply: false});
|
||||
}
|
||||
|
||||
showEdit(event) {
|
||||
event.preventDefault();
|
||||
this.setState({showEdit: true});
|
||||
}
|
||||
|
||||
editCancelCallback(event) {
|
||||
event.preventDefault();
|
||||
this.setState({showEdit: false});
|
||||
}
|
||||
|
||||
editSuccessCallback() {
|
||||
this.setState({showEdit: false});
|
||||
}
|
||||
|
||||
removeSuccessCallback({documentId}) {
|
||||
const deleteDocumentSuccess = this.context.intl.formatMessage({id: 'comments.delete_success'});
|
||||
this.props.flash(deleteDocumentSuccess, "success");
|
||||
// todo: handle events in async callback
|
||||
// this.context.events.track("comment deleted", {_id: documentId});
|
||||
}
|
||||
|
||||
renderComment() {
|
||||
const htmlBody = {__html: this.props.comment.htmlBody};
|
||||
|
||||
const showReplyButton = !this.props.comment.isDeleted && !!this.props.currentUser;
|
||||
|
||||
return (
|
||||
<div className="comments-item-text">
|
||||
<div dangerouslySetInnerHTML={htmlBody}></div>
|
||||
{ showReplyButton ?
|
||||
<a className="comments-item-reply-link" onClick={this.showReply}>
|
||||
<Components.Icon name="reply"/> <FormattedMessage id="comments.reply"/>
|
||||
</a> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderReply() {
|
||||
|
||||
return (
|
||||
<div className="comments-item-reply">
|
||||
<Components.CommentsNewForm
|
||||
postId={this.props.comment.postId}
|
||||
parentComment={this.props.comment}
|
||||
successCallback={this.replySuccessCallback}
|
||||
cancelCallback={this.replyCancelCallback}
|
||||
type="reply"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
renderEdit() {
|
||||
|
||||
return (
|
||||
<Components.CommentsEditForm
|
||||
comment={this.props.comment}
|
||||
successCallback={this.editSuccessCallback}
|
||||
cancelCallback={this.editCancelCallback}
|
||||
removeSuccessCallback={this.removeSuccessCallback}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
const comment = this.props.comment;
|
||||
|
||||
return (
|
||||
<div className="comments-item" id={comment._id}>
|
||||
<div className="comments-item-body">
|
||||
<div className="comments-item-meta">
|
||||
<div className="comments-item-vote">
|
||||
<Components.Vote collection={Comments} document={this.props.comment} currentUser={this.props.currentUser}/>
|
||||
</div>
|
||||
<Components.UsersAvatar size="small" user={comment.user}/>
|
||||
<Components.UsersName user={comment.user}/>
|
||||
<div className="comments-item-date">{moment(new Date(comment.postedAt)).fromNow()}</div>
|
||||
<Components.ShowIf check={Comments.options.mutations.edit.check} document={this.props.comment}>
|
||||
<div>
|
||||
<a className="comment-edit" onClick={this.showEdit}><FormattedMessage id="comments.edit"/></a>
|
||||
</div>
|
||||
</Components.ShowIf>
|
||||
</div>
|
||||
{this.state.showEdit ? this.renderEdit() : this.renderComment()}
|
||||
</div>
|
||||
{this.state.showReply ? this.renderReply() : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
CommentsItem.propTypes = {
|
||||
comment: PropTypes.object.isRequired, // the current comment
|
||||
currentUser: PropTypes.object,
|
||||
};
|
||||
|
||||
CommentsItem.contextTypes = {
|
||||
events: PropTypes.object,
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
registerComponent('CommentsItem', CommentsItem);
|
|
@ -0,0 +1,28 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
|
||||
const CommentsList = ({comments, commentCount, currentUser}) => {
|
||||
|
||||
if (commentCount > 0) {
|
||||
return (
|
||||
<div className="comments-list">
|
||||
{comments.map(comment => <Components.CommentsNode currentUser={currentUser} comment={comment} key={comment._id} />)}
|
||||
{/*hasMore ? (ready ? <Components.CommentsLoadMore loadMore={loadMore} count={count} totalCount={totalCount} /> : <Components.Loading/>) : null*/}
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className="comments-list">
|
||||
<p>
|
||||
<FormattedMessage id="comments.no_comments"/>
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
CommentsList.displayName = "CommentsList";
|
||||
|
||||
registerComponent('CommentsList', CommentsList);
|
|
@ -0,0 +1,11 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
|
||||
const CommentsLoadMore = ({loadMore, count, totalCount}) => {
|
||||
const label = totalCount ? `Load More (${count}/${totalCount})` : "Load More";
|
||||
return <a className="comments-load-more" onClick={e => { e.preventDefault(); loadMore();}}>{label}</a>
|
||||
}
|
||||
|
||||
CommentsLoadMore.displayName = "CommentsLoadMore";
|
||||
|
||||
registerComponent('CommentsLoadMore', CommentsLoadMore);
|
|
@ -0,0 +1,51 @@
|
|||
import { Components, registerComponent, getFragment, withMessages } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Comments from '../../modules/comments/index.js';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
|
||||
const CommentsNewForm = (props, context) => {
|
||||
|
||||
let prefilledProps = {postId: props.postId};
|
||||
|
||||
if (props.parentComment) {
|
||||
prefilledProps = Object.assign(prefilledProps, {
|
||||
parentCommentId: props.parentComment._id,
|
||||
// if parent comment has a topLevelCommentId use it; if it doesn't then it *is* the top level comment
|
||||
topLevelCommentId: props.parentComment.topLevelCommentId || props.parentComment._id
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Components.ShowIf
|
||||
check={Comments.options.mutations.new.check}
|
||||
failureComponent={<FormattedMessage id="users.cannot_comment"/>}
|
||||
>
|
||||
<div className="comments-new-form">
|
||||
<Components.SmartForm
|
||||
collection={Comments}
|
||||
mutationFragment={getFragment('CommentsList')}
|
||||
successCallback={props.successCallback}
|
||||
cancelCallback={props.type === "reply" ? props.cancelCallback : null}
|
||||
prefilledProps={prefilledProps}
|
||||
layout="elementOnly"
|
||||
/>
|
||||
</div>
|
||||
</Components.ShowIf>
|
||||
)
|
||||
|
||||
};
|
||||
|
||||
CommentsNewForm.propTypes = {
|
||||
postId: PropTypes.string.isRequired,
|
||||
type: PropTypes.string, // "comment" or "reply"
|
||||
parentComment: PropTypes.object, // if reply, the comment being replied to
|
||||
parentCommentId: PropTypes.string, // if reply
|
||||
topLevelCommentId: PropTypes.string, // if reply
|
||||
successCallback: PropTypes.func, // a callback to execute when the submission has been successful
|
||||
cancelCallback: PropTypes.func,
|
||||
router: PropTypes.object,
|
||||
flash: PropTypes.func,
|
||||
};
|
||||
|
||||
registerComponent('CommentsNewForm', CommentsNewForm, withMessages);
|
|
@ -0,0 +1,20 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const CommentsNode = ({ comment, currentUser }) =>
|
||||
<div className="comments-node">
|
||||
<Components.CommentsItem currentUser={currentUser} comment={comment} key={comment._id} />
|
||||
{comment.childrenResults ?
|
||||
<div className="comments-children">
|
||||
{comment.childrenResults.map(comment => <CommentsNode currentUser={currentUser} comment={comment} key={comment._id} />)}
|
||||
</div>
|
||||
: null
|
||||
}
|
||||
</div>
|
||||
|
||||
CommentsNode.propTypes = {
|
||||
comment: PropTypes.object.isRequired, // the current comment
|
||||
};
|
||||
|
||||
registerComponent('CommentsNode', CommentsNode);
|
39
packages/example-forum/lib/components/common/Flash.jsx
Normal file
39
packages/example-forum/lib/components/common/Flash.jsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Alert from 'react-bootstrap/lib/Alert'
|
||||
|
||||
class Flash extends PureComponent {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.dismissFlash = this.dismissFlash.bind(this);
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.props.markAsSeen(this.props.message._id);
|
||||
}
|
||||
|
||||
dismissFlash(e) {
|
||||
e.preventDefault();
|
||||
this.props.clear(this.props.message._id);
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
let flashType = this.props.message.flashType;
|
||||
flashType = flashType === "error" ? "danger" : flashType; // if flashType is "error", use "danger" instead
|
||||
|
||||
return (
|
||||
<Alert className="flash-message" bsStyle={flashType} onDismiss={this.dismissFlash}>
|
||||
{this.props.message.content}
|
||||
</Alert>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Flash.propTypes = {
|
||||
message: PropTypes.object.isRequired
|
||||
}
|
||||
|
||||
registerComponent('Flash', Flash);
|
|
@ -0,0 +1,16 @@
|
|||
import { Components, registerComponent, withMessages } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
|
||||
const FlashMessages = ({messages, clear, markAsSeen}) => {
|
||||
return (
|
||||
<div className="flash-messages">
|
||||
{messages
|
||||
.filter(message => message.show)
|
||||
.map(message => <Components.Flash key={message._id} message={message} clear={clear} markAsSeen={markAsSeen} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
FlashMessages.displayName = "FlashMessages";
|
||||
|
||||
registerComponent('FlashMessages', FlashMessages, withMessages);
|
13
packages/example-forum/lib/components/common/Footer.jsx
Normal file
13
packages/example-forum/lib/components/common/Footer.jsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
|
||||
const Footer = props => {
|
||||
return (
|
||||
<div className="footer"><a href="http://vulcanjs.org" target="_blank"><FormattedMessage id="app.powered_by"/></a></div>
|
||||
)
|
||||
}
|
||||
|
||||
Footer.displayName = "Footer";
|
||||
|
||||
registerComponent('Footer', Footer);
|
44
packages/example-forum/lib/components/common/Header.jsx
Normal file
44
packages/example-forum/lib/components/common/Header.jsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { withCurrentUser, getSetting, Components, registerComponent } from 'meteor/vulcan:core';
|
||||
|
||||
const Header = (props, context) => {
|
||||
|
||||
const logoUrl = getSetting("logoUrl");
|
||||
const siteTitle = getSetting("title", "My App");
|
||||
const tagline = getSetting("tagline");
|
||||
|
||||
return (
|
||||
<div className="header-wrapper">
|
||||
|
||||
<header className="header">
|
||||
|
||||
<div className="logo">
|
||||
<Components.Logo logoUrl={logoUrl} siteTitle={siteTitle} />
|
||||
{tagline ? <h2 className="tagline">{tagline}</h2> : "" }
|
||||
</div>
|
||||
|
||||
<div className="nav">
|
||||
|
||||
<div className="nav-user">
|
||||
{!!props.currentUser ? <Components.UsersMenu/> : <Components.UsersAccountMenu/>}
|
||||
</div>
|
||||
|
||||
<div className="nav-new-post">
|
||||
<Components.PostsNewButton/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</header>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Header.displayName = "Header";
|
||||
|
||||
Header.propTypes = {
|
||||
currentUser: PropTypes.object,
|
||||
};
|
||||
|
||||
registerComponent('Header', Header, withCurrentUser);
|
33
packages/example-forum/lib/components/common/Layout.jsx
Normal file
33
packages/example-forum/lib/components/common/Layout.jsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core';
|
||||
import React, { PropTypes, Component } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import Helmet from 'react-helmet';
|
||||
|
||||
const Layout = ({currentUser, children, currentRoute}) =>
|
||||
|
||||
<div className={classNames('wrapper', `wrapper-${currentRoute.name.replace('.', '-')}`)} id="wrapper">
|
||||
|
||||
<Helmet>
|
||||
<link name="bootstrap" rel="stylesheet" type="text/css" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0-alpha.5/css/bootstrap.min.css"/>
|
||||
<link name="font-awesome" rel="stylesheet" type="text/css" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css"/>
|
||||
</Helmet>
|
||||
|
||||
{currentUser ? <Components.UsersProfileCheck currentUser={currentUser} documentId={currentUser._id} /> : null}
|
||||
|
||||
<Components.Header />
|
||||
|
||||
<div className="main">
|
||||
|
||||
<Components.FlashMessages />
|
||||
|
||||
<Components.Newsletter />
|
||||
|
||||
{children}
|
||||
|
||||
</div>
|
||||
|
||||
<Components.Footer />
|
||||
|
||||
</div>
|
||||
|
||||
registerComponent('Layout', Layout, withCurrentUser);
|
25
packages/example-forum/lib/components/common/Logo.jsx
Normal file
25
packages/example-forum/lib/components/common/Logo.jsx
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { IndexLink } from 'react-router';
|
||||
|
||||
const Logo = ({logoUrl, siteTitle}) => {
|
||||
if (logoUrl) {
|
||||
return (
|
||||
<h1 className="logo-image ">
|
||||
<IndexLink to={{pathname: "/"}}>
|
||||
<img src={logoUrl} alt={siteTitle} />
|
||||
</IndexLink>
|
||||
</h1>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<h1 className="logo-text">
|
||||
<IndexLink to={{pathname: "/"}}>{siteTitle}</IndexLink>
|
||||
</h1>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Logo.displayName = "Logo";
|
||||
|
||||
registerComponent('Logo', Logo);
|
113
packages/example-forum/lib/components/common/Newsletter.jsx
Normal file
113
packages/example-forum/lib/components/common/Newsletter.jsx
Normal file
|
@ -0,0 +1,113 @@
|
|||
import { Components, registerComponent, withCurrentUser, withMutation, withMessages, Utils } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
|
||||
import Formsy from 'formsy-react';
|
||||
import { Input } from 'formsy-react-components';
|
||||
import Button from 'react-bootstrap/lib/Button';
|
||||
import Cookie from 'react-cookie';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
class Newsletter extends PureComponent {
|
||||
|
||||
constructor(props, context) {
|
||||
super(props);
|
||||
this.subscribeEmail = this.subscribeEmail.bind(this);
|
||||
this.successCallbackSubscription = this.successCallbackSubscription.bind(this);
|
||||
this.dismissBanner = this.dismissBanner.bind(this);
|
||||
|
||||
this.state = {
|
||||
showBanner: showBanner(props.currentUser)
|
||||
};
|
||||
}
|
||||
|
||||
componentWillReceiveProps(nextProps, nextContext) {
|
||||
if (nextProps.currentUser) {
|
||||
this.setState({showBanner: showBanner(nextProps.currentUser)});
|
||||
}
|
||||
}
|
||||
|
||||
async subscribeEmail(data) {
|
||||
try {
|
||||
const result = await this.props.addEmailNewsletter({email: data.email});
|
||||
this.successCallbackSubscription(result);
|
||||
} catch(error) {
|
||||
const graphQLError = error.graphQLErrors[0];
|
||||
console.error(graphQLError); // eslint-disable-line no-console
|
||||
const message = this.context.intl.formatMessage({id: `newsletter.error_${this.state.error.name}`}, {message: this.state.error.message});
|
||||
this.props.flash(message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
successCallbackSubscription(/* result*/) {
|
||||
this.props.flash(this.context.intl.formatMessage({ id: 'newsletter.success_message'}), 'success' );
|
||||
this.dismissBanner();
|
||||
}
|
||||
|
||||
dismissBanner(e) {
|
||||
if (e && e.preventDefault) e.preventDefault();
|
||||
|
||||
this.setState({showBanner: false});
|
||||
|
||||
// set cookie to keep the banner dismissed persistently
|
||||
Cookie.save('showBanner', 'no');
|
||||
}
|
||||
|
||||
renderButton() {
|
||||
return (
|
||||
<Components.NewsletterButton
|
||||
label="newsletter.subscribe"
|
||||
mutationName="addUserNewsletter"
|
||||
successCallback={() => this.successCallbackSubscription()}
|
||||
user={this.props.currentUser}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
renderForm() {
|
||||
return (
|
||||
<Formsy.Form className="newsletter-form" onSubmit={this.subscribeEmail}>
|
||||
<Input
|
||||
name="email"
|
||||
value=""
|
||||
placeholder={this.context.intl.formatMessage({id: "newsletter.email"})}
|
||||
type="text"
|
||||
layout="elementOnly"
|
||||
/>
|
||||
<Button className="newsletter-button" type="submit" bsStyle="primary"><FormattedMessage id="newsletter.subscribe"/></Button>
|
||||
</Formsy.Form>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.state.showBanner
|
||||
? (
|
||||
<div className="newsletter">
|
||||
<h4 className="newsletter-tagline"><FormattedMessage id="newsletter.subscribe_prompt"/></h4>
|
||||
{this.props.currentUser ? this.renderButton() : this.renderForm()}
|
||||
<a onClick={this.dismissBanner} className="newsletter-close"><Components.Icon name="close"/></a>
|
||||
</div>
|
||||
) : null;
|
||||
}
|
||||
}
|
||||
|
||||
Newsletter.contextTypes = {
|
||||
actions: PropTypes.object,
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
const mutationOptions = {
|
||||
name: 'addEmailNewsletter',
|
||||
args: { email: 'String' }
|
||||
}
|
||||
|
||||
function showBanner (user) {
|
||||
return (
|
||||
// showBanner cookie either doesn't exist or is not set to "no"
|
||||
Cookie.load('showBanner') !== 'no'
|
||||
// and user is not subscribed to the newsletter already (setting either DNE or is not set to false)
|
||||
&& !Users.getSetting(user, 'newsletter_subscribeToNewsletter', false)
|
||||
);
|
||||
}
|
||||
|
||||
registerComponent('Newsletter', Newsletter, withMutation(mutationOptions), withCurrentUser, withMessages);
|
|
@ -0,0 +1,66 @@
|
|||
import { Components, registerComponent, withMutation, withCurrentUser, withMessages, Utils } from 'meteor/vulcan:core';
|
||||
import React, { PropTypes, Component } from 'react';
|
||||
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
|
||||
import Button from 'react-bootstrap/lib/Button';
|
||||
|
||||
class NewsletterButton extends Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.subscriptionAction = this.subscriptionAction.bind(this);
|
||||
}
|
||||
|
||||
// use async/await + try/catch <=> promise.then(res => ..).catch(e => ...)
|
||||
async subscriptionAction() {
|
||||
|
||||
const {
|
||||
flash,
|
||||
mutationName,
|
||||
successCallback,
|
||||
user,
|
||||
[mutationName]: mutationToTrigger, // dynamic 'mutationToTrigger' variable based on the mutationName (addUserNewsletter or removeUserNewsletter)
|
||||
} = this.props;
|
||||
|
||||
try {
|
||||
const mutationResult = await mutationToTrigger({userId: user._id});
|
||||
|
||||
successCallback(mutationResult);
|
||||
} catch(error) {
|
||||
console.error(error); // eslint-disable-line no-console
|
||||
flash(
|
||||
this.context.intl.formatMessage(Utils.decodeIntlError(error)),
|
||||
"error"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<Button
|
||||
className="newsletter-button"
|
||||
onClick={this.subscriptionAction}
|
||||
bsStyle="primary"
|
||||
>
|
||||
<FormattedMessage id={this.props.label}/>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
NewsletterButton.propTypes = {
|
||||
mutationName: PropTypes.string.isRequired, // mutation to fire
|
||||
label: PropTypes.string.isRequired, // label of the button
|
||||
user: PropTypes.object.isRequired, // user to operate on
|
||||
successCallback: PropTypes.func.isRequired, // what do to after the mutationName
|
||||
addUserNewsletter: PropTypes.func.isRequired, // prop given by withMutation HOC
|
||||
removeUserNewsletter: PropTypes.func.isRequired, // prop given by withMutation HOC
|
||||
};
|
||||
|
||||
NewsletterButton.contextTypes = {
|
||||
intl: intlShape,
|
||||
};
|
||||
|
||||
const addOptions = {name: 'addUserNewsletter', args: {userId: 'String'}};
|
||||
const removeOptions = {name: 'removeUserNewsletter', args: {userId: 'String'}};
|
||||
|
||||
registerComponent('NewsletterButton', NewsletterButton, withCurrentUser, withMutation(addOptions), withMutation(removeOptions), withMessages);
|
76
packages/example-forum/lib/components/common/SearchForm.jsx
Normal file
76
packages/example-forum/lib/components/common/SearchForm.jsx
Normal file
|
@ -0,0 +1,76 @@
|
|||
import { registerComponent, Components } from 'meteor/vulcan:core';
|
||||
import React, { PropTypes, Component } from 'react';
|
||||
import { intlShape } from 'meteor/vulcan:i18n';
|
||||
import Formsy from 'formsy-react';
|
||||
import FRC from 'formsy-react-components';
|
||||
import { withRouter, Link } from 'react-router'
|
||||
|
||||
const Input = FRC.Input;
|
||||
|
||||
// see: http://stackoverflow.com/questions/1909441/jquery-keyup-delay
|
||||
const delay = (function(){
|
||||
var timer = 0;
|
||||
return function(callback, ms){
|
||||
clearTimeout (timer);
|
||||
timer = setTimeout(callback, ms);
|
||||
};
|
||||
})();
|
||||
|
||||
class SearchForm extends Component{
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.search = this.search.bind(this);
|
||||
this.state = {
|
||||
search: props.router.location.query.query || ''
|
||||
}
|
||||
}
|
||||
|
||||
// note: why do we need this?
|
||||
componentWillReceiveProps(nextProps) {
|
||||
this.setState({
|
||||
search: this.props.router.location.query.query || ''
|
||||
});
|
||||
}
|
||||
|
||||
search(data) {
|
||||
|
||||
const router = this.props.router;
|
||||
const routerQuery = _.clone(router.location.query);
|
||||
delete routerQuery.query;
|
||||
|
||||
const query = data.searchQuery === '' ? routerQuery : {...routerQuery, query: data.searchQuery};
|
||||
|
||||
delay(() => {
|
||||
router.push({pathname: "/", query: query});
|
||||
}, 700 );
|
||||
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const resetQuery = _.clone(this.props.location.query);
|
||||
delete resetQuery.query;
|
||||
|
||||
return (
|
||||
<div className="search-form">
|
||||
<Formsy.Form onChange={this.search}>
|
||||
<Input
|
||||
name="searchQuery"
|
||||
value={this.state.search}
|
||||
placeholder={this.context.intl.formatMessage({id: "posts.search"})}
|
||||
type="text"
|
||||
layout="elementOnly"
|
||||
/>
|
||||
{this.state.search !== '' ? <Link className="search-form-reset" to={{pathname: '/', query: resetQuery}}><Components.Icon name="close" /></Link> : null}
|
||||
</Formsy.Form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
SearchForm.contextTypes = {
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
registerComponent('SearchForm', SearchForm, withRouter);
|
98
packages/example-forum/lib/components/common/Vote.jsx
Normal file
98
packages/example-forum/lib/components/common/Vote.jsx
Normal file
|
@ -0,0 +1,98 @@
|
|||
import { Components, registerComponent, withMessages } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import classNames from 'classnames';
|
||||
import { withVote, hasUpvoted, hasDownvoted } from 'meteor/vulcan:voting';
|
||||
import { /*FormattedMessage,*/ intlShape } from 'meteor/vulcan:i18n';
|
||||
|
||||
class Vote extends PureComponent {
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.upvote = this.upvote.bind(this);
|
||||
this.getActionClass = this.getActionClass.bind(this);
|
||||
// this.startLoading = this.startLoading.bind(this);
|
||||
// this.stopLoading = this.stopLoading.bind(this);
|
||||
this.state = {
|
||||
loading: false
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
note: with optimisitc UI, loading functions are not needed
|
||||
also, setState triggers issues when the component is unmounted
|
||||
before the vote mutation returns.
|
||||
|
||||
*/
|
||||
|
||||
// startLoading() {
|
||||
// this.setState({ loading: true });
|
||||
// }
|
||||
|
||||
// stopLoading() {
|
||||
// this.setState({ loading: false });
|
||||
// }
|
||||
|
||||
upvote(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// this.startLoading();
|
||||
|
||||
const document = this.props.document;
|
||||
const collection = this.props.collection;
|
||||
const user = this.props.currentUser;
|
||||
|
||||
if(!user){
|
||||
this.props.flash(this.context.intl.formatMessage({id: 'users.please_log_in'}));
|
||||
// this.stopLoading();
|
||||
} else {
|
||||
const voteType = hasUpvoted(user, document) ? "cancelUpvote" : "upvote";
|
||||
this.props.vote({document, voteType, collection, currentUser: this.props.currentUser}).then(result => {
|
||||
// this.stopLoading();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
getActionClass() {
|
||||
const document = this.props.document;
|
||||
const user = this.props.currentUser;
|
||||
|
||||
const isUpvoted = hasUpvoted(user, document);
|
||||
const isDownvoted = hasDownvoted(user, document);
|
||||
const actionsClass = classNames(
|
||||
'vote',
|
||||
{voted: isUpvoted || isDownvoted},
|
||||
{upvoted: isUpvoted},
|
||||
{downvoted: isDownvoted}
|
||||
);
|
||||
|
||||
return actionsClass;
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<div className={this.getActionClass()}>
|
||||
<a className="upvote-button" onClick={this.upvote}>
|
||||
{this.state.loading ? <Components.Icon name="spinner" /> : <Components.Icon name="upvote" /> }
|
||||
<div className="sr-only">Upvote</div>
|
||||
<div className="vote-count">{this.props.document.baseScore || 0}</div>
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Vote.propTypes = {
|
||||
document: PropTypes.object.isRequired, // the document to upvote
|
||||
collection: PropTypes.object.isRequired, // the collection containing the document
|
||||
vote: PropTypes.func.isRequired, // mutate function with callback inside
|
||||
currentUser: PropTypes.object, // user might not be logged in, so don't make it required
|
||||
};
|
||||
|
||||
Vote.contextTypes = {
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
registerComponent('Vote', Vote, withMessages, withVote);
|
|
@ -0,0 +1,17 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const PostsCategories = ({post}) => {
|
||||
return (
|
||||
<div className="posts-categories">
|
||||
{post.categories.map(category =>
|
||||
<Link className="posts-category" key={category._id} to={{pathname: "/", query: {cat: category.slug}}}>{category.name}</Link>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
};
|
||||
|
||||
PostsCategories.displayName = "PostsCategories";
|
||||
|
||||
registerComponent('PostsCategories', PostsCategories);
|
|
@ -0,0 +1,25 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
|
||||
const PostsCommenters = ({post}) => {
|
||||
return (
|
||||
<div className="posts-commenters">
|
||||
<div className="posts-commenters-avatars">
|
||||
{_.take(post.commenters, 4).map(user => <Components.UsersAvatar key={user._id} user={user}/>)}
|
||||
</div>
|
||||
<div className="posts-commenters-discuss">
|
||||
<Link to={Posts.getPageUrl(post)}>
|
||||
<Components.Icon name="comment" />
|
||||
<span className="posts-commenters-comments-count">{post.commentCount}</span>
|
||||
<span className="sr-only">Comments</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
PostsCommenters.displayName = "PostsCommenters";
|
||||
|
||||
registerComponent('PostsCommenters', PostsCommenters);
|
|
@ -0,0 +1,56 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import { withList, withCurrentUser, Components, registerComponent, Utils } from 'meteor/vulcan:core';
|
||||
import Comments from '../../modules/comments/index.js';
|
||||
|
||||
const PostsCommentsThread = (props, /* context*/) => {
|
||||
|
||||
const {loading, terms: { postId }, results, totalCount, currentUser} = props;
|
||||
|
||||
if (loading) {
|
||||
|
||||
return <div className="posts-comments-thread"><Components.Loading/></div>
|
||||
|
||||
} else {
|
||||
|
||||
const resultsClone = _.map(results, _.clone); // we don't want to modify the objects we got from props
|
||||
const nestedComments = Utils.unflatten(resultsClone, {idProperty: '_id', parentIdProperty: 'parentCommentId'});
|
||||
|
||||
return (
|
||||
<div className="posts-comments-thread">
|
||||
<h4 className="posts-comments-thread-title"><FormattedMessage id="comments.comments"/></h4>
|
||||
<Components.CommentsList currentUser={currentUser} comments={nestedComments} commentCount={totalCount}/>
|
||||
{!!currentUser ?
|
||||
<div className="posts-comments-thread-new">
|
||||
<h4><FormattedMessage id="comments.new"/></h4>
|
||||
<Components.CommentsNewForm
|
||||
postId={postId}
|
||||
type="comment"
|
||||
/>
|
||||
</div> :
|
||||
<div>
|
||||
<Components.ModalTrigger size="small" component={<a href="#"><FormattedMessage id="comments.please_log_in"/></a>}>
|
||||
<Components.AccountsLoginForm/>
|
||||
</Components.ModalTrigger>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
PostsCommentsThread.displayName = 'PostsCommentsThread';
|
||||
|
||||
PostsCommentsThread.propTypes = {
|
||||
currentUser: PropTypes.object
|
||||
};
|
||||
|
||||
const options = {
|
||||
collection: Comments,
|
||||
queryName: 'commentsListQuery',
|
||||
fragmentName: 'CommentsList',
|
||||
limit: 0,
|
||||
};
|
||||
|
||||
registerComponent('PostsCommentsThread', PostsCommentsThread, [withList, options], withCurrentUser);
|
19
packages/example-forum/lib/components/posts/PostsDaily.jsx
Normal file
19
packages/example-forum/lib/components/posts/PostsDaily.jsx
Normal file
|
@ -0,0 +1,19 @@
|
|||
import { Components, registerComponent, getSetting } from 'meteor/vulcan:core';
|
||||
import React, { PropTypes, Component } from 'react';
|
||||
import moment from 'moment';
|
||||
|
||||
const PostsDaily = props => {
|
||||
// const terms = props.location && props.location.query;
|
||||
const numberOfDays = getSetting('numberOfDays', 5);
|
||||
const terms = {
|
||||
view: 'top',
|
||||
after: moment().subtract(numberOfDays - 1, 'days').format("YYYY-MM-DD"),
|
||||
before: moment().format("YYYY-MM-DD"),
|
||||
};
|
||||
|
||||
return <Components.PostsDailyList terms={terms}/>
|
||||
};
|
||||
|
||||
PostsDaily.displayName = "PostsDaily";
|
||||
|
||||
registerComponent('PostsDaily', PostsDaily);
|
119
packages/example-forum/lib/components/posts/PostsDailyList.jsx
Normal file
119
packages/example-forum/lib/components/posts/PostsDailyList.jsx
Normal file
|
@ -0,0 +1,119 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import moment from 'moment';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import { withCurrentUser, withList, getSetting, Components, getRawComponent, registerComponent } from 'meteor/vulcan:core';
|
||||
|
||||
class PostsDailyList extends PureComponent {
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
this.loadMoreDays = this.loadMoreDays.bind(this);
|
||||
this.state = {
|
||||
days: props.days,
|
||||
after: props.terms.after,
|
||||
daysLoaded: props.days,
|
||||
afterLoaded: props.terms.after,
|
||||
before: props.terms.before,
|
||||
loading: true,
|
||||
};
|
||||
}
|
||||
|
||||
// intercept prop change and only show more days once data is done loading
|
||||
componentWillReceiveProps(nextProps) {
|
||||
if (nextProps.networkStatus === 2) {
|
||||
this.setState({loading: true});
|
||||
} else {
|
||||
this.setState((prevState, props) => ({
|
||||
loading: false,
|
||||
daysLoaded: prevState.days,
|
||||
afterLoaded: prevState.after,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// return date objects for all the dates in a range
|
||||
getDateRange(after, before) {
|
||||
const mAfter = moment(after, 'YYYY-MM-DD');
|
||||
const mBefore = moment(before, 'YYYY-MM-DD');
|
||||
const daysCount = mBefore.diff(mAfter, 'days') + 1;
|
||||
const range = _.range(daysCount).map(
|
||||
i => moment(before, 'YYYY-MM-DD').subtract(i, 'days').startOf('day')
|
||||
);
|
||||
return range;
|
||||
}
|
||||
|
||||
getDatePosts(posts, date) {
|
||||
return _.filter(posts, post => moment(new Date(post.postedAt)).startOf('day').isSame(date, 'day'));
|
||||
}
|
||||
|
||||
// variant 1: reload everything each time (works with polling)
|
||||
loadMoreDays(e) {
|
||||
e.preventDefault();
|
||||
const numberOfDays = getSetting('numberOfDays', 5);
|
||||
const loadMoreAfter = moment(this.state.after, 'YYYY-MM-DD').subtract(numberOfDays, 'days').format('YYYY-MM-DD');
|
||||
|
||||
this.props.loadMore({
|
||||
...this.props.terms,
|
||||
after: loadMoreAfter,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
days: this.state.days + this.props.increment,
|
||||
after: loadMoreAfter,
|
||||
});
|
||||
}
|
||||
|
||||
// variant 2: only load new data (need to disable polling)
|
||||
loadMoreDaysInc(e) {
|
||||
e.preventDefault();
|
||||
const numberOfDays = getSetting('numberOfDays', 5);
|
||||
const loadMoreAfter = moment(this.state.after, 'YYYY-MM-DD').subtract(numberOfDays, 'days').format('YYYY-MM-DD');
|
||||
const loadMoreBefore = moment(this.state.after, 'YYYY-MM-DD').subtract(1, 'days').format('YYYY-MM-DD');
|
||||
|
||||
this.props.loadMoreInc({
|
||||
...this.props.terms,
|
||||
before: loadMoreBefore,
|
||||
after: loadMoreAfter,
|
||||
});
|
||||
|
||||
this.setState({
|
||||
days: this.state.days + this.props.increment,
|
||||
after: loadMoreAfter,
|
||||
});
|
||||
}
|
||||
|
||||
render() {
|
||||
const posts = this.props.results;
|
||||
const dates = this.getDateRange(this.state.afterLoaded, this.state.before);
|
||||
|
||||
return (
|
||||
<div className="posts-daily">
|
||||
<Components.PostsListHeader />
|
||||
{dates.map((date, index) => <Components.PostsDay key={index} number={index} date={date} posts={this.getDatePosts(posts, date)} networkStatus={this.props.networkStatus} currentUser={this.props.currentUser} />)}
|
||||
{this.state.loading? <Components.PostsLoading /> : <a className="posts-load-more posts-load-more-days" onClick={this.loadMoreDays}><FormattedMessage id="posts.load_more_days"/></a>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PostsDailyList.propTypes = {
|
||||
currentUser: PropTypes.object,
|
||||
days: PropTypes.number,
|
||||
increment: PropTypes.number
|
||||
};
|
||||
|
||||
PostsDailyList.defaultProps = {
|
||||
days: getSetting('numberOfDays', 5),
|
||||
increment: getSetting('numberOfDays', 5)
|
||||
};
|
||||
|
||||
const options = {
|
||||
collection: Posts,
|
||||
queryName: 'postsDailyListQuery',
|
||||
fragmentName: 'PostsList',
|
||||
limit: 0,
|
||||
};
|
||||
|
||||
registerComponent('PostsDailyList', PostsDailyList, withCurrentUser, [withList, options]);
|
32
packages/example-forum/lib/components/posts/PostsDay.jsx
Normal file
32
packages/example-forum/lib/components/posts/PostsDay.jsx
Normal file
|
@ -0,0 +1,32 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
|
||||
class PostsDay extends PureComponent {
|
||||
|
||||
render() {
|
||||
const {date, posts} = this.props;
|
||||
const noPosts = posts.length === 0;
|
||||
|
||||
return (
|
||||
<div className="posts-day">
|
||||
<h4 className="posts-day-heading">{date.format('dddd, MMMM Do YYYY')}</h4>
|
||||
{ noPosts ? <Components.PostsNoMore /> :
|
||||
<div className="posts-list">
|
||||
<div className="posts-list-content">
|
||||
{posts.map((post, index) => <Components.PostsItem post={post} key={post._id} index={index} currentUser={this.props.currentUser} />)}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
PostsDay.propTypes = {
|
||||
currentUser: PropTypes.object,
|
||||
date: PropTypes.object,
|
||||
number: PropTypes.number
|
||||
};
|
||||
|
||||
registerComponent('PostsDay', PostsDay);
|
|
@ -0,0 +1,65 @@
|
|||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Components, registerComponent, getFragment, withMessages, withCurrentUser } from 'meteor/vulcan:core';
|
||||
import { intlShape } from 'meteor/vulcan:i18n';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import Users from "meteor/vulcan:users";
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
class PostsEditForm extends PureComponent {
|
||||
|
||||
renderAdminArea() {
|
||||
return (
|
||||
<Components.ShowIf check={Posts.options.mutations.edit.check} document={this.props.post}>
|
||||
<div className="posts-edit-form-admin">
|
||||
<div className="posts-edit-form-id">ID: {this.props.post._id}</div>
|
||||
<Components.PostsStats post={this.props.post} />
|
||||
</div>
|
||||
</Components.ShowIf>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
return (
|
||||
<div className="posts-edit-form">
|
||||
{Users.isAdmin(this.props.currentUser) ? this.renderAdminArea() : null}
|
||||
<Components.SmartForm
|
||||
collection={Posts}
|
||||
documentId={this.props.post._id}
|
||||
mutationFragment={getFragment('PostsPage')}
|
||||
successCallback={post => {
|
||||
this.props.closeModal();
|
||||
this.props.flash(this.context.intl.formatMessage({ id: 'posts.edit_success' }, { title: post.title }), 'success');
|
||||
}}
|
||||
removeSuccessCallback={({ documentId, documentTitle }) => {
|
||||
// post edit form is being included from a single post, redirect to index
|
||||
// note: this.props.params is in the worst case an empty obj (from react-router)
|
||||
if (this.props.params._id) {
|
||||
this.props.router.push('/');
|
||||
}
|
||||
|
||||
const deleteDocumentSuccess = this.context.intl.formatMessage({ id: 'posts.delete_success' }, { title: documentTitle });
|
||||
this.props.flash(deleteDocumentSuccess, 'success');
|
||||
// todo: handle events in collection callbacks
|
||||
// this.context.events.track("post deleted", {_id: documentId});
|
||||
}}
|
||||
showRemove={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
PostsEditForm.propTypes = {
|
||||
closeModal: PropTypes.func,
|
||||
flash: PropTypes.func,
|
||||
post: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
PostsEditForm.contextTypes = {
|
||||
intl: intlShape
|
||||
}
|
||||
|
||||
registerComponent('PostsEditForm', PostsEditForm, withMessages, withRouter, withCurrentUser);
|
11
packages/example-forum/lib/components/posts/PostsHome.jsx
Normal file
11
packages/example-forum/lib/components/posts/PostsHome.jsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React, { PropTypes, Component } from 'react';
|
||||
|
||||
const PostsHome = (props, context) => {
|
||||
const terms = _.isEmpty(props.location && props.location.query) ? {view: 'top'}: props.location.query;
|
||||
return <Components.PostsList terms={terms}/>
|
||||
};
|
||||
|
||||
PostsHome.displayName = "PostsHome";
|
||||
|
||||
registerComponent('PostsHome', PostsHome);
|
84
packages/example-forum/lib/components/posts/PostsItem.jsx
Normal file
84
packages/example-forum/lib/components/posts/PostsItem.jsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import { Components, registerComponent, ModalTrigger } from 'meteor/vulcan:core';
|
||||
import React, { PureComponent } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import { Link } from 'react-router';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import moment from 'moment';
|
||||
|
||||
class PostsItem extends PureComponent {
|
||||
|
||||
renderCategories() {
|
||||
return this.props.post.categories && this.props.post.categories.length > 0 ? <Components.PostsCategories post={this.props.post} /> : "";
|
||||
}
|
||||
|
||||
renderCommenters() {
|
||||
return this.props.post.commenters && this.props.post.commenters.length > 0 ? <Components.PostsCommenters post={this.props.post}/> : "";
|
||||
}
|
||||
|
||||
renderActions() {
|
||||
return (
|
||||
<div className="posts-actions">
|
||||
<ModalTrigger title="Edit Post" component={<a className="posts-action-edit"><FormattedMessage id="posts.edit"/></a>}>
|
||||
<Components.PostsEditForm post={this.props.post} />
|
||||
</ModalTrigger>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
const {post} = this.props;
|
||||
|
||||
let postClass = "posts-item";
|
||||
if (post.sticky) postClass += " posts-sticky";
|
||||
|
||||
return (
|
||||
<div className={postClass}>
|
||||
|
||||
<div className="posts-item-vote">
|
||||
<Components.Vote collection={Posts} document={post} currentUser={this.props.currentUser}/>
|
||||
</div>
|
||||
|
||||
{post.thumbnailUrl ? <Components.PostsThumbnail post={post}/> : null}
|
||||
|
||||
<div className="posts-item-content">
|
||||
|
||||
<h3 className="posts-item-title">
|
||||
<Link to={Posts.getLink(post)} className="posts-item-title-link" target={Posts.getLinkTarget(post)}>
|
||||
{post.title}
|
||||
</Link>
|
||||
{this.renderCategories()}
|
||||
</h3>
|
||||
|
||||
<div className="posts-item-meta">
|
||||
{post.user? <div className="posts-item-user"><Components.UsersAvatar user={post.user} size="small"/><Components.UsersName user={post.user}/></div> : null}
|
||||
<div className="posts-item-date">{post.postedAt ? moment(new Date(post.postedAt)).fromNow() : <FormattedMessage id="posts.dateNotDefined"/>}</div>
|
||||
<div className="posts-item-comments">
|
||||
<Link to={Posts.getPageUrl(post)}>
|
||||
{!post.commentCount || post.commentCount === 0 ? <FormattedMessage id="comments.count_0"/> :
|
||||
post.commentCount === 1 ? <FormattedMessage id="comments.count_1" /> :
|
||||
<FormattedMessage id="comments.count_2" values={{count: post.commentCount}}/>
|
||||
}
|
||||
</Link>
|
||||
</div>
|
||||
{this.props.currentUser && this.props.currentUser.isAdmin ? <Components.PostsStats post={post} /> : null}
|
||||
{Posts.options.mutations.edit.check(this.props.currentUser, post) ? this.renderActions() : null}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{this.renderCommenters()}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
PostsItem.propTypes = {
|
||||
currentUser: PropTypes.object,
|
||||
post: PropTypes.object.isRequired,
|
||||
terms: PropTypes.object,
|
||||
};
|
||||
|
||||
registerComponent('PostsItem', PostsItem);
|
81
packages/example-forum/lib/components/posts/PostsList.jsx
Normal file
81
packages/example-forum/lib/components/posts/PostsList.jsx
Normal file
|
@ -0,0 +1,81 @@
|
|||
import { Components, registerComponent, withList, withCurrentUser, Utils } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import Alert from 'react-bootstrap/lib/Alert'
|
||||
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const Error = ({error}) => <Alert className="flash-message" bsStyle="danger"><FormattedMessage id={error.id} values={{value: error.value}}/>{error.message}</Alert>
|
||||
|
||||
const PostsList = ({className, results, loading, count, totalCount, loadMore, showHeader = true, showLoadMore = true, networkStatus, currentUser, error, terms}) => {
|
||||
|
||||
const loadingMore = networkStatus === 2;
|
||||
|
||||
if (results && results.length) {
|
||||
|
||||
const hasMore = totalCount > results.length;
|
||||
|
||||
return (
|
||||
<div className={classNames(className, 'posts-list')}>
|
||||
{showHeader ? <Components.PostsListHeader/> : null}
|
||||
{error ? <Error error={Utils.decodeIntlError(error)} /> : null }
|
||||
<div className="posts-list-content">
|
||||
{results.map(post => <Components.PostsItem post={post} key={post._id} currentUser={currentUser} terms={terms} />)}
|
||||
</div>
|
||||
{showLoadMore ?
|
||||
hasMore ?
|
||||
<Components.PostsLoadMore loading={loadingMore} loadMore={loadMore} count={count} totalCount={totalCount} /> :
|
||||
<Components.PostsNoMore/> :
|
||||
null
|
||||
}
|
||||
</div>
|
||||
)
|
||||
} else if (loading) {
|
||||
return (
|
||||
<div className={classNames(className, 'posts-list')}>
|
||||
{showHeader ? <Components.PostsListHeader /> : null}
|
||||
{error ? <Error error={Utils.decodeIntlError(error)} /> : null }
|
||||
<div className="posts-list-content">
|
||||
<Components.PostsLoading/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<div className={classNames(className, 'posts-list')}>
|
||||
{showHeader ? <Components.PostsListHeader /> : null}
|
||||
{error ? <Error error={Utils.decodeIntlError(error)} /> : null }
|
||||
<div className="posts-list-content">
|
||||
<Components.PostsNoResults/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
PostsList.displayName = "PostsList";
|
||||
|
||||
PostsList.propTypes = {
|
||||
results: PropTypes.array,
|
||||
terms: PropTypes.object,
|
||||
hasMore: PropTypes.bool,
|
||||
loading: PropTypes.bool,
|
||||
count: PropTypes.number,
|
||||
totalCount: PropTypes.number,
|
||||
loadMore: PropTypes.func,
|
||||
showHeader: PropTypes.bool,
|
||||
};
|
||||
|
||||
PostsList.contextTypes = {
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
const options = {
|
||||
collection: Posts,
|
||||
queryName: 'postsListQuery',
|
||||
fragmentName: 'PostsList',
|
||||
};
|
||||
|
||||
registerComponent('PostsList', PostsList, withCurrentUser, [withList, options]);
|
|
@ -0,0 +1,21 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
|
||||
const PostsListHeader = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="posts-list-header">
|
||||
<div className="posts-list-header-categories">
|
||||
<Components.CategoriesList />
|
||||
</div>
|
||||
<Components.PostsViews />
|
||||
<Components.SearchForm/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PostsListHeader.displayName = "PostsListHeader";
|
||||
|
||||
registerComponent('PostsListHeader', PostsListHeader);
|
|
@ -0,0 +1,21 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const PostsLoadMore = ({loading, loadMore, count, totalCount}) => {
|
||||
return (
|
||||
<div className={classNames('posts-load-more', {'posts-load-more-loading': loading})}>
|
||||
<a className="posts-load-more-link" href="#" onClick={e => {e.preventDefault(); loadMore();}}>
|
||||
<span><FormattedMessage id="posts.load_more"/></span>
|
||||
|
||||
{totalCount ? <span className="load-more-count">{`(${count}/${totalCount})`}</span> : null}
|
||||
</a>
|
||||
{loading ? <div className="posts-load-more-loader"><Components.Loading/></div> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PostsLoadMore.displayName = "PostsLoadMore";
|
||||
|
||||
registerComponent('PostsLoadMore', PostsLoadMore);
|
10
packages/example-forum/lib/components/posts/PostsLoading.jsx
Normal file
10
packages/example-forum/lib/components/posts/PostsLoading.jsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
|
||||
const PostsLoading = props => {
|
||||
return <div className="posts-load-more-loading"><Components.Loading/></div>
|
||||
};
|
||||
|
||||
PostsLoading.displayName = "PostsLoading";
|
||||
|
||||
registerComponent('PostsLoading', PostsLoading);
|
|
@ -0,0 +1,29 @@
|
|||
import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
|
||||
import Button from 'react-bootstrap/lib/Button';
|
||||
|
||||
const PostsNewButton = (props, context) => {
|
||||
|
||||
const size = props.currentUser ? 'large' : 'small';
|
||||
const button = <Button className="posts-new-button" bsStyle="primary"><Components.Icon name="new"/> <FormattedMessage id="posts.new_post"/></Button>;
|
||||
return (
|
||||
<Components.ModalTrigger size={size} title={context.intl.formatMessage({ id: 'posts.new_post' })} component={button}>
|
||||
<Components.PostsNewForm />
|
||||
</Components.ModalTrigger>
|
||||
)
|
||||
}
|
||||
|
||||
PostsNewButton.displayName = 'PostsNewButton';
|
||||
|
||||
PostsNewButton.propTypes = {
|
||||
currentUser: PropTypes.object,
|
||||
};
|
||||
|
||||
PostsNewButton.contextTypes = {
|
||||
messages: PropTypes.object,
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
registerComponent('PostsNewButton', PostsNewButton, withCurrentUser);
|
40
packages/example-forum/lib/components/posts/PostsNewForm.jsx
Normal file
40
packages/example-forum/lib/components/posts/PostsNewForm.jsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { Components, registerComponent, getRawComponent, getFragment, withMessages } from 'meteor/vulcan:core';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { intlShape, FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import { withRouter } from 'react-router'
|
||||
|
||||
const PostsNewForm = (props, context) =>
|
||||
<Components.ShowIf
|
||||
check={Posts.options.mutations.new.check}
|
||||
failureComponent={<div><p className="posts-new-form-message"><FormattedMessage id="posts.sign_up_or_log_in_first" /></p><Components.AccountsLoginForm /></div>}
|
||||
>
|
||||
<div className="posts-new-form">
|
||||
<Components.SmartForm
|
||||
collection={Posts}
|
||||
mutationFragment={getFragment('PostsPage')}
|
||||
successCallback={post => {
|
||||
props.closeModal();
|
||||
props.router.push({pathname: props.redirect || Posts.getPageUrl(post)});
|
||||
props.flash(context.intl.formatMessage({id: "posts.created_message"}), "success");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Components.ShowIf>
|
||||
|
||||
PostsNewForm.propTypes = {
|
||||
closeModal: React.PropTypes.func,
|
||||
router: React.PropTypes.object,
|
||||
flash: React.PropTypes.func,
|
||||
redirect: React.PropTypes.string,
|
||||
}
|
||||
|
||||
PostsNewForm.contextTypes = {
|
||||
closeCallback: PropTypes.func,
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
PostsNewForm.displayName = "PostsNewForm";
|
||||
|
||||
registerComponent('PostsNewForm', PostsNewForm, withRouter, withMessages);
|
|
@ -0,0 +1,9 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from "react";
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
|
||||
const PostsNoMore = props => <p className="posts-no-more"><FormattedMessage id="posts.no_more"/></p>;
|
||||
|
||||
PostsNoMore.displayName = "PostsNoMore";
|
||||
|
||||
registerComponent('PostsNoMore', PostsNoMore);
|
|
@ -0,0 +1,9 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
|
||||
const PostsNoResults = props => <p className="posts-no-results"><FormattedMessage id="posts.no_results"/></p>;
|
||||
|
||||
PostsNoResults.displayName = "PostsNoResults";
|
||||
|
||||
registerComponent('PostsNoResults', PostsNoResults);
|
110
packages/example-forum/lib/components/posts/PostsPage.jsx
Normal file
110
packages/example-forum/lib/components/posts/PostsPage.jsx
Normal file
|
@ -0,0 +1,110 @@
|
|||
import { Components, registerComponent, withDocument, withCurrentUser, getActions, withMutation } from 'meteor/vulcan:core';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import React, { Component, PropTypes } from 'react';
|
||||
import { connect } from 'react-redux';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
|
||||
class PostsPage extends Component {
|
||||
|
||||
render() {
|
||||
if (this.props.loading) {
|
||||
|
||||
return <div className="posts-page"><Components.Loading/></div>
|
||||
|
||||
} else if (!this.props.document) {
|
||||
|
||||
console.log(`// missing post (_id: ${this.props.documentId})`);
|
||||
return <div className="posts-page"><FormattedMessage id="app.404"/></div>
|
||||
|
||||
} else {
|
||||
const post = this.props.document;
|
||||
|
||||
const htmlBody = {__html: post.htmlBody};
|
||||
|
||||
return (
|
||||
<div className="posts-page">
|
||||
|
||||
<Components.HeadTags url={Posts.getPageUrl(post, true)} title={post.title} image={post.thumbnailUrl} description={post.excerpt} />
|
||||
|
||||
<Components.PostsItem post={post} currentUser={this.props.currentUser} />
|
||||
|
||||
{post.htmlBody ? <div className="posts-page-body" dangerouslySetInnerHTML={htmlBody}></div> : null}
|
||||
|
||||
<Components.PostsCommentsThread terms={{postId: post._id, view: 'postComments'}} />
|
||||
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// triggered after the component did mount on the client
|
||||
async componentDidMount() {
|
||||
try {
|
||||
|
||||
// destructure the relevant props
|
||||
const {
|
||||
// from the parent component, used in withDocument, GraphQL HOC
|
||||
documentId,
|
||||
// from connect, Redux HOC
|
||||
setViewed,
|
||||
postsViewed,
|
||||
// from withMutation, GraphQL HOC
|
||||
increasePostViewCount,
|
||||
} = this.props;
|
||||
|
||||
// a post id has been found & it's has not been seen yet on this client session
|
||||
if (documentId && !postsViewed.includes(documentId)) {
|
||||
|
||||
// trigger the asynchronous mutation with postId as an argument
|
||||
await increasePostViewCount({postId: documentId});
|
||||
|
||||
// once the mutation is done, update the redux store
|
||||
setViewed(documentId);
|
||||
}
|
||||
|
||||
} catch(error) {
|
||||
console.log(error); // eslint-disable-line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PostsPage.displayName = "PostsPage";
|
||||
|
||||
PostsPage.propTypes = {
|
||||
documentId: PropTypes.string,
|
||||
document: PropTypes.object,
|
||||
postsViewed: PropTypes.array,
|
||||
setViewed: PropTypes.func,
|
||||
increasePostViewCount: PropTypes.func,
|
||||
}
|
||||
|
||||
const queryOptions = {
|
||||
collection: Posts,
|
||||
queryName: 'postsSingleQuery',
|
||||
fragmentName: 'PostsPage',
|
||||
};
|
||||
|
||||
const mutationOptions = {
|
||||
name: 'increasePostViewCount',
|
||||
args: {postId: 'String'},
|
||||
};
|
||||
|
||||
const mapStateToProps = state => ({ postsViewed: state.postsViewed });
|
||||
const mapDispatchToProps = dispatch => bindActionCreators(getActions().postsViewed, dispatch);
|
||||
|
||||
registerComponent(
|
||||
// component name used by Vulcan
|
||||
'PostsPage',
|
||||
// React component
|
||||
PostsPage,
|
||||
// HOC to give access to the current user
|
||||
withCurrentUser,
|
||||
// HOC to load the data of the document, based on queryOptions & a documentId props
|
||||
[withDocument, queryOptions],
|
||||
// HOC to provide a single mutation, based on mutationOptions
|
||||
withMutation(mutationOptions),
|
||||
// HOC to give access to the redux store & related actions
|
||||
connect(mapStateToProps, mapDispatchToProps)
|
||||
);
|
10
packages/example-forum/lib/components/posts/PostsSingle.jsx
Normal file
10
packages/example-forum/lib/components/posts/PostsSingle.jsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
|
||||
const PostsSingle = (props, context) => {
|
||||
return <Components.PostsPage documentId={props.params._id} />
|
||||
};
|
||||
|
||||
PostsSingle.displayName = "PostsSingle";
|
||||
|
||||
registerComponent('PostsSingle', PostsSingle);
|
18
packages/example-forum/lib/components/posts/PostsStats.jsx
Normal file
18
packages/example-forum/lib/components/posts/PostsStats.jsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
|
||||
const PostsStats = ({post}) => {
|
||||
|
||||
return (
|
||||
<div className="posts-stats">
|
||||
{post.score ? <span className="posts-stats-item" title="Score"><Components.Icon name="score"/> {Math.floor(post.score*10000)/10000} <span className="sr-only">Score</span></span> : ""}
|
||||
<span className="posts-stats-item" title="Upvotes"><Components.Icon name="upvote"/> {post.upvotes} <span className="sr-only">Upvotes</span></span>
|
||||
<span className="posts-stats-item" title="Clicks"><Components.Icon name="clicks"/> {post.clickCount} <span className="sr-only">Clicks</span></span>
|
||||
<span className="posts-stats-item" title="Views"><Components.Icon name="views"/> {post.viewCount} <span className="sr-only">Views</span></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PostsStats.displayName = "PostsStats";
|
||||
|
||||
registerComponent('PostsStats', PostsStats);
|
|
@ -0,0 +1,12 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
|
||||
const PostsThumbnail = ({post}) =>
|
||||
<a className="posts-thumbnail" href={Posts.getLink(post)} target={Posts.getLinkTarget(post)}>
|
||||
<span><img src={Posts.getThumbnailUrl(post)} /></span>
|
||||
</a>
|
||||
|
||||
PostsThumbnail.displayName = "PostsThumbnail";
|
||||
|
||||
registerComponent('PostsThumbnail', PostsThumbnail);
|
63
packages/example-forum/lib/components/posts/PostsViews.jsx
Normal file
63
packages/example-forum/lib/components/posts/PostsViews.jsx
Normal file
|
@ -0,0 +1,63 @@
|
|||
import { registerComponent, withCurrentUser } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
|
||||
import DropdownButton from 'react-bootstrap/lib/DropdownButton';
|
||||
import MenuItem from 'react-bootstrap/lib/MenuItem';
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import { withRouter } from 'react-router'
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
const PostsViews = (props, context) => {
|
||||
|
||||
let views = ["top", "new", "best"];
|
||||
const adminViews = ["pending", "rejected", "scheduled"];
|
||||
|
||||
if (Users.canDo(props.currentUser, "posts.edit.all")) {
|
||||
views = views.concat(adminViews);
|
||||
}
|
||||
|
||||
const query = _.clone(props.router.location.query);
|
||||
|
||||
return (
|
||||
<div className="posts-views">
|
||||
<DropdownButton
|
||||
bsStyle="default"
|
||||
className="views btn-secondary"
|
||||
title={context.intl.formatMessage({id: "posts.view"})}
|
||||
id="views-dropdown"
|
||||
>
|
||||
{views.map(view =>
|
||||
<LinkContainer key={view} to={{pathname: "/", query: {...query, view: view}}} className="dropdown-item">
|
||||
<MenuItem>
|
||||
<FormattedMessage id={"posts."+view}/>
|
||||
</MenuItem>
|
||||
</LinkContainer>
|
||||
)}
|
||||
<LinkContainer to={"/daily"} className="dropdown-item">
|
||||
<MenuItem className={"bar"}>
|
||||
<FormattedMessage id="posts.daily"/>
|
||||
</MenuItem>
|
||||
</LinkContainer>
|
||||
</DropdownButton>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
PostsViews.propTypes = {
|
||||
currentUser: PropTypes.object,
|
||||
defaultView: PropTypes.string
|
||||
};
|
||||
|
||||
PostsViews.defaultProps = {
|
||||
defaultView: "top"
|
||||
};
|
||||
|
||||
PostsViews.contextTypes = {
|
||||
currentRoute: PropTypes.object,
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
PostsViews.displayName = "PostsViews";
|
||||
|
||||
registerComponent('PostsViews', PostsViews, withCurrentUser, withRouter);
|
17
packages/example-forum/lib/components/users/UsersAccount.jsx
Normal file
17
packages/example-forum/lib/components/users/UsersAccount.jsx
Normal file
|
@ -0,0 +1,17 @@
|
|||
import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const UsersAccount = (props, /* context*/) => {
|
||||
// note: terms is as the same as a document-shape the SmartForm edit-mode expects to receive
|
||||
const terms = props.params.slug ? { slug: props.params.slug } : props.currentUser ? { documentId: props.currentUser._id } : {};
|
||||
return <Components.UsersEditForm terms={terms} />
|
||||
};
|
||||
|
||||
UsersAccount.propTypes = {
|
||||
currentUser: PropTypes.object
|
||||
};
|
||||
|
||||
UsersAccount.displayName = 'UsersAccount';
|
||||
|
||||
registerComponent('UsersAccount', UsersAccount, withCurrentUser);
|
|
@ -0,0 +1,21 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React, { PropTypes, Component } from 'react';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import Dropdown from 'react-bootstrap/lib/Dropdown';
|
||||
import { STATES } from 'meteor/vulcan:accounts';
|
||||
|
||||
const UsersAccountMenu = ({state}) =>
|
||||
|
||||
<Dropdown id="accounts-dropdown" className="users-account-menu">
|
||||
<Dropdown.Toggle>
|
||||
<Components.Icon name="user"/>
|
||||
<FormattedMessage id="users.sign_up_log_in"/>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<Components.AccountsLoginForm formState={state? STATES[state] : STATES.SIGN_UP} />
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
|
||||
UsersAccountMenu.displayName = "UsersAccountMenu";
|
||||
|
||||
registerComponent('UsersAccountMenu', UsersAccountMenu);
|
43
packages/example-forum/lib/components/users/UsersAvatar.jsx
Normal file
43
packages/example-forum/lib/components/users/UsersAvatar.jsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { Link } from 'react-router';
|
||||
import classNames from 'classnames';
|
||||
|
||||
const UsersAvatar = ({className, user, link}) => {
|
||||
|
||||
const avatarUrl = user.avatarUrl || Users.avatar.getUrl(user);
|
||||
|
||||
const img = <img alt={Users.getDisplayName(user)} className="avatar-image" src={avatarUrl} title={user.username}/>;
|
||||
const initials = <span className="avatar-initials"><span>{Users.avatar.getInitials(user)}</span></span>;
|
||||
|
||||
const avatar = avatarUrl ? img : initials;
|
||||
|
||||
return (
|
||||
<div className={classNames('avatar', className)}>
|
||||
{link ?
|
||||
<Link to={Users.getProfileUrl(user)}>
|
||||
<span>{avatar}</span>
|
||||
</Link>
|
||||
: <span>{avatar}</span>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
|
||||
}
|
||||
|
||||
UsersAvatar.propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
size: PropTypes.string,
|
||||
link: PropTypes.bool
|
||||
}
|
||||
|
||||
UsersAvatar.defaultProps = {
|
||||
size: 'medium',
|
||||
link: true
|
||||
}
|
||||
|
||||
UsersAvatar.displayName = 'UsersAvatar';
|
||||
|
||||
registerComponent('UsersAvatar', UsersAvatar);
|
|
@ -0,0 +1,48 @@
|
|||
import { Components, registerComponent, withCurrentUser, withMessages } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { STATES } from 'meteor/vulcan:accounts';
|
||||
|
||||
const UsersEditForm = (props, context) => {
|
||||
return (
|
||||
<Components.ShowIf
|
||||
check={Users.options.mutations.edit.check}
|
||||
document={props.terms.documentId ? {_id: props.terms.documentId} : {slug: props.terms.slug}}
|
||||
failureComponent={<FormattedMessage id="app.noPermission"/>}
|
||||
>
|
||||
<div className="page users-edit-form">
|
||||
<h2 className="page-title users-edit-form-title"><FormattedMessage id="users.edit_account"/></h2>
|
||||
|
||||
<div className="change-password-link">
|
||||
<Components.ModalTrigger size="small" title={context.intl.formatMessage({id: "accounts.change_password"})} component={<a href="#"><FormattedMessage id="accounts.change_password" /></a>}>
|
||||
<Components.AccountsLoginForm formState={STATES.PASSWORD_CHANGE} />
|
||||
</Components.ModalTrigger>
|
||||
</div>
|
||||
|
||||
<Components.SmartForm
|
||||
collection={Users}
|
||||
{...props.terms}
|
||||
successCallback={user => {
|
||||
props.flash(context.intl.formatMessage({ id: 'users.edit_success' }, {name: Users.getDisplayName(user)}), 'success')
|
||||
}}
|
||||
showRemove={true}
|
||||
/>
|
||||
</div>
|
||||
</Components.ShowIf>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
UsersEditForm.propTypes = {
|
||||
terms: PropTypes.object, // a user is defined by its unique _id or its unique slug
|
||||
};
|
||||
|
||||
UsersEditForm.contextTypes = {
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
UsersEditForm.displayName = 'UsersEditForm';
|
||||
|
||||
registerComponent('UsersEditForm', UsersEditForm, withMessages, withCurrentUser);
|
41
packages/example-forum/lib/components/users/UsersMenu.jsx
Normal file
41
packages/example-forum/lib/components/users/UsersMenu.jsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import Dropdown from 'react-bootstrap/lib/Dropdown';
|
||||
import MenuItem from 'react-bootstrap/lib/MenuItem';
|
||||
import { LinkContainer } from 'react-router-bootstrap';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { withApollo } from 'react-apollo';
|
||||
|
||||
const UsersMenu = ({currentUser, client}) =>
|
||||
<div className="users-menu">
|
||||
<Dropdown id="user-dropdown">
|
||||
<Dropdown.Toggle>
|
||||
<Components.UsersAvatar size="small" user={currentUser} addLink={false} />
|
||||
<div className="users-menu-name">{Users.getDisplayName(currentUser)}</div>
|
||||
</Dropdown.Toggle>
|
||||
<Dropdown.Menu>
|
||||
<LinkContainer to={`/users/${currentUser.slug}`}>
|
||||
<MenuItem className="dropdown-item" eventKey="1"><FormattedMessage id="users.profile"/></MenuItem>
|
||||
</LinkContainer>
|
||||
<LinkContainer to={`/account`}>
|
||||
<MenuItem className="dropdown-item" eventKey="2"><FormattedMessage id="users.edit_account"/></MenuItem>
|
||||
</LinkContainer>
|
||||
|
||||
<LinkContainer to={`/admin`}>
|
||||
<MenuItem className="dropdown-item" eventKey="2">Admin</MenuItem>
|
||||
</LinkContainer>
|
||||
<MenuItem className="dropdown-item" eventKey="4" onClick={() => Meteor.logout(() => client.resetStore())}><FormattedMessage id="users.log_out"/></MenuItem>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
</div>
|
||||
|
||||
|
||||
UsersMenu.propsTypes = {
|
||||
currentUser: PropTypes.object,
|
||||
client: PropTypes.object,
|
||||
};
|
||||
|
||||
registerComponent('UsersMenu', UsersMenu, withCurrentUser, withApollo);
|
15
packages/example-forum/lib/components/users/UsersName.jsx
Normal file
15
packages/example-forum/lib/components/users/UsersName.jsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import { registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const UsersName = ({user}) => <Link className="users-name" to={Users.getProfileUrl(user)}>{Users.getDisplayName(user)}</Link>
|
||||
|
||||
UsersName.propTypes = {
|
||||
user: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
UsersName.displayName = 'UsersName';
|
||||
|
||||
registerComponent('UsersName', UsersName);
|
54
packages/example-forum/lib/components/users/UsersProfile.jsx
Normal file
54
packages/example-forum/lib/components/users/UsersProfile.jsx
Normal file
|
@ -0,0 +1,54 @@
|
|||
import { Components, registerComponent, withDocument, withCurrentUser } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
import { FormattedMessage } from 'meteor/vulcan:i18n';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { Link } from 'react-router';
|
||||
|
||||
const UsersProfile = (props) => {
|
||||
if (props.loading) {
|
||||
|
||||
return <div className="page users-profile"><Components.Loading/></div>
|
||||
|
||||
} else if (!props.document) {
|
||||
|
||||
console.log(`// missing user (_id/slug: ${props.documentId || props.slug})`);
|
||||
return <div className="page users-profile"><FormattedMessage id="app.404"/></div>
|
||||
|
||||
} else {
|
||||
|
||||
const user = props.document;
|
||||
|
||||
const terms = {view: "userPosts", userId: user._id};
|
||||
|
||||
return (
|
||||
<div className="page users-profile">
|
||||
<Components.HeadTags url={Users.getProfileUrl(user, true)} title={Users.getDisplayName(user)} />
|
||||
<h2 className="page-title">{Users.getDisplayName(user)}</h2>
|
||||
{user.htmlBio ? <div dangerouslySetInnerHTML={{__html: user.htmlBio}}></div> : null }
|
||||
<ul>
|
||||
{user.twitterUsername ? <li><a href={"http://twitter.com/" + user.twitterUsername}>@{user.twitterUsername}</a></li> : null }
|
||||
{user.website ? <li><a href={user.website}>{user.website}</a></li> : null }
|
||||
<Components.ShowIf check={Users.options.mutations.edit.check} document={user}>
|
||||
<li><Link to={Users.getEditUrl(user)}><FormattedMessage id="users.edit_account"/></Link></li>
|
||||
</Components.ShowIf>
|
||||
</ul>
|
||||
<h3><FormattedMessage id="users.posts"/></h3>
|
||||
<Components.PostsList terms={terms} showHeader={false} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
UsersProfile.propTypes = {
|
||||
// document: PropTypes.object.isRequired,
|
||||
}
|
||||
|
||||
UsersProfile.displayName = "UsersProfile";
|
||||
|
||||
const options = {
|
||||
collection: Users,
|
||||
queryName: 'usersSingleQuery',
|
||||
fragmentName: 'UsersProfile',
|
||||
};
|
||||
|
||||
registerComponent('UsersProfile', UsersProfile, withCurrentUser, [withDocument, options]);
|
|
@ -0,0 +1,84 @@
|
|||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import Modal from 'react-bootstrap/lib/Modal'
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { withDocument, Components, registerComponent, withMessages } from 'meteor/vulcan:core';
|
||||
import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n';
|
||||
import { gql } from 'react-apollo';
|
||||
|
||||
const UsersProfileCheck = ({currentUser, document, loading, flash}, context) => {
|
||||
|
||||
// we're loading all fields marked as "mustComplete" using withDocument
|
||||
const userMustCompleteFields = document;
|
||||
|
||||
// if user is not logged in, or userMustCompleteFields is still loading, don't return anything
|
||||
if (!currentUser || loading) {
|
||||
|
||||
return null;
|
||||
|
||||
} else {
|
||||
|
||||
// return fields that are required by the schema but haven't been filled out yet
|
||||
const fieldsToComplete = _.filter(Users.getRequiredFields(), fieldName => {
|
||||
return !userMustCompleteFields[fieldName];
|
||||
});
|
||||
|
||||
if (fieldsToComplete.length > 0) {
|
||||
|
||||
return (
|
||||
<Modal bsSize='small' show={ true }>
|
||||
<Modal.Header>
|
||||
<Modal.Title><FormattedMessage id="users.complete_profile"/></Modal.Title>
|
||||
</Modal.Header>
|
||||
<Modal.Body>
|
||||
<Components.SmartForm
|
||||
collection={ Users }
|
||||
documentId={ currentUser._id }
|
||||
fields={ fieldsToComplete }
|
||||
successCallback={user => {
|
||||
const newUser = {...currentUser, ...user};
|
||||
if (Users.hasCompletedProfile(newUser)) {
|
||||
flash(context.intl.formatMessage({id: "users.profile_completed"}), 'success');
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<FormattedMessage id="app.or"/> <a className="complete-profile-logout" onClick={ () => Meteor.logout(() => window.location.reload() /* something is broken here when giving the apollo client as a prop*/) }><FormattedMessage id="users.log_out"/></a>
|
||||
</Modal.Footer>
|
||||
</Modal>
|
||||
)
|
||||
} else {
|
||||
|
||||
return null;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
|
||||
UsersProfileCheck.propTypes = {
|
||||
currentUser: PropTypes.object
|
||||
};
|
||||
|
||||
UsersProfileCheck.contextTypes = {
|
||||
intl: intlShape
|
||||
};
|
||||
|
||||
UsersProfileCheck.displayName = 'UsersProfileCheck';
|
||||
|
||||
const mustCompleteFragment = gql`
|
||||
fragment UsersMustCompleteFragment on User {
|
||||
_id
|
||||
${Users.getRequiredFields().join('\n')}
|
||||
}
|
||||
`
|
||||
|
||||
const options = {
|
||||
collection: Users,
|
||||
queryName: 'usersMustCompleteQuery',
|
||||
fragment: mustCompleteFragment,
|
||||
};
|
||||
|
||||
registerComponent('UsersProfileCheck', UsersProfileCheck, withMessages, [withDocument, options]);
|
10
packages/example-forum/lib/components/users/UsersSingle.jsx
Normal file
10
packages/example-forum/lib/components/users/UsersSingle.jsx
Normal file
|
@ -0,0 +1,10 @@
|
|||
import { Components, registerComponent } from 'meteor/vulcan:core';
|
||||
import React from 'react';
|
||||
|
||||
const UsersSingle = (props, context) => {
|
||||
return <Components.UsersProfile userId={props.params._id} slug={props.params.slug} />
|
||||
};
|
||||
|
||||
UsersSingle.displayName = "UsersSingle";
|
||||
|
||||
registerComponent('UsersSingle', UsersSingle);
|
101
packages/example-forum/lib/modules/categories/callbacks.js
Normal file
101
packages/example-forum/lib/modules/categories/callbacks.js
Normal file
|
@ -0,0 +1,101 @@
|
|||
/*
|
||||
|
||||
|
||||
Callbacks to validate categories and generate category slugs
|
||||
|
||||
*/
|
||||
|
||||
import { addCallback, Utils } from 'meteor/vulcan:core';
|
||||
import Posts from '../posts/collection.js';
|
||||
import Categories from './collection.js';
|
||||
|
||||
// add callback that adds categories CSS classes
|
||||
function addCategoryClass (postClass, post) {
|
||||
var classArray = _.map(Posts.getCategories(post), function (category){return "category-"+category.slug;});
|
||||
return postClass + " " + classArray.join(' ');
|
||||
}
|
||||
addCallback("postClass", addCategoryClass);
|
||||
|
||||
// ------- Categories Check -------- //
|
||||
|
||||
// make sure all categories in the post.categories array exist in the db
|
||||
var checkCategories = function (post) {
|
||||
|
||||
// if there are no categories, stop here
|
||||
if (!post.categories || post.categories.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check how many of the categories given also exist in the db
|
||||
var categoryCount = Categories.find({_id: {$in: post.categories}}).count();
|
||||
|
||||
if (post.categories.length !== categoryCount) {
|
||||
throw new Error({id: 'categories.invalid'});
|
||||
}
|
||||
};
|
||||
|
||||
function postsNewCheckCategories (post) {
|
||||
checkCategories(post);
|
||||
return post;
|
||||
}
|
||||
addCallback("posts.new.sync", postsNewCheckCategories);
|
||||
|
||||
function postEditCheckCategories (modifier) {
|
||||
checkCategories(modifier.$set);
|
||||
return modifier;
|
||||
}
|
||||
addCallback("posts.edit.sync", postEditCheckCategories);
|
||||
|
||||
function categoriesNewGenerateSlug (category) {
|
||||
// if no slug has been provided, generate one
|
||||
const slug = category.slug || Utils.slugify(category.name);
|
||||
category.slug = Utils.getUnusedSlug(Categories, slug);
|
||||
return category;
|
||||
}
|
||||
addCallback("categories.new.sync", categoriesNewGenerateSlug);
|
||||
|
||||
function categoriesEditGenerateSlug (modifier, document) {
|
||||
// if slug is changing
|
||||
if (modifier.$set && modifier.$set.slug && modifier.$set.slug !== document.slug) {
|
||||
const slug = modifier.$set.slug;
|
||||
modifier.$set.slug = Utils.getUnusedSlug(Categories, slug);
|
||||
}
|
||||
return modifier;
|
||||
}
|
||||
addCallback("categories.edit.sync", categoriesEditGenerateSlug);
|
||||
|
||||
// TODO: debug this
|
||||
|
||||
// function addParentCategoriesOnSubmit (post) {
|
||||
// var categories = post.categories;
|
||||
// var newCategories = [];
|
||||
// if (categories) {
|
||||
// categories.forEach(function (categoryId) {
|
||||
// var category = Categories.findOne(categoryId);
|
||||
// newCategories = newCategories.concat(_.pluck(category.getParents().reverse(), "_id"));
|
||||
// newCategories.push(category._id);
|
||||
// });
|
||||
// }
|
||||
// post.categories = _.unique(newCategories);
|
||||
// return post;
|
||||
// }
|
||||
// addCallback("posts.new.sync", addParentCategoriesOnSubmit);
|
||||
|
||||
// function addParentCategoriesOnEdit (modifier, post) {
|
||||
// if (modifier.$unset && modifier.$unset.categories !== undefined) {
|
||||
// return modifier;
|
||||
// }
|
||||
|
||||
// var categories = modifier.$set.categories;
|
||||
// var newCategories = [];
|
||||
// if (categories) {
|
||||
// categories.forEach(function (categoryId) {
|
||||
// var category = Categories.findOne(categoryId);
|
||||
// newCategories = newCategories.concat(_.pluck(category.getParents().reverse(), "_id"));
|
||||
// newCategories.push(category._id);
|
||||
// });
|
||||
// }
|
||||
// modifier.$set.categories = _.unique(newCategories);
|
||||
// return modifier;
|
||||
// }
|
||||
// addCallback("posts.edit.sync", addParentCategoriesOnEdit);
|
27
packages/example-forum/lib/modules/categories/collection.js
Normal file
27
packages/example-forum/lib/modules/categories/collection.js
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
|
||||
The Categories collection
|
||||
|
||||
*/
|
||||
|
||||
import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core';
|
||||
import schema from './schema.js';
|
||||
|
||||
/**
|
||||
* @summary The global namespace for Categories.
|
||||
* @namespace Categories
|
||||
*/
|
||||
const Categories = createCollection({
|
||||
|
||||
collectionName: 'Categories',
|
||||
|
||||
typeName: 'Category',
|
||||
|
||||
schema,
|
||||
|
||||
resolvers: getDefaultResolvers('Categories'),
|
||||
|
||||
mutations: getDefaultMutations('Categories'),
|
||||
|
||||
});
|
||||
export default Categories;
|
|
@ -0,0 +1,44 @@
|
|||
/*
|
||||
|
||||
Custom fields on Posts collection
|
||||
|
||||
*/
|
||||
|
||||
import Posts from '../../modules/posts/index.js';
|
||||
import { getCategoriesAsOptions } from './schema.js';
|
||||
|
||||
Posts.addField([
|
||||
{
|
||||
fieldName: 'categories',
|
||||
fieldSchema: {
|
||||
type: Array,
|
||||
control: 'checkboxgroup',
|
||||
optional: true,
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
viewableBy: ['guests'],
|
||||
form: {
|
||||
noselect: true,
|
||||
type: 'bootstrap-category',
|
||||
order: 50,
|
||||
options: formProps => getCategoriesAsOptions(formProps.client),
|
||||
},
|
||||
resolveAs: {
|
||||
fieldName: 'categories',
|
||||
type: '[Category]',
|
||||
resolver: async (post, args, {currentUser, Users, Categories}) => {
|
||||
if (!post.categories) return [];
|
||||
const categories = _.compact(await Categories.loader.loadMany(post.categories));
|
||||
return Users.restrictViewableFields(currentUser, Categories, categories);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldName: 'categories.$',
|
||||
fieldSchema: {
|
||||
type: String,
|
||||
optional: true
|
||||
}
|
||||
}
|
||||
]);
|
25
packages/example-forum/lib/modules/categories/fragments.js
Normal file
25
packages/example-forum/lib/modules/categories/fragments.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { registerFragment } from 'meteor/vulcan:core';
|
||||
|
||||
// note: fragment used by default on CategoriesList & PostsList fragments
|
||||
registerFragment(`
|
||||
fragment CategoriesMinimumInfo on Category {
|
||||
# vulcan:categories
|
||||
_id
|
||||
name
|
||||
slug
|
||||
}
|
||||
`);
|
||||
|
||||
registerFragment(`
|
||||
fragment CategoriesList on Category {
|
||||
# vulcan:categories
|
||||
...CategoriesMinimumInfo
|
||||
description
|
||||
order
|
||||
image
|
||||
parentId
|
||||
parent {
|
||||
...CategoriesMinimumInfo
|
||||
}
|
||||
}
|
||||
`);
|
67
packages/example-forum/lib/modules/categories/helpers.js
Normal file
67
packages/example-forum/lib/modules/categories/helpers.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
import Posts from '../posts/index.js';
|
||||
import Categories from './collection.js';
|
||||
import { Utils } from 'meteor/vulcan:core';
|
||||
|
||||
/**
|
||||
* @summary Get all of a category's parents
|
||||
* @param {Object} category
|
||||
*/
|
||||
Categories.getParents = function (category, store) {
|
||||
const categoriesArray = [];
|
||||
const getParents = function recurse (category) {
|
||||
if (category && category.parentId) {
|
||||
const parent = store ? Categories.findOneInStore(store, category.parentId) : Categories.findOne(category.parentId);
|
||||
if (parent) {
|
||||
categoriesArray.push(parent);
|
||||
recurse(parent);
|
||||
}
|
||||
}
|
||||
};
|
||||
getParents(category);
|
||||
|
||||
return categoriesArray;
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get all of a category's children
|
||||
* @param {Object} category
|
||||
*/
|
||||
Categories.getChildren = function (category) {
|
||||
var categoriesArray = [];
|
||||
|
||||
var getChildren = function recurse (categories) {
|
||||
var children = Categories.find({parentId: {$in: _.pluck(categories, '_id')}}).fetch()
|
||||
if (children.length > 0) {
|
||||
categoriesArray = categoriesArray.concat(children);
|
||||
recurse(children);
|
||||
}
|
||||
};
|
||||
getChildren([category]);
|
||||
|
||||
return categoriesArray;
|
||||
};
|
||||
/**
|
||||
* @summary Get all of a post's categories
|
||||
* @param {Object} post
|
||||
*/
|
||||
Posts.getCategories = function (post) {
|
||||
return !!post.categories ? Categories.find({_id: {$in: post.categories}}).fetch() : [];
|
||||
};
|
||||
/**
|
||||
* @summary Get a category's URL
|
||||
* @param {Object} category
|
||||
*/
|
||||
Categories.getUrl = function (category, isAbsolute) {
|
||||
isAbsolute = typeof isAbsolute === 'undefined' ? false : isAbsolute; // default to false
|
||||
const prefix = isAbsolute ? Utils.getSiteUrl().slice(0,-1) : '';
|
||||
// return prefix + FlowRouter.path('postsCategory', category);
|
||||
return `${prefix}/?cat=${category.slug}`;
|
||||
};
|
||||
/**
|
||||
* @summary Get a category's counter name
|
||||
* @param {Object} category
|
||||
*/
|
||||
Categories.getCounterName = function (category) {
|
||||
return category._id + '-postsCount';
|
||||
}
|
||||
|
7
packages/example-forum/lib/modules/categories/index.js
Normal file
7
packages/example-forum/lib/modules/categories/index.js
Normal file
|
@ -0,0 +1,7 @@
|
|||
export * from './collection.js';
|
||||
|
||||
import './fragments.js';
|
||||
import './custom_fields.js';
|
||||
import './helpers.js';
|
||||
import './permissions.js';
|
||||
import './parameters.js';
|
58
packages/example-forum/lib/modules/categories/parameters.js
Normal file
58
packages/example-forum/lib/modules/categories/parameters.js
Normal file
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
|
||||
Categories parameter
|
||||
|
||||
*/
|
||||
|
||||
import Categories from './index.js';
|
||||
import { addCallback, getSetting } from 'meteor/vulcan:core';
|
||||
import { getCategories } from './schema.js';
|
||||
|
||||
// Category Default Sorting by Ascending order (1, 2, 3..)
|
||||
function CategoriesAscOrderSorting(parameters, terms) {
|
||||
parameters.options.sort = {order: 1};
|
||||
|
||||
return parameters;
|
||||
}
|
||||
|
||||
addCallback('categories.parameters', CategoriesAscOrderSorting);
|
||||
|
||||
// Category Posts Parameters
|
||||
// Add a "categories" property to terms which can be used to filter *all* existing Posts views.
|
||||
function PostsCategoryParameter(parameters, terms, apolloClient) {
|
||||
|
||||
const cat = terms.cat || terms["cat[]"];
|
||||
// filter by category if category slugs are provided
|
||||
if (cat) {
|
||||
|
||||
let categoriesIds = [];
|
||||
let selector = {};
|
||||
let slugs;
|
||||
|
||||
if (typeof cat === "string") { // cat is a string
|
||||
selector = {slug: cat};
|
||||
slugs = [cat];
|
||||
} else if (Array.isArray(cat)) { // cat is an array
|
||||
selector = {slug: {$in: cat}};
|
||||
slugs = cat;
|
||||
}
|
||||
|
||||
// TODO: use new Apollo imperative API
|
||||
// get all categories passed in terms
|
||||
const categories = !!apolloClient ? _.filter(getCategories(apolloClient), category => _.contains(slugs, category.slug) ) : Categories.find(selector).fetch();
|
||||
|
||||
// for each category, add its ID and the IDs of its children to categoriesId array
|
||||
categories.forEach(function (category) {
|
||||
categoriesIds.push(category._id);
|
||||
// TODO: find a better way to handle child categories
|
||||
// categoriesIds = categoriesIds.concat(_.pluck(Categories.getChildren(category), "_id"));
|
||||
});
|
||||
|
||||
const operator = getSetting('categoriesFilter', 'union') === 'union' ? '$in' : '$all';
|
||||
|
||||
parameters.selector = Meteor.isClient ? {...parameters.selector, 'categories._id': {$in: categoriesIds}} : {...parameters.selector, categories: {[operator]: categoriesIds}};
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
addCallback("posts.parameters", PostsCategoryParameter);
|
25
packages/example-forum/lib/modules/categories/permissions.js
Normal file
25
packages/example-forum/lib/modules/categories/permissions.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
|
||||
Categories permissions
|
||||
|
||||
*/
|
||||
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
const guestsActions = [
|
||||
'categories.view'
|
||||
];
|
||||
Users.groups.guests.can(guestsActions);
|
||||
|
||||
const membersActions = [
|
||||
'categories.view'
|
||||
];
|
||||
Users.groups.members.can(membersActions);
|
||||
|
||||
const adminActions = [
|
||||
'categories.view',
|
||||
'categories.new',
|
||||
'categories.edit.all',
|
||||
'categories.remove.all'
|
||||
];
|
||||
Users.groups.admins.can(adminActions);
|
118
packages/example-forum/lib/modules/categories/schema.js
Normal file
118
packages/example-forum/lib/modules/categories/schema.js
Normal file
|
@ -0,0 +1,118 @@
|
|||
/*
|
||||
|
||||
Categories schema
|
||||
|
||||
*/
|
||||
|
||||
import { Utils } from 'meteor/vulcan:core';
|
||||
|
||||
export function getCategories (apolloClient) {
|
||||
|
||||
// get the current data of the store
|
||||
const apolloData = apolloClient.store.getState().apollo.data;
|
||||
|
||||
// filter these data based on their typename: we are interested in the categories data
|
||||
let categories = _.filter(apolloData, (object, key) => {
|
||||
return object.__typename === 'Category'
|
||||
});
|
||||
|
||||
// order categories
|
||||
categories = _.sortBy(categories, cat => cat.order);
|
||||
|
||||
return categories;
|
||||
}
|
||||
|
||||
export function getCategoriesAsOptions (apolloClient) {
|
||||
// give the form component (here: checkboxgroup) exploitable data
|
||||
return getCategories(apolloClient).map(function (category) {
|
||||
return {
|
||||
value: category._id,
|
||||
label: category.name,
|
||||
// slug: category.slug, // note: it may be used to look up from prefilled props
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
export function getCategoriesAsNestedOptions (apolloClient) {
|
||||
// give the form component (here: checkboxgroup) exploitable data
|
||||
const formattedCategories = getCategories(apolloClient).map(function (category) {
|
||||
return {
|
||||
value: category._id,
|
||||
label: category.name,
|
||||
parentId: category.parentId,
|
||||
_id: category._id
|
||||
// slug: category.slug, // note: it may be used to look up from prefilled props
|
||||
};
|
||||
});
|
||||
const nestedCategories = Utils.unflatten(formattedCategories, {idProperty: '_id', parentIdProperty: 'parentId', childrenProperty: 'options'});
|
||||
return nestedCategories;
|
||||
}
|
||||
|
||||
// category schema
|
||||
const schema = {
|
||||
_id: {
|
||||
type: String,
|
||||
viewableBy: ['guests'],
|
||||
optional: true,
|
||||
},
|
||||
name: {
|
||||
type: String,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
form: {
|
||||
rows: 3
|
||||
}
|
||||
},
|
||||
order: {
|
||||
type: Number,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
},
|
||||
slug: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
},
|
||||
image: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
},
|
||||
parentId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
control: "select",
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
resolveAs: {
|
||||
fieldName: 'parent',
|
||||
type: 'Category',
|
||||
resolver: async (category, args, {currentUser, Users, Categories}) => {
|
||||
if (!category.parentId) return null;
|
||||
const parent = await Categories.loader.load(category.parentId);
|
||||
return Users.restrictViewableFields(currentUser, Categories, parent);
|
||||
},
|
||||
addOriginalField: true
|
||||
},
|
||||
form: {
|
||||
options: formProps => getCategoriesAsOptions(formProps.client)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default schema;
|
|
@ -0,0 +1,13 @@
|
|||
import marked from 'marked';
|
||||
import { addCallback, Utils } from 'meteor/vulcan:core';
|
||||
|
||||
// ------------------------------------- comments.edit.sync -------------------------------- //
|
||||
|
||||
function CommentsEditGenerateHTMLBody (modifier, comment, user) {
|
||||
// if body is being modified, update htmlBody too
|
||||
if (modifier.$set && modifier.$set.body) {
|
||||
modifier.$set.htmlBody = Utils.sanitize(marked(modifier.$set.body));
|
||||
}
|
||||
return modifier;
|
||||
}
|
||||
addCallback("comments.edit.sync", CommentsEditGenerateHTMLBody);
|
|
@ -0,0 +1,49 @@
|
|||
import marked from 'marked';
|
||||
import Posts from '../../posts/index.js';
|
||||
import Comments from '../index.js';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import { addCallback, Utils, getSetting } from 'meteor/vulcan:core';
|
||||
|
||||
// ------------------------------------- comments.new.validate -------------------------------- //
|
||||
|
||||
function CommentsNewRateLimit (comment, user) {
|
||||
if (!Users.isAdmin(user)) {
|
||||
const timeSinceLastComment = Users.timeSinceLast(user, Comments);
|
||||
const commentInterval = Math.abs(parseInt(getSetting('commentInterval',15)));
|
||||
|
||||
// check that user waits more than 15 seconds between comments
|
||||
if((timeSinceLastComment < commentInterval)) {
|
||||
throw new Error(Utils.encodeIntlError({id: 'comments.rate_limit_error', value: commentInterval-timeSinceLastComment}));
|
||||
}
|
||||
}
|
||||
return comment;
|
||||
}
|
||||
addCallback('comments.new.validate', CommentsNewRateLimit);
|
||||
|
||||
// ------------------------------------- comments.new.sync -------------------------------- //
|
||||
|
||||
function CommentsNewGenerateHTMLBody (comment, user) {
|
||||
comment.htmlBody = Utils.sanitize(marked(comment.body));
|
||||
return comment;
|
||||
}
|
||||
addCallback('comments.new.sync', CommentsNewGenerateHTMLBody);
|
||||
|
||||
function CommentsNewOperations (comment) {
|
||||
|
||||
var userId = comment.userId;
|
||||
|
||||
// increment comment count
|
||||
Users.update({_id: userId}, {
|
||||
$inc: {'commentCount': 1}
|
||||
});
|
||||
|
||||
// update post
|
||||
Posts.update(comment.postId, {
|
||||
$inc: {commentCount: 1},
|
||||
$set: {lastCommentedAt: new Date()},
|
||||
$addToSet: {commenters: userId}
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
addCallback('comments.new.sync', CommentsNewOperations);
|
|
@ -0,0 +1,47 @@
|
|||
import { removeMutation, addCallback } from 'meteor/vulcan:core';
|
||||
import Posts from '../../posts/index.js';
|
||||
import Comments from '../index.js';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
function CommentsRemovePostCommenters (comment, currentUser) {
|
||||
const { userId, postId } = comment;
|
||||
|
||||
// dec user's comment count
|
||||
Users.update({_id: userId}, {
|
||||
$inc: {'commentCount': -1}
|
||||
});
|
||||
|
||||
const postComments = Comments.find({postId}, {sort: {postedAt: -1}}).fetch();
|
||||
|
||||
const commenters = _.uniq(postComments.map(comment => comment.userId));
|
||||
const lastCommentedAt = postComments[0] && postComments[0].postedAt;
|
||||
|
||||
// update post with a decremented comment count, a unique list of commenters and corresponding last commented at date
|
||||
Posts.update(postId, {
|
||||
$inc: {commentCount: -1},
|
||||
$set: {lastCommentedAt, commenters},
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
addCallback('comments.remove.async', CommentsRemovePostCommenters);
|
||||
|
||||
function CommentsRemoveChildrenComments (comment, currentUser) {
|
||||
|
||||
const childrenComments = Comments.find({parentCommentId: comment._id}).fetch();
|
||||
|
||||
childrenComments.forEach(childComment => {
|
||||
removeMutation({
|
||||
action: 'comments.remove',
|
||||
collection: Comments,
|
||||
documentId: childComment._id,
|
||||
currentUser: currentUser,
|
||||
validate: false
|
||||
});
|
||||
});
|
||||
|
||||
return comment;
|
||||
}
|
||||
|
||||
addCallback('comments.remove.async', CommentsRemoveChildrenComments);
|
|
@ -0,0 +1,24 @@
|
|||
import Comments from '../collection.js';
|
||||
import { addCallback } from 'meteor/vulcan:core';
|
||||
|
||||
function UsersRemoveDeleteComments (user, options) {
|
||||
if (options.deleteComments) {
|
||||
Comments.remove({userId: user._id});
|
||||
} else {
|
||||
// not sure if anything should be done in that scenario yet
|
||||
// Comments.update({userId: userId}, {$set: {author: "\[deleted\]"}}, {multi: true});
|
||||
}
|
||||
}
|
||||
addCallback("users.remove.async", UsersRemoveDeleteComments);
|
||||
|
||||
// Add to posts.single publication
|
||||
|
||||
function PostsSingleAddCommentsUsers (users, post) {
|
||||
// get IDs from all commenters on the post
|
||||
const comments = Comments.find({postId: post._id}).fetch();
|
||||
if (comments.length) {
|
||||
users = users.concat(_.pluck(comments, "userId"));
|
||||
}
|
||||
return users;
|
||||
}
|
||||
addCallback("posts.single.getUsers", PostsSingleAddCommentsUsers);
|
39
packages/example-forum/lib/modules/comments/collection.js
Normal file
39
packages/example-forum/lib/modules/comments/collection.js
Normal file
|
@ -0,0 +1,39 @@
|
|||
/*
|
||||
|
||||
Comments collection
|
||||
|
||||
*/
|
||||
|
||||
import schema from './schema.js';
|
||||
import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
/**
|
||||
* @summary The global namespace for Comments.
|
||||
* @namespace Comments
|
||||
*/
|
||||
const Comments = createCollection({
|
||||
|
||||
collectionName: 'Comments',
|
||||
|
||||
typeName: 'Comment',
|
||||
|
||||
schema,
|
||||
|
||||
resolvers: getDefaultResolvers('Comments'),
|
||||
|
||||
mutations: getDefaultMutations('Comments'),
|
||||
|
||||
});
|
||||
|
||||
Comments.checkAccess = (currentUser, comment) => {
|
||||
if (Users.isAdmin(currentUser) || Users.owns(currentUser, comment)) { // admins can always see everything, users can always see their own posts
|
||||
return true;
|
||||
} else if (comment.isDeleted) {
|
||||
return false;
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
export default Comments;
|
59
packages/example-forum/lib/modules/comments/custom_fields.js
Normal file
59
packages/example-forum/lib/modules/comments/custom_fields.js
Normal file
|
@ -0,0 +1,59 @@
|
|||
import Posts from '../posts/index.js';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
Users.addField([
|
||||
/**
|
||||
Count of the user's comments
|
||||
*/
|
||||
{
|
||||
fieldName: 'commentCount',
|
||||
fieldSchema: {
|
||||
type: Number,
|
||||
optional: true,
|
||||
defaultValue: 0,
|
||||
viewableBy: ['guests'],
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
Posts.addField([
|
||||
/**
|
||||
Count of the post's comments
|
||||
*/
|
||||
{
|
||||
fieldName: 'commentCount',
|
||||
fieldSchema: {
|
||||
type: Number,
|
||||
optional: true,
|
||||
defaultValue: 0,
|
||||
viewableBy: ['guests'],
|
||||
}
|
||||
},
|
||||
/**
|
||||
An array containing the `_id`s of commenters
|
||||
*/
|
||||
{
|
||||
fieldName: 'commenters',
|
||||
fieldSchema: {
|
||||
type: Array,
|
||||
optional: true,
|
||||
resolveAs: {
|
||||
fieldName: 'commenters',
|
||||
type: '[User]',
|
||||
resolver: async (post, args, {currentUser, Users}) => {
|
||||
if (!post.commenters) return [];
|
||||
const commenters = await Users.loader.loadMany(post.commenters);
|
||||
return Users.restrictViewableFields(currentUser, Users, commenters);
|
||||
},
|
||||
},
|
||||
viewableBy: ['guests'],
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldName: 'commenters.$',
|
||||
fieldSchema: {
|
||||
type: String,
|
||||
optional: true
|
||||
}
|
||||
}
|
||||
]);
|
40
packages/example-forum/lib/modules/comments/fragments.js
Normal file
40
packages/example-forum/lib/modules/comments/fragments.js
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { registerFragment } from 'meteor/vulcan:core';
|
||||
|
||||
// ----------------------------- Comments ------------------------------ //
|
||||
|
||||
registerFragment(`
|
||||
fragment CommentsList on Comment {
|
||||
# vulcan:comments
|
||||
_id
|
||||
postId
|
||||
parentCommentId
|
||||
topLevelCommentId
|
||||
body
|
||||
htmlBody
|
||||
postedAt
|
||||
# vulcan:users
|
||||
userId
|
||||
user {
|
||||
...UsersMinimumInfo
|
||||
}
|
||||
# vulcan:posts
|
||||
post {
|
||||
_id
|
||||
commentCount
|
||||
commenters {
|
||||
...UsersMinimumInfo
|
||||
}
|
||||
}
|
||||
# vulcan:voting
|
||||
upvoters {
|
||||
_id
|
||||
}
|
||||
downvoters {
|
||||
_id
|
||||
}
|
||||
#upvotes
|
||||
#downvotes
|
||||
#baseScore
|
||||
#score
|
||||
}
|
||||
`);
|
35
packages/example-forum/lib/modules/comments/helpers.js
Normal file
35
packages/example-forum/lib/modules/comments/helpers.js
Normal file
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
|
||||
Comments helpers
|
||||
|
||||
*/
|
||||
|
||||
import Comments from './index.js';
|
||||
import Posts from '../posts/index.js';
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
//////////////////
|
||||
// Link Helpers //
|
||||
//////////////////
|
||||
|
||||
/**
|
||||
* @summary Get URL of a comment page.
|
||||
* @param {Object} comment
|
||||
*/
|
||||
Comments.getPageUrl = function(comment, isAbsolute = false){
|
||||
const post = Posts.findOne(comment.postId);
|
||||
return `${Posts.getPageUrl(post, isAbsolute)}/#${comment._id}`;
|
||||
};
|
||||
|
||||
///////////////////
|
||||
// Other Helpers //
|
||||
///////////////////
|
||||
|
||||
/**
|
||||
* @summary Get a comment author's name
|
||||
* @param {Object} comment
|
||||
*/
|
||||
Comments.getAuthorName = function (comment) {
|
||||
var user = Users.findOne(comment.userId);
|
||||
return user ? Users.getDisplayName(user) : comment.author;
|
||||
};
|
13
packages/example-forum/lib/modules/comments/index.js
Normal file
13
packages/example-forum/lib/modules/comments/index.js
Normal file
|
@ -0,0 +1,13 @@
|
|||
export * from './collection.js';
|
||||
|
||||
import './callbacks/callbacks_comments_new.js';
|
||||
import './callbacks/callbacks_comments_edit.js';
|
||||
import './callbacks/callbacks_comments_remove.js';
|
||||
import './callbacks/callbacks_other.js';
|
||||
|
||||
import './fragments.js';
|
||||
import './custom_fields.js';
|
||||
import './helpers.js';
|
||||
import './parameters.js';
|
||||
import './permissions.js';
|
||||
import './views.js';
|
24
packages/example-forum/lib/modules/comments/parameters.js
Normal file
24
packages/example-forum/lib/modules/comments/parameters.js
Normal file
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
|
||||
Comments parameters
|
||||
|
||||
*/
|
||||
|
||||
import { addCallback } from 'meteor/vulcan:core';
|
||||
|
||||
// limit the number of items that can be requested at once
|
||||
function CommentsMaxLimit (parameters, terms) {
|
||||
var maxLimit = 1000;
|
||||
// if a limit was provided with the terms, add it too (note: limit=0 means "no limit")
|
||||
if (typeof terms.limit !== 'undefined') {
|
||||
_.extend(parameters.options, {limit: parseInt(terms.limit)});
|
||||
}
|
||||
|
||||
// limit to "maxLimit" items at most when limit is undefined, equal to 0, or superior to maxLimit
|
||||
if(!parameters.options.limit || parameters.options.limit === 0 || parameters.options.limit > maxLimit) {
|
||||
parameters.options.limit = maxLimit;
|
||||
}
|
||||
return parameters;
|
||||
}
|
||||
|
||||
addCallback("comments.parameters", CommentsMaxLimit);
|
30
packages/example-forum/lib/modules/comments/permissions.js
Normal file
30
packages/example-forum/lib/modules/comments/permissions.js
Normal file
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
|
||||
Comments permissions
|
||||
|
||||
*/
|
||||
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
const guestsActions = [
|
||||
'comments.view'
|
||||
];
|
||||
Users.groups.guests.can(guestsActions);
|
||||
|
||||
const membersActions = [
|
||||
'comments.view',
|
||||
'comments.new',
|
||||
'comments.edit.own',
|
||||
'comments.remove.own',
|
||||
'comments.upvote',
|
||||
'comments.cancelUpvote',
|
||||
'comments.downvote',
|
||||
'comments.cancelDownvote'
|
||||
];
|
||||
Users.groups.members.can(membersActions);
|
||||
|
||||
const adminActions = [
|
||||
'comments.edit.all',
|
||||
'comments.remove.all'
|
||||
];
|
||||
Users.groups.admins.can(adminActions);
|
202
packages/example-forum/lib/modules/comments/schema.js
Normal file
202
packages/example-forum/lib/modules/comments/schema.js
Normal file
|
@ -0,0 +1,202 @@
|
|||
/*
|
||||
|
||||
Comments schema
|
||||
|
||||
*/
|
||||
|
||||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
/**
|
||||
* @summary Comments schema
|
||||
* @type {Object}
|
||||
*/
|
||||
const schema = {
|
||||
/**
|
||||
ID
|
||||
*/
|
||||
_id: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
},
|
||||
/**
|
||||
The `_id` of the parent comment, if there is one
|
||||
*/
|
||||
parentCommentId: {
|
||||
type: String,
|
||||
// regEx: SimpleSchema.RegEx.Id,
|
||||
max: 500,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
optional: true,
|
||||
resolveAs: {
|
||||
fieldName: 'parentComment',
|
||||
type: 'Comment',
|
||||
resolver: async (comment, args, {currentUser, Users, Comments}) => {
|
||||
if (!comment.parentCommentId) return null;
|
||||
const parentComment = await Comments.loader.load(comment.parentCommentId);
|
||||
return Users.restrictViewableFields(currentUser, Comments, parentComment);
|
||||
},
|
||||
addOriginalField: true
|
||||
},
|
||||
hidden: true // never show this
|
||||
},
|
||||
/**
|
||||
The `_id` of the top-level parent comment, if there is one
|
||||
*/
|
||||
topLevelCommentId: {
|
||||
type: String,
|
||||
// regEx: SimpleSchema.RegEx.Id,
|
||||
max: 500,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
optional: true,
|
||||
resolveAs: {
|
||||
fieldName: 'topLevelComment',
|
||||
type: 'Comment',
|
||||
resolver: async (comment, args, {currentUser, Users, Comments}) => {
|
||||
if (!comment.topLevelCommentId) return null;
|
||||
const topLevelComment = await Comments.loader.load(comment.topLevelCommentId);
|
||||
return Users.restrictViewableFields(currentUser, Comments, topLevelComment);
|
||||
},
|
||||
addOriginalField: true
|
||||
},
|
||||
hidden: true // never show this
|
||||
},
|
||||
/**
|
||||
The timestamp of comment creation
|
||||
*/
|
||||
createdAt: {
|
||||
type: Date,
|
||||
optional: true,
|
||||
viewableBy: ['admins'],
|
||||
onInsert: (document, currentUser) => {
|
||||
return new Date();
|
||||
}
|
||||
},
|
||||
/**
|
||||
The timestamp of the comment being posted. For now, comments are always created and posted at the same time
|
||||
*/
|
||||
postedAt: {
|
||||
type: Date,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
onInsert: (document, currentUser) => {
|
||||
return new Date();
|
||||
}
|
||||
},
|
||||
/**
|
||||
The comment body (Markdown)
|
||||
*/
|
||||
body: {
|
||||
type: String,
|
||||
max: 3000,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
control: "textarea"
|
||||
},
|
||||
/**
|
||||
The HTML version of the comment body
|
||||
*/
|
||||
htmlBody: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
},
|
||||
/**
|
||||
The comment author's name
|
||||
*/
|
||||
author: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
onEdit: (modifier, document, currentUser) => {
|
||||
// if userId is changing, change the author name too
|
||||
if (modifier.$set && modifier.$set.userId) {
|
||||
return Users.getDisplayNameById(modifier.$set.userId)
|
||||
}
|
||||
}
|
||||
},
|
||||
/**
|
||||
The post's `_id`
|
||||
*/
|
||||
postId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
// regEx: SimpleSchema.RegEx.Id,
|
||||
max: 500,
|
||||
resolveAs: {
|
||||
fieldName: 'post',
|
||||
type: 'Post',
|
||||
resolver: async (comment, args, {currentUser, Users, Posts}) => {
|
||||
if (!comment.postId) return null;
|
||||
const post = await Posts.loader.load(comment.postId);
|
||||
return Users.restrictViewableFields(currentUser, Posts, post);
|
||||
},
|
||||
addOriginalField: true
|
||||
},
|
||||
hidden: true // never show this
|
||||
},
|
||||
/**
|
||||
The comment author's `_id`
|
||||
*/
|
||||
userId: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
hidden: true,
|
||||
resolveAs: {
|
||||
fieldName: 'user',
|
||||
type: 'User',
|
||||
resolver: async (comment, args, {currentUser, Users}) => {
|
||||
if (!comment.userId) return null;
|
||||
const user = await Users.loader.load(comment.userId);
|
||||
return Users.restrictViewableFields(currentUser, Users, user);
|
||||
},
|
||||
addOriginalField: true
|
||||
},
|
||||
},
|
||||
/**
|
||||
Whether the comment is deleted. Delete comments' content doesn't appear on the site.
|
||||
*/
|
||||
isDeleted: {
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
viewableBy: ['guests'],
|
||||
},
|
||||
userIP: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['admins'],
|
||||
},
|
||||
userAgent: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['admins'],
|
||||
},
|
||||
referrer: {
|
||||
type: String,
|
||||
optional: true,
|
||||
viewableBy: ['admins'],
|
||||
},
|
||||
|
||||
// GraphQL only fields
|
||||
|
||||
pageUrl: {
|
||||
type: String,
|
||||
optional: true,
|
||||
resolveAs: {
|
||||
fieldName: 'pageUrl',
|
||||
type: 'String',
|
||||
resolver: (comment, args, context) => {
|
||||
return context.Comments.getPageUrl(comment, true);
|
||||
},
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default schema;
|
28
packages/example-forum/lib/modules/comments/views.js
Normal file
28
packages/example-forum/lib/modules/comments/views.js
Normal file
|
@ -0,0 +1,28 @@
|
|||
/*
|
||||
|
||||
Comments views
|
||||
|
||||
*/
|
||||
|
||||
import Comments from './index.js';
|
||||
|
||||
// will be common to all other view unless specific properties are overwritten
|
||||
Comments.addDefaultView(function (terms) {
|
||||
return {
|
||||
options: {limit: 1000}
|
||||
};
|
||||
});
|
||||
|
||||
Comments.addView("postComments", function (terms) {
|
||||
return {
|
||||
selector: {postId: terms.postId},
|
||||
options: {sort: {postedAt: -1}}
|
||||
};
|
||||
});
|
||||
|
||||
Comments.addView("userComments", function (terms) {
|
||||
return {
|
||||
selector: {userId: terms.userId},
|
||||
options: {sort: {postedAt: -1}}
|
||||
};
|
||||
});
|
67
packages/example-forum/lib/modules/components.js
Normal file
67
packages/example-forum/lib/modules/components.js
Normal file
|
@ -0,0 +1,67 @@
|
|||
|
||||
// common
|
||||
|
||||
import '../components/common/Footer.jsx';
|
||||
import '../components/common/Header.jsx';
|
||||
import '../components/common/Layout.jsx';
|
||||
import '../components/common/Logo.jsx';
|
||||
import '../components/common/Flash.jsx';
|
||||
import '../components/common/FlashMessages.jsx';
|
||||
import '../components/common/Newsletter.jsx';
|
||||
import '../components/common/NewsletterButton.jsx';
|
||||
import '../components/common/SearchForm.jsx';
|
||||
import '../components/common/Vote.jsx';
|
||||
|
||||
// posts
|
||||
|
||||
import '../components/posts/PostsHome.jsx';
|
||||
import '../components/posts/PostsSingle.jsx';
|
||||
import '../components/posts/PostsNewButton.jsx';
|
||||
import '../components/posts/PostsLoadMore.jsx';
|
||||
import '../components/posts/PostsNoMore.jsx';
|
||||
import '../components/posts/PostsNoResults.jsx';
|
||||
import '../components/posts/PostsItem.jsx';
|
||||
import '../components/posts/PostsLoading.jsx';
|
||||
import '../components/posts/PostsViews.jsx';
|
||||
import '../components/posts/PostsList.jsx';
|
||||
import '../components/posts/PostsListHeader.jsx';
|
||||
import '../components/posts/PostsCategories.jsx';
|
||||
import '../components/posts/PostsCommenters.jsx';
|
||||
import '../components/posts/PostsPage.jsx';
|
||||
import '../components/posts/PostsStats.jsx';
|
||||
import '../components/posts/PostsDaily.jsx';
|
||||
import '../components/posts/PostsDailyList.jsx';
|
||||
import '../components/posts/PostsDay.jsx';
|
||||
import '../components/posts/PostsThumbnail.jsx';
|
||||
import '../components/posts/PostsEditForm.jsx';
|
||||
import '../components/posts/PostsNewForm.jsx';
|
||||
import '../components/posts/PostsCommentsThread.jsx';
|
||||
|
||||
// comments
|
||||
|
||||
import '../components/comments/CommentsItem.jsx';
|
||||
import '../components/comments/CommentsList.jsx';
|
||||
import '../components/comments/CommentsNode.jsx';
|
||||
import '../components/comments/CommentsNewForm.jsx';
|
||||
import '../components/comments/CommentsEditForm.jsx';
|
||||
import '../components/comments/CommentsLoadMore.jsx';
|
||||
|
||||
// categories
|
||||
|
||||
import '../components/categories/CategoriesList.jsx';
|
||||
import '../components/categories/CategoriesNode.jsx';
|
||||
import '../components/categories/Category.jsx';
|
||||
import '../components/categories/CategoriesEditForm.jsx';
|
||||
import '../components/categories/CategoriesNewForm.jsx';
|
||||
|
||||
// users
|
||||
|
||||
import '../components/users/UsersSingle.jsx';
|
||||
import '../components/users/UsersAccount.jsx';
|
||||
import '../components/users/UsersEditForm.jsx';
|
||||
import '../components/users/UsersProfile.jsx';
|
||||
import '../components/users/UsersProfileCheck.jsx';
|
||||
import '../components/users/UsersAvatar.jsx';
|
||||
import '../components/users/UsersName.jsx';
|
||||
import '../components/users/UsersMenu.jsx';
|
||||
import '../components/users/UsersAccountMenu.jsx';
|
6
packages/example-forum/lib/modules/config.js
Normal file
6
packages/example-forum/lib/modules/config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
Users.avatar.setOptions({
|
||||
'gravatarDefault': 'mm',
|
||||
'defaultImageUrl': 'http://www.gravatar.com/avatar/00000000000000000000000000000000?d=mm&f=y'
|
||||
});
|
83
packages/example-forum/lib/modules/email/callbacks.js
Normal file
83
packages/example-forum/lib/modules/email/callbacks.js
Normal file
|
@ -0,0 +1,83 @@
|
|||
import Users from 'meteor/vulcan:users';
|
||||
import { addCallback } from 'meteor/vulcan:core';
|
||||
import { createNotification } from './notifications.js';
|
||||
|
||||
// note: leverage weak dependencies on packages
|
||||
const Comments = Package['vulcan:comments'] ? Package['vulcan:comments'].default : null;
|
||||
const Posts = Package['vulcan:posts'] ? Package['vulcan:posts'].default : null;
|
||||
|
||||
/**
|
||||
* @summary Add notification callback when a post is approved
|
||||
*/
|
||||
function PostsApprovedNotification (post) {
|
||||
createNotification(post.userId, 'postApproved', {documentId: post._id});
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Add new post notification callback on post submit
|
||||
*/
|
||||
function PostsNewNotifications (post) {
|
||||
|
||||
let adminIds = _.pluck(Users.adminUsers({fields: {_id:1}}), '_id');
|
||||
let notifiedUserIds = _.pluck(Users.find({'notifications_posts': true}, {fields: {_id:1}}).fetch(), '_id');
|
||||
|
||||
// remove post author ID from arrays
|
||||
adminIds = _.without(adminIds, post.userId);
|
||||
notifiedUserIds = _.without(notifiedUserIds, post.userId);
|
||||
|
||||
if (post.status === Posts.config.STATUS_PENDING && !!adminIds.length) {
|
||||
// if post is pending, only notify admins
|
||||
createNotification(adminIds, 'newPendingPost', {documentId: post._id});
|
||||
} else if (!!notifiedUserIds.length) {
|
||||
// if post is approved, notify everybody
|
||||
createNotification(notifiedUserIds, 'newPost', {documentId: post._id});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
addCallback("posts.approve.async", PostsApprovedNotification);
|
||||
addCallback("posts.new.async", PostsNewNotifications);
|
||||
|
||||
// add new comment notification callback on comment submit
|
||||
function CommentsNewNotifications (comment) {
|
||||
|
||||
// note: dummy content has disableNotifications set to true
|
||||
if(Meteor.isServer && !comment.disableNotifications) {
|
||||
|
||||
const post = Posts.findOne(comment.postId);
|
||||
const postAuthor = Users.findOne(post.userId);
|
||||
|
||||
|
||||
let userIdsNotified = [];
|
||||
|
||||
// 1. Notify author of post (if they have new comment notifications turned on)
|
||||
// but do not notify author of post if they're the ones posting the comment
|
||||
if (Users.getSetting(postAuthor, "notifications_comments", false) && comment.userId !== postAuthor._id) {
|
||||
createNotification(post.userId, 'newComment', {documentId: comment._id});
|
||||
userIdsNotified.push(post.userId);
|
||||
}
|
||||
|
||||
// 2. Notify author of comment being replied to
|
||||
if (!!comment.parentCommentId) {
|
||||
|
||||
const parentComment = Comments.findOne(comment.parentCommentId);
|
||||
|
||||
// do not notify author of parent comment if they're also post author or comment author
|
||||
// (someone could be replying to their own comment)
|
||||
if (parentComment.userId !== post.userId && parentComment.userId !== comment.userId) {
|
||||
|
||||
const parentCommentAuthor = Users.findOne(parentComment.userId);
|
||||
|
||||
// do not notify parent comment author if they have reply notifications turned off
|
||||
if (Users.getSetting(parentCommentAuthor, "notifications_replies", false)) {
|
||||
createNotification(parentComment.userId, 'newReply', {documentId: parentComment._id});
|
||||
userIdsNotified.push(parentComment.userId);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
addCallback("comments.new.async", CommentsNewNotifications);
|
66
packages/example-forum/lib/modules/email/custom_fields.js
Normal file
66
packages/example-forum/lib/modules/email/custom_fields.js
Normal file
|
@ -0,0 +1,66 @@
|
|||
import Users from 'meteor/vulcan:users';
|
||||
|
||||
const notificationsGroup = {
|
||||
name: "notifications",
|
||||
order: 2
|
||||
};
|
||||
|
||||
// Add notifications options to user profile settings
|
||||
Users.addField([
|
||||
{
|
||||
fieldName: 'notifications_users',
|
||||
fieldSchema: {
|
||||
label: 'New users',
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
control: "checkbox",
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['admins'],
|
||||
editableBy: ['admins'],
|
||||
group: notificationsGroup,
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldName: 'notifications_posts',
|
||||
fieldSchema: {
|
||||
label: 'New posts',
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
control: "checkbox",
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
group: notificationsGroup,
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldName: 'notifications_comments',
|
||||
fieldSchema: {
|
||||
label: 'Comments on my posts',
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
control: "checkbox",
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
group: notificationsGroup,
|
||||
}
|
||||
},
|
||||
{
|
||||
fieldName: 'notifications_replies',
|
||||
fieldSchema: {
|
||||
label: 'Replies to my comments',
|
||||
type: Boolean,
|
||||
optional: true,
|
||||
defaultValue: false,
|
||||
control: "checkbox",
|
||||
viewableBy: ['guests'],
|
||||
insertableBy: ['members'],
|
||||
editableBy: ['members'],
|
||||
group: notificationsGroup,
|
||||
}
|
||||
}
|
||||
]);
|
192
packages/example-forum/lib/modules/email/emails.js
Normal file
192
packages/example-forum/lib/modules/email/emails.js
Normal file
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
|
||||
Emails
|
||||
|
||||
*/
|
||||
|
||||
import VulcanEmail from 'meteor/vulcan:email';
|
||||
|
||||
/*
|
||||
|
||||
Test
|
||||
|
||||
*/
|
||||
|
||||
VulcanEmail.addEmails({
|
||||
|
||||
test: {
|
||||
template: "test",
|
||||
path: "/email/test",
|
||||
data() {
|
||||
return {date: new Date()};
|
||||
},
|
||||
subject() {
|
||||
return "This is a test";
|
||||
},
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Users
|
||||
|
||||
*/
|
||||
|
||||
VulcanEmail.addEmails({
|
||||
|
||||
newUser: {
|
||||
template: "newUser",
|
||||
path: "/email/new-user/:_id?",
|
||||
subject() {
|
||||
return "A new user has been created";
|
||||
},
|
||||
query: `
|
||||
query UsersSingleQuery($documentId: String){
|
||||
UsersSingle(documentId: $documentId){
|
||||
displayName
|
||||
pageUrl
|
||||
}
|
||||
}
|
||||
`
|
||||
},
|
||||
|
||||
accountApproved: {
|
||||
template: "accountApproved",
|
||||
path: "/email/account-approved/:_id?",
|
||||
subject() {
|
||||
return "Your account has been approved.";
|
||||
},
|
||||
query: `
|
||||
query UsersSingleQuery($documentId: String){
|
||||
UsersSingle(documentId: $documentId){
|
||||
displayName
|
||||
}
|
||||
SiteData{
|
||||
title
|
||||
url
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Posts
|
||||
|
||||
*/
|
||||
|
||||
const postsQuery = `
|
||||
query PostsSingleQuery($documentId: String){
|
||||
PostsSingle(documentId: $documentId){
|
||||
title
|
||||
url
|
||||
pageUrl
|
||||
linkUrl
|
||||
htmlBody
|
||||
thumbnailUrl
|
||||
user{
|
||||
pageUrl
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const dummyPost = {title: '[title]', user: {displayName: '[user]'}};
|
||||
|
||||
VulcanEmail.addEmails({
|
||||
|
||||
newPost: {
|
||||
template: "newPost",
|
||||
path: "/email/new-post/:_id?",
|
||||
subject(data) {
|
||||
const post = _.isEmpty(data) ? dummyPost : data.PostsSingle;
|
||||
return post.user.displayName+' has created a new post: '+post.title;
|
||||
},
|
||||
query: postsQuery
|
||||
},
|
||||
|
||||
newPendingPost: {
|
||||
template: "newPendingPost",
|
||||
path: "/email/new-pending-post/:_id?",
|
||||
subject(data) {
|
||||
const post = _.isEmpty(data) ? dummyPost : data.PostsSingle;
|
||||
return post.user.displayName+' has a new post pending approval: '+post.title;
|
||||
},
|
||||
query: postsQuery
|
||||
},
|
||||
|
||||
postApproved: {
|
||||
template: "postApproved",
|
||||
path: "/email/post-approved/:_id?",
|
||||
subject(data) {
|
||||
const post = _.isEmpty(data) ? dummyPost : data.PostsSingle;
|
||||
return 'Your post “'+post.title+'” has been approved';
|
||||
},
|
||||
query: postsQuery
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
/*
|
||||
|
||||
Comments
|
||||
|
||||
*/
|
||||
|
||||
const commentsQuery = `
|
||||
query CommentsSingleQuery($documentId: String){
|
||||
CommentsSingle(documentId: $documentId){
|
||||
pageUrl
|
||||
htmlBody
|
||||
post{
|
||||
pageUrl
|
||||
title
|
||||
}
|
||||
user{
|
||||
pageUrl
|
||||
displayName
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
const dummyComment = {post: {title: '[title]'}, user: {displayName: '[user]'}};
|
||||
|
||||
VulcanEmail.addEmails({
|
||||
|
||||
newComment: {
|
||||
template: "newComment",
|
||||
path: "/email/new-comment/:_id?",
|
||||
subject(data) {
|
||||
const comment = _.isEmpty(data) ? dummyComment : data.CommentsSingle;
|
||||
return comment.user.displayName+' left a new comment on your post "' + comment.post.title + '"';
|
||||
},
|
||||
query: commentsQuery
|
||||
},
|
||||
|
||||
newReply: {
|
||||
template: "newReply",
|
||||
path: "/email/new-reply/:_id?",
|
||||
subject(data) {
|
||||
const comment = _.isEmpty(data) ? dummyComment : data.CommentsSingle;
|
||||
return comment.user.displayName+' replied to your comment on "'+comment.post.title+'"';
|
||||
},
|
||||
query: commentsQuery
|
||||
},
|
||||
|
||||
newCommentSubscribed: {
|
||||
template: "newComment",
|
||||
path: "/email/new-comment-subscribed/:_id?",
|
||||
subject(data) {
|
||||
const comment = _.isEmpty(data) ? dummyComment : data.CommentsSingle;
|
||||
return comment.user.displayName+' left a new comment on "' + comment.post.title + '"';
|
||||
},
|
||||
query: commentsQuery
|
||||
}
|
||||
|
||||
});
|
||||
|
2
packages/example-forum/lib/modules/email/index.js
Normal file
2
packages/example-forum/lib/modules/email/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
import './custom_fields.js';
|
||||
import './emails.js';
|
23
packages/example-forum/lib/modules/email/notifications.js
Normal file
23
packages/example-forum/lib/modules/email/notifications.js
Normal file
|
@ -0,0 +1,23 @@
|
|||
import Users from 'meteor/vulcan:users';
|
||||
import VulcanEmail from 'meteor/vulcan:email';
|
||||
import { getSetting } from 'meteor/vulcan:core';
|
||||
|
||||
export const createNotification = (userIds, notificationName, variables) => {
|
||||
|
||||
if (getSetting('emailNotifications', true)) {
|
||||
// if userIds is not an array, wrap it in one
|
||||
if (!Array.isArray(userIds)) userIds = [userIds];
|
||||
|
||||
const emailName = notificationName;
|
||||
|
||||
userIds.forEach(userId => {
|
||||
const to = Users.getEmail(Users.findOne(userId));
|
||||
if (to) {
|
||||
VulcanEmail.buildAndSend({ to, emailName, variables });
|
||||
} else {
|
||||
console.log(`// Couldn't send notification: user ${user._id} doesn't have an email`); // eslint-disable-line
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
};
|
|
@ -1,5 +1,5 @@
|
|||
import { addCallback, getSetting } from 'meteor/vulcan:core';
|
||||
import Embed from '../modules/embed.js';
|
||||
import Embed from 'meteor/vulcan:embedly';
|
||||
|
||||
const embedProvider = getSetting('embedProvider', 'builtin');
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import EmbedlyURL from '../components/EmbedlyURL.jsx';
|
||||
import Posts from "meteor/vulcan:posts";
|
||||
import Posts from '../posts/index.js';
|
||||
import { EmbedlyURL } from 'meteor/vulcan:embedly';
|
||||
|
||||
Posts.addField([
|
||||
{
|
2
packages/example-forum/lib/modules/embedly/index.js
Normal file
2
packages/example-forum/lib/modules/embedly/index.js
Normal file
|
@ -0,0 +1,2 @@
|
|||
import './custom_fields.js';
|
||||
import './callbacks.js';
|
61
packages/example-forum/lib/modules/fragments.js
Normal file
61
packages/example-forum/lib/modules/fragments.js
Normal file
|
@ -0,0 +1,61 @@
|
|||
import { registerFragment } from 'meteor/vulcan:core';
|
||||
|
||||
// ------------------------------ Vote ------------------------------ //
|
||||
|
||||
// note: fragment used by default on the UsersProfile fragment
|
||||
registerFragment(`
|
||||
fragment VotedItem on Vote {
|
||||
# vulcan:voting
|
||||
itemId
|
||||
power
|
||||
votedAt
|
||||
}
|
||||
`);
|
||||
|
||||
// ------------------------------ Users ------------------------------ //
|
||||
|
||||
// note: fragment used by default on UsersProfile, PostsList & CommentsList fragments
|
||||
registerFragment(`
|
||||
fragment UsersMinimumInfo on User {
|
||||
# vulcan:users
|
||||
_id
|
||||
slug
|
||||
username
|
||||
displayName
|
||||
emailHash
|
||||
avatarUrl
|
||||
}
|
||||
`);
|
||||
|
||||
registerFragment(`
|
||||
fragment UsersProfile on User {
|
||||
# vulcan:users
|
||||
...UsersMinimumInfo
|
||||
createdAt
|
||||
isAdmin
|
||||
bio
|
||||
htmlBio
|
||||
twitterUsername
|
||||
website
|
||||
groups
|
||||
karma
|
||||
# vulcan:posts
|
||||
postCount
|
||||
# vulcan:comments
|
||||
commentCount
|
||||
# vulcan:voting
|
||||
downvotedComments {
|
||||
...VotedItem
|
||||
}
|
||||
downvotedPosts {
|
||||
...VotedItem
|
||||
}
|
||||
upvotedComments {
|
||||
...VotedItem
|
||||
}
|
||||
upvotedPosts {
|
||||
...VotedItem
|
||||
}
|
||||
}
|
||||
`);
|
||||
|
9
packages/example-forum/lib/modules/headtags.js
Normal file
9
packages/example-forum/lib/modules/headtags.js
Normal file
|
@ -0,0 +1,9 @@
|
|||
import { Head, Utils } from 'meteor/vulcan:core';
|
||||
|
||||
// add permanent <link /> markup
|
||||
Head.link.push({
|
||||
name: 'rss',
|
||||
rel: 'alternate',
|
||||
type: 'application/rss+xml',
|
||||
href: `${Utils.getSiteUrl()}feed.xml`
|
||||
});
|
14
packages/example-forum/lib/modules/index.js
Normal file
14
packages/example-forum/lib/modules/index.js
Normal file
|
@ -0,0 +1,14 @@
|
|||
|
||||
import './fragments.js';
|
||||
import './components.js';
|
||||
import './config.js';
|
||||
import './routes.js';
|
||||
import './headtags.js';
|
||||
|
||||
export * from './categories/index.js';
|
||||
export * from './comments/index.js';
|
||||
export * from './posts/index.js';
|
||||
|
||||
import './embedly/index.js';
|
||||
import './voting/index.js';
|
||||
import './email/index.js';
|
25
packages/example-forum/lib/modules/posts/admin.js
Normal file
25
packages/example-forum/lib/modules/posts/admin.js
Normal file
|
@ -0,0 +1,25 @@
|
|||
/*
|
||||
|
||||
Admin dashboard extension
|
||||
|
||||
*/
|
||||
|
||||
import { extendFragment, addAdminColumn, addStrings } from 'meteor/vulcan:core';
|
||||
import AdminUsersPosts from '../../components/admin/AdminUsersPosts';
|
||||
|
||||
extendFragment('UsersAdmin', `
|
||||
posts(limit: 5){
|
||||
...PostsPage
|
||||
}
|
||||
`);
|
||||
|
||||
addAdminColumn({
|
||||
name: 'posts',
|
||||
order: 50,
|
||||
component: AdminUsersPosts
|
||||
});
|
||||
|
||||
|
||||
addStrings('en', {
|
||||
'admin.users.posts': 'Posts',
|
||||
});
|
180
packages/example-forum/lib/modules/posts/callbacks.js
Normal file
180
packages/example-forum/lib/modules/posts/callbacks.js
Normal file
|
@ -0,0 +1,180 @@
|
|||
import Posts from '../collection.js'
|
||||
import Users from 'meteor/vulcan:users';
|
||||
import Events from 'meteor/vulcan:events';
|
||||
import { getSetting, runCallbacks, runCallbacksAsync, addCallback } from 'meteor/vulcan:core';
|
||||
import { createError } from 'apollo-errors';
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// posts.new.validate //
|
||||
//////////////////////////////////////////////////////
|
||||
|
||||
/**
|
||||
* @summary Rate limiting
|
||||
*/
|
||||
function PostsNewRateLimit (post, user) {
|
||||
|
||||
if(!Users.isAdmin(user)){
|
||||
|
||||
var timeSinceLastPost = Users.timeSinceLast(user, Posts),
|
||||
numberOfPostsInPast24Hours = Users.numberOfItemsInPast24Hours(user, Posts),
|
||||
postInterval = Math.abs(parseInt(getSetting('postInterval', 30))),
|
||||
maxPostsPer24Hours = Math.abs(parseInt(getSetting('maxPostsPerDay', 5)));
|
||||
|
||||
// check that user waits more than X seconds between posts
|
||||
if(timeSinceLastPost < postInterval){
|
||||
const RateLimitError = createError('posts.rate_limit_error', {message: 'posts.rate_limit_error'});
|
||||
throw new RateLimitError({data: {break: true, value: postInterval-timeSinceLastPost}});
|
||||
}
|
||||
// check that the user doesn't post more than Y posts per day
|
||||
if(numberOfPostsInPast24Hours >= maxPostsPer24Hours){
|
||||
const RateLimitError = createError('posts.max_per_day', {message: 'posts.max_per_day'});
|
||||
throw new RateLimitError({data: {break: true, value: maxPostsPer24Hours}});
|
||||
}
|
||||
}
|
||||
|
||||
return post;
|
||||
}
|
||||
addCallback('posts.new.validate', PostsNewRateLimit);
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// posts.new.sync //
|
||||
//////////////////////////////////////////////////////
|
||||
|
||||
|
||||
/**
|
||||
* @summary Check for duplicate links
|
||||
*/
|
||||
function PostsNewDuplicateLinksCheck (post, user) {
|
||||
if(!!post.url && Posts.checkForSameUrl(post.url)) {
|
||||
const DuplicateError = createError('posts.link_already_posted', {message: 'posts.link_already_posted'});
|
||||
throw new DuplicateError({data: {break: true, url: post.url}});
|
||||
}
|
||||
return post;
|
||||
}
|
||||
addCallback('posts.new.sync', PostsNewDuplicateLinksCheck);
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// posts.new.async //
|
||||
//////////////////////////////////////////////////////
|
||||
|
||||
|
||||
/**
|
||||
* @summary Increment the user's post count
|
||||
*/
|
||||
function PostsNewIncrementPostCount (post) {
|
||||
var userId = post.userId;
|
||||
Users.update({_id: userId}, {$inc: {'postCount': 1}});
|
||||
}
|
||||
addCallback('posts.new.async', PostsNewIncrementPostCount);
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// posts.edit.sync //
|
||||
//////////////////////////////////////////////////////
|
||||
|
||||
|
||||
/**
|
||||
* @summary Check for duplicate links
|
||||
*/
|
||||
function PostsEditDuplicateLinksCheck (modifier, post) {
|
||||
if(post.url !== modifier.$set.url && !!modifier.$set.url) {
|
||||
Posts.checkForSameUrl(modifier.$set.url);
|
||||
}
|
||||
return modifier;
|
||||
}
|
||||
addCallback('posts.edit.sync', PostsEditDuplicateLinksCheck);
|
||||
|
||||
|
||||
function PostsEditRunPostApprovedSyncCallbacks (modifier, post) {
|
||||
if (modifier.$set && Posts.isApproved(modifier.$set) && !Posts.isApproved(post)) {
|
||||
modifier = runCallbacks('posts.approve.sync', modifier, post);
|
||||
}
|
||||
return modifier;
|
||||
}
|
||||
addCallback('posts.edit.sync', PostsEditRunPostApprovedSyncCallbacks);
|
||||
|
||||
//////////////////////////////////////////////////////
|
||||
// posts.edit.async //
|
||||
//////////////////////////////////////////////////////
|
||||
|
||||
function PostsEditRunPostApprovedAsyncCallbacks (post, oldPost) {
|
||||
if (Posts.isApproved(post) && !Posts.isApproved(oldPost)) {
|
||||
runCallbacksAsync('posts.approve.async', post);
|
||||
}
|
||||
}
|
||||
addCallback('posts.edit.async', PostsEditRunPostApprovedAsyncCallbacks);
|
||||
|
||||
|
||||
// ------------------------------------- posts.remove.sync -------------------------------- //
|
||||
|
||||
function PostsRemoveOperations (post) {
|
||||
Users.update({_id: post.userId}, {$inc: {'postCount': -1}});
|
||||
return post;
|
||||
}
|
||||
addCallback('posts.remove.sync', PostsRemoveOperations);
|
||||
|
||||
// ------------------------------------- posts.approve.async -------------------------------- //
|
||||
|
||||
/**
|
||||
* @summary set postedAt when a post is approved and it doesn't have a postedAt date
|
||||
*/
|
||||
function PostsSetPostedAt (modifier, post) {
|
||||
if (!modifier.$set.postedAt && !post.postedAt) {
|
||||
modifier.$set.postedAt = new Date();
|
||||
if (modifier.$unset) {
|
||||
delete modifier.$unset.postedAt;
|
||||
}
|
||||
}
|
||||
return modifier;
|
||||
}
|
||||
addCallback('posts.approve.sync', PostsSetPostedAt);
|
||||
|
||||
// ------------------------------------- users.remove.async -------------------------------- //
|
||||
|
||||
function UsersRemoveDeletePosts (user, options) {
|
||||
if (options.deletePosts) {
|
||||
Posts.remove({userId: user._id});
|
||||
} else {
|
||||
// not sure if anything should be done in that scenario yet
|
||||
// Posts.update({userId: userId}, {$set: {author: '\[deleted\]'}}, {multi: true});
|
||||
}
|
||||
}
|
||||
addCallback('users.remove.async', UsersRemoveDeletePosts);
|
||||
|
||||
|
||||
// /**
|
||||
// * @summary Increase the number of clicks on a post
|
||||
// * @param {string} postId – the ID of the post being edited
|
||||
// * @param {string} ip – the IP of the current user
|
||||
// */
|
||||
Posts.increaseClicks = (post, ip) => {
|
||||
const clickEvent = {
|
||||
name: 'click',
|
||||
properties: {
|
||||
postId: post._id,
|
||||
ip: ip
|
||||
}
|
||||
};
|
||||
|
||||
if (getSetting('trackClickEvents', true)) {
|
||||
// make sure this IP hasn't previously clicked on this post
|
||||
const existingClickEvent = Events.findOne({name: 'click', 'properties.postId': post._id, 'properties.ip': ip});
|
||||
|
||||
if(!existingClickEvent) {
|
||||
Events.log(clickEvent);
|
||||
return Posts.update(post._id, { $inc: { clickCount: 1 }});
|
||||
}
|
||||
} else {
|
||||
return Posts.update(post._id, { $inc: { clickCount: 1 }});
|
||||
}
|
||||
};
|
||||
|
||||
function PostsClickTracking(post, ip) {
|
||||
return Posts.increaseClicks(post, ip);
|
||||
}
|
||||
|
||||
// track links clicked, locally in Events collection
|
||||
// note: this event is not sent to segment cause we cannot access the current user
|
||||
// in our server-side route /out -> sending an event would create a new anonymous
|
||||
// user: the free limit of 1,000 unique users per month would be reached quickly
|
||||
addCallback('posts.click.async', PostsClickTracking);
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Reference in a new issue