Merge branch 'example-forum' into devel

This commit is contained in:
SachaG 2017-09-13 10:13:15 +02:00
commit d1a98463cc
186 changed files with 8089 additions and 614 deletions

View file

@ -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

View file

@ -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

View 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!

View 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).

View file

@ -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).

View 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!

View file

@ -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!

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1 @@
export * from '../modules/index.js';

View file

@ -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;

View file

@ -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);

View file

@ -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]);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View 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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View 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);

View file

@ -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);

View 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);

View 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);

View 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);

View 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);

View 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);

View file

@ -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);

View 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);

View 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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View 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);

View 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]);

View 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);

View file

@ -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);

View 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);

View 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);

View 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]);

View file

@ -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);

View file

@ -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>
&nbsp;
{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);

View 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);

View file

@ -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);

View 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);

View file

@ -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);

View file

@ -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);

View 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)
);

View 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);

View 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);

View file

@ -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);

View 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);

View 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);

View file

@ -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);

View 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);

View file

@ -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);

View 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);

View 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);

View 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]);

View file

@ -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]);

View 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);

View 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);

View 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;

View file

@ -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
}
}
]);

View 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
}
}
`);

View 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';
}

View 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';

View 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);

View 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);

View 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;

View file

@ -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);

View file

@ -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);

View file

@ -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);

View file

@ -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);

View 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;

View 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
}
}
]);

View 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
}
`);

View 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;
};

View 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';

View 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);

View 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);

View 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;

View 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}}
};
});

View 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';

View 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'
});

View 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);

View 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,
}
}
]);

View 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
}
});

View file

@ -0,0 +1,2 @@
import './custom_fields.js';
import './emails.js';

View 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
}
});
}
};

View file

@ -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');

View file

@ -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([
{

View file

@ -0,0 +1,2 @@
import './custom_fields.js';
import './callbacks.js';

View 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
}
}
`);

View 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`
});

View 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';

View 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',
});

View 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