diff --git a/packages/news/README.md b/packages/example-forum/README.md old mode 100644 new mode 100755 similarity index 100% rename from packages/news/README.md rename to packages/example-forum/README.md diff --git a/packages/news/lib/assets/content/customizing.md b/packages/example-forum/lib/assets/content/customizing.md old mode 100644 new mode 100755 similarity index 83% rename from packages/news/lib/assets/content/customizing.md rename to packages/example-forum/lib/assets/content/customizing.md index ada373bc3..ed609a274 --- a/packages/news/lib/assets/content/customizing.md +++ b/packages/example-forum/lib/assets/content/customizing.md @@ -1,6 +1,6 @@ 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`. +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 `example-customization` in `.meteor/packages`. Here are two tutorials to get further: * [Understanding the Vulcan Framework](http://docs.vulcanjs.org/tutorial-framework.html ) diff --git a/packages/example-forum/lib/assets/content/deploying.md b/packages/example-forum/lib/assets/content/deploying.md new file mode 100755 index 000000000..8018c9ec2 --- /dev/null +++ b/packages/example-forum/lib/assets/content/deploying.md @@ -0,0 +1,5 @@ +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](http://meteor-up.com/) to deploy to a [Digital Ocean](http://digitalocean.com) server, along with [Compose](http://compose.io) to host your database. Another good solution is [Galaxy](http://galaxy.meteor.com/). + +Learn more in the [Vulcan docs](http://docs.vulcanjs.org/deployment.html). diff --git a/packages/example-forum/lib/assets/content/getting_help.md b/packages/example-forum/lib/assets/content/getting_help.md new file mode 100755 index 000000000..0b3bca7fa --- /dev/null +++ b/packages/example-forum/lib/assets/content/getting_help.md @@ -0,0 +1,9 @@ +### 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 this codebase, then please [leave an issue on GitHub](https://github.com/VulcanJS/Vulcan-Starter/issues/). + +If on the other hand the issue is with Vulcan in general, [leave an issue on the Vulcan core repo](https://github.com/VulcanJS/Vulcan/issues/). If you're not sure which to use, feel free to drop by the Slack chatroom to ask! \ No newline at end of file diff --git a/packages/news/lib/assets/content/read_this_first.md b/packages/example-forum/lib/assets/content/read_this_first.md old mode 100644 new mode 100755 similarity index 56% rename from packages/news/lib/assets/content/read_this_first.md rename to packages/example-forum/lib/assets/content/read_this_first.md index ff3ddeb08..7d36418b9 --- a/packages/news/lib/assets/content/read_this_first.md +++ b/packages/example-forum/lib/assets/content/read_this_first.md @@ -1,12 +1,12 @@ -### Welcome to Vulcan! +### Welcome to Vulcan.js! -If you're reading this, it means you've successfully got Vulcan to run. +If you're reading this, it means you've successfully got Vulcan's Forum example project 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. +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 the app. ### 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. +The first thing you'll need to do is create your account. Since this will be the first ever account created in this project, 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! diff --git a/packages/news/lib/assets/content/removing_getting_started_posts.md b/packages/example-forum/lib/assets/content/removing_getting_started_posts.md old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/assets/content/removing_getting_started_posts.md rename to packages/example-forum/lib/assets/content/removing_getting_started_posts.md diff --git a/packages/news/lib/assets/images/stackoverflow.png b/packages/example-forum/lib/assets/images/stackoverflow.png old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/assets/images/stackoverflow.png rename to packages/example-forum/lib/assets/images/stackoverflow.png diff --git a/packages/news/lib/assets/images/telescope.png b/packages/example-forum/lib/assets/images/telescope.png old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/assets/images/telescope.png rename to packages/example-forum/lib/assets/images/telescope.png diff --git a/packages/example-forum/lib/client/main.js b/packages/example-forum/lib/client/main.js new file mode 100755 index 000000000..67d11275b --- /dev/null +++ b/packages/example-forum/lib/client/main.js @@ -0,0 +1 @@ +export * from '../modules/index.js'; \ No newline at end of file diff --git a/packages/example-forum/lib/components/admin/AdminUsersPosts.js b/packages/example-forum/lib/components/admin/AdminUsersPosts.js new file mode 100755 index 000000000..7dbb87f33 --- /dev/null +++ b/packages/example-forum/lib/components/admin/AdminUsersPosts.js @@ -0,0 +1,12 @@ +import React from 'react'; +import { Posts } from '../../modules/posts/index.js'; +import { Link } from 'react-router'; + +const AdminUsersPosts = ({ document: user }) => + + +export default AdminUsersPosts; \ No newline at end of file diff --git a/packages/example-forum/lib/components/categories/CategoriesDashboard.jsx b/packages/example-forum/lib/components/categories/CategoriesDashboard.jsx new file mode 100755 index 000000000..dc8330306 --- /dev/null +++ b/packages/example-forum/lib/components/categories/CategoriesDashboard.jsx @@ -0,0 +1,30 @@ +/* + +Show a dashboard of all categories + +http://docs.vulcanjs.org/core-components.html#Datatable + +*/ + +import React from 'react'; +import { Components, registerComponent } from 'meteor/vulcan:core'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; + +import { Categories } from '../../modules/categories'; + +const CategoriesDashboard = () => + +
+ +

+ + + +
+ +registerComponent({ name: 'CategoriesDashboard', component: CategoriesDashboard }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/categories/CategoriesEditForm.jsx b/packages/example-forum/lib/components/categories/CategoriesEditForm.jsx new file mode 100755 index 000000000..98f7daf3e --- /dev/null +++ b/packages/example-forum/lib/components/categories/CategoriesEditForm.jsx @@ -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 ( +
+
+
ID: {props.category._id}
+
+ { + props.closeModal(); + props.flash({ id: 'categories.edit_success', properties: { name: category.name }, type: 'success'}); + }} + removeSuccessCallback={({ documentId, documentTitle }) => { + props.closeModal(); + props.flash({ id: 'categories.delete_success', properties: {name: documentTitle }, type: 'success'}); + // context.events.track("category deleted", {_id: documentId}); + }} + showRemove={true} + /> +
+ ); +}; + +CategoriesEditForm.propTypes = { + category: PropTypes.object.isRequired, + closeModal: PropTypes.func, + flash: PropTypes.func, +}; + +CategoriesEditForm.contextTypes = { + intl: intlShape, +}; + +registerComponent({ name: 'CategoriesEditForm', component: CategoriesEditForm, hocs: [withMessages] }); diff --git a/packages/example-forum/lib/components/categories/CategoriesMenu.jsx b/packages/example-forum/lib/components/categories/CategoriesMenu.jsx new file mode 100755 index 000000000..cbdd7497e --- /dev/null +++ b/packages/example-forum/lib/components/categories/CategoriesMenu.jsx @@ -0,0 +1,129 @@ +import { Components, registerComponent, withList, Utils, withCurrentUser } from 'meteor/vulcan:core'; +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import { withRouter } from 'react-router'; +import { Categories } from '../../modules/categories/index.js'; +import { withApollo } from 'react-apollo'; + +/* + +Category menu item + +*/ +const CategoryMenuItem = ({ category, active, expanded }) => {category.name}; + +class CategoriesMenu extends PureComponent { + + /* + + Menu item for the "All Categories" link + + */ + getResetCategoriesItem = () => { + + const resetCategoriesQuery = _.clone(this.props.router.location.query); + delete resetCategoriesQuery.cat; + + const menuItem = { + to: { pathname: Utils.getRoutePath('posts.list'), query: resetCategoriesQuery }, + itemProps: { + eventKey: 0, + className: 'category-menu-item category-menu-item-all dropdown-item', + }, + component: , + }; + + return menuItem; + } + + /* + + Menu items for categoeries + + */ + getCategoriesItems = () => { + 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 = categories.map((category, index) => { + + const query = _.clone(this.props.router.location.query); + query.cat = category.slug; + + const active = currentCategory && category.slug === currentCategory.slug; + const expanded = parentCategories && _.contains(_.pluck(parentCategories, 'slug'), category.slug); + + return { + to: { pathname: Utils.getRoutePath('posts.list'), query }, + component: , + itemProps: { + active, + className: 'dropdown-item', + }, + componentProps: { // will be passed to component defined above + _id: category._id, + parentId: category.parentId, + category, + index, + currentUser: this.props.currentUser, + active, + expanded, + } + }; + }); + + // add `childrenItems` on each item in categoriesClone + const nestedCategories = Utils.unflatten(categoriesClone, { idProperty: 'componentProps._id', parentIdProperty: 'componentProps.parentId', childrenProperty: 'childrenItems' }); + + return nestedCategories; + } + + /* + + Get all menu items + + */ + getMenuItems = () => { + const menuItems = [this.getResetCategoriesItem(), ...this.getCategoriesItems()]; + return menuItems; + }; + + render() { + + return ( +
+ {this.props.loading ? ( + + ) : ( + + )} +
+ ); + } +} + +CategoriesMenu.propTypes = { + results: PropTypes.array, +}; + +const options = { + collection: Categories, + queryName: 'categoriesListQuery', + fragmentName: 'CategoriesList', + limit: 0, + pollInterval: 0, +}; + +registerComponent({ name: 'CategoriesMenu', component: CategoriesMenu, hocs: [withRouter, withApollo, [withList, options], withCurrentUser] }); diff --git a/packages/example-forum/lib/components/categories/CategoriesNewForm.jsx b/packages/example-forum/lib/components/categories/CategoriesNewForm.jsx new file mode 100755 index 000000000..9556d519d --- /dev/null +++ b/packages/example-forum/lib/components/categories/CategoriesNewForm.jsx @@ -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 ( +
+ { + props.closeModal(); + props.flash({id: 'categories.new_success', properties: {name: category.name}, type: "success"}); + }} + /> +
+ ) +} + +CategoriesNewForm.displayName = "CategoriesNewForm"; + +CategoriesNewForm.propTypes = { + closeCallback: PropTypes.func, + flash: PropTypes.func, +}; + +CategoriesNewForm.contextTypes = { + intl: intlShape, +}; + +registerComponent({ name: 'CategoriesNewForm', component: CategoriesNewForm, hocs: [withMessages] }); diff --git a/packages/news/lib/components/comments/CommentsEditForm.js b/packages/example-forum/lib/components/comments/CommentsEditForm.jsx old mode 100644 new mode 100755 similarity index 63% rename from packages/news/lib/components/comments/CommentsEditForm.js rename to packages/example-forum/lib/components/comments/CommentsEditForm.jsx index c18b5df88..840194fcf --- a/packages/news/lib/components/comments/CommentsEditForm.js +++ b/packages/example-forum/lib/components/comments/CommentsEditForm.jsx @@ -1,17 +1,12 @@ -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 { 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 (
- {this[methodName] = this[methodName].bind(this)}); + this.state = { + showReply: false, + showEdit: false + }; + } + + showReply(event) { + event.preventDefault(); + this.setState({showReply: true}); + } + + replyCancelCallback(event) { + this.setState({showReply: false}); + } + + replySuccessCallback() { + this.setState({showReply: false}); + } + + showEdit(event) { + event.preventDefault(); + this.setState({showEdit: true}); + } + + editCancelCallback(event) { + this.setState({showEdit: false}); + } + + editSuccessCallback() { + this.setState({showEdit: false}); + } + + removeSuccessCallback({documentId}) { + this.props.flash({id: 'comments.delete_success', type: 'success'}); + } + + renderComment() { + const htmlBody = {__html: this.props.comment.htmlBody}; + + const showReplyButton = !this.props.comment.isDeleted && !!this.props.currentUser; + + return ( +
+
+ { showReplyButton ? + + + : null} +
+ ) + } + + renderReply() { + + return ( +
+ +
+ ) + } + + renderEdit() { + + return ( + + ) + } + + render() { + const comment = this.props.comment; + + return ( +
+
+
+
+ +
+ + +
{moment(new Date(comment.postedAt)).fromNow()}
+ {Comments.options.mutations.edit.check(this.props.currentUser, this.props.comment) && +
+ +
+ } +
+ {this.state.showEdit ? this.renderEdit() : this.renderComment()} +
+ {this.state.showReply ? this.renderReply() : null} +
+ ) + } + +} + +CommentsItem.propTypes = { + comment: PropTypes.object.isRequired, // the current comment + currentUser: PropTypes.object, + flash: PropTypes.func, +}; + +registerComponent({ name: 'CommentsItem', component: CommentsItem, hocs: [withMessages] }); diff --git a/packages/example-forum/lib/components/comments/CommentsList.jsx b/packages/example-forum/lib/components/comments/CommentsList.jsx new file mode 100755 index 000000000..b84a1a87e --- /dev/null +++ b/packages/example-forum/lib/components/comments/CommentsList.jsx @@ -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 ( +
+ {comments.map(comment => )} + {/*hasMore ? (ready ? : ) : null*/} +
+ ) + } else { + return ( +
+

+ +

+
+ ) + } + +}; + +CommentsList.displayName = "CommentsList"; + +registerComponent({ name: 'CommentsList', component: CommentsList }); diff --git a/packages/example-forum/lib/components/comments/CommentsLoadMore.jsx b/packages/example-forum/lib/components/comments/CommentsLoadMore.jsx new file mode 100755 index 000000000..0f3b78358 --- /dev/null +++ b/packages/example-forum/lib/components/comments/CommentsLoadMore.jsx @@ -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 { e.preventDefault(); loadMore();}}>{label} +} + +CommentsLoadMore.displayName = "CommentsLoadMore"; + +registerComponent({ name: 'CommentsLoadMore', component: CommentsLoadMore }); \ No newline at end of file diff --git a/packages/news/lib/components/comments/CommentsNewForm.js b/packages/example-forum/lib/components/comments/CommentsNewForm.jsx old mode 100644 new mode 100755 similarity index 64% rename from packages/news/lib/components/comments/CommentsNewForm.js rename to packages/example-forum/lib/components/comments/CommentsNewForm.jsx index d8d238b22..5d8efec99 --- a/packages/news/lib/components/comments/CommentsNewForm.js +++ b/packages/example-forum/lib/components/comments/CommentsNewForm.jsx @@ -1,44 +1,40 @@ -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' +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 } + + 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 - }) + topLevelCommentId: props.parentComment.topLevelCommentId || props.parentComment._id + }); } return ( } + failureComponent={} >
) -} + +}; CommentsNewForm.propTypes = { postId: PropTypes.string.isRequired, @@ -49,7 +45,7 @@ CommentsNewForm.propTypes = { successCallback: PropTypes.func, // a callback to execute when the submission has been successful cancelCallback: PropTypes.func, router: PropTypes.object, - flash: PropTypes.func -} + flash: PropTypes.func, +}; -registerComponent('CommentsNewForm', CommentsNewForm, withMessages) +registerComponent({ name: 'CommentsNewForm', component: CommentsNewForm, hocs: [withMessages] }); diff --git a/packages/example-forum/lib/components/comments/CommentsNode.jsx b/packages/example-forum/lib/components/comments/CommentsNode.jsx new file mode 100755 index 000000000..604969b59 --- /dev/null +++ b/packages/example-forum/lib/components/comments/CommentsNode.jsx @@ -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 }) => +
+ + {comment.childrenResults ? +
+ {comment.childrenResults.map(comment => )} +
+ : null + } +
+ +CommentsNode.propTypes = { + comment: PropTypes.object.isRequired, // the current comment +}; + +registerComponent({ name: 'CommentsNode', component: CommentsNode }); diff --git a/packages/example-forum/lib/components/common/Footer.jsx b/packages/example-forum/lib/components/common/Footer.jsx new file mode 100755 index 000000000..126c42a79 --- /dev/null +++ b/packages/example-forum/lib/components/common/Footer.jsx @@ -0,0 +1,13 @@ +import { registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; + +const Footer = props => { + return ( +
+ ) +} + +Footer.displayName = "Footer"; + +registerComponent({ name: 'Footer', component: Footer }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/common/Header.jsx b/packages/example-forum/lib/components/common/Header.jsx new file mode 100755 index 000000000..93cd8a667 --- /dev/null +++ b/packages/example-forum/lib/components/common/Header.jsx @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { withCurrentUser, getSetting, Components, registerComponent } from 'meteor/vulcan:core'; +import { Posts } from '../../modules/posts/index.js'; + +const Header = (props) => { + + const logoUrl = getSetting('logoUrl'); + const siteTitle = getSetting('title', 'My App'); + const tagline = getSetting('tagline'); + + return ( +
+ +
+ +
+ + {tagline ?

{tagline}

: "" } +
+ +
+ +
+ {!!props.currentUser ? : } +
+ +
+ + + +
+ +
+ +
+
+ ) +} + +Header.displayName = "Header"; + +Header.propTypes = { + currentUser: PropTypes.object, +}; + +registerComponent({ name: 'Header', component: Header, hocs: [withCurrentUser] }); diff --git a/packages/example-forum/lib/components/common/Layout.jsx b/packages/example-forum/lib/components/common/Layout.jsx new file mode 100755 index 000000000..fdb4aa7ad --- /dev/null +++ b/packages/example-forum/lib/components/common/Layout.jsx @@ -0,0 +1,36 @@ +import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import Helmet from 'react-helmet'; + +const Layout = ({currentUser, children, currentRoute}) => + +
+ + + + + + + + + {currentUser ? : null} + + + +
+ + + + + + {children} + +
+ + + +
+ +registerComponent({ name: 'Layout', component: Layout, hocs: [withCurrentUser] }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/common/Logo.jsx b/packages/example-forum/lib/components/common/Logo.jsx new file mode 100755 index 000000000..f0a85561e --- /dev/null +++ b/packages/example-forum/lib/components/common/Logo.jsx @@ -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 ( +

+ + {siteTitle} + +

+ ) + } else { + return ( +

+ {siteTitle} +

+ ) + } +} + +Logo.displayName = "Logo"; + +registerComponent({ name: 'Logo', component: Logo }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/common/Newsletter.jsx b/packages/example-forum/lib/components/common/Newsletter.jsx new file mode 100755 index 000000000..7604f7104 --- /dev/null +++ b/packages/example-forum/lib/components/common/Newsletter.jsx @@ -0,0 +1,119 @@ +import { Components, registerComponent, withCurrentUser, withMutation, withMessages } 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 Users from 'meteor/vulcan:users'; +import Cookies from 'universal-cookie'; + +const cookies = new Cookies(); + +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: false + }; + } + + componentDidMount() { + this.setState({ + showBanner: showBanner(this.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 + this.props.flash({id: `newsletter.error_${this.state.error.name}`, message: this.state.error.message, type: 'error'}); + } + } + + successCallbackSubscription(/* result*/) { + this.props.flash({ id: 'newsletter.success_message', type: 'success' }); + this.dismissBanner(); + } + + dismissBanner(e) { + if (e && e.preventDefault) e.preventDefault(); + + this.setState({showBanner: false}); + + // set cookie to keep the banner dismissed persistently + cookies.set('showBanner', 'no'); + } + + renderButton() { + return ( + this.successCallbackSubscription()} + user={this.props.currentUser} + /> + ); + } + + renderForm() { + return ( + + + + + ) + } + + render() { + return this.state.showBanner + ? ( +
+

+ {this.props.currentUser ? this.renderButton() : this.renderForm()} + +
+ ) : 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" + cookies.get('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({ name: 'Newsletter', component: Newsletter, hocs: [withMutation(mutationOptions), withCurrentUser, withMessages] }); diff --git a/packages/example-forum/lib/components/common/NewsletterButton.jsx b/packages/example-forum/lib/components/common/NewsletterButton.jsx new file mode 100755 index 000000000..9eeebcc2b --- /dev/null +++ b/packages/example-forum/lib/components/common/NewsletterButton.jsx @@ -0,0 +1,63 @@ +import { Components, registerComponent, withMutation, withCurrentUser, withMessages, getErrors } from 'meteor/vulcan:core'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n'; + +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(getErrors(error)); // eslint-disable-line no-console + flash(getErrors(error)[0]); + } + } + + render() { + + return ( + + + + ) + } +} + +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({ name: 'NewsletterButton', component: NewsletterButton, hocs: [withCurrentUser, withMutation(addOptions), withMutation(removeOptions), withMessages] }); diff --git a/packages/example-forum/lib/components/common/SearchForm.jsx b/packages/example-forum/lib/components/common/SearchForm.jsx new file mode 100755 index 000000000..25fe0a73e --- /dev/null +++ b/packages/example-forum/lib/components/common/SearchForm.jsx @@ -0,0 +1,81 @@ +import { registerComponent, Components, Utils } from 'meteor/vulcan:core'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +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 = { + pathname: props.router.location.pathname, + 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(() => { + // only update the route if the path hasn't changed in the meantime + if (this.state.pathname === router.location.pathname) { + router.push({pathname: Utils.getRoutePath('posts.list'), query: query}); + } + }, 700 ); + + } + + render() { + + const resetQuery = _.clone(this.props.location.query); + delete resetQuery.query; + + return ( +
+ + + {this.state.search !== '' ? : null} + +
+ ) + } +} + +SearchForm.contextTypes = { + intl: intlShape +}; + +registerComponent({ name: 'SearchForm', component: SearchForm, hocs: [withRouter] }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/common/Vote.jsx b/packages/example-forum/lib/components/common/Vote.jsx new file mode 100755 index 000000000..976b8c6eb --- /dev/null +++ b/packages/example-forum/lib/components/common/Vote.jsx @@ -0,0 +1,71 @@ +import { Components, registerComponent, withMessages } from 'meteor/vulcan:core'; +import React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { withVote, hasVotedClient } from 'meteor/vulcan:voting'; +import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n'; + +class Vote extends PureComponent { + + constructor() { + super(); + this.vote = this.vote.bind(this); + this.getActionClass = this.getActionClass.bind(this); + this.hasVoted = this.hasVoted.bind(this); + } + + vote(e) { + + e.preventDefault(); + + const document = this.props.document; + const collection = this.props.collection; + const user = this.props.currentUser; + + if(!user){ + this.props.flash({id: 'users.please_log_in'}); + } else { + this.props.vote({document, voteType: 'upvote', collection, currentUser: this.props.currentUser}); + } + } + + hasVoted() { + return hasVotedClient({document: this.props.document, voteType: 'upvote'}) + } + + getActionClass() { + + const actionsClass = classNames( + 'vote-button', + {upvoted: this.hasVoted()}, + ); + + return actionsClass; + } + + render() { + return ( + + ) + } + +} + +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({ name: 'Vote', component: Vote, hocs: [withMessages, withVote] }); diff --git a/packages/example-forum/lib/components/posts/PostsCategories.jsx b/packages/example-forum/lib/components/posts/PostsCategories.jsx new file mode 100755 index 000000000..5c868c808 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsCategories.jsx @@ -0,0 +1,17 @@ +import { registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; +import { Link } from 'react-router'; + +const PostsCategories = ({post}) => { + return ( +
+ {post.categories.map(category => + {category.name} + )} +
+ ) +}; + +PostsCategories.displayName = "PostsCategories"; + +registerComponent({ name: 'PostsCategories', component: PostsCategories }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/posts/PostsCommenters.jsx b/packages/example-forum/lib/components/posts/PostsCommenters.jsx new file mode 100755 index 000000000..24aae7795 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsCommenters.jsx @@ -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 ( +
+
+ {_.take(post.commenters, 4).map(user => user && )} +
+
+ + + {post.commentCount} + Comments + +
+
+ ); +}; + +PostsCommenters.displayName = "PostsCommenters"; + +registerComponent({ name: 'PostsCommenters', component: PostsCommenters }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/posts/PostsCommentsThread.jsx b/packages/example-forum/lib/components/posts/PostsCommentsThread.jsx new file mode 100755 index 000000000..cd6f43515 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsCommentsThread.jsx @@ -0,0 +1,55 @@ +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'; + +const PostsCommentsThread = (props, /* context*/) => { + + const {loading, terms: { postId }, results, totalCount, currentUser} = props; + + if (loading) { + + return
+ + } 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 ( +
+

+ + {!!currentUser ? +
+

+ +
: +
+ }> + + +
+ } +
+ ); + } +}; + +PostsCommentsThread.displayName = 'PostsCommentsThread'; + +PostsCommentsThread.propTypes = { + currentUser: PropTypes.object +}; + +const options = { + collectionName: 'Comments', + queryName: 'commentsListQuery', + fragmentName: 'CommentsList', + limit: 0, +}; + +registerComponent({ name: 'PostsCommentsThread', component: PostsCommentsThread, hocs: [[withList, options], withCurrentUser] }); diff --git a/packages/example-forum/lib/components/posts/PostsDaily.jsx b/packages/example-forum/lib/components/posts/PostsDaily.jsx new file mode 100755 index 000000000..18eb55f64 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsDaily.jsx @@ -0,0 +1,22 @@ +import { Components, registerComponent, getSetting, registerSetting } from 'meteor/vulcan:core'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import moment from 'moment'; + +registerSetting('forum.numberOfDays', 5, 'Number of days to display in Daily view'); + +const PostsDaily = props => { + // const terms = props.location && props.location.query; + const numberOfDays = getSetting('forum.numberOfDays', 5); + const terms = { + view: 'top', + after: moment().subtract(numberOfDays - 1, 'days').format('YYYY-MM-DD'), + before: moment().format('YYYY-MM-DD'), + }; + + return +}; + +PostsDaily.displayName = 'PostsDaily'; + +registerComponent({ name: 'PostsDaily', component: PostsDaily }); diff --git a/packages/example-forum/lib/components/posts/PostsDailyList.jsx b/packages/example-forum/lib/components/posts/PostsDailyList.jsx new file mode 100755 index 000000000..3c8f01574 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsDailyList.jsx @@ -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('forum.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('forum.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 ( +
+ + {dates.map((date, index) => )} + {this.state.loading? : } +
+ ) + } +} + +PostsDailyList.propTypes = { + currentUser: PropTypes.object, + days: PropTypes.number, + increment: PropTypes.number +}; + +PostsDailyList.defaultProps = { + days: getSetting('forum.numberOfDays', 5), + increment: getSetting('forum.numberOfDays', 5) +}; + +const options = { + collection: Posts, + queryName: 'postsDailyListQuery', + fragmentName: 'PostsList', + limit: 0, +}; + +registerComponent({ name: 'PostsDailyList', component: PostsDailyList, hocs: [withCurrentUser, [withList, options]] }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/posts/PostsDay.jsx b/packages/example-forum/lib/components/posts/PostsDay.jsx new file mode 100755 index 000000000..f028a5494 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsDay.jsx @@ -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 ( +
+

{date.format('dddd, MMMM Do YYYY')}

+ { noPosts ? : +
+
+ {posts.map((post, index) => )} +
+
+ } +
+ ); + } +} + +PostsDay.propTypes = { + currentUser: PropTypes.object, + date: PropTypes.object, + number: PropTypes.number +}; + +registerComponent({ name: 'PostsDay', component: PostsDay }); diff --git a/packages/news/lib/components/posts/PostsEditForm.js b/packages/example-forum/lib/components/posts/PostsEditForm.jsx old mode 100644 new mode 100755 similarity index 53% rename from packages/news/lib/components/posts/PostsEditForm.js rename to packages/example-forum/lib/components/posts/PostsEditForm.jsx index f63385507..b5e4ba013 --- a/packages/news/lib/components/posts/PostsEditForm.js +++ b/packages/example-forum/lib/components/posts/PostsEditForm.jsx @@ -1,23 +1,16 @@ -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import { - Components, - registerComponent, - 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 React, { PureComponent } from 'react'; +import PropTypes from 'prop-types'; +import { Components, registerComponent, 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 ( - +
ID: {this.props.post._id}
@@ -27,6 +20,7 @@ class PostsEditForm extends PureComponent { } render() { + return (
{Users.isAdmin(this.props.currentUser) ? this.renderAdminArea() : null} @@ -34,51 +28,37 @@ class PostsEditForm extends PureComponent { collection={Posts} documentId={this.props.post._id} successCallback={post => { - this.props.closeModal() - this.props.flash( - this.context.intl.formatMessage( - { id: 'posts.edit_success' }, - { title: post.title } - ), - 'success' - ) + this.props.closeModal(); + this.props.flash({ id: 'posts.edit_success', properties: { title: post.title }, type: 'success'}); }} + mutationFragmentName="PostsPage" 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('/') + this.props.router.push('/'); } - const deleteDocumentSuccess = this.context.intl.formatMessage( - { id: 'posts.delete_success' }, - { title: documentTitle } - ) - this.props.flash(deleteDocumentSuccess, 'success') + this.props.flash({ id: 'posts.delete_success' , properties: { title: documentTitle }, type: 'success'}); // todo: handle events in collection callbacks // this.context.events.track("post deleted", {_id: documentId}); }} showRemove={true} />
- ) + ); + } } PostsEditForm.propTypes = { closeModal: PropTypes.func, flash: PropTypes.func, - post: PropTypes.object.isRequired + post: PropTypes.object.isRequired, } PostsEditForm.contextTypes = { intl: intlShape } -registerComponent( - 'PostsEditForm', - PostsEditForm, - withMessages, - withRouter, - withCurrentUser -) +registerComponent({ name: 'PostsEditForm', component: PostsEditForm, hocs: [withMessages, withRouter, withCurrentUser] }); diff --git a/packages/example-forum/lib/components/posts/PostsHome.jsx b/packages/example-forum/lib/components/posts/PostsHome.jsx new file mode 100755 index 000000000..e5e593d76 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsHome.jsx @@ -0,0 +1,12 @@ +import { Components, registerComponent } from 'meteor/vulcan:core'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; + +const PostsHome = (props, context) => { + const terms = _.isEmpty(props.location && props.location.query) ? {view: 'top'}: props.location.query; + return +}; + +PostsHome.displayName = "PostsHome"; + +registerComponent({ name: 'PostsHome', component: PostsHome }); diff --git a/packages/example-forum/lib/components/posts/PostsItem.jsx b/packages/example-forum/lib/components/posts/PostsItem.jsx new file mode 100755 index 000000000..07c26ccd9 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsItem.jsx @@ -0,0 +1,84 @@ +import { Components, registerComponent } 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 ? : ""; + } + + renderCommenters() { + return this.props.post.commenters && this.props.post.commenters.length > 0 ? : ""; + } + + renderActions() { + return ( +
+ }> + + +
+ ) + } + + render() { + + const {post} = this.props; + + let postClass = "posts-item"; + if (post.sticky) postClass += " posts-sticky"; + + return ( +
+ +
+ +
+ + {post.thumbnailUrl ? : null} + +
+ +

+ + {post.title} + + {this.renderCategories()} +

+ +
+ {post.user?
: null} +
{post.postedAt ? moment(new Date(post.postedAt)).fromNow() : }
+
+ + {!post.commentCount || post.commentCount === 0 ? : + post.commentCount === 1 ? : + + } + +
+ {this.props.currentUser && this.props.currentUser.isAdmin ? : null} + {Posts.options.mutations.edit.check(this.props.currentUser, post) && this.renderActions()} +
+ +
+ + {this.renderCommenters()} + +
+ ) + } +} + +PostsItem.propTypes = { + currentUser: PropTypes.object, + post: PropTypes.object.isRequired, + terms: PropTypes.object, +}; + +registerComponent({ name: 'PostsItem', component: PostsItem }); diff --git a/packages/example-forum/lib/components/posts/PostsList.jsx b/packages/example-forum/lib/components/posts/PostsList.jsx new file mode 100755 index 000000000..180ad5358 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsList.jsx @@ -0,0 +1,80 @@ +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 { FormattedMessage, intlShape } from 'meteor/vulcan:i18n'; +import classNames from 'classnames'; + +const Error = ({error}) => {error.message} + +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 ( +
+ {showHeader ? : null} + {error ? : null } +
+ {results.map(post => )} +
+ {showLoadMore ? + hasMore ? + : + : + null + } +
+ ) + } else if (loading) { + return ( +
+ {showHeader ? : null} + {error ? : null } +
+ +
+
+ ) + } else { + return ( +
+ {showHeader ? : null} + {error ? : null } +
+ +
+
+ ) + } + +}; + +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({ name: 'PostsList', component: PostsList, hocs: [withCurrentUser, [withList, options]] }); diff --git a/packages/news/lib/components/posts/PostsListHeader.js b/packages/example-forum/lib/components/posts/PostsListHeader.jsx old mode 100644 new mode 100755 similarity index 56% rename from packages/news/lib/components/posts/PostsListHeader.js rename to packages/example-forum/lib/components/posts/PostsListHeader.jsx index 6653371fb..dcf73f68f --- a/packages/news/lib/components/posts/PostsListHeader.js +++ b/packages/example-forum/lib/components/posts/PostsListHeader.jsx @@ -1,20 +1,21 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React from 'react' +import { Components, registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; const PostsListHeader = () => { + return (
- +
- +
) } -PostsListHeader.displayName = 'PostsListHeader' +PostsListHeader.displayName = "PostsListHeader"; -registerComponent('PostsListHeader', PostsListHeader) +registerComponent({ name: 'PostsListHeader', component: PostsListHeader }); diff --git a/packages/example-forum/lib/components/posts/PostsLoadMore.jsx b/packages/example-forum/lib/components/posts/PostsLoadMore.jsx new file mode 100755 index 000000000..4874a3d63 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsLoadMore.jsx @@ -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 ( + + ) +} + +PostsLoadMore.displayName = "PostsLoadMore"; + +registerComponent({ name: 'PostsLoadMore', component: PostsLoadMore }); diff --git a/packages/example-forum/lib/components/posts/PostsLoading.jsx b/packages/example-forum/lib/components/posts/PostsLoading.jsx new file mode 100755 index 000000000..3b0f31845 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsLoading.jsx @@ -0,0 +1,10 @@ +import { Components, registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; + +const PostsLoading = props => { + return
+}; + +PostsLoading.displayName = "PostsLoading"; + +registerComponent({ name: 'PostsLoading', component: PostsLoading }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/posts/PostsNewButton.jsx b/packages/example-forum/lib/components/posts/PostsNewButton.jsx new file mode 100755 index 000000000..2b9b6bb39 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsNewButton.jsx @@ -0,0 +1,28 @@ +import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n'; + +const PostsNewButton = (props, context) => { + + const size = props.currentUser ? 'large' : 'small'; + const button = ; + return ( + + + + ) +} + +PostsNewButton.displayName = 'PostsNewButton'; + +PostsNewButton.propTypes = { + currentUser: PropTypes.object, +}; + +PostsNewButton.contextTypes = { + messages: PropTypes.object, + intl: intlShape +}; + +registerComponent({ name: 'PostsNewButton', component: PostsNewButton, hocs: [withCurrentUser] }); diff --git a/packages/news/lib/components/posts/PostsNewForm.js b/packages/example-forum/lib/components/posts/PostsNewForm.jsx old mode 100644 new mode 100755 similarity index 57% rename from packages/news/lib/components/posts/PostsNewForm.js rename to packages/example-forum/lib/components/posts/PostsNewForm.jsx index c0daa2b9f..3b2638513 --- a/packages/news/lib/components/posts/PostsNewForm.js +++ b/packages/example-forum/lib/components/posts/PostsNewForm.jsx @@ -4,21 +4,17 @@ import { getRawComponent, getFragment, withMessages, - withList -} 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' + withList, +} from 'meteor/vulcan:core'; +import { Posts } from '../../modules/posts/index.js'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; import { withRouter } from 'react-router' const PostsNewForm = (props, context) => { if (props.loading) { - return ( -
- -
- ) + return
; } return ( { 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' - ) + props.closeModal(); + props.router.push({pathname: props.redirect || Posts.getPageUrl(post)}); + props.flash({id: "posts.created_message", type: "success"}); }} />
- ) -} + ); +}; PostsNewForm.propTypes = { closeModal: PropTypes.func, router: PropTypes.object, flash: PropTypes.func, - redirect: PropTypes.string + redirect: PropTypes.string, } PostsNewForm.contextTypes = { closeCallback: PropTypes.func, - intl: intlShape -} +}; -PostsNewForm.displayName = 'PostsNewForm' +PostsNewForm.displayName = "PostsNewForm"; const options = { collectionName: 'Categories', queryName: 'categoriesListQuery', fragmentName: 'CategoriesList', limit: 0, - pollInterval: 0 -} + pollInterval: 0, +}; -registerComponent('PostsNewForm', PostsNewForm, withRouter, withMessages, [ - withList, - options -]) +registerComponent({ name: 'PostsNewForm', component: PostsNewForm, hocs: [withRouter, withMessages, [withList, options]] }); diff --git a/packages/example-forum/lib/components/posts/PostsNoMore.jsx b/packages/example-forum/lib/components/posts/PostsNoMore.jsx new file mode 100755 index 000000000..85b9f7dd4 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsNoMore.jsx @@ -0,0 +1,9 @@ +import { registerComponent } from 'meteor/vulcan:core'; +import React from "react"; +import { FormattedMessage } from 'meteor/vulcan:i18n'; + +const PostsNoMore = props =>

; + +PostsNoMore.displayName = "PostsNoMore"; + +registerComponent({ name: 'PostsNoMore', component: PostsNoMore }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/posts/PostsNoResults.jsx b/packages/example-forum/lib/components/posts/PostsNoResults.jsx new file mode 100755 index 000000000..c66926b0d --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsNoResults.jsx @@ -0,0 +1,9 @@ +import { registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; + +const PostsNoResults = props =>

; + +PostsNoResults.displayName = "PostsNoResults"; + +registerComponent({ name: 'PostsNoResults', component: PostsNoResults }); diff --git a/packages/example-forum/lib/components/posts/PostsPage.jsx b/packages/example-forum/lib/components/posts/PostsPage.jsx new file mode 100755 index 000000000..c3ebbce55 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsPage.jsx @@ -0,0 +1,111 @@ +import { Components, registerComponent, withDocument, withCurrentUser, getActions, withMutation } from 'meteor/vulcan:core'; +import { Posts } from '../../modules/posts/index.js'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +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
+ + } else if (!this.props.document) { + + // console.log(`// missing post (_id: ${this.props.documentId})`); + return
+ + } else { + const post = this.props.document; + + const htmlBody = {__html: post.htmlBody}; + + return ( +
+ + + + + + {post.htmlBody ?
: null} + + + +
+ ); + + } + } + + // 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) +); diff --git a/packages/news/lib/components/posts/PostsSingle.js b/packages/example-forum/lib/components/posts/PostsSingle.jsx old mode 100644 new mode 100755 similarity index 54% rename from packages/news/lib/components/posts/PostsSingle.js rename to packages/example-forum/lib/components/posts/PostsSingle.jsx index 41e9f94b8..8d1dfa265 --- a/packages/news/lib/components/posts/PostsSingle.js +++ b/packages/example-forum/lib/components/posts/PostsSingle.jsx @@ -1,10 +1,10 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React from 'react' +import { Components, registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; const PostsSingle = (props, context) => { return -} +}; -PostsSingle.displayName = 'PostsSingle' +PostsSingle.displayName = "PostsSingle"; -registerComponent('PostsSingle', PostsSingle) +registerComponent({ name: 'PostsSingle', component: PostsSingle }); diff --git a/packages/example-forum/lib/components/posts/PostsStats.jsx b/packages/example-forum/lib/components/posts/PostsStats.jsx new file mode 100755 index 000000000..dab46a9e0 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsStats.jsx @@ -0,0 +1,18 @@ +import { Components, registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; + +const PostsStats = ({post}) => { + + return ( +
+ {post.score ? {Math.floor((post.score || 0)*10000)/10000} Score : ""} + {post.baseScore || 0} Upvotes + {post.clickCount || 0} Clicks + {post.viewCount || 0} Views +
+ ) +} + +PostsStats.displayName = "PostsStats"; + +registerComponent({ name: 'PostsStats', component: PostsStats }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/posts/PostsThumbnail.jsx b/packages/example-forum/lib/components/posts/PostsThumbnail.jsx new file mode 100755 index 000000000..c72ac9662 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsThumbnail.jsx @@ -0,0 +1,12 @@ +import { registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; +import { Posts } from '../../modules/posts/index.js'; + +const PostsThumbnail = ({post}) => + + + + +PostsThumbnail.displayName = "PostsThumbnail"; + +registerComponent({ name: 'PostsThumbnail', component: PostsThumbnail }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/posts/PostsViews.jsx b/packages/example-forum/lib/components/posts/PostsViews.jsx new file mode 100755 index 000000000..994335cb0 --- /dev/null +++ b/packages/example-forum/lib/components/posts/PostsViews.jsx @@ -0,0 +1,54 @@ +import { Components, registerComponent, withCurrentUser, Utils } from 'meteor/vulcan:core'; +import React from 'react'; +import PropTypes from 'prop-types'; +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 ( +
+ ({ + to: { pathname: Utils.getRoutePath('posts.list'), query: { ...query, view: view } }, + labelId: `posts.${view}`, + })), + { + to: `/daily`, + labelId: `posts.daily`, + }, + ]} + /> +
+ ); +}; + +PostsViews.propTypes = { + currentUser: PropTypes.object, + defaultView: PropTypes.string, +}; + +PostsViews.defaultProps = { + defaultView: 'top', +}; + +PostsViews.contextTypes = { + currentRoute: PropTypes.object, +}; + +PostsViews.displayName = 'PostsViews'; + +registerComponent({ name: 'PostsViews', component: PostsViews, hocs: [withCurrentUser, withRouter] }); diff --git a/packages/example-forum/lib/components/users/UsersAccount.jsx b/packages/example-forum/lib/components/users/UsersAccount.jsx new file mode 100755 index 000000000..2c97de306 --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersAccount.jsx @@ -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 +}; + +UsersAccount.propTypes = { + currentUser: PropTypes.object +}; + +UsersAccount.displayName = 'UsersAccount'; + +registerComponent({ name: 'UsersAccount', component: UsersAccount, hocs: [withCurrentUser] }); diff --git a/packages/example-forum/lib/components/users/UsersAccountMenu.jsx b/packages/example-forum/lib/components/users/UsersAccountMenu.jsx new file mode 100755 index 000000000..8ffcf7198 --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersAccountMenu.jsx @@ -0,0 +1,25 @@ +import { Components, registerComponent } from 'meteor/vulcan:core'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'meteor/vulcan:i18n'; +import { STATES } from 'meteor/vulcan:accounts'; + +const UsersAccountMenu = ({ state }) => ( + + + +
+ } + pullRight + menuContents={} + /> +); + +UsersAccountMenu.displayName = 'UsersAccountMenu'; + +registerComponent({ name: 'UsersAccountMenu', component: UsersAccountMenu }); diff --git a/packages/example-forum/lib/components/users/UsersAvatar.jsx b/packages/example-forum/lib/components/users/UsersAvatar.jsx new file mode 100755 index 000000000..531871774 --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersAvatar.jsx @@ -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 = {Users.getDisplayName(user)}; + const initials = {Users.avatar.getInitials(user)}; + + const avatar = avatarUrl ? img : initials; + + return ( +
+ {link ? + + {avatar} + + : {avatar} + } +
+ ); + +} + +UsersAvatar.propTypes = { + user: PropTypes.object.isRequired, + size: PropTypes.string, + link: PropTypes.bool +} + +UsersAvatar.defaultProps = { + size: 'medium', + link: true +} + +UsersAvatar.displayName = 'UsersAvatar'; + +registerComponent({ name: 'UsersAvatar', component: UsersAvatar }); diff --git a/packages/example-forum/lib/components/users/UsersEditForm.jsx b/packages/example-forum/lib/components/users/UsersEditForm.jsx new file mode 100755 index 000000000..ed61265cc --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersEditForm.jsx @@ -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 ( + } + > +
+

+ +
+ }> + + +
+ + { + props.flash({ id: 'users.edit_success', properties: {name: Users.getDisplayName(user)}, type: 'success'}) + }} + showRemove={true} + /> +
+
+ ); +}; + + +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({ name: 'UsersEditForm', component: UsersEditForm, hocs: [withMessages, withCurrentUser] }); diff --git a/packages/example-forum/lib/components/users/UsersMenu.jsx b/packages/example-forum/lib/components/users/UsersMenu.jsx new file mode 100755 index 000000000..22988f13b --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersMenu.jsx @@ -0,0 +1,61 @@ +import { Components, registerComponent, withCurrentUser } from 'meteor/vulcan:core'; +import React from 'react'; +import PropTypes from 'prop-types'; +import { Meteor } from 'meteor/meteor'; +import Users from 'meteor/vulcan:users'; +import { withApollo } from 'react-apollo'; + +const UsersMenu = ({ currentUser, client }) => { + const menuItems = [ + { + to: `/users/${currentUser.slug}`, + labelId: 'users.profile', + }, + { + to: `/account`, + labelId: 'users.edit_account', + }, + ]; + + if (Users.isAdmin(currentUser)) { + menuItems.push({ + to: `/admin/users`, + labelId: 'admin.users', + }); + menuItems.push({ + to: `/admin/categories`, + labelId: 'admin.categories', + }); + } + + menuItems.push({ + labelId: 'users.log_out', + itemProps: { + onClick: () => Meteor.logout(() => client.resetStore()), + }, + }); + + return ( +
+ + +
{Users.getDisplayName(currentUser)}
+
+ } + pullRight + menuItems={menuItems} + /> + + ); +}; + +UsersMenu.propsTypes = { + currentUser: PropTypes.object, + client: PropTypes.object, +}; + +registerComponent({ name: 'UsersMenu', component: UsersMenu, hocs: [withCurrentUser, withApollo] }); diff --git a/packages/example-forum/lib/components/users/UsersName.jsx b/packages/example-forum/lib/components/users/UsersName.jsx new file mode 100755 index 000000000..e9a362f27 --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersName.jsx @@ -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}) => {Users.getDisplayName(user)} + +UsersName.propTypes = { + user: PropTypes.object.isRequired, +} + +UsersName.displayName = 'UsersName'; + +registerComponent({ name: 'UsersName', component: UsersName }); \ No newline at end of file diff --git a/packages/example-forum/lib/components/users/UsersProfile.jsx b/packages/example-forum/lib/components/users/UsersProfile.jsx new file mode 100755 index 000000000..912c2a73e --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersProfile.jsx @@ -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
+ + } else if (!props.document) { + + // console.log(`// missing user (_id/slug: ${props.documentId || props.slug})`); + return
+ + } else { + + const user = props.document; + + const terms = {view: "userPosts", userId: user._id}; + + return ( +
+ +

{Users.getDisplayName(user)}

+ {user.htmlBio ?
: null } + +

+ +
+ ) + } +} + +UsersProfile.propTypes = { + // document: PropTypes.object.isRequired, +} + +UsersProfile.displayName = "UsersProfile"; + +const options = { + collection: Users, + queryName: 'usersSingleQuery', + fragmentName: 'UsersProfile', +}; + +registerComponent({ name: 'UsersProfile', component: UsersProfile, hocs: [withCurrentUser, [withDocument, options]] }); diff --git a/packages/example-forum/lib/components/users/UsersProfileCheck.jsx b/packages/example-forum/lib/components/users/UsersProfileCheck.jsx new file mode 100755 index 000000000..84639afd6 --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersProfileCheck.jsx @@ -0,0 +1,82 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Users from 'meteor/vulcan:users'; +import { withDocument, Components, registerComponent, withMessages } from 'meteor/vulcan:core'; +import { FormattedMessage } 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) { + const footer = ( + Meteor.logout(() => window.location.reload() /* something is broken here when giving the apollo client as a prop*/) }> + + + ); + return ( + } + footerContent={ footer } + > + { + const newUser = {...currentUser, ...user}; + if (Users.hasCompletedProfile(newUser)) { + flash({id: "users.profile_completed", type: 'success'}); + } + }} + /> + + ); + } else { + + return null; + + } + } + +}; + + +UsersProfileCheck.propTypes = { + currentUser: PropTypes.object +}; + +UsersProfileCheck.displayName = 'UsersProfileCheck'; + +const mustCompleteFragment = gql` + fragment UsersMustCompleteFragment on User { + _id + ${Users.getRequiredFields().join('\n')} + } +` + +const options = { + collection: Users, + queryName: 'usersMustCompleteQuery', + fragment: mustCompleteFragment, +}; + +registerComponent({ name: 'UsersProfileCheck', component: UsersProfileCheck, hocs: [withMessages, [withDocument, options]] }); diff --git a/packages/example-forum/lib/components/users/UsersSingle.jsx b/packages/example-forum/lib/components/users/UsersSingle.jsx new file mode 100755 index 000000000..58d311aff --- /dev/null +++ b/packages/example-forum/lib/components/users/UsersSingle.jsx @@ -0,0 +1,10 @@ +import { Components, registerComponent } from 'meteor/vulcan:core'; +import React from 'react'; + +const UsersSingle = (props, context) => { + return +}; + +UsersSingle.displayName = "UsersSingle"; + +registerComponent({ name: 'UsersSingle', component: UsersSingle }); diff --git a/packages/news/lib/modules/categories/collection.js b/packages/example-forum/lib/modules/categories/collection.js old mode 100644 new mode 100755 similarity index 50% rename from packages/news/lib/modules/categories/collection.js rename to packages/example-forum/lib/modules/categories/collection.js index fee4a3773..7e6e3126c --- a/packages/news/lib/modules/categories/collection.js +++ b/packages/example-forum/lib/modules/categories/collection.js @@ -4,18 +4,15 @@ The Categories collection */ -import { - createCollection, - getDefaultResolvers, - getDefaultMutations -} from 'meteor/vulcan:core' -import schema from './schema.js' +import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core'; +import schema from './schema.js'; /** * @summary The global namespace for Categories. * @namespace Categories */ -export const Categories = createCollection({ + export const Categories = createCollection({ + collectionName: 'Categories', typeName: 'Category', @@ -24,5 +21,6 @@ export const Categories = createCollection({ resolvers: getDefaultResolvers('Categories'), - mutations: getDefaultMutations('Categories') -}) + mutations: getDefaultMutations('Categories'), + +}); \ No newline at end of file diff --git a/packages/example-forum/lib/modules/categories/custom_fields.js b/packages/example-forum/lib/modules/categories/custom_fields.js new file mode 100755 index 000000000..8e20317e6 --- /dev/null +++ b/packages/example-forum/lib/modules/categories/custom_fields.js @@ -0,0 +1,52 @@ +/* + +Custom fields on Posts collection + +*/ + +import { Posts } from '../../modules/posts/index.js'; +import { getCategoriesAsOptions } from './schema.js'; + +Posts.addField([ + { + fieldName: 'categoriesIds', + fieldSchema: { + type: Array, + input: 'checkboxgroup', + optional: true, + canCreate: ['members'], + canUpdate: ['members'], + canRead: ['guests'], + options: props => { + return getCategoriesAsOptions(props.data.categories.results); + }, + query: ` + categories{ + results{ + _id + name + slug + order + } + } + `, + resolveAs: { + fieldName: 'categories', + type: '[Category]', + resolver: async (post, args, {currentUser, Users, Categories}) => { + if (!post.categoriesIds) return []; + const categories = _.compact(await Categories.loader.loadMany(post.categoriesIds)); + return Users.restrictViewableFields(currentUser, Categories, categories); + }, + addOriginalField: true, + } + } + }, + { + fieldName: 'categoriesIds.$', + fieldSchema: { + type: String, + optional: true + } + } +]); diff --git a/packages/news/lib/modules/categories/fragments.js b/packages/example-forum/lib/modules/categories/fragments.js old mode 100644 new mode 100755 similarity index 86% rename from packages/news/lib/modules/categories/fragments.js rename to packages/example-forum/lib/modules/categories/fragments.js index edb8a4d9f..609822c08 --- a/packages/news/lib/modules/categories/fragments.js +++ b/packages/example-forum/lib/modules/categories/fragments.js @@ -1,4 +1,4 @@ -import { registerFragment } from 'meteor/vulcan:core' +import { registerFragment } from 'meteor/vulcan:core'; // note: fragment used by default on CategoriesList & PostsList fragments registerFragment(` @@ -8,7 +8,7 @@ registerFragment(` name slug } -`) +`); registerFragment(` fragment CategoriesList on Category { @@ -22,4 +22,4 @@ registerFragment(` ...CategoriesMinimumInfo } } -`) +`); diff --git a/packages/example-forum/lib/modules/categories/helpers.js b/packages/example-forum/lib/modules/categories/helpers.js new file mode 100755 index 000000000..fcf29c1a0 --- /dev/null +++ b/packages/example-forum/lib/modules/categories/helpers.js @@ -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'; +} + diff --git a/packages/example-forum/lib/modules/categories/index.js b/packages/example-forum/lib/modules/categories/index.js new file mode 100755 index 000000000..9ed572249 --- /dev/null +++ b/packages/example-forum/lib/modules/categories/index.js @@ -0,0 +1,8 @@ +export * from './collection.js'; + +import './fragments.js'; +import './views.js'; +import './custom_fields.js'; +import './helpers.js'; +import './permissions.js'; +import './parameters.js'; \ No newline at end of file diff --git a/packages/news/lib/modules/categories/parameters.js b/packages/example-forum/lib/modules/categories/parameters.js old mode 100644 new mode 100755 similarity index 55% rename from packages/news/lib/modules/categories/parameters.js rename to packages/example-forum/lib/modules/categories/parameters.js index f87d8c8e6..e4b67c834 --- a/packages/news/lib/modules/categories/parameters.js +++ b/packages/example-forum/lib/modules/categories/parameters.js @@ -4,25 +4,23 @@ Categories parameter */ -import { addCallback, getSetting, registerSetting } from 'meteor/vulcan:core' -import gql from 'graphql-tag' -import { Categories } from './collection.js' +import { addCallback, getSetting, registerSetting } from 'meteor/vulcan:core'; +import gql from 'graphql-tag'; +import { Categories } from './collection.js'; -registerSetting( - 'forum.categoriesFilter', - 'union', - 'Display posts belonging to all (“intersection”) or at least one of (“union”) the selected categories' -) +registerSetting('forum.categoriesFilter', 'union', 'Display posts belonging to all (“intersection”) or at least one of (“union”) the selected categories'); // Category Posts Parameters -// Add a 'categories' property to terms which can be used to filter *all* existing Posts views. +// Add a 'categories' property to terms which can be used to filter *all* existing Posts views. function PostsCategoryParameter(parameters, terms, apolloClient) { + // get category slugs - const cat = terms.cat || terms['cat[]'] - const categoriesSlugs = Array.isArray(cat) ? cat : [cat] - let allCategories = [] + const cat = terms.cat || terms['cat[]']; + const categoriesSlugs = Array.isArray(cat) ? cat : [cat]; + let allCategories = []; if (cat && cat.length) { + // get all categories // note: specify all arguments, see https://github.com/apollographql/apollo-client/issues/2051 const query = ` @@ -38,35 +36,26 @@ function PostsCategoryParameter(parameters, terms, apolloClient) { // get categories from Redux store allCategories = apolloClient.readQuery({ query: gql`${query}`, - variables: { terms: { limit: 0, itemsPerPage: 0 }, enableCache: false } - }).CategoriesList + variables: {terms: {limit: 0, itemsPerPage: 0}, enableCache: false} + }).CategoriesList; } else { // TODO: figure out how to make this async without messing up withList on the client // get categories through GraphQL API using runQuery // const results = await runQuery(query); // allCategories = results.data.CategoriesList; - allCategories = Categories.find().fetch() + allCategories = Categories.find().fetch(); } // get corresponding category ids - const categoriesIds = _.pluck( - _.filter(allCategories, category => - _.contains(categoriesSlugs, category.slug) - ), - '_id' - ) + const categoriesIds = _.pluck(_.filter(allCategories, category => _.contains(categoriesSlugs, category.slug)), '_id'); - const operator = - getSetting('forum.categoriesFilter', 'union') === 'union' ? '$in' : '$all' + const operator = getSetting('forum.categoriesFilter', 'union') === 'union' ? '$in' : '$all'; // parameters.selector = Meteor.isClient ? {...parameters.selector, 'categories._id': {$in: categoriesIds}} : {...parameters.selector, categories: {[operator]: categoriesIds}}; - parameters.selector = { - ...parameters.selector, - categoriesIds: { [operator]: categoriesIds } - } + parameters.selector = {...parameters.selector, categoriesIds: {[operator]: categoriesIds}}; } - return parameters + return parameters; } -addCallback('posts.parameters', PostsCategoryParameter) +addCallback('posts.parameters', PostsCategoryParameter); diff --git a/packages/example-forum/lib/modules/categories/permissions.js b/packages/example-forum/lib/modules/categories/permissions.js new file mode 100755 index 000000000..2181bd17c --- /dev/null +++ b/packages/example-forum/lib/modules/categories/permissions.js @@ -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); diff --git a/packages/news/lib/modules/categories/schema.js b/packages/example-forum/lib/modules/categories/schema.js old mode 100644 new mode 100755 similarity index 50% rename from packages/news/lib/modules/categories/schema.js rename to packages/example-forum/lib/modules/categories/schema.js index 6e546b508..90068b72a --- a/packages/news/lib/modules/categories/schema.js +++ b/packages/example-forum/lib/modules/categories/schema.js @@ -4,125 +4,119 @@ Categories schema */ -import { Utils } from 'meteor/vulcan:core' +import { Utils } from 'meteor/vulcan:core'; -export function getCategoriesAsOptions(categories) { +export function getCategoriesAsOptions (categories) { // give the form component (here: checkboxgroup) exploitable data return categories.map(category => ({ value: category._id, - label: category.name + label: category.name, // slug: category.slug, // note: it may be used to look up from prefilled props - })) + })); } -export function getCategoriesAsNestedOptions(categories) { +export function getCategoriesAsNestedOptions (categories) { // give the form component (here: checkboxgroup) exploitable data - const formattedCategories = categories.map(function(category) { + const formattedCategories = categories.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 + }; + }); + const nestedCategories = Utils.unflatten(formattedCategories, {idProperty: '_id', parentIdProperty: 'parentId', childrenProperty: 'options'}); + return nestedCategories; } // category schema const schema = { _id: { type: String, - viewableBy: ['guests'], - optional: true + canRead: ['guests'], + optional: true, }, name: { type: String, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'] + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], }, description: { type: String, optional: true, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - form: { + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + inputProperties: { rows: 3 } }, order: { type: Number, optional: true, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'] + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], }, slug: { type: String, optional: true, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - onInsert: category => { + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + onCreate: ({newDocument: category}) => { // if no slug has been provided, generate one - const slug = category.slug || Utils.slugify(category.name) - return Utils.getUnusedSlugByCollectionName('Categories', slug) + const slug = category.slug || Utils.slugify(category.name); + return Utils.getUnusedSlugByCollectionName('Categories', slug); }, - onEdit: (modifier, category) => { + onUpdate: ({data, document: category}) => { // if slug is changing - if ( - modifier.$set && - modifier.$set.slug && - modifier.$set.slug !== category.slug - ) { - const slug = modifier.$set.slug - return Utils.getUnusedSlugByCollectionName('Categories', slug) + if (data.slug && data.slug !== category.slug) { + const slug = data.slug; + return Utils.getUnusedSlugByCollectionName('Categories', slug); } } }, image: { type: String, optional: true, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'] + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], }, parentId: { type: String, optional: true, - control: 'select', - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], + input: "select", + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['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) + 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 }, options: props => { - return getCategoriesAsOptions(props.data.CategoriesList) + return getCategoriesAsOptions(props.data.categories.results); }, query: ` - CategoriesList{ - _id - name - slug - order + categories{ + results{ + _id + name + slug + order + } } - ` + `, } -} +}; -export default schema +export default schema; diff --git a/packages/news/lib/modules/categories/views.js b/packages/example-forum/lib/modules/categories/views.js old mode 100644 new mode 100755 similarity index 68% rename from packages/news/lib/modules/categories/views.js rename to packages/example-forum/lib/modules/categories/views.js index 91cd268bb..68ce2aacc --- a/packages/news/lib/modules/categories/views.js +++ b/packages/example-forum/lib/modules/categories/views.js @@ -4,7 +4,7 @@ Default sort */ -import { Categories } from './collection.js' +import { Categories } from './collection.js'; Categories.addDefaultView(terms => ({ options: { @@ -12,4 +12,4 @@ Categories.addDefaultView(terms => ({ order: 1 } } -})) +})); \ No newline at end of file diff --git a/packages/example-forum/lib/modules/comments/collection.js b/packages/example-forum/lib/modules/comments/collection.js new file mode 100755 index 000000000..368aad769 --- /dev/null +++ b/packages/example-forum/lib/modules/comments/collection.js @@ -0,0 +1,37 @@ +/* + +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 + */ + export 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; + } +} \ No newline at end of file diff --git a/packages/news/lib/modules/comments/custom_fields.js b/packages/example-forum/lib/modules/comments/custom_fields.js old mode 100644 new mode 100755 similarity index 73% rename from packages/news/lib/modules/comments/custom_fields.js rename to packages/example-forum/lib/modules/comments/custom_fields.js index 4c5c74eb9..957ae3173 --- a/packages/news/lib/modules/comments/custom_fields.js +++ b/packages/example-forum/lib/modules/comments/custom_fields.js @@ -1,5 +1,5 @@ -import { Posts } from '../posts/index.js' -import Users from 'meteor/vulcan:users' +import { Posts } from '../posts/index.js'; +import Users from 'meteor/vulcan:users'; Users.addField([ /** @@ -11,10 +11,10 @@ Users.addField([ type: Number, optional: true, defaultValue: 0, - viewableBy: ['guests'] + canRead: ['guests'], } } -]) +]); Posts.addField([ /** @@ -26,7 +26,7 @@ Posts.addField([ type: Number, optional: true, defaultValue: 0, - viewableBy: ['guests'] + canRead: ['guests'], } }, /** @@ -40,13 +40,13 @@ Posts.addField([ 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) - } + 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'] + canRead: ['guests'], } }, { @@ -56,4 +56,4 @@ Posts.addField([ optional: true } } -]) +]); diff --git a/packages/news/lib/modules/comments/fragments.js b/packages/example-forum/lib/modules/comments/fragments.js old mode 100644 new mode 100755 similarity index 84% rename from packages/news/lib/modules/comments/fragments.js rename to packages/example-forum/lib/modules/comments/fragments.js index 77f77db38..db359508b --- a/packages/news/lib/modules/comments/fragments.js +++ b/packages/example-forum/lib/modules/comments/fragments.js @@ -1,8 +1,8 @@ -import { registerFragment } from 'meteor/vulcan:core' +import { registerFragment } from 'meteor/vulcan:core'; // ----------------------------- Comments ------------------------------ // -registerFragment(` +registerFragment(/* GraphQL */` fragment CommentsList on Comment { # vulcan:comments _id @@ -32,4 +32,5 @@ registerFragment(` baseScore score } -`) +`); + diff --git a/packages/example-forum/lib/modules/comments/helpers.js b/packages/example-forum/lib/modules/comments/helpers.js new file mode 100755 index 000000000..546b9fc6d --- /dev/null +++ b/packages/example-forum/lib/modules/comments/helpers.js @@ -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; +}; diff --git a/packages/example-forum/lib/modules/comments/index.js b/packages/example-forum/lib/modules/comments/index.js new file mode 100755 index 000000000..7e7b91dde --- /dev/null +++ b/packages/example-forum/lib/modules/comments/index.js @@ -0,0 +1,7 @@ +export * from './collection.js'; + +import './fragments.js'; +import './custom_fields.js'; +import './helpers.js'; +import './permissions.js'; +import './views.js'; \ No newline at end of file diff --git a/packages/example-forum/lib/modules/comments/permissions.js b/packages/example-forum/lib/modules/comments/permissions.js new file mode 100755 index 000000000..5c6a27fef --- /dev/null +++ b/packages/example-forum/lib/modules/comments/permissions.js @@ -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); \ No newline at end of file diff --git a/packages/news/lib/modules/comments/schema.js b/packages/example-forum/lib/modules/comments/schema.js old mode 100644 new mode 100755 similarity index 53% rename from packages/news/lib/modules/comments/schema.js rename to packages/example-forum/lib/modules/comments/schema.js index 1ea19699e..33ee5bc87 --- a/packages/news/lib/modules/comments/schema.js +++ b/packages/example-forum/lib/modules/comments/schema.js @@ -4,9 +4,9 @@ Comments schema */ -import Users from 'meteor/vulcan:users' -import marked from 'marked' -import { Utils } from 'meteor/vulcan:core' +import Users from 'meteor/vulcan:users'; +import marked from 'marked'; +import { Utils } from 'meteor/vulcan:core'; /** * @summary Comments schema @@ -19,7 +19,7 @@ const schema = { _id: { type: String, optional: true, - viewableBy: ['guests'] + canRead: ['guests'], }, /** The `_id` of the parent comment, if there is one @@ -28,22 +28,16 @@ const schema = { type: String, // regEx: SimpleSchema.RegEx.Id, max: 500, - viewableBy: ['guests'], - insertableBy: ['members'], + canRead: ['guests'], + canCreate: ['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 - ) + 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 }, @@ -56,22 +50,16 @@ const schema = { type: String, // regEx: SimpleSchema.RegEx.Id, max: 500, - viewableBy: ['guests'], - insertableBy: ['members'], + canRead: ['guests'], + canCreate: ['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 - ) + 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 }, @@ -83,9 +71,9 @@ const schema = { createdAt: { type: Date, optional: true, - viewableBy: ['admins'], - onInsert: (document, currentUser) => { - return new Date() + canRead: ['admins'], + onCreate: () => { + return new Date(); } }, /** @@ -94,9 +82,9 @@ const schema = { postedAt: { type: Date, optional: true, - viewableBy: ['guests'], - onInsert: (document, currentUser) => { - return new Date() + canRead: ['guests'], + onCreate: () => { + return new Date(); } }, /** @@ -105,10 +93,10 @@ const schema = { body: { type: String, max: 3000, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - control: 'textarea' + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + input: "textarea" }, /** The HTML version of the comment body @@ -116,15 +104,15 @@ const schema = { htmlBody: { type: String, optional: true, - viewableBy: ['guests'], - onInsert: comment => { + canRead: ['guests'], + onCreate: ({newDocument: comment}) => { if (comment.body) { - return Utils.sanitize(marked(comment.body)) + return Utils.sanitize(marked(comment.body)); } }, - onEdit: (modifier, comment) => { - if (modifier.$set.body) { - return Utils.sanitize(marked(modifier.$set.body)) + onUpdate: ({data}) => { + if (data.body) { + return Utils.sanitize(marked(data.body)); } } }, @@ -134,11 +122,11 @@ const schema = { author: { type: String, optional: true, - viewableBy: ['guests'], - onEdit: (modifier, document, currentUser) => { + canRead: ['guests'], + onUpdate: ({data}) => { // if userId is changing, change the author name too - if (modifier.$set && modifier.$set.userId) { - return Users.getDisplayNameById(modifier.$set.userId) + if (data.userId) { + return Users.getDisplayNameById(data.userId) } } }, @@ -148,17 +136,17 @@ const schema = { postId: { type: String, optional: true, - viewableBy: ['guests'], - insertableBy: ['members'], + canRead: ['guests'], + canCreate: ['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) + 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 }, @@ -170,19 +158,19 @@ const schema = { userId: { type: String, optional: true, - viewableBy: ['guests'], - insertableBy: ['members'], + canRead: ['guests'], + canCreate: ['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) + 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. @@ -190,22 +178,22 @@ const schema = { isDeleted: { type: Boolean, optional: true, - viewableBy: ['guests'] + canRead: ['guests'], }, userIP: { type: String, optional: true, - viewableBy: ['admins'] + canRead: ['admins'], }, userAgent: { type: String, optional: true, - viewableBy: ['admins'] + canRead: ['admins'], }, referrer: { type: String, optional: true, - viewableBy: ['admins'] + canRead: ['admins'], }, // GraphQL only fields @@ -213,15 +201,15 @@ const schema = { pageUrl: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { fieldName: 'pageUrl', type: 'String', resolver: (comment, args, context) => { - return context.Comments.getPageUrl(comment, true) - } + return context.Comments.getPageUrl(comment, true); + }, } - } -} + }, +}; -export default schema +export default schema; diff --git a/packages/example-forum/lib/modules/comments/views.js b/packages/example-forum/lib/modules/comments/views.js new file mode 100755 index 000000000..6f596711d --- /dev/null +++ b/packages/example-forum/lib/modules/comments/views.js @@ -0,0 +1,21 @@ +/* + +Comments views + +*/ + +import { Comments } from './index.js'; + +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}} + }; +}); \ No newline at end of file diff --git a/packages/example-forum/lib/modules/components.js b/packages/example-forum/lib/modules/components.js new file mode 100755 index 000000000..68b352338 --- /dev/null +++ b/packages/example-forum/lib/modules/components.js @@ -0,0 +1,64 @@ + +// common + +import '../components/common/Footer.jsx'; +import '../components/common/Header.jsx'; +import '../components/common/Layout.jsx'; +import '../components/common/Logo.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/CategoriesMenu.jsx'; +import '../components/categories/CategoriesEditForm.jsx'; +import '../components/categories/CategoriesNewForm.jsx'; +import '../components/categories/CategoriesDashboard.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'; \ No newline at end of file diff --git a/packages/example-forum/lib/modules/config.js b/packages/example-forum/lib/modules/config.js new file mode 100755 index 000000000..40ae7ca0f --- /dev/null +++ b/packages/example-forum/lib/modules/config.js @@ -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' +}); diff --git a/packages/news/lib/modules/fragments.js b/packages/example-forum/lib/modules/fragments.js old mode 100644 new mode 100755 similarity index 81% rename from packages/news/lib/modules/fragments.js rename to packages/example-forum/lib/modules/fragments.js index 56706fbcf..bb1e5accc --- a/packages/news/lib/modules/fragments.js +++ b/packages/example-forum/lib/modules/fragments.js @@ -1,21 +1,21 @@ -import { registerFragment } from 'meteor/vulcan:core' +import { registerFragment } from 'meteor/vulcan:core'; // ------------------------------ Vote ------------------------------ // // note: fragment used by default on the UsersProfile fragment -registerFragment(` +registerFragment(/* GraphQL */` fragment VotedItem on Vote { # vulcan:voting documentId power votedAt } -`) +`); // ------------------------------ Users ------------------------------ // // note: fragment used by default on UsersProfile, PostsList & CommentsList fragments -registerFragment(` +registerFragment(/* GraphQL */` fragment UsersMinimumInfo on User { # vulcan:users _id @@ -24,10 +24,11 @@ registerFragment(` displayName emailHash avatarUrl + pageUrl } -`) +`); -registerFragment(` +registerFragment(/* GraphQL */` fragment UsersProfile on User { # vulcan:users ...UsersMinimumInfo @@ -38,10 +39,10 @@ registerFragment(` twitterUsername website groups - karma # vulcan:posts postCount # vulcan:comments commentCount } -`) +`); + diff --git a/packages/news/lib/modules/headtags.js b/packages/example-forum/lib/modules/headtags.js old mode 100644 new mode 100755 similarity index 64% rename from packages/news/lib/modules/headtags.js rename to packages/example-forum/lib/modules/headtags.js index 21794f643..ae78576b8 --- a/packages/news/lib/modules/headtags.js +++ b/packages/example-forum/lib/modules/headtags.js @@ -1,9 +1,9 @@ -import { Head, Utils } from 'meteor/vulcan:core' +import { Head, Utils } from 'meteor/vulcan:core'; // add permanent markup Head.link.push({ name: 'rss', - rel: 'alternate', + rel: 'alternate', type: 'application/rss+xml', href: `${Utils.getSiteUrl()}feed.xml` -}) +}); diff --git a/packages/news/lib/modules/i18n.js b/packages/example-forum/lib/modules/i18n.js old mode 100644 new mode 100755 similarity index 80% rename from packages/news/lib/modules/i18n.js rename to packages/example-forum/lib/modules/i18n.js index 8f4c67015..858720323 --- a/packages/news/lib/modules/i18n.js +++ b/packages/example-forum/lib/modules/i18n.js @@ -1,6 +1,7 @@ -import { addStrings } from 'meteor/vulcan:core' +import { addStrings } from 'meteor/vulcan:core'; addStrings('en', { + 'posts.new_post': 'New Post', 'posts.edit': 'Edit', 'posts.edit_success': 'Post “{title}” edited.', @@ -39,15 +40,13 @@ addStrings('en', { 'posts.unsubscribe': 'Unsubscribe', 'posts.subscribed': 'You have subscribed to “{name}” comments.', 'posts.unsubscribed': 'You have unsubscribed from “{name}” comments.', - 'posts.subscribed_posts': 'Posts subscribed to', + 'posts.subscribed_posts' : 'Posts subscribed to', 'posts.link_already_posted': 'This link has already been posted.', - 'posts.max_per_day': - 'Sorry you cannot submit more than {value} posts per day.', + 'posts.max_per_day': 'Sorry you cannot submit more than {value} posts per day.', 'posts.like': 'Like', 'comments.comments': 'Comments', - 'comments.count': - '{count, plural, =0 {No comments} one {# comment} other {# comments}}', + 'comments.count': '{count, plural, =0 {No comments} one {# comment} other {# comments}}', 'comments.count_0': 'No comments', 'comments.count_1': '1 comment', 'comments.count_2': '{count} comments', @@ -62,10 +61,9 @@ addStrings('en', { 'comments.parentCommentId': 'Parent Comment ID', 'comments.topLevelCommentId': 'Top Level Comment ID', 'comments.body': 'Body', - 'comments.rate_limit_error': - 'Please wait {value} seconds before commenting again.', + 'comments.rate_limit_error': 'Please wait {value} seconds before commenting again.', - categories: 'Categories', + 'categories': 'Categories', 'categories.all': 'All Categories', 'categories.edit': 'Edit Category', 'categories.edit_success': 'Category “{name}” edited.', @@ -78,12 +76,16 @@ addStrings('en', { 'categories.slug': 'Slug', 'categories.image': 'Image', 'categories.parentId': 'Parent ID', - 'categories.subscribe': "Subscribe to this category's posts", - 'categories.unsubscribe': "Unsubscribe to this category's posts", + 'categories.subscribe': 'Subscribe to this category\'s posts', + 'categories.unsubscribe': 'Unsubscribe to this category\'s posts', 'categories.subscribed': 'You have subscribed to “{name}” posts.', 'categories.unsubscribed': 'You have unsubscribed from “{name}” posts.', - 'categories.subscribed_categories': 'Categories subscribed to', + 'categories.subscribed_categories' : 'Categories subscribed to', 'categories.delete_confirm': 'Delete category “{title}”?', 'categories.delete_success': 'Category “{name}” deleted.', - 'categories.invalid': 'Invalid category' -}) + 'categories.invalid': 'Invalid category', + + 'admin.categories': 'Categories (admin)', + 'admin.users': 'Users (admin)', + +}); diff --git a/packages/example-forum/lib/modules/index.js b/packages/example-forum/lib/modules/index.js new file mode 100755 index 000000000..1a64d1a62 --- /dev/null +++ b/packages/example-forum/lib/modules/index.js @@ -0,0 +1,13 @@ +import './voting.js'; +import './fragments.js'; +import './components.js'; +import './config.js'; +import './routes.js'; +import './headtags.js'; +import './i18n.js'; + +export { Posts } from './posts/index.js'; +export { Categories } from './categories/index.js'; +export { Comments } from './comments/index.js'; + +import './notifications/index.js'; diff --git a/packages/news/lib/modules/notifications/custom_fields.js b/packages/example-forum/lib/modules/notifications/custom_fields.js old mode 100644 new mode 100755 similarity index 54% rename from packages/news/lib/modules/notifications/custom_fields.js rename to packages/example-forum/lib/modules/notifications/custom_fields.js index 0d598a4ee..285fe2c66 --- a/packages/news/lib/modules/notifications/custom_fields.js +++ b/packages/example-forum/lib/modules/notifications/custom_fields.js @@ -1,9 +1,9 @@ -import Users from 'meteor/vulcan:users' +import Users from 'meteor/vulcan:users'; const notificationsGroup = { - name: 'notifications', + name: "notifications", order: 2 -} +}; // Add notifications options to user profile settings Users.addField([ @@ -14,11 +14,11 @@ Users.addField([ type: Boolean, optional: true, defaultValue: false, - control: 'checkbox', - viewableBy: ['guests'], - insertableBy: ['admins'], - editableBy: ['admins'], - group: notificationsGroup + input: "checkbox", + canRead: ['guests'], + canCreate: ['admins'], + canUpdate: ['admins'], + group: notificationsGroup, } }, { @@ -28,11 +28,11 @@ Users.addField([ type: Boolean, optional: true, defaultValue: false, - control: 'checkbox', - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - group: notificationsGroup + input: "checkbox", + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + group: notificationsGroup, } }, { @@ -42,11 +42,11 @@ Users.addField([ type: Boolean, optional: true, defaultValue: false, - control: 'checkbox', - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - group: notificationsGroup + input: "checkbox", + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + group: notificationsGroup, } }, { @@ -56,11 +56,11 @@ Users.addField([ type: Boolean, optional: true, defaultValue: false, - control: 'checkbox', - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - group: notificationsGroup + input: "checkbox", + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + group: notificationsGroup, } } -]) +]); diff --git a/packages/news/lib/modules/notifications/emails.js b/packages/example-forum/lib/modules/notifications/emails.js old mode 100644 new mode 100755 similarity index 54% rename from packages/news/lib/modules/notifications/emails.js rename to packages/example-forum/lib/modules/notifications/emails.js index f8f0b65c4..265ff63de --- a/packages/news/lib/modules/notifications/emails.js +++ b/packages/example-forum/lib/modules/notifications/emails.js @@ -4,7 +4,7 @@ Emails */ -import VulcanEmail from 'meteor/vulcan:email' +import VulcanEmail from 'meteor/vulcan:email'; /* @@ -13,17 +13,19 @@ Test */ VulcanEmail.addEmails({ + test: { - template: 'test', - path: '/email/test', + template: "test", + path: "/email/test", data() { - return { date: new Date() } + return {date: new Date()}; }, subject() { - return 'This is a test' - } + return "This is a test"; + }, } -}) + +}); /* @@ -32,11 +34,12 @@ Users */ VulcanEmail.addEmails({ + newUser: { - template: 'newUser', - path: '/email/new-user/:_id?', + template: "newUser", + path: "/email/new-user/:_id?", subject() { - return 'A new user has been created' + return "A new user has been created"; }, query: ` query UsersSingleQuery($documentId: String){ @@ -49,10 +52,10 @@ VulcanEmail.addEmails({ }, accountApproved: { - template: 'accountApproved', - path: '/email/account-approved/:_id?', + template: "accountApproved", + path: "/email/account-approved/:_id?", subject() { - return 'Your account has been approved.' + return "Your account has been approved."; }, query: ` query UsersSingleQuery($documentId: String){ @@ -66,7 +69,8 @@ VulcanEmail.addEmails({ } ` } -}) + +}); /* @@ -91,44 +95,42 @@ const postsQuery = ` } ` -const dummyPost = { title: '[title]', user: { displayName: '[user]' } } +const dummyPost = {title: '[title]', user: {displayName: '[user]'}}; VulcanEmail.addEmails({ + newPost: { - template: 'newPost', - path: '/email/new-post/:_id?', + 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 + 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?', + 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 - ) + 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?', + template: "postApproved", + path: "/email/post-approved/:_id?", subject(data) { - const post = _.isEmpty(data) ? dummyPost : data.PostsSingle - return 'Your post “' + post.title + '” has been approved' + const post = _.isEmpty(data) ? dummyPost : data.PostsSingle; + return 'Your post “'+post.title+'” has been approved'; }, query: postsQuery } -}) - + +}); + /* Comments @@ -150,56 +152,41 @@ const commentsQuery = ` } } } -` +` -const dummyComment = { - post: { title: '[title]' }, - user: { displayName: '[user]' } -} +const dummyComment = {post: {title: '[title]'}, user: {displayName: '[user]'}}; VulcanEmail.addEmails({ + newComment: { - template: 'newComment', - path: '/email/new-comment/:_id?', + 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 + - '"' - ) + 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?', + 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 + - '"' - ) + 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?', + 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 + - '"' - ) + const comment = _.isEmpty(data) ? dummyComment : data.CommentsSingle; + return comment.user.displayName+' left a new comment on "' + comment.post.title + '"'; }, query: commentsQuery } -}) + +}); + diff --git a/packages/example-forum/lib/modules/notifications/index.js b/packages/example-forum/lib/modules/notifications/index.js new file mode 100755 index 000000000..78a491c9b --- /dev/null +++ b/packages/example-forum/lib/modules/notifications/index.js @@ -0,0 +1,2 @@ +import './custom_fields.js'; +import './emails.js'; \ No newline at end of file diff --git a/packages/news/lib/modules/posts/admin.js b/packages/example-forum/lib/modules/posts/admin.js old mode 100644 new mode 100755 similarity index 75% rename from packages/news/lib/modules/posts/admin.js rename to packages/example-forum/lib/modules/posts/admin.js index dc7b98e80..3b6062093 --- a/packages/news/lib/modules/posts/admin.js +++ b/packages/example-forum/lib/modules/posts/admin.js @@ -4,24 +4,22 @@ Admin dashboard extension */ -import { extendFragment, addAdminColumn, addStrings } from 'meteor/vulcan:core' -import AdminUsersPosts from '../../components/admin/AdminUsersPosts' +import { extendFragment, addAdminColumn, addStrings } from 'meteor/vulcan:core'; +import AdminUsersPosts from '../../components/admin/AdminUsersPosts'; -extendFragment( - 'UsersAdmin', - ` +extendFragment('UsersAdmin', ` posts(limit: 5){ ...PostsPage } -` -) +`); addAdminColumn({ name: 'posts', order: 50, component: AdminUsersPosts -}) +}); + addStrings('en', { - 'admin.users.posts': 'Posts' -}) + 'admin.users.posts': 'Posts', +}); \ No newline at end of file diff --git a/packages/news/lib/modules/posts/collection.js b/packages/example-forum/lib/modules/posts/collection.js old mode 100644 new mode 100755 similarity index 56% rename from packages/news/lib/modules/posts/collection.js rename to packages/example-forum/lib/modules/posts/collection.js index b4f25746d..92a6ae16c --- a/packages/news/lib/modules/posts/collection.js +++ b/packages/example-forum/lib/modules/posts/collection.js @@ -4,19 +4,16 @@ Posts collection */ -import schema from './schema.js' -import { - createCollection, - getDefaultResolvers, - getDefaultMutations -} from 'meteor/vulcan:core' -import Users from 'meteor/vulcan:users' +import schema from './schema.js'; +import { createCollection, getDefaultResolvers, getDefaultMutations } from 'meteor/vulcan:core'; +import Users from 'meteor/vulcan:users'; /** * @summary The global namespace for Posts. * @namespace Posts */ export const Posts = createCollection({ + collectionName: 'Posts', typeName: 'Post', @@ -25,17 +22,19 @@ export const Posts = createCollection({ resolvers: getDefaultResolvers('Posts'), - mutations: getDefaultMutations('Posts') -}) + mutations: getDefaultMutations('Posts'), + +}); // refactor: moved here from schema.js -Posts.config = {} +Posts.config = {}; + +Posts.config.STATUS_PENDING = 1; +Posts.config.STATUS_APPROVED = 2; +Posts.config.STATUS_REJECTED = 3; +Posts.config.STATUS_SPAM = 4; +Posts.config.STATUS_DELETED = 5; -Posts.config.STATUS_PENDING = 1 -Posts.config.STATUS_APPROVED = 2 -Posts.config.STATUS_REJECTED = 3 -Posts.config.STATUS_SPAM = 4 -Posts.config.STATUS_DELETED = 5 /** * @summary Posts statuses @@ -62,16 +61,15 @@ Posts.statuses = [ value: 5, label: 'deleted' } -] +]; Posts.checkAccess = (currentUser, post) => { - if (Users.isAdmin(currentUser) || Users.owns(currentUser, post)) { - // admins can always see everything, users can always see their own posts - return true + if (Users.isAdmin(currentUser) || Users.owns(currentUser, post)) { // admins can always see everything, users can always see their own posts + return true; } else if (post.isFuture) { - return false - } else { - const status = _.findWhere(Posts.statuses, { value: post.status }) - return Users.canDo(currentUser, `posts.view.${status.label}`) + return false; + } else { + const status = _.findWhere(Posts.statuses, {value: post.status}); + return Users.canDo(currentUser, `posts.view.${status.label}`); } -} +} \ No newline at end of file diff --git a/packages/example-forum/lib/modules/posts/custom_fields.js b/packages/example-forum/lib/modules/posts/custom_fields.js new file mode 100755 index 000000000..bb0601880 --- /dev/null +++ b/packages/example-forum/lib/modules/posts/custom_fields.js @@ -0,0 +1,89 @@ +/* + +Custom fields on Users collection + +*/ + +import Users from 'meteor/vulcan:users'; +import SimpleSchema from 'simpl-schema'; + +Users.addField([ + /** + Count of the user's posts + */ + { + fieldName: 'postCount', + fieldSchema: { + type: Number, + optional: true, + defaultValue: 0, + canRead: ['guests'], + } + }, + /** + The user's associated posts (GraphQL only) + */ + { + fieldName: 'posts', + fieldSchema: { + type: Object, + optional: true, + canRead: ['guests'], + resolveAs: { + arguments: 'limit: Int = 5', + type: '[Post]', + resolver: (user, { limit }, { currentUser, Users, Posts }) => { + const posts = Posts.find({ userId: user._id }, { limit }).fetch(); + + // restrict documents fields + const viewablePosts = _.filter(posts, post => Posts.checkAccess(currentUser, post)); + const restrictedPosts = Users.restrictViewableFields(currentUser, Posts, viewablePosts); + return restrictedPosts; + } + } + } + }, + /** + User's bio (Markdown version) + */ + { + fieldName: 'bio', + fieldSchema: { + type: String, + optional: true, + input: "textarea", + canCreate: ['members'], + canUpdate: ['members'], + canRead: ['guests'], + order: 30, + searchable: true, + } + }, + /** + User's bio (Markdown version) + */ + { + fieldName: 'htmlBio', + fieldSchema: { + type: String, + optional: true, + canRead: ['guests'], + } + }, + /** + A link to the user's homepage + */ + { + fieldName: 'website', + fieldSchema: { + type: String, + regEx: SimpleSchema.RegEx.Url, + optional: true, + input: "text", + canCreate: ['members'], + canUpdate: ['members'], + canRead: ['guests'], + order: 50, + } + } +]); diff --git a/packages/news/lib/modules/posts/embedly.js b/packages/example-forum/lib/modules/posts/embedly.js old mode 100644 new mode 100755 similarity index 63% rename from packages/news/lib/modules/posts/embedly.js rename to packages/example-forum/lib/modules/posts/embedly.js index 6549e5d45..dd98bbb17 --- a/packages/news/lib/modules/posts/embedly.js +++ b/packages/example-forum/lib/modules/posts/embedly.js @@ -1,10 +1,10 @@ -import { Posts } from '../posts/index.js' +import { Posts } from '../posts/index.js'; Posts.addField([ { fieldName: 'url', fieldSchema: { - control: 'EmbedURL' // we are just extending the field url, not replacing it + input: 'EmbedURL', // we are just extending the field url, not replacing it } }, { @@ -12,9 +12,9 @@ Posts.addField([ fieldSchema: { type: String, optional: true, - insertableBy: ['members'], - editableBy: ['members'], - viewableBy: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + canRead: ['guests'], hidden: true } }, @@ -24,7 +24,7 @@ Posts.addField([ type: Object, optional: true, blackbox: true, - viewableBy: ['guests'] + canRead: ['guests'], } }, { @@ -32,7 +32,7 @@ Posts.addField([ fieldSchema: { type: String, optional: true, - viewableBy: ['guests'] + canRead: ['guests'], } }, { @@ -40,7 +40,7 @@ Posts.addField([ fieldSchema: { type: String, optional: true, - viewableBy: ['guests'] + canRead: ['guests'], } } -]) +]); \ No newline at end of file diff --git a/packages/news/lib/modules/posts/fragments.js b/packages/example-forum/lib/modules/posts/fragments.js old mode 100644 new mode 100755 similarity index 82% rename from packages/news/lib/modules/posts/fragments.js rename to packages/example-forum/lib/modules/posts/fragments.js index 959640dad..8ffa4aeff --- a/packages/news/lib/modules/posts/fragments.js +++ b/packages/example-forum/lib/modules/posts/fragments.js @@ -1,6 +1,6 @@ -import { registerFragment } from 'meteor/vulcan:core' +import { registerFragment } from 'meteor/vulcan:core'; -registerFragment(` +registerFragment(/* GraphQL */` fragment PostsList on Post { # posts _id @@ -37,12 +37,13 @@ registerFragment(` baseScore score } -`) +`); -registerFragment(` +registerFragment(/* GraphQL */` fragment PostsPage on Post { ...PostsList body htmlBody } -`) +`); + diff --git a/packages/example-forum/lib/modules/posts/helpers.js b/packages/example-forum/lib/modules/posts/helpers.js new file mode 100755 index 000000000..bcb61dc18 --- /dev/null +++ b/packages/example-forum/lib/modules/posts/helpers.js @@ -0,0 +1,182 @@ +/* + +Posts helpers + +*/ + +import moment from 'moment'; +import { Posts } from './collection.js'; +import Users from 'meteor/vulcan:users'; +import { Utils, getSetting, registerSetting } from 'meteor/vulcan:core'; + +registerSetting('forum.outsideLinksPointTo', 'link', 'Whether to point RSS links to the linked URL (“link”) or back to the post page (“page”)'); +registerSetting('forum.requirePostsApproval', false, 'Require posts to be approved manually'); +registerSetting('twitterAccount', null, 'Twitter account associated with the app'); +registerSetting('siteUrl', null, 'Main site URL'); + +////////////////// +// Link Helpers // +////////////////// + +/** + * @summary Return a post's link if it has one, else return its post page URL + * @param {Object} post + */ +Posts.getLink = function (post, isAbsolute = false, isRedirected = true) { + const url = isRedirected ? Utils.getOutgoingUrl(post.url) : post.url; + return !!post.url ? url : Posts.getPageUrl(post, isAbsolute); +}; + +/** + * @summary Depending on the settings, return either a post's URL link (if it has one) or its page URL. + * @param {Object} post + */ +Posts.getShareableLink = function (post) { + return getSetting('forum.outsideLinksPointTo', 'link') === 'link' ? Posts.getLink(post) : Posts.getPageUrl(post, true); +}; + +/** + * @summary Whether a post's link should open in a new tab or not + * @param {Object} post + */ +Posts.getLinkTarget = function (post) { + return !!post.url ? '_blank' : ''; +}; + +/** + * @summary Get URL of a post page. + * @param {Object} post + */ +Posts.getPageUrl = function(post, isAbsolute = false){ + const prefix = isAbsolute ? Utils.getSiteUrl().slice(0,-1) : ''; + return `${prefix}/posts/${post._id}/${post.slug}`; +}; + +/////////////////// +// Other Helpers // +/////////////////// + +/** + * @summary Get a post author's name + * @param {Object} post + */ +Posts.getAuthorName = function (post) { + var user = Users.findOne(post.userId); + if (user) { + return Users.getDisplayName(user); + } else { + return post.author; + } +}; + +/** + * @summary Get default status for new posts. + * @param {Object} user + */ +Posts.getDefaultStatus = function (user) { + const canPostApproved = typeof user === 'undefined' ? false : Users.canDo(user, 'posts.new.approved'); + if (!getSetting('forum.requirePostsApproval', false) || canPostApproved) { + // if user can post straight to 'approved', or else post approval is not required + return Posts.config.STATUS_APPROVED; + } else { + return Posts.config.STATUS_PENDING; + } +}; + +/** + * @summary Get status name + * @param {Object} user + */ +Posts.getStatusName = function (post) { + return Utils.findWhere(Posts.statuses, {value: post.status}).label; +}; + +/** + * @summary Check if a post is approved + * @param {Object} post + */ +Posts.isApproved = function (post) { + return post.status === Posts.config.STATUS_APPROVED; +}; + +/** + * @summary Check if a post is pending + * @param {Object} post + */ +Posts.isPending = function (post) { + return post.status === Posts.config.STATUS_PENDING; +}; + + +/** + * @summary Check to see if post URL is unique. + * We need the current user so we know who to upvote the existing post as. + * @param {String} url + */ +Posts.checkForSameUrl = function (url) { + + // check that there are no previous posts with the same link in the past 6 months + var sixMonthsAgo = moment().subtract(6, 'months').toDate(); + var postWithSameLink = Posts.findOne({url: url, postedAt: {$gte: sixMonthsAgo}}); + + return !!postWithSameLink; +}; + +/** + * @summary When on a post page, return the current post + */ +Posts.current = function () { + return Posts.findOne('foo'); +}; + +/** + * @summary Check to see if a post is a link to a video + * @param {Object} post + */ +Posts.isVideo = function (post) { + return post.media && post.media.type === 'video'; +}; + +/** + * @summary Get the complete thumbnail url whether it is hosted on Embedly or on an external website, or locally in the app. + * @param {Object} post + */ +Posts.getThumbnailUrl = (post) => { + const thumbnailUrl = post.thumbnailUrl; + if (!!thumbnailUrl) { + return thumbnailUrl.indexOf('//') > -1 ? Utils.addHttp(thumbnailUrl) : Utils.getSiteUrl().slice(0,-1) + thumbnailUrl; + } +}; + +/** + * @summary Get URL for sharing on Twitter. + * @param {Object} post + */ +Posts.getTwitterShareUrl = post => { + const via = getSetting('twitterAccount', null) ? `&via=${getSetting('twitterAccount')}` : ''; + return `https://twitter.com/intent/tweet?text=${ encodeURIComponent(post.title) }%20${ encodeURIComponent(Posts.getLink(post, true)) }${via}`; +}; + +/** + * @summary Get URL for sharing on Facebook. + * @param {Object} post + */ +Posts.getFacebookShareUrl = post => { + return `https://www.facebook.com/sharer/sharer.php?u=${ encodeURIComponent(Posts.getLink(post, true)) }`; +}; + +/** + * @summary Get URL for sharing by Email. + * @param {Object} post + */ +Posts.getEmailShareUrl = post => { + const subject = `Interesting link: ${post.title}`; + const body = `I thought you might find this interesting: + +${post.title} +${Posts.getLink(post, true, false)} + +(found via ${getSetting('siteUrl')}) + `; + return `mailto:?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`; +}; diff --git a/packages/example-forum/lib/modules/posts/index.js b/packages/example-forum/lib/modules/posts/index.js new file mode 100755 index 000000000..0f02a968c --- /dev/null +++ b/packages/example-forum/lib/modules/posts/index.js @@ -0,0 +1,12 @@ +export * from './collection.js'; + +import './fragments.js'; +import './custom_fields.js'; +import './parameters.js'; +import './views.js'; +import './helpers.js'; +import './permissions.js'; +import './redux.js'; +import './admin.js'; +import './newsletter.js'; +import './embedly.js'; diff --git a/packages/news/lib/modules/posts/newsletter.js b/packages/example-forum/lib/modules/posts/newsletter.js old mode 100644 new mode 100755 similarity index 64% rename from packages/news/lib/modules/posts/newsletter.js rename to packages/example-forum/lib/modules/posts/newsletter.js index 910ae1bf8..cb66bfd94 --- a/packages/news/lib/modules/posts/newsletter.js +++ b/packages/example-forum/lib/modules/posts/newsletter.js @@ -4,21 +4,20 @@ Newsletter setup */ -import VulcanEmail from 'meteor/vulcan:email' -import { addCallback } from 'meteor/vulcan:core' +import VulcanEmail from 'meteor/vulcan:email'; +import { addCallback } from 'meteor/vulcan:core'; // email test routes (make available to client & server) -import Newsletters from 'meteor/vulcan:newsletter' -import { Posts } from './collection.js' -import moment from 'moment' +import Newsletters from 'meteor/vulcan:newsletter'; +import { Posts } from './collection.js'; +import moment from 'moment'; VulcanEmail.addEmails({ + newsletter: { template: 'newsletter', path: '/email/newsletter', subject(data) { - return _.isEmpty(data) - ? '[Generated on server]' - : Newsletters.getSubject(data.PostsList) + return _.isEmpty(data) ? '[Generated on server]' : Newsletters.getSubject(data.PostsList); }, data() { return { @@ -62,11 +61,11 @@ VulcanEmail.addEmails({ } `, isValid(data) { - return data.PostsList && data.PostsList.length + return data.PostsList && data.PostsList.length; }, testVariables() { return { - terms: { + terms : { view: 'newsletter' } } @@ -77,21 +76,18 @@ VulcanEmail.addEmails({ template: 'newsletterConfirmation', path: '/email/newsletter-confirmation', subject() { - return 'Newsletter confirmation' + return 'Newsletter confirmation'; } } -}) -function MarkPostsAsScheduled(email) { - const postsIds = _.pluck(email.data.PostsList, '_id') +}); + +function MarkPostsAsScheduled (email) { + const postsIds = _.pluck(email.data.PostsList, '_id'); // eslint-disable-next-line no-console console.log(postsIds) - const updated = Posts.update( - { _id: { $in: postsIds } }, - { $set: { scheduledAt: new Date() } }, - { multi: true } - ) // eslint-disable-line + const updated = Posts.update({_id: {$in: postsIds}}, {$set: {scheduledAt: new Date()}}, {multi: true}) // eslint-disable-line // eslint-disable-next-line no-console console.log(`updated ${updated} posts`) } -addCallback('newsletter.send.async', MarkPostsAsScheduled) +addCallback('newsletter.send.async', MarkPostsAsScheduled); diff --git a/packages/news/lib/modules/posts/parameters.js b/packages/example-forum/lib/modules/posts/parameters.js old mode 100644 new mode 100755 similarity index 55% rename from packages/news/lib/modules/posts/parameters.js rename to packages/example-forum/lib/modules/posts/parameters.js index e652a4ac6..d0c3a7cca --- a/packages/news/lib/modules/posts/parameters.js +++ b/packages/example-forum/lib/modules/posts/parameters.js @@ -4,23 +4,18 @@ Posts parameters */ -import { Injected } from 'meteor/meteorhacks:inject-initial' -import moment from 'moment' -import { addCallback } from 'meteor/vulcan:core' +import { Injected } from 'meteor/meteorhacks:inject-initial'; +import moment from 'moment'; +import { addCallback } from 'meteor/vulcan:core'; // Add 'after' and 'before' properties to terms which can be used to limit posts in time. -function PostsAddBeforeAfterParameters(parameters, terms, apolloClient) { +function PostsAddBeforeAfterParameters (parameters, terms, apolloClient) { + // console.log('// addBeforeAfterParameters') if (typeof parameters.selector.postedAt === 'undefined') { - let postedAt = {}, - mAfter, - mBefore, - startOfDay, - endOfDay, - clientTimezoneOffset, - serverTimezoneOffset, - timeDifference + + let postedAt = {}, mAfter, mBefore, startOfDay, endOfDay, clientTimezoneOffset, serverTimezoneOffset, timeDifference; /* @@ -38,9 +33,9 @@ function PostsAddBeforeAfterParameters(parameters, terms, apolloClient) { */ if (Meteor.isClient) { - clientTimezoneOffset = -1 * new Date().getTimezoneOffset() - serverTimezoneOffset = -1 * Injected.obj('serverTimezoneOffset').offset - timeDifference = clientTimezoneOffset - serverTimezoneOffset + clientTimezoneOffset = -1 * new Date().getTimezoneOffset(); + serverTimezoneOffset = -1 * Injected.obj('serverTimezoneOffset').offset; + timeDifference = clientTimezoneOffset - serverTimezoneOffset; // console.log('client time:'+clientTimezoneOffset); // console.log('server time:'+serverTimezoneOffset); @@ -48,42 +43,47 @@ function PostsAddBeforeAfterParameters(parameters, terms, apolloClient) { } if (terms.after) { + // console.log('// after: '+terms.after); - mAfter = moment(terms.after, 'YYYY-MM-DD') - startOfDay = mAfter.startOf('day') + mAfter = moment(terms.after, 'YYYY-MM-DD'); + startOfDay = mAfter.startOf('day'); - // console.log('// normal ', mAfter.toDate(), mAfter.valueOf()); - // console.log('// startOfDay ', startOfDay.toDate(), startOfDay.valueOf()); + // console.log('// normal ', mAfter.toDate(), mAfter.valueOf()); + // console.log('// startOfDay ', startOfDay.toDate(), startOfDay.valueOf()); if (Meteor.isClient) { - startOfDay.add(timeDifference, 'minutes') + startOfDay.add(timeDifference, 'minutes'); // console.log('// after add ', startOfDay.toDate(), startOfDay.valueOf()); - // note: on the client, dates are stored as strings, + // note: on the client, dates are stored as strings, // so use strings for MongoDB filtering options too - postedAt.$gte = startOfDay.toISOString() + postedAt.$gte = startOfDay.toISOString(); } else { - postedAt.$gte = startOfDay.toDate() + postedAt.$gte = startOfDay.toDate(); } + } if (terms.before) { - mBefore = moment(terms.before, 'YYYY-MM-DD') - endOfDay = mBefore.endOf('day') + + mBefore = moment(terms.before, 'YYYY-MM-DD'); + endOfDay = mBefore.endOf('day'); if (Meteor.isClient) { - endOfDay.add(timeDifference, 'minutes') - postedAt.$lt = endOfDay.toISOString() + endOfDay.add(timeDifference, 'minutes'); + postedAt.$lt = endOfDay.toISOString(); } else { - postedAt.$lt = endOfDay.toDate() + postedAt.$lt = endOfDay.toDate(); } + } if (!_.isEmpty(postedAt)) { - parameters.selector.postedAt = postedAt + parameters.selector.postedAt = postedAt; } + } - return parameters + return parameters; } -addCallback('posts.parameters', PostsAddBeforeAfterParameters) +addCallback('posts.parameters', PostsAddBeforeAfterParameters); diff --git a/packages/example-forum/lib/modules/posts/permissions.js b/packages/example-forum/lib/modules/posts/permissions.js new file mode 100755 index 000000000..fa6b9b179 --- /dev/null +++ b/packages/example-forum/lib/modules/posts/permissions.js @@ -0,0 +1,32 @@ +/* + +Posts permissions + +*/ + +import Users from 'meteor/vulcan:users'; + +const guestsActions = [ + 'posts.view.approved' +]; +Users.groups.guests.can(guestsActions); + +const membersActions = [ + 'posts.new', + 'posts.edit.own', + 'posts.remove.own', + 'posts.upvote', + 'posts.downvote', +]; +Users.groups.members.can(membersActions); + +const adminActions = [ + 'posts.view.pending', + 'posts.view.rejected', + 'posts.view.spam', + 'posts.view.deleted', + 'posts.new.approved', + 'posts.edit.all', + 'posts.remove.all' +]; +Users.groups.admins.can(adminActions); \ No newline at end of file diff --git a/packages/example-forum/lib/modules/posts/redux.js b/packages/example-forum/lib/modules/posts/redux.js new file mode 100755 index 000000000..09e419955 --- /dev/null +++ b/packages/example-forum/lib/modules/posts/redux.js @@ -0,0 +1,29 @@ +/* + +Redux + +*/ + +import { addAction, addReducer } from 'meteor/vulcan:core'; + +addAction({ + postsViewed: { + setViewed: (postId) => ({ + type: 'SET_VIEWED', + postId, + }), + }, +}); + +addReducer({ + postsViewed: (state = [], action) => { + if (action.type === 'SET_VIEWED') { + return [ + ...state, + action.postId, + ]; + } + + return state; + }, +}); diff --git a/packages/news/lib/modules/posts/schema.js b/packages/example-forum/lib/modules/posts/schema.js old mode 100644 new mode 100755 similarity index 57% rename from packages/news/lib/modules/posts/schema.js rename to packages/example-forum/lib/modules/posts/schema.js index 01019e942..186857896 --- a/packages/news/lib/modules/posts/schema.js +++ b/packages/example-forum/lib/modules/posts/schema.js @@ -4,21 +4,12 @@ Posts schema */ -import Users from 'meteor/vulcan:users' -import { - Utils, - getSetting, - registerSetting, - getCollection -} from 'meteor/vulcan:core' -import moment from 'moment' -import marked from 'marked' +import Users from 'meteor/vulcan:users'; +import { Utils, getSetting, registerSetting, getCollection } from 'meteor/vulcan:core'; +import moment from 'moment'; +import marked from 'marked'; -registerSetting( - 'forum.postExcerptLength', - 30, - 'Length of posts excerpts in words' -) +registerSetting('forum.postExcerptLength', 30, 'Length of posts excerpts in words'); /** * @summary Posts config namespace @@ -29,7 +20,7 @@ const formGroups = { name: 'admin', order: 2 } -} +}; /** * @summary Posts schema @@ -42,7 +33,7 @@ const schema = { _id: { type: String, optional: true, - viewableBy: ['guests'] + canRead: ['guests'], }, /** Timetstamp of post creation @@ -50,9 +41,9 @@ const schema = { createdAt: { type: Date, optional: true, - viewableBy: ['admins'], - onInsert: () => { - return new Date() + canRead: ['admins'], + onCreate: () => { + return new Date(); } }, /** @@ -61,19 +52,21 @@ const schema = { postedAt: { type: Date, optional: true, - viewableBy: ['guests'], - insertableBy: ['admins'], - editableBy: ['admins'], - control: 'datetime', + canRead: ['guests'], + canCreate: ['admins'], + canUpdate: ['admins'], + input: 'datetime', group: formGroups.admin, - onInsert: (post, currentUser) => { + onCreate: ({newDocument: post, currentUser}) => { // Set the post's postedAt if it's going to be approved - if ( - !post.postedAt && - getCollection('Posts').getDefaultStatus(currentUser) === - getCollection('Posts').config.STATUS_APPROVED - ) { - return new Date() + if (!post.postedAt && getCollection('Posts').getDefaultStatus(currentUser) === getCollection('Posts').config.STATUS_APPROVED) { + return new Date(); + } + }, + onUpdate: ({data, document: post}) => { + // Set the post's postedAt if it's going to be approved + if (!post.postedAt && data.status === getCollection('Posts').config.STATUS_APPROVED) { + return new Date(); } } }, @@ -84,20 +77,18 @@ const schema = { type: String, optional: true, max: 500, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - control: 'url', + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + input: 'url', order: 10, searchable: true, - form: { - query: ` - SiteData{ - logoUrl - title - } - ` - } + query: ` + SiteData{ + logoUrl + title + } + `, }, /** Title @@ -106,10 +97,10 @@ const schema = { type: String, optional: false, max: 500, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - control: 'text', + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + input: 'text', order: 20, searchable: true }, @@ -119,13 +110,13 @@ const schema = { slug: { type: String, optional: true, - viewableBy: ['guests'], - onInsert: post => { - return Utils.slugify(post.title) + canRead: ['guests'], + onCreate: ({newDocument: post}) => { + return Utils.slugify(post.title); }, - onEdit: (modifier, post) => { - if (modifier.$set.title) { - return Utils.slugify(modifier.$set.title) + onUpdate: ({data}) => { + if (data.title) { + return Utils.slugify(data.title); } } }, @@ -136,10 +127,10 @@ const schema = { type: String, optional: true, max: 3000, - viewableBy: ['guests'], - insertableBy: ['members'], - editableBy: ['members'], - control: 'textarea', + canRead: ['guests'], + canCreate: ['members'], + canUpdate: ['members'], + input: 'textarea', order: 30 }, /** @@ -148,15 +139,15 @@ const schema = { htmlBody: { type: String, optional: true, - viewableBy: ['guests'], - onInsert: post => { + canRead: ['guests'], + onCreate: ({newDocument: post}) => { if (post.body) { - return Utils.sanitize(marked(post.body)) + return Utils.sanitize(marked(post.body)); } }, - onEdit: (modifier, post) => { - if (modifier.$set.body) { - return Utils.sanitize(marked(modifier.$set.body)) + onUpdate: ({data}) => { + if (data.body) { + return Utils.sanitize(marked(data.body)); } } }, @@ -166,22 +157,19 @@ const schema = { excerpt: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], searchable: true, - onInsert: post => { + onCreate: ({newDocument: post}) => { if (post.body) { // excerpt length is configurable via the settings (30 words by default, ~255 characters) - const excerptLength = getSetting('forum.postExcerptLength', 30) - return Utils.trimHTML(Utils.sanitize(marked(post.body)), excerptLength) + const excerptLength = getSetting('forum.postExcerptLength', 30); + return Utils.trimHTML(Utils.sanitize(marked(post.body)), excerptLength); } }, - onEdit: (modifier, post) => { - if (modifier.$set.body) { - const excerptLength = getSetting('forum.postExcerptLength', 30) - return Utils.trimHTML( - Utils.sanitize(marked(modifier.$set.body)), - excerptLength - ) + onUpdate: ({data}) => { + if (data.body) { + const excerptLength = getSetting('forum.postExcerptLength', 30); + return Utils.trimHTML(Utils.sanitize(marked(data.body)), excerptLength); } } }, @@ -191,7 +179,7 @@ const schema = { viewCount: { type: Number, optional: true, - viewableBy: ['admins'], + canRead: ['admins'], defaultValue: 0 }, /** @@ -200,7 +188,7 @@ const schema = { lastCommentedAt: { type: Date, optional: true, - viewableBy: ['guests'] + canRead: ['guests'], }, /** Count of how many times the post's link was clicked @@ -208,7 +196,7 @@ const schema = { clickCount: { type: Number, optional: true, - viewableBy: ['admins'], + canRead: ['admins'], defaultValue: 0 }, /** @@ -217,24 +205,22 @@ const schema = { status: { type: Number, optional: true, - viewableBy: ['guests'], - insertableBy: ['admins'], - editableBy: ['admins'], - control: 'select', - onInsert: (document, currentUser) => { + canRead: ['guests'], + canCreate: ['admins'], + canUpdate: ['admins'], + input: 'select', + onCreate: ({newDocument: document, currentUser}) => { if (!document.status) { - return getCollection('Posts').getDefaultStatus(currentUser) + return getCollection('Posts').getDefaultStatus(currentUser); } }, - onEdit: (modifier, document, currentUser) => { + onUpdate: ({data, currentUser}) => { // if for some reason post status has been removed, give it default status - if (modifier.$unset && modifier.$unset.status) { - return getCollection('Posts').getDefaultStatus(currentUser) + if (data.status === null) { + return getCollection('Posts').getDefaultStatus(currentUser); } }, - form: { - options: () => getCollection('Posts').statuses - }, + options: () => getCollection('Posts').statuses, group: formGroups.admin }, /** @@ -243,26 +229,26 @@ const schema = { isFuture: { type: Boolean, optional: true, - viewableBy: ['guests'], - onInsert: post => { + canRead: ['guests'], + onCreate: ({newDocument: post}) => { // Set the post's isFuture to true if necessary if (post.postedAt) { - const postTime = new Date(post.postedAt).getTime() - const currentTime = new Date().getTime() + 1000 - return postTime > currentTime // round up to the second + const postTime = new Date(post.postedAt).getTime(); + const currentTime = new Date().getTime() + 1000; + return postTime > currentTime; // round up to the second } }, - onEdit: (modifier, post) => { + onUpdate: ({data, document: post}) => { // Set the post's isFuture to true if necessary - if (modifier.$set.postedAt) { - const postTime = new Date(modifier.$set.postedAt).getTime() - const currentTime = new Date().getTime() + 1000 + if (data.postedAt) { + const postTime = new Date(data.postedAt).getTime(); + const currentTime = new Date().getTime() + 1000; if (postTime > currentTime) { // if a post's postedAt date is in the future, set isFuture to true - return true + return true; } else if (post.isFuture) { // else if a post has isFuture to true but its date is in the past, set isFuture to false - return false + return false; } } } @@ -274,19 +260,19 @@ const schema = { type: Boolean, optional: true, defaultValue: false, - viewableBy: ['guests'], - insertableBy: ['admins'], - editableBy: ['admins'], - control: 'checkbox', + canRead: ['guests'], + canCreate: ['admins'], + canUpdate: ['admins'], + input: 'checkbox', group: formGroups.admin, - onInsert: post => { - if (!post.sticky) { - return false + onCreate: ({newDocument: post}) => { + if(!post.sticky) { + return false; } }, - onEdit: (modifier, post) => { - if (!modifier.$set.sticky) { - return false + onUpdate: ({data}) => { + if (!data.sticky) { + return false; } } }, @@ -296,17 +282,17 @@ const schema = { userIP: { type: String, optional: true, - viewableBy: ['admins'] + canRead: ['admins'], }, userAgent: { type: String, optional: true, - viewableBy: ['admins'] + canRead: ['admins'], }, referrer: { type: String, optional: true, - viewableBy: ['admins'] + canRead: ['admins'], }, /** The post author's name @@ -314,11 +300,11 @@ const schema = { author: { type: String, optional: true, - viewableBy: ['guests'], - onEdit: (modifier, document, currentUser) => { + canRead: ['guests'], + onUpdate: ({data}) => { // if userId is changing, change the author name too - if (modifier.$set && modifier.$set.userId) { - return Users.getDisplayNameById(modifier.$set.userId) + if (data.userId) { + return Users.getDisplayNameById(data.userId) } } }, @@ -328,24 +314,20 @@ const schema = { userId: { type: String, optional: true, - control: 'select', - viewableBy: ['guests'], - insertableBy: ['members'], + input: 'select', + canRead: ['guests'], + canCreate: ['members'], hidden: true, resolveAs: { fieldName: 'user', type: 'User', resolver: async (post, args, context) => { - if (!post.userId) return null - const user = await context.Users.loader.load(post.userId) - return context.Users.restrictViewableFields( - context.currentUser, - context.Users, - user - ) + if (!post.userId) return null; + const user = await context.Users.loader.load(post.userId); + return context.Users.restrictViewableFields(context.currentUser, context.Users, user); }, addOriginalField: true - } + }, }, /** @@ -354,7 +336,7 @@ const schema = { scheduledAt: { type: Date, optional: true, - viewableBy: ['admins'] + canRead: ['admins'], }, // GraphQL-only fields @@ -362,49 +344,47 @@ const schema = { domain: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'String', resolver: (post, args, context) => { - return Utils.getDomain(post.url) - } + return Utils.getDomain(post.url); + }, } }, pageUrl: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'String', resolver: (post, args, { Posts }) => { - return Posts.getPageUrl(post, true) - } + return Posts.getPageUrl(post, true); + }, } }, linkUrl: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'String', resolver: (post, args, { Posts }) => { - return post.url - ? Utils.getOutgoingUrl(post.url) - : Posts.getPageUrl(post, true) - } + return post.url ? Utils.getOutgoingUrl(post.url) : Posts.getPageUrl(post, true); + }, } }, postedAtFormatted: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'String', resolver: (post, args, context) => { - return moment(post.postedAt).format('dddd, MMMM Do YYYY') + return moment(post.postedAt).format('dddd, MMMM Do YYYY'); } } }, @@ -412,37 +392,31 @@ const schema = { commentsCount: { type: Number, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'Int', resolver: (post, args, { Comments }) => { - const commentsCount = Comments.find({ postId: post._id }).count() - return commentsCount - } + const commentsCount = Comments.find({ postId: post._id }).count(); + return commentsCount; + }, } }, comments: { - type: Array, + type: Object, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { arguments: 'limit: Int = 5', type: '[Comment]', resolver: (post, { limit }, { currentUser, Users, Comments }) => { - const comments = Comments.find({ postId: post._id }, { limit }).fetch() + const comments = Comments.find({ postId: post._id }, { limit }).fetch(); // restrict documents fields - const viewableComments = _.filter(comments, comments => - Comments.checkAccess(currentUser, comments) - ) - const restrictedComments = Users.restrictViewableFields( - currentUser, - Comments, - viewableComments - ) + const viewableComments = _.filter(comments, comments => Comments.checkAccess(currentUser, comments)); + const restrictedComments = Users.restrictViewableFields(currentUser, Comments, viewableComments); - return restrictedComments + return restrictedComments; } } }, @@ -450,11 +424,11 @@ const schema = { emailShareUrl: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'String', resolver: (post, args, { Posts }) => { - return Posts.getEmailShareUrl(post) + return Posts.getEmailShareUrl(post); } } }, @@ -462,11 +436,11 @@ const schema = { twitterShareUrl: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'String', resolver: (post, args, { Posts }) => { - return Posts.getTwitterShareUrl(post) + return Posts.getTwitterShareUrl(post); } } }, @@ -474,14 +448,15 @@ const schema = { facebookShareUrl: { type: String, optional: true, - viewableBy: ['guests'], + canRead: ['guests'], resolveAs: { type: 'String', resolver: (post, args, { Posts }) => { - return Posts.getFacebookShareUrl(post) + return Posts.getFacebookShareUrl(post); } } - } -} + }, + +}; -export default schema +export default schema; diff --git a/packages/news/lib/modules/posts/views.js b/packages/example-forum/lib/modules/posts/views.js old mode 100644 new mode 100755 similarity index 56% rename from packages/news/lib/modules/posts/views.js rename to packages/example-forum/lib/modules/posts/views.js index b22656dea..0d46d92a8 --- a/packages/news/lib/modules/posts/views.js +++ b/packages/example-forum/lib/modules/posts/views.js @@ -1,7 +1,7 @@ -import Users from 'meteor/vulcan:users' +import Users from 'meteor/vulcan:users'; import { Posts } from './collection.js' -import moment from 'moment' -import Newsletters from 'meteor/vulcan:newsletter' +import moment from 'moment'; +import Newsletters from 'meteor/vulcan:newsletter'; /** * @summary Base parameters that will be common to all other view unless specific properties are overwritten @@ -9,36 +9,36 @@ import Newsletters from 'meteor/vulcan:newsletter' Posts.addDefaultView(terms => ({ selector: { status: Posts.config.STATUS_APPROVED, - isFuture: { $ne: true } // match both false and undefined + isFuture: {$ne: true} // match both false and undefined } -})) +})); /** * @summary Top view */ Posts.addView('top', terms => ({ options: { - sort: { sticky: -1, score: -1 } + sort: {sticky: -1, score: -1} } -})) +})); /** * @summary New view */ Posts.addView('new', terms => ({ options: { - sort: { sticky: -1, postedAt: -1 } + sort: {sticky: -1, postedAt: -1} } -})) +})); /** * @summary Best view */ Posts.addView('best', terms => ({ options: { - sort: { sticky: -1, baseScore: -1 } + sort: {sticky: -1, baseScore: -1} } -})) +})); /** * @summary Pending view @@ -48,9 +48,9 @@ Posts.addView('pending', terms => ({ status: Posts.config.STATUS_PENDING }, options: { - sort: { createdAt: -1 } + sort: {createdAt: -1} } -})) +})); /** * @summary Rejected view @@ -60,9 +60,9 @@ Posts.addView('rejected', terms => ({ status: Posts.config.STATUS_REJECTED }, options: { - sort: { createdAt: -1 } + sort: {createdAt: -1} } -})) +})); /** * @summary Scheduled view @@ -73,9 +73,9 @@ Posts.addView('scheduled', terms => ({ isFuture: true }, options: { - sort: { postedAt: -1 } + sort: {postedAt: -1} } -})) +})); /** * @summary User posts view @@ -84,7 +84,7 @@ Posts.addView('userPosts', terms => ({ selector: { userId: terms.userId, status: Posts.config.STATUS_APPROVED, - isFuture: { $ne: true } + isFuture: {$ne: true} }, options: { limit: 5, @@ -92,62 +92,55 @@ Posts.addView('userPosts', terms => ({ postedAt: -1 } } -})) +})); /** * @summary User upvoted posts view */ Posts.addView('userUpvotedPosts', (terms, apolloClient) => { - var user = apolloClient - ? Users.findOneInStore(apolloClient.store, terms.userId) - : Users.findOne(terms.userId) + var user = apolloClient ? Users.findOneInStore(apolloClient.store, terms.userId) : Users.findOne(terms.userId); - var postsIds = _.pluck(user.upvotedPosts, 'documentId') + var postsIds = _.pluck(user.upvotedPosts, 'documentId'); return { - selector: { _id: { $in: postsIds }, userId: { $ne: terms.userId } }, // exclude own posts - options: { limit: 5, sort: { postedAt: -1 } } - } -}) + selector: {_id: {$in: postsIds}, userId: {$ne: terms.userId}}, // exclude own posts + options: {limit: 5, sort: {postedAt: -1}} + }; +}); /** * @summary User downvoted posts view */ Posts.addView('userDownvotedPosts', (terms, apolloClient) => { - var user = apolloClient - ? Users.findOneInStore(apolloClient.store, terms.userId) - : Users.findOne(terms.userId) + var user = apolloClient ? Users.findOneInStore(apolloClient.store, terms.userId) : Users.findOne(terms.userId); - var postsIds = _.pluck(user.downvotedPosts, 'documentId') + var postsIds = _.pluck(user.downvotedPosts, 'documentId'); // TODO: sort based on votedAt timestamp and not postedAt, if possible return { - selector: { _id: { $in: postsIds } }, - options: { limit: 5, sort: { postedAt: -1 } } - } -}) + selector: {_id: {$in: postsIds}}, + options: {limit: 5, sort: {postedAt: -1}} + }; +}); /** * @summary Newsletter posts view */ // create new 'newsletter' view for all posts from the past X days that haven't been scheduled yet Posts.addView('newsletter', terms => { - const lastNewsletter = Newsletters.findOne({}, { sort: { createdAt: -1 } }) + const lastNewsletter = Newsletters.findOne({}, {sort: {createdAt: -1}}); // if there is a last newsletter and it was sent less than 7 days ago use its date, else default to posts from the last 7 days - const lastWeek = moment().subtract(7, 'days') - const lastNewsletterIsAfterLastWeek = - lastNewsletter && moment(lastNewsletter.createdAt).isAfter(lastWeek) - const after = lastNewsletterIsAfterLastWeek - ? lastNewsletter.createdAt - : lastWeek.toDate() + const lastWeek = moment().subtract(7, 'days'); + const lastNewsletterIsAfterLastWeek = lastNewsletter && moment(lastNewsletter.createdAt).isAfter(lastWeek); + const after = lastNewsletterIsAfterLastWeek ? lastNewsletter.createdAt : lastWeek.toDate(); return { selector: { - scheduledAt: { $exists: false }, - postedAt: { $gte: after } + scheduledAt: {$exists: false}, + postedAt: {$gte: after} }, options: { - sort: { baseScore: -1 }, + sort: {baseScore: -1}, limit: terms.limit } } -}) +}); diff --git a/packages/example-forum/lib/modules/routes.js b/packages/example-forum/lib/modules/routes.js new file mode 100755 index 000000000..1702ead9c --- /dev/null +++ b/packages/example-forum/lib/modules/routes.js @@ -0,0 +1,11 @@ +import { addRoute } from 'meteor/vulcan:core'; + +addRoute([ + {name:'posts.list', path: '/', componentName: 'PostsHome'}, // index route + {name:'posts.daily', path:'daily', componentName: 'PostsDaily'}, + {name:'posts.single', path:'posts/:_id(/:slug)', componentName: 'PostsSingle'}, + {name:'users.single', path:'users/:slug', componentName: 'UsersSingle'}, + {name:'users.account', path:'account', componentName: 'UsersAccount'}, + {name:'users.edit', path:'users/:slug/edit', componentName: 'UsersAccount'}, + {name:'admin.categories', path:'admin/categories', componentName: 'CategoriesDashboard'}, +]); diff --git a/packages/example-forum/lib/modules/voting.js b/packages/example-forum/lib/modules/voting.js new file mode 100755 index 000000000..52ecafc35 --- /dev/null +++ b/packages/example-forum/lib/modules/voting.js @@ -0,0 +1,6 @@ +import { makeVoteable } from 'meteor/vulcan:voting'; +import { Posts } from './posts/index.js'; +import { Comments } from './comments/index.js'; + +makeVoteable(Posts); +makeVoteable(Comments); \ No newline at end of file diff --git a/packages/example-forum/lib/server/api.js b/packages/example-forum/lib/server/api.js new file mode 100755 index 000000000..393f0e2a4 --- /dev/null +++ b/packages/example-forum/lib/server/api.js @@ -0,0 +1,90 @@ +import { Posts } from '../modules/posts/index.js'; +import { Comments } from '../modules/comments/index.js'; +import Users from 'meteor/vulcan:users'; +import { Utils } from 'meteor/vulcan:core'; +import { Picker } from 'meteor/meteorhacks:picker'; + +export const servePostsApi = (terms) => { + var posts = []; + + if (!terms.limit) { + terms.limit = 50; + } + + var parameters = Posts.getParameters(terms); + + const postsCursor = Posts.find(parameters.selector, parameters.options); + + postsCursor.forEach(function(post) { + var url = Posts.getLink(post); + var postOutput = { + title: post.title, + headline: post.title, // for backwards compatibility + author: post.author, + date: post.postedAt, + url: url, + pageUrl: Posts.getPageUrl(post, true), + guid: post._id + }; + + if(post.body) + postOutput.body = post.body; + + if(post.url) + postOutput.domain = Utils.getDomain(url); + + if (post.thumbnailUrl) { + postOutput.thumbnailUrl = Utils.addHttp(post.thumbnailUrl); + } + + var twitterName = Users.getTwitterNameById(post.userId); + if(twitterName) + postOutput.twitterName = twitterName; + + var comments = []; + + Comments.find({postId: post._id}, {sort: {postedAt: -1}, limit: 50}).forEach(function(comment) { + var commentProperties = { + body: comment.body, + author: comment.author, + date: comment.postedAt, + guid: comment._id, + parentCommentId: comment.parentCommentId + }; + comments.push(commentProperties); + }); + + var commentsToDelete = []; + + comments.forEach(function(comment, index) { + if (comment.parentCommentId) { + var parent = comments.filter(function(obj) { + return obj.guid === comment.parentCommentId; + })[0]; + if (parent) { + parent.replies = parent.replies || []; + parent.replies.push(JSON.parse(JSON.stringify(comment))); + commentsToDelete.push(index); + } + } + }); + + commentsToDelete.reverse().forEach(function(index) { + comments.splice(index,1); + }); + + postOutput.comments = comments; + + posts.push(postOutput); + }); + + return JSON.stringify(posts); +}; + +// for backwards compatibility's sake, accept a "limit" segment +Picker.route('/api/:limit?', function(params, req, res, next) { + if (typeof params.limit !== "undefined") { + params.query.limit = params.limit; + } + res.end(servePostsApi(params.query)); +}); diff --git a/packages/news/lib/server/categories/callbacks.js b/packages/example-forum/lib/server/categories/callbacks.js old mode 100644 new mode 100755 similarity index 76% rename from packages/news/lib/server/categories/callbacks.js rename to packages/example-forum/lib/server/categories/callbacks.js index 61d7ac151..9780db61c --- a/packages/news/lib/server/categories/callbacks.js +++ b/packages/example-forum/lib/server/categories/callbacks.js @@ -5,37 +5,38 @@ Callbacks to validate categories and generate category slugs */ -import { addCallback } from 'meteor/vulcan:core' -import { Categories } from '../../modules/categories/collection.js' +import { addCallback } from 'meteor/vulcan:core'; +import { Categories } from '../../modules/categories/collection.js'; // ------- Categories Check -------- // // make sure all categories in the post.categories array exist in the db -var checkCategories = function(post) { +var checkCategories = function (post) { + // if there are no categories, stop here if (!post.categories || post.categories.length === 0) { - return + return; } // check how many of the categories given also exist in the db - var categoryCount = Categories.find({ _id: { $in: post.categories } }).count() + var categoryCount = Categories.find({_id: {$in: post.categories}}).count(); if (post.categories.length !== categoryCount) { - throw new Error({ id: 'categories.invalid' }) + throw new Error({id: 'categories.invalid'}); } -} +}; -function PostsNewCheckCategories(post) { - checkCategories(post) - return post +function PostsNewCheckCategories (post) { + checkCategories(post); + return post; } -addCallback('posts.new.sync', PostsNewCheckCategories) +addCallback("posts.new.sync", PostsNewCheckCategories); -function PostEditCheckCategories(modifier) { - checkCategories(modifier.$set) - return modifier +function PostEditCheckCategories (modifier) { + checkCategories(modifier.$set); + return modifier; } -addCallback('posts.edit.sync', PostEditCheckCategories) +addCallback("posts.edit.sync", PostEditCheckCategories); // TODO: debug this diff --git a/packages/example-forum/lib/server/categories/index.js b/packages/example-forum/lib/server/categories/index.js new file mode 100755 index 000000000..a9a682c0c --- /dev/null +++ b/packages/example-forum/lib/server/categories/index.js @@ -0,0 +1,3 @@ + +import './callbacks.js'; +import './indexes.js'; diff --git a/packages/example-forum/lib/server/categories/indexes.js b/packages/example-forum/lib/server/categories/indexes.js new file mode 100755 index 000000000..c340d3e2a --- /dev/null +++ b/packages/example-forum/lib/server/categories/indexes.js @@ -0,0 +1,3 @@ +import { Posts } from '../../modules/posts/index.js'; + +Posts._ensureIndex({'categories': 1}); diff --git a/packages/example-forum/lib/server/comments/callbacks/notifications.js b/packages/example-forum/lib/server/comments/callbacks/notifications.js new file mode 100755 index 000000000..3db16faa7 --- /dev/null +++ b/packages/example-forum/lib/server/comments/callbacks/notifications.js @@ -0,0 +1,54 @@ +/* + +Comment notification callbacks + +*/ + +import Users from 'meteor/vulcan:users'; +import { addCallback } from 'meteor/vulcan:core'; +import { createNotification } from '../../email/notifications.js'; +import { Posts } from '../../../modules/posts/index.js'; +import { Comments } from '../../../modules/comments/index.js'; + +// 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); diff --git a/packages/news/lib/server/comments/callbacks/other.js b/packages/example-forum/lib/server/comments/callbacks/other.js old mode 100644 new mode 100755 similarity index 58% rename from packages/news/lib/server/comments/callbacks/other.js rename to packages/example-forum/lib/server/comments/callbacks/other.js index ab96da9d2..893b93f7b --- a/packages/news/lib/server/comments/callbacks/other.js +++ b/packages/example-forum/lib/server/comments/callbacks/other.js @@ -1,43 +1,38 @@ -import Users from 'meteor/vulcan:users' -import { - addCallback, - runCallbacksAsync, - removeMutation -} from 'meteor/vulcan:core' +import Users from 'meteor/vulcan:users'; +import { addCallback, runCallbacksAsync, removeMutation } from 'meteor/vulcan:core'; -import { Posts } from '../../../modules/posts/index.js' -import { Comments } from '../../../modules/comments/index.js' +import { Posts } from '../../../modules/posts/index.js'; +import { Comments } from '../../../modules/comments/index.js'; ////////////////////////////////////////////////////// // comments.new.sync // ////////////////////////////////////////////////////// -function CommentsNewOperations(comment) { - var userId = comment.userId +function CommentsNewOperations (comment) { + + var userId = comment.userId; // increment comment count - Users.update( - { _id: userId }, - { - $inc: { commentCount: 1 } - } - ) + Users.update({_id: userId}, { + $inc: {'commentCount': 1} + }); // update post Posts.update(comment.postId, { - $inc: { commentCount: 1 }, - $set: { lastCommentedAt: new Date() }, - $addToSet: { commenters: userId } - }) + $inc: {commentCount: 1}, + $set: {lastCommentedAt: new Date()}, + $addToSet: {commenters: userId} + }); - return comment + return comment; } -addCallback('comments.new.sync', CommentsNewOperations) +addCallback('comments.new.sync', CommentsNewOperations); ////////////////////////////////////////////////////// // comments.new.async // ////////////////////////////////////////////////////// + /** * @summary Run the 'upvote.async' callbacks *once* the item exists in the database * @param {object} item - The item being operated on @@ -45,75 +40,68 @@ addCallback('comments.new.sync', CommentsNewOperations) * @param {object} collection - The collection the item belongs to */ function UpvoteAsyncCallbacksAfterDocumentInsert(item, user, collection) { - runCallbacksAsync('upvote.async', item, user, collection, 'upvote') + runCallbacksAsync('upvote.async', item, user, collection, 'upvote'); } -addCallback('comments.new.async', UpvoteAsyncCallbacksAfterDocumentInsert) +addCallback('comments.new.async', UpvoteAsyncCallbacksAfterDocumentInsert); ////////////////////////////////////////////////////// // comments.remove.async // ////////////////////////////////////////////////////// -function CommentsRemovePostCommenters(comment, currentUser) { - const { userId, postId } = comment +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 + 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 } - }) + $inc: {commentCount: -1}, + $set: {lastCommentedAt, commenters}, + }); - return comment + return comment; } -addCallback('comments.remove.async', CommentsRemovePostCommenters) +addCallback('comments.remove.async', CommentsRemovePostCommenters); + +function CommentsRemoveChildrenComments (comment, currentUser) { -function CommentsRemoveChildrenComments(comment, currentUser) { - const childrenComments = Comments.find({ - parentCommentId: comment._id - }).fetch() + const childrenComments = Comments.find({parentCommentId: comment._id}).fetch(); childrenComments.forEach(childComment => { removeMutation({ action: 'comments.remove', collection: Comments, - documentId: childComment._id, + documentId: childComment._id, currentUser: currentUser, validate: false - }) - }) + }); + }); - return comment + return comment; } -addCallback('comments.remove.async', CommentsRemoveChildrenComments) +addCallback('comments.remove.async', CommentsRemoveChildrenComments); ////////////////////////////////////////////////////// // other // ////////////////////////////////////////////////////// -function UsersRemoveDeleteComments(user, options) { +function UsersRemoveDeleteComments (user, options) { if (options.deleteComments) { - Comments.remove({ userId: user._id }) + 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) +addCallback('users.remove.async', UsersRemoveDeleteComments); diff --git a/packages/example-forum/lib/server/comments/callbacks/validation.js b/packages/example-forum/lib/server/comments/callbacks/validation.js new file mode 100755 index 000000000..6948255c0 --- /dev/null +++ b/packages/example-forum/lib/server/comments/callbacks/validation.js @@ -0,0 +1,19 @@ +import Users from 'meteor/vulcan:users'; +import { addCallback, getSetting, registerSetting } from 'meteor/vulcan:core'; +import { Comments } from '../../../modules/comments/index.js'; + +registerSetting('forum.commentInterval', 15, 'How long users should wait in between comments (in seconds)'); + +function CommentsNewRateLimit (comment, user) { + if (!Users.isAdmin(user)) { + const timeSinceLastComment = Users.timeSinceLast(user, Comments); + const commentInterval = Math.abs(parseInt(getSetting('forum.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); diff --git a/packages/example-forum/lib/server/comments/callbacks/voting.js b/packages/example-forum/lib/server/comments/callbacks/voting.js new file mode 100755 index 000000000..cbac25c23 --- /dev/null +++ b/packages/example-forum/lib/server/comments/callbacks/voting.js @@ -0,0 +1,16 @@ +import Users from 'meteor/vulcan:users'; +import { addCallback } from 'meteor/vulcan:core'; +import { Comments } from '../../../modules/comments/index.js'; + +import { performVoteServer } from 'meteor/vulcan:voting'; + +/** + * @summary Make users upvote their own new comments + */ +function CommentsNewUpvoteOwnComment(comment) { + var commentAuthor = Users.findOne(comment.userId); + const votedComent = performVoteServer({ document: comment, voteType: 'upvote', collection: Comments, user: commentAuthor }) + return {...comment, ...votedComent}; +} + +addCallback('comments.new.after', CommentsNewUpvoteOwnComment); \ No newline at end of file diff --git a/packages/example-forum/lib/server/comments/index.js b/packages/example-forum/lib/server/comments/index.js new file mode 100755 index 000000000..510fa9a4e --- /dev/null +++ b/packages/example-forum/lib/server/comments/index.js @@ -0,0 +1,4 @@ +import './callbacks/notifications.js'; +import './callbacks/other.js'; +import './callbacks/validation.js'; +import './callbacks/voting.js'; diff --git a/packages/example-forum/lib/server/email/notifications.js b/packages/example-forum/lib/server/email/notifications.js new file mode 100755 index 000000000..fd9706046 --- /dev/null +++ b/packages/example-forum/lib/server/email/notifications.js @@ -0,0 +1,25 @@ +import Users from 'meteor/vulcan:users'; +import VulcanEmail from 'meteor/vulcan:email'; +import { getSetting, registerSetting } from 'meteor/vulcan:core'; + +registerSetting('emailNotifications', true, 'Enable email notifications'); + +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 + } + }); + } + +}; diff --git a/packages/example-forum/lib/server/email/templates.js b/packages/example-forum/lib/server/email/templates.js new file mode 100755 index 000000000..c725f2a15 --- /dev/null +++ b/packages/example-forum/lib/server/email/templates.js @@ -0,0 +1,16 @@ +import VulcanEmail from 'meteor/vulcan:email'; + +VulcanEmail.addTemplates({ + test: Assets.getText("lib/server/email/templates/common/test.handlebars"), + wrapper: Assets.getText("lib/server/email/templates/common/wrapper.handlebars"), + newPost: Assets.getText("lib/server/email/templates/posts/newPost.handlebars"), + newPendingPost: Assets.getText("lib/server/email/templates/posts/newPendingPost.handlebars"), + postApproved: Assets.getText("lib/server/email/templates/posts/postApproved.handlebars"), + newComment: Assets.getText("lib/server/email/templates/comments/newComment.handlebars"), + newReply: Assets.getText("lib/server/email/templates/comments/newReply.handlebars"), + accountApproved: Assets.getText("lib/server/email/templates/users/accountApproved.handlebars"), + newUser: Assets.getText("lib/server/email/templates/users/newUser.handlebars"), + newsletter: Assets.getText("lib/server/email/templates/newsletter/newsletter.handlebars"), + newsletterConfirmation: Assets.getText("lib/server/email/templates/newsletter/newsletterConfirmation.handlebars"), + postItem: Assets.getText("lib/server/email/templates/newsletter/postItem.handlebars"), +}); \ No newline at end of file diff --git a/packages/news/lib/server/email/templates/comments/newComment.handlebars b/packages/example-forum/lib/server/email/templates/comments/newComment.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/comments/newComment.handlebars rename to packages/example-forum/lib/server/email/templates/comments/newComment.handlebars diff --git a/packages/news/lib/server/email/templates/comments/newReply.handlebars b/packages/example-forum/lib/server/email/templates/comments/newReply.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/comments/newReply.handlebars rename to packages/example-forum/lib/server/email/templates/comments/newReply.handlebars diff --git a/packages/news/lib/server/email/templates/common/test.handlebars b/packages/example-forum/lib/server/email/templates/common/test.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/common/test.handlebars rename to packages/example-forum/lib/server/email/templates/common/test.handlebars diff --git a/packages/news/lib/server/email/templates/common/wrapper.handlebars b/packages/example-forum/lib/server/email/templates/common/wrapper.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/common/wrapper.handlebars rename to packages/example-forum/lib/server/email/templates/common/wrapper.handlebars diff --git a/packages/news/lib/server/email/templates/newsletter/newsletter.handlebars b/packages/example-forum/lib/server/email/templates/newsletter/newsletter.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/newsletter/newsletter.handlebars rename to packages/example-forum/lib/server/email/templates/newsletter/newsletter.handlebars diff --git a/packages/news/lib/server/email/templates/newsletter/newsletterConfirmation.handlebars b/packages/example-forum/lib/server/email/templates/newsletter/newsletterConfirmation.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/newsletter/newsletterConfirmation.handlebars rename to packages/example-forum/lib/server/email/templates/newsletter/newsletterConfirmation.handlebars diff --git a/packages/news/lib/server/email/templates/newsletter/postItem.handlebars b/packages/example-forum/lib/server/email/templates/newsletter/postItem.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/newsletter/postItem.handlebars rename to packages/example-forum/lib/server/email/templates/newsletter/postItem.handlebars diff --git a/packages/news/lib/server/email/templates/posts/newPendingPost.handlebars b/packages/example-forum/lib/server/email/templates/posts/newPendingPost.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/posts/newPendingPost.handlebars rename to packages/example-forum/lib/server/email/templates/posts/newPendingPost.handlebars diff --git a/packages/news/lib/server/email/templates/posts/newPost.handlebars b/packages/example-forum/lib/server/email/templates/posts/newPost.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/posts/newPost.handlebars rename to packages/example-forum/lib/server/email/templates/posts/newPost.handlebars diff --git a/packages/news/lib/server/email/templates/posts/postApproved.handlebars b/packages/example-forum/lib/server/email/templates/posts/postApproved.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/posts/postApproved.handlebars rename to packages/example-forum/lib/server/email/templates/posts/postApproved.handlebars diff --git a/packages/news/lib/server/email/templates/users/accountApproved.handlebars b/packages/example-forum/lib/server/email/templates/users/accountApproved.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/users/accountApproved.handlebars rename to packages/example-forum/lib/server/email/templates/users/accountApproved.handlebars diff --git a/packages/news/lib/server/email/templates/users/newUser.handlebars b/packages/example-forum/lib/server/email/templates/users/newUser.handlebars old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/server/email/templates/users/newUser.handlebars rename to packages/example-forum/lib/server/email/templates/users/newUser.handlebars diff --git a/packages/example-forum/lib/server/main.js b/packages/example-forum/lib/server/main.js new file mode 100755 index 000000000..e181d4271 --- /dev/null +++ b/packages/example-forum/lib/server/main.js @@ -0,0 +1,21 @@ +// Modules + +export * from '../modules/index.js'; + +export * from './email/notifications.js'; + +// Server + +import './email/templates.js'; + +import './seed/seed_posts.js'; +import './seed/seed_categories.js'; + +import './comments/index.js'; + +import './categories/index.js'; + +import './posts/index.js'; + +import './api.js'; +import './rss.js'; \ No newline at end of file diff --git a/packages/example-forum/lib/server/posts/callbacks/embedly.js b/packages/example-forum/lib/server/posts/callbacks/embedly.js new file mode 100755 index 000000000..51e431056 --- /dev/null +++ b/packages/example-forum/lib/server/posts/callbacks/embedly.js @@ -0,0 +1,87 @@ +/* + +Callbacks to add media/thumbnail after submit and on edit + +*/ + +import { addCallback, getSetting } from 'meteor/vulcan:core'; +import { Embed } from 'meteor/vulcan:embed'; + +const embedProvider = getSetting('embedProvider'); + +// For security reason, we make the media property non-modifiable by the client and +// we use a separate server-side API call to set it (and the thumbnail object if it hasn't already been set) + +// Async variant that directly modifies the post object with update() +function AddMediaAfterSubmit (post) { + + if(post.url){ + + const data = Embed[embedProvider].getData(post.url); + + if (data) { + + // only add a thumbnailUrl if there isn't one already + if (!post.thumbnailUrl && data.thumbnailUrl) { + post.thumbnailUrl = data.thumbnailUrl; + } + + // add media if necessary + if (data.media && data.media.html) { + post.media = data.media; + } + + // add source name & url if they exist + if (data.sourceName && data.sourceUrl) { + post.sourceName = data.sourceName; + post.sourceUrl = data.sourceUrl; + } + + } + + } + + return post; +} +addCallback('posts.new.sync', AddMediaAfterSubmit); + +function updateMediaOnEdit (modifier, post) { + + const newUrl = modifier.$set.url; + + if(newUrl && newUrl !== post.url){ + + const data = Embed[embedProvider].getData(newUrl); + + if(data) { + + if (data.media && data.media.html) { + if (modifier.$unset.media) { + delete modifier.$unset.media + } + modifier.$set.media = data.media; + } + + // add source name & url if they exist + if (data.sourceName && data.sourceUrl) { + modifier.$set.sourceName = data.sourceName; + modifier.$set.sourceUrl = data.sourceUrl; + } + + } + } + return modifier; +} +addCallback('posts.edit.sync', updateMediaOnEdit); + +const addMediaAfterSubmit = AddMediaAfterSubmit; + +const regenerateThumbnail = function (post) { + delete post.thumbnailUrl; + delete post.media; + delete post.sourceName; + delete post.sourceUrl; + addMediaAfterSubmit(post); +}; + +export { addMediaAfterSubmit, updateMediaOnEdit, regenerateThumbnail } diff --git a/packages/example-forum/lib/server/posts/callbacks/notifications.js b/packages/example-forum/lib/server/posts/callbacks/notifications.js new file mode 100755 index 000000000..e039ca4e5 --- /dev/null +++ b/packages/example-forum/lib/server/posts/callbacks/notifications.js @@ -0,0 +1,43 @@ +/* + +Notifications for new posts and post approval. + +*/ + +import { Posts } from '../../../modules/posts/index.js' +import Users from 'meteor/vulcan:users'; +import { Connectors, addCallback } from 'meteor/vulcan:core'; +import { createNotification } from '../../email/notifications.js'; + +/** + * @summary Add notification callback when a post is approved + */ +function PostsApprovedNotification (post) { + createNotification(post.userId, 'postApproved', {documentId: post._id}); +} +addCallback('posts.approve.async', PostsApprovedNotification); + + +/** + * @summary Add new post notification callback on post submit + */ +function PostsNewNotifications (post) { + + const adminUsers = Connectors.find(Users, { isAdmin: true }, { fields: { _id: 1 }}); + let adminIds = _.pluck(adminUsers, '_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.new.async', PostsNewNotifications); diff --git a/packages/news/lib/server/posts/callbacks/other.js b/packages/example-forum/lib/server/posts/callbacks/other.js old mode 100644 new mode 100755 similarity index 62% rename from packages/news/lib/server/posts/callbacks/other.js rename to packages/example-forum/lib/server/posts/callbacks/other.js index ac3dc8d9b..92141c299 --- a/packages/news/lib/server/posts/callbacks/other.js +++ b/packages/example-forum/lib/server/posts/callbacks/other.js @@ -11,42 +11,32 @@ Callbacks to: */ import { Posts } from '../../../modules/posts/index.js' -import Users from 'meteor/vulcan:users' -import { - addCallback, - getSetting, - registerSetting, - runCallbacks, - runCallbacksAsync -} from 'meteor/vulcan:core' -import Events from 'meteor/vulcan:events' - -registerSetting('forum.trackClickEvents', true, 'Track clicks to posts pages') +import Users from 'meteor/vulcan:users'; +import { Connectors, addCallback, getSetting, registerSetting, runCallbacks, runCallbacksAsync } from 'meteor/vulcan:core'; +import Events from 'meteor/vulcan:events'; + +registerSetting('forum.trackClickEvents', true, 'Track clicks to posts pages'); /** * @summary Increment the user's post count */ function PostsNewIncrementPostCount(post) { - var userId = post.userId - Users.update({ _id: userId }, { $inc: { postCount: 1 } }) + var userId = post.userId; + Users.update({ _id: userId }, { $inc: { 'postCount': 1 } }); } -addCallback('posts.new.async', PostsNewIncrementPostCount) +addCallback('posts.new.async', PostsNewIncrementPostCount); ////////////////////////////////////////////////////// // posts.edit.sync // ////////////////////////////////////////////////////// function PostsEditRunPostApprovedSyncCallbacks(modifier, post) { - if ( - modifier.$set && - Posts.isApproved(modifier.$set) && - !Posts.isApproved(post) - ) { - modifier = runCallbacks('posts.approve.sync', modifier, post) + if (modifier.$set && Posts.isApproved(modifier.$set) && !Posts.isApproved(post)) { + modifier = runCallbacks('posts.approve.sync', modifier, post); } - return modifier + return modifier; } -addCallback('posts.edit.sync', PostsEditRunPostApprovedSyncCallbacks) +addCallback('posts.edit.sync', PostsEditRunPostApprovedSyncCallbacks); ////////////////////////////////////////////////////// // posts.edit.async // @@ -54,20 +44,20 @@ addCallback('posts.edit.sync', PostsEditRunPostApprovedSyncCallbacks) function PostsEditRunPostApprovedAsyncCallbacks(post, oldPost) { if (Posts.isApproved(post) && !Posts.isApproved(oldPost)) { - runCallbacksAsync('posts.approve.async', post) + runCallbacksAsync('posts.approve.async', post); } } -addCallback('posts.edit.async', PostsEditRunPostApprovedAsyncCallbacks) +addCallback('posts.edit.async', PostsEditRunPostApprovedAsyncCallbacks); ////////////////////////////////////////////////////// // posts.remove.sync // ////////////////////////////////////////////////////// function PostsRemoveOperations(post) { - Users.update({ _id: post.userId }, { $inc: { postCount: -1 } }) - return post + Users.update({ _id: post.userId }, { $inc: { 'postCount': -1 } }); + return post; } -addCallback('posts.remove.sync', PostsRemoveOperations) +addCallback('posts.remove.sync', PostsRemoveOperations); ////////////////////////////////////////////////////// // users.remove.async // @@ -75,13 +65,13 @@ addCallback('posts.remove.sync', PostsRemoveOperations) function UsersRemoveDeletePosts(user, options) { if (options.deletePosts) { - Posts.remove({ userId: user._id }) + 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) +addCallback('users.remove.async', UsersRemoveDeletePosts); ////////////////////////////////////////////////////// // posts.click.async // @@ -92,45 +82,26 @@ addCallback('users.remove.async', UsersRemoveDeletePosts) // * @param {string} postId – the ID of the post being edited // * @param {string} ip – the IP of the current user // */ -Posts.increaseClicks = (post, ip) => { - if (getSetting('forum.trackClickEvents', true)) { - // make sure this IP hasn't previously clicked on this post - let existingClickEvent = false - try { - existingClickEvent = Events.findOne({ - name: 'click', - 'properties.postId': post._id, - 'properties.ip': ip - }) - } catch (error) { - console.error(error) - } - - if (!existingClickEvent) { - // Events.log(clickEvent); // Sidebar only: don't log event - 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) + if (getSetting('forum.trackClickEvents', true)) { + Events.track('post.click', { title: post.title, postId: post._id }); + Connectors.update(Posts, post._id, { $inc: { clickCount: 1 } }); + } } // 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 +// 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) +addCallback('posts.click.async', PostsClickTracking); ////////////////////////////////////////////////////// // posts.approve.sync // ////////////////////////////////////////////////////// function PostsApprovedSetPostedAt(modifier, post) { - modifier.postedAt = new Date() - return modifier + modifier.$set.postedAt = new Date(); + return modifier; } -addCallback('posts.approve.sync', PostsApprovedSetPostedAt) +addCallback('posts.approve.sync', PostsApprovedSetPostedAt); diff --git a/packages/example-forum/lib/server/posts/callbacks/validation.js b/packages/example-forum/lib/server/posts/callbacks/validation.js new file mode 100755 index 000000000..ffcdb4496 --- /dev/null +++ b/packages/example-forum/lib/server/posts/callbacks/validation.js @@ -0,0 +1,83 @@ +/* + +Post validation and rate limiting callbacks + +*/ + +import { Posts } from '../../../modules/posts/index.js' +import Users from 'meteor/vulcan:users'; +import { addCallback, getSetting, registerSetting } from 'meteor/vulcan:core'; +import { createError } from 'apollo-errors'; + +registerSetting('forum.postInterval', 30, 'How long users should wait between each posts, in seconds'); +registerSetting('forum.maxPostsPerDay', 5, 'Maximum number of posts a user can create in a day'); + +/** + * @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('forum.postInterval', 30))), + maxPostsPer24Hours = Math.abs(parseInt(getSetting('forum.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, id: 'posts.rate_limit_error', properties: { 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, id: 'posts.max_per_day', properties: { value: maxPostsPer24Hours }}}); + } + } + + return post; +} +addCallback('posts.new.validate', PostsNewRateLimit); + +/** + * @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, + id: 'posts.link_already_posted', + path: 'url', + properties: { url: post.url }, + }, + }); + } + return post; +} +addCallback('posts.new.sync', PostsNewDuplicateLinksCheck); + + +/** + * @summary Check for duplicate links + */ +function PostsEditDuplicateLinksCheck (modifier, post) { + if(post.url !== modifier.$set.url && !!modifier.$set.url) { + if (Posts.checkForSameUrl(modifier.$set.url)){ + const DuplicateError = createError('posts.link_already_posted', {message: 'posts.link_already_posted'}); + throw new DuplicateError({ + data: { + break: true, + id: 'posts.link_already_posted', + path: 'url', + properties: { url: post.url }, + }, + }); + } + } + return modifier; +} +addCallback('posts.edit.sync', PostsEditDuplicateLinksCheck); diff --git a/packages/example-forum/lib/server/posts/callbacks/voting.js b/packages/example-forum/lib/server/posts/callbacks/voting.js new file mode 100755 index 000000000..131e2927f --- /dev/null +++ b/packages/example-forum/lib/server/posts/callbacks/voting.js @@ -0,0 +1,20 @@ +/* + +Voting callbacks + +*/ + +import { Posts } from '../../../modules/posts/index.js'; +import Users from 'meteor/vulcan:users'; +import { addCallback } from 'meteor/vulcan:core'; +import { performVoteServer } from 'meteor/vulcan:voting'; + +/** + * @summary Make users upvote their own new posts + */ +function PostsNewUpvoteOwnPost(post) { + var postAuthor = Users.findOne(post.userId); + return {...post, ...performVoteServer({ document: post, voteType: 'upvote', collection: Posts, user: postAuthor, updateDocument: true })}; +} + +addCallback('posts.new.after', PostsNewUpvoteOwnPost); diff --git a/packages/example-forum/lib/server/posts/cron.js b/packages/example-forum/lib/server/posts/cron.js new file mode 100755 index 000000000..c423677f1 --- /dev/null +++ b/packages/example-forum/lib/server/posts/cron.js @@ -0,0 +1,41 @@ +import { SyncedCron } from 'meteor/percolatestudio:synced-cron'; +// import moment from 'moment'; +import { Posts } from '../../modules/posts/index.js'; + +SyncedCron.options = { + log: true, + collectionName: 'cronHistory', + utc: false, + collectionTTL: 172800 +}; + + +const addJob = function () { + SyncedCron.add({ + name: 'checkScheduledPosts', + schedule(parser) { + return parser.text('every 10 minutes'); + }, + job() { + // fetch all posts tagged as future + const scheduledPosts = Posts.find({isFuture: true}, {fields: {_id: 1, status: 1, postedAt: 1, userId: 1, title: 1}}).fetch(); + + // filter the scheduled posts to retrieve only the one that should update, considering their schedule + const postsToUpdate = scheduledPosts.filter(post => post.postedAt <= new Date()); + + // update posts found + if (!_.isEmpty(postsToUpdate)) { + const postsIds = _.pluck(postsToUpdate, '_id'); + Posts.update({_id: {$in: postsIds}}, {$set: {isFuture: false}}, {multi: true}); + + // log the action + console.log('// Scheduled posts approved:', postsIds); // eslint-disable-line + } + } + }); +}; + +Meteor.startup(function () { + addJob(); +}); + diff --git a/packages/news/lib/server/posts/graphql.js b/packages/example-forum/lib/server/posts/graphql.js old mode 100644 new mode 100755 similarity index 64% rename from packages/news/lib/server/posts/graphql.js rename to packages/example-forum/lib/server/posts/graphql.js index a4c320d9a..ac2421c42 --- a/packages/news/lib/server/posts/graphql.js +++ b/packages/example-forum/lib/server/posts/graphql.js @@ -4,15 +4,18 @@ GraphQL config */ -import { addGraphQLMutation, addGraphQLResolvers } from 'meteor/vulcan:core' +import { addGraphQLMutation, addGraphQLResolvers } from 'meteor/vulcan:core'; const specificResolvers = { Mutation: { increasePostViewCount(root, { postId }, context) { - return context.Posts.update({ _id: postId }, { $inc: { viewCount: 1 } }) + return context.Posts.update({_id: postId}, { $inc: { viewCount: 1 }}); } } -} +}; + +addGraphQLResolvers(specificResolvers); +addGraphQLMutation('increasePostViewCount(postId: String): Float'); + + -addGraphQLResolvers(specificResolvers) -addGraphQLMutation('increasePostViewCount(postId: String): Float') diff --git a/packages/example-forum/lib/server/posts/index.js b/packages/example-forum/lib/server/posts/index.js new file mode 100755 index 000000000..ce6c960af --- /dev/null +++ b/packages/example-forum/lib/server/posts/index.js @@ -0,0 +1,11 @@ +import './cron.js'; +import './out.js'; +import './indexes.js'; +import './graphql.js'; + +import './callbacks/embedly.js'; +import './callbacks/notifications.js'; +import './callbacks/other.js'; +import './callbacks/validation.js'; +import './callbacks/voting.js'; + diff --git a/packages/example-forum/lib/server/posts/indexes.js b/packages/example-forum/lib/server/posts/indexes.js new file mode 100755 index 000000000..210699e51 --- /dev/null +++ b/packages/example-forum/lib/server/posts/indexes.js @@ -0,0 +1,4 @@ +import { Posts } from '../../modules/posts/index.js'; + +Posts._ensureIndex({"status": 1, "isFuture": 1}); +Posts._ensureIndex({"status": 1, "isFuture": 1, "postedAt": 1}); \ No newline at end of file diff --git a/packages/example-forum/lib/server/posts/out.js b/packages/example-forum/lib/server/posts/out.js new file mode 100755 index 000000000..101f3a7a2 --- /dev/null +++ b/packages/example-forum/lib/server/posts/out.js @@ -0,0 +1,41 @@ +import { runCallbacksAsync } from 'meteor/vulcan:core'; +import escapeStringRegexp from 'escape-string-regexp'; +import { Picker } from 'meteor/meteorhacks:picker'; +import { Posts } from '../../modules/posts/index.js'; + +Picker.route('/out', ({ query}, req, res, next) => { + if(query.url){ // for some reason, query.url doesn't need to be decoded + /* + If the URL passed to ?url= is in plain text, any hash fragment + will get stripped out. + So we search for any post whose URL contains the current URL to get a match + even without the hash + */ + + try { + + const post = Posts.findOne({url: {$regex: escapeStringRegexp(query.url)}}, {sort: {postedAt: -1, createdAt: -1}}); + + if (post) { + const ip = req.headers && req.headers['x-forwarded-for'] || req.connection.remoteAddress; + + runCallbacksAsync('posts.click.async', post, ip); + + res.writeHead(301, {'Location': query.url}); + res.end(); + } else { + // don't redirect if we can't find a post for that link + res.end(`Invalid URL: ${query.url}`); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log('// /out error'); + // eslint-disable-next-line no-console + console.log(error); + // eslint-disable-next-line no-console + console.log(query); + } + } else { + res.end("Please provide a URL"); + } +}); diff --git a/packages/example-forum/lib/server/rss.js b/packages/example-forum/lib/server/rss.js new file mode 100755 index 000000000..800c3977e --- /dev/null +++ b/packages/example-forum/lib/server/rss.js @@ -0,0 +1,103 @@ +import RSS from 'rss'; +import { Posts } from '../modules/posts/index.js'; +import { Comments } from '../modules/comments/index.js'; +import { Utils, getSetting, registerSetting } from 'meteor/vulcan:core'; +import { Picker } from 'meteor/meteorhacks:picker'; + +registerSetting('forum.RSSLinksPointTo', 'link', 'Where to point RSS links to'); + +Posts.addView('rss', Posts.views.new); // default to 'new' view for RSS feed + +const getMeta = (url) => { + const siteUrl = getSetting('siteUrl', Meteor.absoluteUrl()); + + return { + title: getSetting('title'), + description: getSetting('tagline'), + feed_url: siteUrl+url, + site_url: siteUrl, + image_url: siteUrl+'img/favicon.png' + }; +}; + +export const servePostRSS = (terms, url) => { + const feed = new RSS(getMeta(url)); + + let parameters = Posts.getParameters(terms); + delete parameters['options']['sort']['sticky']; + + parameters.options.limit = 50; + + const postsCursor = Posts.find(parameters.selector, parameters.options); + + postsCursor.forEach((post) => { + + const description = !!post.body ? post.body+'

' : ''; + const feedItem = { + title: post.title, + description: description + `Discuss`, + author: post.author, + date: post.postedAt, + guid: post._id, + url: (getSetting('forum.RSSLinksPointTo', 'link') === 'link') ? Posts.getLink(post) : Posts.getPageUrl(post, true) + }; + + if (post.thumbnailUrl) { + const url = Utils.addHttp(post.thumbnailUrl); + feedItem.custom_elements = [{'imageUrl':url}, {'content': url}]; + } + + feed.item(feedItem); + }); + + return feed.xml(); +}; + +export const serveCommentRSS = (terms, url) => { + const feed = new RSS(getMeta(url)); + + const commentsCursor = Comments.find({isDeleted: {$ne: true}}, {sort: {postedAt: -1}, limit: 20}); + + commentsCursor.forEach(function(comment) { + const post = Posts.findOne(comment.postId); + + feed.item({ + title: 'Comment on ' + post.title, + description: `${comment.body}

Discuss`, + author: comment.author, + date: comment.postedAt, + url: Comments.getPageUrl(comment, true), + guid: comment._id + }); + }); + + return feed.xml(); +}; + + +Picker.route('/feed.xml', function(params, req, res, next) { + if (typeof params.query.view === 'undefined') { + params.query.view = 'rss'; + } + res.end(servePostRSS(params.query, 'feed.xml')); +}); + +Picker.route('/rss/posts/new.xml', function(params, req, res, next) { + res.end(servePostRSS({view: 'new'}, '/rss/posts/new.xml')); +}); + +Picker.route('/rss/posts/top.xml', function(params, req, res, next) { + res.end(servePostRSS({view: 'top'}, '/rss/posts/top.xml')); +}); + +Picker.route('/rss/posts/best.xml', function(params, req, res, next) { + res.end(servePostRSS({view: 'best'}, '/rss/posts/best.xml')); +}); + +Picker.route('/rss/category/:slug/feed.xml', function(params, req, res, next) { + res.end(servePostRSS({view: 'new', cat: params.slug}, '/rss/category/:slug/feed.xml')); +}); + +Picker.route('/rss/comments.xml', function(params, req, res, next) { + res.end(serveCommentRSS({}, '/rss/comments.xml')); +}); diff --git a/packages/news/lib/server/seed/seed_categories.js b/packages/example-forum/lib/server/seed/seed_categories.js old mode 100644 new mode 100755 similarity index 64% rename from packages/news/lib/server/seed/seed_categories.js rename to packages/example-forum/lib/server/seed/seed_categories.js index 55aa61127..cff803ef0 --- a/packages/news/lib/server/seed/seed_categories.js +++ b/packages/example-forum/lib/server/seed/seed_categories.js @@ -1,7 +1,7 @@ /* global Vulcan */ -import { Promise } from 'meteor/promise' -import { Utils, newMutation, getSetting } from 'meteor/vulcan:core' -import { Categories } from '../../modules/categories/index.js' +import { Promise } from 'meteor/promise'; +import { Utils, newMutation, getSetting } from 'meteor/vulcan:core'; +import { Categories } from '../../modules/categories/index.js'; if (getSetting('forum.seedOnStart')) { const dummyFlag = { @@ -9,11 +9,11 @@ if (getSetting('forum.seedOnStart')) { fieldSchema: { type: Boolean, optional: true, - hidden: true - } - } + hidden: true, + }, + }; - Categories.addField(dummyFlag) + Categories.addField(dummyFlag); const dummyCategories = [ { @@ -21,87 +21,82 @@ if (getSetting('forum.seedOnStart')) { description: 'The first test category', order: 4, slug: 'testcat1', - isDummy: true + isDummy: true, }, { name: 'Test Category 2', description: 'The second test category', order: 7, slug: 'testcat2', - isDummy: true + isDummy: true, }, { name: 'Test Category 3', description: 'The third test category', order: 10, slug: 'testcat3', - isDummy: true - } - ] + isDummy: true, + }, + ]; - const originalRemoveGettingStartedContent = Vulcan.removeGettingStartedContent + const originalRemoveGettingStartedContent = Vulcan.removeGettingStartedContent; Vulcan.removeGettingStartedContent = () => { if (originalRemoveGettingStartedContent) { - originalRemoveGettingStartedContent() + originalRemoveGettingStartedContent(); } - Categories.remove({ isDummy: true }) + Categories.remove({ isDummy: true }); // eslint-disable-next-line no-console - console.log('// Getting started content removed from seed_categories') - } + console.log('// Getting started content removed from seed_categories'); + }; Meteor.startup(() => { // Load categories from settings, if there are any if (Meteor.settings && Meteor.settings.categories) { Meteor.settings.categories.forEach(category => { + // get slug (or slugified name) - const slug = category.slug || Utils.slugify(category.name) + const slug = category.slug || Utils.slugify(category.name); // look for existing category with same slug - const existingCategory = Categories.findOne({ slug: slug }) + const existingCategory = Categories.findOne({slug: slug}); // look for parent category if (category.parent) { - const parentCategory = Categories.findOne({ slug: category.parent }) + const parentCategory = Categories.findOne({slug: category.parent}); if (parentCategory) { - category.parentId = parentCategory._id - delete category.parent + category.parentId = parentCategory._id; + delete category.parent; } } if (existingCategory) { // if category exists, update it with settings data except slug - delete category.slug - Categories.update(existingCategory._id, { $set: category }) + delete category.slug; + Categories.update(existingCategory._id, { $set: category }); } else { // if not, create it - Promise.await( - newMutation({ - collection: Categories, - document: category, - validate: false - }) - ) + Promise.await(newMutation({ + collection: Categories, + document: category, + validate: false, + })); // Categories.insert(category); // eslint-disable-next-line no-console - console.log(`// Creating category “${category.name}”`) // eslint-disable-line + console.log(`// Creating category “${category.name}”`); // eslint-disable-line } - }) + }); } else if (!Categories.find().count()) { // eslint-disable-next-line no-console - console.log('// inserting dummy categories') + console.log('// inserting dummy categories'); // else if there are no categories yet, create dummy categories - Promise.awaitAll( - dummyCategories.map(document => - newMutation({ - collection: Categories, - document, - validate: false - }) - ) - ) + Promise.awaitAll(dummyCategories.map(document => newMutation({ + collection: Categories, + document, + validate: false, + }))); } - }) + }); } diff --git a/packages/example-forum/lib/server/seed/seed_posts.js b/packages/example-forum/lib/server/seed/seed_posts.js new file mode 100755 index 000000000..20d2d7258 --- /dev/null +++ b/packages/example-forum/lib/server/seed/seed_posts.js @@ -0,0 +1,158 @@ +/* global Vulcan */ +import moment from 'moment'; +import { newMutation, registerSetting, getSetting } from 'meteor/vulcan:core'; +import Users from 'meteor/vulcan:users'; +import { Promise } from 'meteor/promise'; +import { Posts } from '../../modules/posts/index.js'; +import { Comments } from '../../modules/comments/index.js'; + +registerSetting('forum.seedOnStart', true, 'Seed the app with dummy content on startup'); + +if (getSetting('forum.seedOnStart')) { + + const dummyFlag = { + fieldName: 'isDummy', + fieldSchema: { + type: Boolean, + optional: true, + hidden: true, + }, + }; + + Users.addField(dummyFlag); + Posts.addField(dummyFlag); + Comments.addField(dummyFlag); + + Posts.addField({ + fieldName: 'dummySlug', + fieldSchema: { + type: String, + optional: true, + hidden: true, // never show this + }, + }); + + const toTitleCase = (str) => { + return str.replace(/\w\S*/g, function(txt){return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase();}); + }; + + const createPost = async (slug, postedAt, username, thumbnail) => { + const currentUser = await Users.rawCollection().findOne({ username: username }); + const document = { + postedAt: postedAt, + body: Assets.getText("lib/assets/content/" + slug + ".md"), + title: toTitleCase(slug.replace(/_/g, ' ')), + dummySlug: slug, + isDummy: true, + userId: currentUser._id, + }; + + if (typeof thumbnail !== "undefined") { + document.thumbnailUrl = "/packages/example-forum/lib/assets/images/" + thumbnail; + } + + return newMutation({ + collection: Posts, + document, + currentUser, + validate: false, + }); + }; + + const createComment = async (slug, username, body, parentBody) => { + const user = await Users.rawCollection().findOne({ username: username }); + const post = await Posts.rawCollection().findOne({ dummySlug: slug }); + const comment = { + postId: post._id, + userId: user._id, + body: body, + isDummy: true, + disableNotifications: true, + }; + const parentComment = await Comments.rawCollection().findOne({ body: parentBody }); + + if (parentComment) { + comment.parentCommentId = parentComment._id; + } + + return newMutation({ + collection: Comments, + document: comment, + currentUser: user, + validate: false, + }); + }; + + const createUser = async (username, email) => { + const document = { + username, + email, + isDummy: true, + }; + + return newMutation({ + collection: Users, + document, + validate: false, + }); + }; + + const createDummyUsers = async () => { + // eslint-disable-next-line no-console + console.log('// inserting dummy users…'); + return Promise.all([ + createUser('Bruce', 'dummyuser1@telescopeapp.org'), + createUser('Arnold', 'dummyuser2@telescopeapp.org'), + createUser('Julia', 'dummyuser3@telescopeapp.org'), + ]); + }; + + const createDummyPosts = async () => { + // eslint-disable-next-line no-console + console.log('// inserting dummy posts'); + + return Promise.all([ + createPost("read_this_first", moment().toDate(), "Bruce", "telescope.png"), + createPost("deploying", moment().subtract(10, 'minutes').toDate(), "Arnold"), + createPost("customizing", moment().subtract(3, 'hours').toDate(), "Julia"), + createPost("getting_help", moment().subtract(1, 'days').toDate(), "Bruce", "stackoverflow.png"), + createPost("removing_getting_started_posts", moment().subtract(2, 'days').toDate(), "Julia"), + ]); + }; + + const createDummyComments = async () => { + // eslint-disable-next-line no-console + console.log('// inserting dummy comments…'); + + return Promise.all([ + createComment("read_this_first", "Bruce", "What an awesome app!"), + createComment("deploying", "Arnold", "Deploy to da choppah!"), + createComment("deploying", "Julia", "Do you really need to say this all the time?", "Deploy to da choppah!"), + createComment("customizing", "Julia", "This is really cool!"), + createComment("removing_getting_started_posts", "Bruce", "Yippee ki-yay!"), + createComment("removing_getting_started_posts", "Arnold", "I'll be back.", "Yippee ki-yay!"), + ]); + }; + + Vulcan.removeGettingStartedContent = () => { + Users.remove({ 'profile.isDummy': true }); + Posts.remove({ isDummy: true }); + Comments.remove({ isDummy: true }); + // eslint-disable-next-line no-console + console.log('// Getting started content removed from seed_posts'); + }; + + // Uses Promise.await to await async functions in a Fiber to make them "Meteor synchronous" + Meteor.startup(() => { + // insert dummy content only if createDummyContent hasn't happened and there aren't any posts or users in the db + if (!Users.find({ isDummy: true }).count()) { + Promise.await(createDummyUsers()); + } + if (!Posts.find().count()) { + Promise.await(createDummyPosts()); + } + if (!Comments.find().count()) { + Promise.await(createDummyComments()); + } + }); +} diff --git a/packages/news/lib/stylesheets/_accounts.scss b/packages/example-forum/lib/stylesheets/_accounts.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_accounts.scss rename to packages/example-forum/lib/stylesheets/_accounts.scss diff --git a/packages/news/lib/stylesheets/_breakpoints.scss b/packages/example-forum/lib/stylesheets/_breakpoints.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_breakpoints.scss rename to packages/example-forum/lib/stylesheets/_breakpoints.scss diff --git a/packages/news/lib/stylesheets/_categories.scss b/packages/example-forum/lib/stylesheets/_categories.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_categories.scss rename to packages/example-forum/lib/stylesheets/_categories.scss diff --git a/packages/news/lib/stylesheets/_cheatsheet.scss b/packages/example-forum/lib/stylesheets/_cheatsheet.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_cheatsheet.scss rename to packages/example-forum/lib/stylesheets/_cheatsheet.scss diff --git a/packages/news/lib/stylesheets/_colors.scss b/packages/example-forum/lib/stylesheets/_colors.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_colors.scss rename to packages/example-forum/lib/stylesheets/_colors.scss diff --git a/packages/news/lib/stylesheets/_comments.scss b/packages/example-forum/lib/stylesheets/_comments.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_comments.scss rename to packages/example-forum/lib/stylesheets/_comments.scss diff --git a/packages/news/lib/stylesheets/_global.scss b/packages/example-forum/lib/stylesheets/_global.scss old mode 100644 new mode 100755 similarity index 80% rename from packages/news/lib/stylesheets/_global.scss rename to packages/example-forum/lib/stylesheets/_global.scss index 29fcb1e40..bd2fdada9 --- a/packages/news/lib/stylesheets/_global.scss +++ b/packages/example-forum/lib/stylesheets/_global.scss @@ -23,4 +23,12 @@ a{ .message.error { color: $red; +} + +.menu-node-children{ + .dropdown-item{ + a{ + padding-left: 20px; + } + } } \ No newline at end of file diff --git a/packages/news/lib/stylesheets/_header.scss b/packages/example-forum/lib/stylesheets/_header.scss old mode 100644 new mode 100755 similarity index 96% rename from packages/news/lib/stylesheets/_header.scss rename to packages/example-forum/lib/stylesheets/_header.scss index 210d85563..815064c8c --- a/packages/news/lib/stylesheets/_header.scss +++ b/packages/example-forum/lib/stylesheets/_header.scss @@ -56,7 +56,7 @@ } .users-account-menu, .users-menu{ - .dropdown-toggle{ + .dropdown-toggle, .dropdown-toggle-inner{ @include flex-center; padding: 0; background: none; diff --git a/packages/news/lib/stylesheets/_newsletter.scss b/packages/example-forum/lib/stylesheets/_newsletter.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_newsletter.scss rename to packages/example-forum/lib/stylesheets/_newsletter.scss diff --git a/packages/news/lib/stylesheets/_other.scss b/packages/example-forum/lib/stylesheets/_other.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_other.scss rename to packages/example-forum/lib/stylesheets/_other.scss diff --git a/packages/news/lib/stylesheets/_posts.scss b/packages/example-forum/lib/stylesheets/_posts.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_posts.scss rename to packages/example-forum/lib/stylesheets/_posts.scss diff --git a/packages/news/lib/stylesheets/_spinner.scss b/packages/example-forum/lib/stylesheets/_spinner.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_spinner.scss rename to packages/example-forum/lib/stylesheets/_spinner.scss diff --git a/packages/news/lib/stylesheets/_users.scss b/packages/example-forum/lib/stylesheets/_users.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_users.scss rename to packages/example-forum/lib/stylesheets/_users.scss diff --git a/packages/news/lib/stylesheets/_variables.scss b/packages/example-forum/lib/stylesheets/_variables.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/_variables.scss rename to packages/example-forum/lib/stylesheets/_variables.scss diff --git a/packages/news/lib/stylesheets/bootstrap.css b/packages/example-forum/lib/stylesheets/bootstrap.css old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/bootstrap.css rename to packages/example-forum/lib/stylesheets/bootstrap.css diff --git a/packages/news/lib/stylesheets/main.scss b/packages/example-forum/lib/stylesheets/main.scss old mode 100644 new mode 100755 similarity index 100% rename from packages/news/lib/stylesheets/main.scss rename to packages/example-forum/lib/stylesheets/main.scss diff --git a/packages/example-forum/package.js b/packages/example-forum/package.js new file mode 100755 index 000000000..8644c85ab --- /dev/null +++ b/packages/example-forum/package.js @@ -0,0 +1,68 @@ +Package.describe({ + name: "example-forum", + summary: "Vulcan forum package", + version: '1.12.8', + git: "https://github.com/VulcanJS/Vulcan.git" +}); + +Package.onUse(function (api) { + + api.versionsFrom('METEOR@1.5.2'); + + api.use([ + + 'promise', + 'fourseven:scss@4.5.0', + + // vulcan core + 'vulcan:core@1.12.8', + + // vulcan packages + 'vulcan:ui-bootstrap@1.12.8', + 'vulcan:voting@1.12.8', + 'vulcan:accounts@1.12.8', + 'vulcan:email@1.12.8', + 'vulcan:forms@1.12.8', + 'vulcan:newsletter@1.12.8', + 'vulcan:events@1.12.8', + 'vulcan:embed@1.12.8', + 'vulcan:admin@1.12.8', + + ]); + + api.addAssets([ + 'lib/assets/images/stackoverflow.png', + 'lib/assets/images/telescope.png', + ], ['client']); + + api.addAssets([ + 'lib/assets/content/read_this_first.md', + 'lib/assets/content/deploying.md', + 'lib/assets/content/customizing.md', + 'lib/assets/content/getting_help.md', + 'lib/assets/content/removing_getting_started_posts.md', + + 'lib/server/email/templates/common/test.handlebars', + 'lib/server/email/templates/common/wrapper.handlebars', + 'lib/server/email/templates/comments/newComment.handlebars', + 'lib/server/email/templates/comments/newReply.handlebars', + 'lib/server/email/templates/posts/newPendingPost.handlebars', + 'lib/server/email/templates/posts/newPost.handlebars', + 'lib/server/email/templates/posts/postApproved.handlebars', + 'lib/server/email/templates/users/accountApproved.handlebars', + 'lib/server/email/templates/users/newUser.handlebars', + 'lib/server/email/templates/newsletter/newsletter.handlebars', + 'lib/server/email/templates/newsletter/newsletterConfirmation.handlebars', + 'lib/server/email/templates/newsletter/postItem.handlebars', + + ], ['server']); + + api.addFiles([ + // 'lib/stylesheets/bootstrap.css', + 'lib/stylesheets/main.scss' + ], ['client']); + + api.mainModule("lib/server/main.js", "server"); + api.mainModule("lib/client/main.js", "client"); + +}); diff --git a/packages/news/lib/assets/content/deploying.md b/packages/news/lib/assets/content/deploying.md deleted file mode 100644 index 68e2effd8..000000000 --- a/packages/news/lib/assets/content/deploying.md +++ /dev/null @@ -1,7 +0,0 @@ -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). diff --git a/packages/news/lib/assets/content/getting_help.md b/packages/news/lib/assets/content/getting_help.md deleted file mode 100644 index 0c71b5e7a..000000000 --- a/packages/news/lib/assets/content/getting_help.md +++ /dev/null @@ -1,7 +0,0 @@ -### 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). diff --git a/packages/news/lib/client/main.js b/packages/news/lib/client/main.js deleted file mode 100644 index 3c88d95b0..000000000 --- a/packages/news/lib/client/main.js +++ /dev/null @@ -1 +0,0 @@ -export * from '../modules/index.js' diff --git a/packages/news/lib/components/admin/AdminUsersPosts.js b/packages/news/lib/components/admin/AdminUsersPosts.js deleted file mode 100644 index 04838118f..000000000 --- a/packages/news/lib/components/admin/AdminUsersPosts.js +++ /dev/null @@ -1,16 +0,0 @@ -import React from 'react' -import { Posts } from '../../modules/posts/index.js' -import { Link } from 'react-router' - -const AdminUsersPosts = ({ document: user }) => ( -
    - {user.posts && - user.posts.map(post => ( -
  • - {post.title} -
  • - ))} -
-) - -export default AdminUsersPosts diff --git a/packages/news/lib/components/categories/CategoriesEditForm.js b/packages/news/lib/components/categories/CategoriesEditForm.js deleted file mode 100644 index e5b7fec87..000000000 --- a/packages/news/lib/components/categories/CategoriesEditForm.js +++ /dev/null @@ -1,59 +0,0 @@ -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 ( -
-
-
ID: {props.category._id}
-
- { - 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} - /> -
- ) -} - -CategoriesEditForm.propTypes = { - category: PropTypes.object.isRequired, - closeModal: PropTypes.func, - flash: PropTypes.func -} - -CategoriesEditForm.contextTypes = { - intl: intlShape -} - -registerComponent('CategoriesEditForm', CategoriesEditForm, withMessages) diff --git a/packages/news/lib/components/categories/CategoriesList.js b/packages/news/lib/components/categories/CategoriesList.js deleted file mode 100644 index 7120d2448..000000000 --- a/packages/news/lib/components/categories/CategoriesList.js +++ /dev/null @@ -1,163 +0,0 @@ -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: Utils.getRoutePath('posts.list'), - 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 ( -
- } - id="categories-dropdown" - > -
- - - - - -
- {// categories data are loaded - !this.props.loading ? ( - // there are currently categories - nestedCategories && nestedCategories.length > 0 ? ( - nestedCategories.map((category, index) => ( - - )) - ) : // not any category found - null - ) : ( - // categories are loading -
- - - -
- )} - -
- } - component={ - - } - > - - -
-
-
-
- ) - } -} - -CategoriesList.propTypes = { - results: PropTypes.array -} - -const options = { - collection: Categories, - queryName: 'categoriesListQuery', - fragmentName: 'CategoriesList', - limit: 0, - pollInterval: 0 -} - -registerComponent('CategoriesList', CategoriesList, withRouter, withApollo, [ - withList, - options -]) diff --git a/packages/news/lib/components/categories/CategoriesNewForm.js b/packages/news/lib/components/categories/CategoriesNewForm.js deleted file mode 100644 index d53261942..000000000 --- a/packages/news/lib/components/categories/CategoriesNewForm.js +++ /dev/null @@ -1,44 +0,0 @@ -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 ( -
- { - props.closeModal() - props.flash( - context.intl.formatMessage( - { id: 'categories.new_success' }, - { name: category.name } - ), - 'success' - ) - }} - /> -
- ) -} - -CategoriesNewForm.displayName = 'CategoriesNewForm' - -CategoriesNewForm.propTypes = { - closeCallback: PropTypes.func, - flash: PropTypes.func -} - -CategoriesNewForm.contextTypes = { - intl: intlShape -} - -registerComponent('CategoriesNewForm', CategoriesNewForm, withMessages) diff --git a/packages/news/lib/components/categories/CategoriesNode.js b/packages/news/lib/components/categories/CategoriesNode.js deleted file mode 100644 index a02410529..000000000 --- a/packages/news/lib/components/categories/CategoriesNode.js +++ /dev/null @@ -1,43 +0,0 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' - -class CategoriesNode extends PureComponent { - renderCategory(category) { - return ( - - ) - } - - renderChildren(children) { - return ( -
- {children.map(category => ( - - ))} -
- ) - } - - render() { - const category = this.props.category - const children = this.props.category.childrenResults - - return ( -
- {this.renderCategory(category)} - {children ? this.renderChildren(children) : null} -
- ) - } -} - -CategoriesNode.propTypes = { - category: PropTypes.object.isRequired // the current category -} - -registerComponent('CategoriesNode', CategoriesNode) diff --git a/packages/news/lib/components/categories/Category.js b/packages/news/lib/components/categories/Category.js deleted file mode 100644 index ce8d24075..000000000 --- a/packages/news/lib/components/categories/Category.js +++ /dev/null @@ -1,61 +0,0 @@ -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 ( - - - - } - > - - - ) - } - - 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 ( -
- - - {currentCategorySlug === category.slug ? ( - - ) : null} - {category.name} - - - - {this.renderEdit()} - -
- ) - } -} - -Category.propTypes = { - category: PropTypes.object, - index: PropTypes.number, - currentCategorySlug: PropTypes.string, - openModal: PropTypes.func -} - -registerComponent('Category', Category, withRouter) diff --git a/packages/news/lib/components/comments/CommentsItem.js b/packages/news/lib/components/comments/CommentsItem.js deleted file mode 100644 index c3db03763..000000000 --- a/packages/news/lib/components/comments/CommentsItem.js +++ /dev/null @@ -1,156 +0,0 @@ -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) { - this.setState({ showReply: false }) - } - - replySuccessCallback() { - this.setState({ showReply: false }) - } - - showEdit(event) { - event.preventDefault() - this.setState({ showEdit: true }) - } - - editCancelCallback(event) { - 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 ( -
-
- {showReplyButton ? ( - - {' '} - - - ) : null} -
- ) - } - - renderReply() { - return ( -
- -
- ) - } - - renderEdit() { - return ( - - ) - } - - render() { - const comment = this.props.comment - - return ( -
-
-
-
- -
- - -
- {moment(new Date(comment.postedAt)).fromNow()} -
- -
- - - -
-
-
- {this.state.showEdit ? this.renderEdit() : this.renderComment()} -
- {this.state.showReply ? this.renderReply() : null} -
- ) - } -} - -CommentsItem.propTypes = { - comment: PropTypes.object.isRequired, // the current comment - currentUser: PropTypes.object, - flash: PropTypes.func -} - -CommentsItem.contextTypes = { - events: PropTypes.object, - intl: intlShape -} - -registerComponent('CommentsItem', CommentsItem, withMessages) diff --git a/packages/news/lib/components/comments/CommentsList.js b/packages/news/lib/components/comments/CommentsList.js deleted file mode 100644 index e020cbf2d..000000000 --- a/packages/news/lib/components/comments/CommentsList.js +++ /dev/null @@ -1,32 +0,0 @@ -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 ( -
- {comments.map(comment => ( - - ))} - {/*hasMore ? (ready ? : ) : null*/} -
- ) - } else { - return ( -
-

- -

-
- ) - } -} - -CommentsList.displayName = 'CommentsList' - -registerComponent('CommentsList', CommentsList) diff --git a/packages/news/lib/components/comments/CommentsLoadMore.js b/packages/news/lib/components/comments/CommentsLoadMore.js deleted file mode 100644 index 8b98f3a43..000000000 --- a/packages/news/lib/components/comments/CommentsLoadMore.js +++ /dev/null @@ -1,21 +0,0 @@ -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 ( - { - e.preventDefault() - loadMore() - }} - > - {label} - - ) -} - -CommentsLoadMore.displayName = 'CommentsLoadMore' - -registerComponent('CommentsLoadMore', CommentsLoadMore) diff --git a/packages/news/lib/components/comments/CommentsNode.js b/packages/news/lib/components/comments/CommentsNode.js deleted file mode 100644 index 913d9b946..000000000 --- a/packages/news/lib/components/comments/CommentsNode.js +++ /dev/null @@ -1,30 +0,0 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' - -const CommentsNode = ({ comment, currentUser }) => ( -
- - {comment.childrenResults ? ( -
- {comment.childrenResults.map(comment => ( - - ))} -
- ) : null} -
-) - -CommentsNode.propTypes = { - comment: PropTypes.object.isRequired // the current comment -} - -registerComponent('CommentsNode', CommentsNode) diff --git a/packages/news/lib/components/common/Footer.js b/packages/news/lib/components/common/Footer.js deleted file mode 100644 index abede01a9..000000000 --- a/packages/news/lib/components/common/Footer.js +++ /dev/null @@ -1,17 +0,0 @@ -import { registerComponent } from 'meteor/vulcan:core' -import React from 'react' -import { FormattedMessage } from 'meteor/vulcan:i18n' - -const Footer = props => { - return ( -
- - - -
- ) -} - -Footer.displayName = 'Footer' - -registerComponent('Footer', Footer) diff --git a/packages/news/lib/components/common/Header.js b/packages/news/lib/components/common/Header.js deleted file mode 100644 index 736e96d1d..000000000 --- a/packages/news/lib/components/common/Header.js +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react' -import PropTypes from 'prop-types' -import { - withCurrentUser, - getSetting, - Components, - registerComponent -} from 'meteor/vulcan:core' -import { Posts } from '../../modules/posts/index.js' - -const Header = props => { - const logoUrl = getSetting('logoUrl') - const siteTitle = getSetting('title', 'My App') - const tagline = getSetting('tagline') - - return ( -
-
-
- - {tagline ?

{tagline}

: ''} -
- -
-
- {!!props.currentUser ? ( - - ) : ( - - )} -
- -
- - - -
-
-
-
- ) -} - -Header.displayName = 'Header' - -Header.propTypes = { - currentUser: PropTypes.object -} - -registerComponent('Header', Header, withCurrentUser) diff --git a/packages/news/lib/components/common/Layout.js b/packages/news/lib/components/common/Layout.js deleted file mode 100644 index bcb0a1f54..000000000 --- a/packages/news/lib/components/common/Layout.js +++ /dev/null @@ -1,57 +0,0 @@ -import { - Components, - registerComponent, - withCurrentUser -} from 'meteor/vulcan:core' -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import classNames from 'classnames' -import Helmet from 'react-helmet' - -const Layout = ({ currentUser, children, currentRoute }) => ( -
- - - - - - - - {currentUser ? ( - - ) : null} - - - -
- - - - - {children} -
- - -
-) - -registerComponent('Layout', Layout, withCurrentUser) diff --git a/packages/news/lib/components/common/Logo.js b/packages/news/lib/components/common/Logo.js deleted file mode 100644 index 576dc3f1a..000000000 --- a/packages/news/lib/components/common/Logo.js +++ /dev/null @@ -1,25 +0,0 @@ -import { registerComponent } from 'meteor/vulcan:core' -import React from 'react' -import { IndexLink } from 'react-router' - -const Logo = ({ logoUrl, siteTitle }) => { - if (logoUrl) { - return ( -

- - {siteTitle} - -

- ) - } else { - return ( -

- {siteTitle} -

- ) - } -} - -Logo.displayName = 'Logo' - -registerComponent('Logo', Logo) diff --git a/packages/news/lib/components/common/Newsletter.js b/packages/news/lib/components/common/Newsletter.js deleted file mode 100644 index 481fea94b..000000000 --- a/packages/news/lib/components/common/Newsletter.js +++ /dev/null @@ -1,145 +0,0 @@ -import { - Components, - registerComponent, - withCurrentUser, - withMutation, - withMessages -} 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: false - } - } - - componentDidMount() { - this.setState({ - showBanner: showBanner(this.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 ( - this.successCallbackSubscription()} - user={this.props.currentUser} - /> - ) - } - - renderForm() { - return ( - - - - - ) - } - - render() { - return this.state.showBanner ? ( -
-

- -

- {this.props.currentUser ? this.renderButton() : this.renderForm()} - - - -
- ) : 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 -) diff --git a/packages/news/lib/components/common/NewsletterButton.js b/packages/news/lib/components/common/NewsletterButton.js deleted file mode 100644 index 8f73d73a5..000000000 --- a/packages/news/lib/components/common/NewsletterButton.js +++ /dev/null @@ -1,82 +0,0 @@ -import { - Components, - registerComponent, - withMutation, - withCurrentUser, - withMessages, - Utils -} from 'meteor/vulcan:core' -import React, { Component } from 'react' -import PropTypes from 'prop-types' -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 ( - - ) - } -} - -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 -) diff --git a/packages/news/lib/components/common/SearchForm.js b/packages/news/lib/components/common/SearchForm.js deleted file mode 100644 index 7a4b181f9..000000000 --- a/packages/news/lib/components/common/SearchForm.js +++ /dev/null @@ -1,85 +0,0 @@ -import { registerComponent, Components, Utils } from 'meteor/vulcan:core' -import React, { Component } from 'react' -import PropTypes from 'prop-types' -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: Utils.getRoutePath('posts.list'), query: query }) - }, 700) - } - - render() { - const resetQuery = _.clone(this.props.location.query) - delete resetQuery.query - - return ( -
- - - {this.state.search !== '' ? ( - - - - ) : null} - -
- ) - } -} - -SearchForm.contextTypes = { - intl: intlShape -} - -registerComponent('SearchForm', SearchForm, withRouter) diff --git a/packages/news/lib/components/common/Vote.js b/packages/news/lib/components/common/Vote.js deleted file mode 100644 index 968747b64..000000000 --- a/packages/news/lib/components/common/Vote.js +++ /dev/null @@ -1,73 +0,0 @@ -import { Components, registerComponent, withMessages } from 'meteor/vulcan:core' -import React, { PureComponent } from 'react' -import PropTypes from 'prop-types' -import classNames from 'classnames' -import { withVote, hasVotedClient } from 'meteor/vulcan:voting' -import { FormattedMessage, intlShape } from 'meteor/vulcan:i18n' - -class Vote extends PureComponent { - constructor() { - super() - this.vote = this.vote.bind(this) - this.getActionClass = this.getActionClass.bind(this) - this.hasVoted = this.hasVoted.bind(this) - } - - vote(e) { - e.preventDefault() - - 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' }) - ) - } else { - this.props.vote({ - document, - voteType: 'upvote', - collection, - currentUser: this.props.currentUser - }) - } - } - - hasVoted() { - return hasVotedClient({ document: this.props.document, voteType: 'upvote' }) - } - - getActionClass() { - const actionsClass = classNames('vote-button', { upvoted: this.hasVoted() }) - - return actionsClass - } - - render() { - return ( - - ) - } -} - -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) diff --git a/packages/news/lib/components/posts/PostsCategories.js b/packages/news/lib/components/posts/PostsCategories.js deleted file mode 100644 index b56b88c90..000000000 --- a/packages/news/lib/components/posts/PostsCategories.js +++ /dev/null @@ -1,23 +0,0 @@ -import { registerComponent } from 'meteor/vulcan:core' -import React from 'react' -import { Link } from 'react-router' - -const PostsCategories = ({ post }) => { - return ( -
- {post.categories.map(category => ( - - {category.name} - - ))} -
- ) -} - -PostsCategories.displayName = 'PostsCategories' - -registerComponent('PostsCategories', PostsCategories) diff --git a/packages/news/lib/components/posts/PostsCommenters.js b/packages/news/lib/components/posts/PostsCommenters.js deleted file mode 100644 index 438c6da19..000000000 --- a/packages/news/lib/components/posts/PostsCommenters.js +++ /dev/null @@ -1,29 +0,0 @@ -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 ( -
-
- {_.take(post.commenters, 4).map(user => ( - - ))} -
-
- - - - {post.commentCount} - - Comments - -
-
- ) -} - -PostsCommenters.displayName = 'PostsCommenters' - -registerComponent('PostsCommenters', PostsCommenters) diff --git a/packages/news/lib/components/posts/PostsCommentsThread.js b/packages/news/lib/components/posts/PostsCommentsThread.js deleted file mode 100644 index efc0d5c0d..000000000 --- a/packages/news/lib/components/posts/PostsCommentsThread.js +++ /dev/null @@ -1,82 +0,0 @@ -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' - -const PostsCommentsThread = (props /* context*/) => { - const { loading, terms: { postId }, results, totalCount, currentUser } = props - - if (loading) { - return ( -
- -
- ) - } 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 ( -
-

- -

- - {!!currentUser ? ( -
-

- -

- -
- ) : ( -
- - - - } - > - - -
- )} -
- ) - } -} - -PostsCommentsThread.displayName = 'PostsCommentsThread' - -PostsCommentsThread.propTypes = { - currentUser: PropTypes.object -} - -const options = { - collectionName: 'Comments', - queryName: 'commentsListQuery', - fragmentName: 'CommentsList', - limit: 0 -} - -registerComponent( - 'PostsCommentsThread', - PostsCommentsThread, - [withList, options], - withCurrentUser -) diff --git a/packages/news/lib/components/posts/PostsDaily.js b/packages/news/lib/components/posts/PostsDaily.js deleted file mode 100644 index 93dd78c6d..000000000 --- a/packages/news/lib/components/posts/PostsDaily.js +++ /dev/null @@ -1,33 +0,0 @@ -import { - Components, - registerComponent, - getSetting, - registerSetting -} from 'meteor/vulcan:core' -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import moment from 'moment' - -registerSetting( - 'forum.numberOfDays', - 5, - 'Number of days to display in Daily view' -) - -const PostsDaily = props => { - // const terms = props.location && props.location.query; - const numberOfDays = getSetting('forum.numberOfDays', 5) - const terms = { - view: 'top', - after: moment() - .subtract(numberOfDays - 1, 'days') - .format('YYYY-MM-DD'), - before: moment().format('YYYY-MM-DD') - } - - return -} - -PostsDaily.displayName = 'PostsDaily' - -registerComponent('PostsDaily', PostsDaily) diff --git a/packages/news/lib/components/posts/PostsDailyList.js b/packages/news/lib/components/posts/PostsDailyList.js deleted file mode 100644 index 93bbaac8c..000000000 --- a/packages/news/lib/components/posts/PostsDailyList.js +++ /dev/null @@ -1,158 +0,0 @@ -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('forum.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('forum.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 ( -
- - {dates.map((date, index) => ( - - ))} - {this.state.loading ? ( - - ) : ( - - - - )} -
- ) - } -} - -PostsDailyList.propTypes = { - currentUser: PropTypes.object, - days: PropTypes.number, - increment: PropTypes.number -} - -PostsDailyList.defaultProps = { - days: getSetting('forum.numberOfDays', 5), - increment: getSetting('forum.numberOfDays', 5) -} - -const options = { - collection: Posts, - queryName: 'postsDailyListQuery', - fragmentName: 'PostsList', - limit: 0 -} - -registerComponent('PostsDailyList', PostsDailyList, withCurrentUser, [ - withList, - options -]) diff --git a/packages/news/lib/components/posts/PostsDay.js b/packages/news/lib/components/posts/PostsDay.js deleted file mode 100644 index 9b71f2a85..000000000 --- a/packages/news/lib/components/posts/PostsDay.js +++ /dev/null @@ -1,42 +0,0 @@ -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 ( -
-

- {date.format('dddd, MMMM Do YYYY')} -

- {noPosts ? ( - - ) : ( -
-
- {posts.map((post, index) => ( - - ))} -
-
- )} -
- ) - } -} - -PostsDay.propTypes = { - currentUser: PropTypes.object, - date: PropTypes.object, - number: PropTypes.number -} - -registerComponent('PostsDay', PostsDay) diff --git a/packages/news/lib/components/posts/PostsHome.js b/packages/news/lib/components/posts/PostsHome.js deleted file mode 100644 index 39f4cf0fa..000000000 --- a/packages/news/lib/components/posts/PostsHome.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React, { Component } from 'react' -import PropTypes from 'prop-types' - -const PostsHome = (props, context) => { - const terms = _.isEmpty(props.location && props.location.query) - ? { view: 'top' } - : props.location.query - return -} - -PostsHome.displayName = 'PostsHome' - -registerComponent('PostsHome', PostsHome) diff --git a/packages/news/lib/components/posts/PostsItem.js b/packages/news/lib/components/posts/PostsItem.js deleted file mode 100644 index 1f22d3c95..000000000 --- a/packages/news/lib/components/posts/PostsItem.js +++ /dev/null @@ -1,124 +0,0 @@ -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 ? ( - - ) : ( - '' - ) - } - - renderCommenters() { - return this.props.post.commenters && - this.props.post.commenters.length > 0 ? ( - - ) : ( - '' - ) - } - - renderActions() { - return ( -
- - - - } - > - - -
- ) - } - - render() { - const { post } = this.props - - let postClass = 'posts-item' - if (post.sticky) postClass += ' posts-sticky' - - return ( -
-
- -
- - {post.thumbnailUrl ? : null} - -
-

- - {post.title} - - {this.renderCategories()} -

- -
- {post.user ? ( -
- - -
- ) : null} -
- {post.postedAt ? ( - moment(new Date(post.postedAt)).fromNow() - ) : ( - - )} -
-
- - {!post.commentCount || post.commentCount === 0 ? ( - - ) : post.commentCount === 1 ? ( - - ) : ( - - )} - -
- {this.props.currentUser && this.props.currentUser.isAdmin ? ( - - ) : null} - {Posts.options.mutations.edit.check(this.props.currentUser, post) - ? this.renderActions() - : null} -
-
- - {this.renderCommenters()} -
- ) - } -} - -PostsItem.propTypes = { - currentUser: PropTypes.object, - post: PropTypes.object.isRequired, - terms: PropTypes.object -} - -registerComponent('PostsItem', PostsItem) diff --git a/packages/news/lib/components/posts/PostsList.js b/packages/news/lib/components/posts/PostsList.js deleted file mode 100644 index 263a7a91a..000000000 --- a/packages/news/lib/components/posts/PostsList.js +++ /dev/null @@ -1,121 +0,0 @@ -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 }) => ( - - - {error.message} - -) - -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 ( -
- {showHeader ? : null} - {error ? : null} -
- {results.map(post => ( - - ))} -
- {showLoadMore ? ( - hasMore ? ( - - ) : ( - - ) - ) : null} -
- ) - } else if (loading) { - return ( -
- {showHeader ? : null} - {error ? : null} -
- -
-
- ) - } else { - return ( -
- {showHeader ? : null} - {error ? : null} -
- -
-
- ) - } -} - -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]) diff --git a/packages/news/lib/components/posts/PostsLoadMore.js b/packages/news/lib/components/posts/PostsLoadMore.js deleted file mode 100644 index 8e8979e34..000000000 --- a/packages/news/lib/components/posts/PostsLoadMore.js +++ /dev/null @@ -1,40 +0,0 @@ -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 ( - - ) -} - -PostsLoadMore.displayName = 'PostsLoadMore' - -registerComponent('PostsLoadMore', PostsLoadMore) diff --git a/packages/news/lib/components/posts/PostsLoading.js b/packages/news/lib/components/posts/PostsLoading.js deleted file mode 100644 index 5c6703bd2..000000000 --- a/packages/news/lib/components/posts/PostsLoading.js +++ /dev/null @@ -1,14 +0,0 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React from 'react' - -const PostsLoading = props => { - return ( -
- -
- ) -} - -PostsLoading.displayName = 'PostsLoading' - -registerComponent('PostsLoading', PostsLoading) diff --git a/packages/news/lib/components/posts/PostsNewButton.js b/packages/news/lib/components/posts/PostsNewButton.js deleted file mode 100644 index fe7cef00f..000000000 --- a/packages/news/lib/components/posts/PostsNewButton.js +++ /dev/null @@ -1,40 +0,0 @@ -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 = ( - - ) - return ( - - - - ) -} - -PostsNewButton.displayName = 'PostsNewButton' - -PostsNewButton.propTypes = { - currentUser: PropTypes.object -} - -PostsNewButton.contextTypes = { - messages: PropTypes.object, - intl: intlShape -} - -registerComponent('PostsNewButton', PostsNewButton, withCurrentUser) diff --git a/packages/news/lib/components/posts/PostsNoMore.js b/packages/news/lib/components/posts/PostsNoMore.js deleted file mode 100644 index 74f17a6c6..000000000 --- a/packages/news/lib/components/posts/PostsNoMore.js +++ /dev/null @@ -1,13 +0,0 @@ -import { registerComponent } from 'meteor/vulcan:core' -import React from 'react' -import { FormattedMessage } from 'meteor/vulcan:i18n' - -const PostsNoMore = props => ( -

- -

-) - -PostsNoMore.displayName = 'PostsNoMore' - -registerComponent('PostsNoMore', PostsNoMore) diff --git a/packages/news/lib/components/posts/PostsNoResults.js b/packages/news/lib/components/posts/PostsNoResults.js deleted file mode 100644 index 361bb1a51..000000000 --- a/packages/news/lib/components/posts/PostsNoResults.js +++ /dev/null @@ -1,13 +0,0 @@ -import { registerComponent } from 'meteor/vulcan:core' -import React from 'react' -import { FormattedMessage } from 'meteor/vulcan:i18n' - -const PostsNoResults = props => ( -

- -

-) - -PostsNoResults.displayName = 'PostsNoResults' - -registerComponent('PostsNoResults', PostsNoResults) diff --git a/packages/news/lib/components/posts/PostsPage.js b/packages/news/lib/components/posts/PostsPage.js deleted file mode 100644 index 8a5c414aa..000000000 --- a/packages/news/lib/components/posts/PostsPage.js +++ /dev/null @@ -1,131 +0,0 @@ -import { - Components, - registerComponent, - withDocument, - withCurrentUser, - getActions, - withMutation -} from 'meteor/vulcan:core' -import { Posts } from '../../modules/posts/index.js' -import React, { Component } from 'react' -import PropTypes from 'prop-types' -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 ( -
- -
- ) - } else if (!this.props.document) { - // console.log(`// missing post (_id: ${this.props.documentId})`); - return ( -
- -
- ) - } else { - const post = this.props.document - - const htmlBody = { __html: post.htmlBody } - - return ( -
- - - - - {post.htmlBody ? ( -
- ) : null} - - -
- ) - } - } - - // 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) -) diff --git a/packages/news/lib/components/posts/PostsStats.js b/packages/news/lib/components/posts/PostsStats.js deleted file mode 100644 index 86284fed4..000000000 --- a/packages/news/lib/components/posts/PostsStats.js +++ /dev/null @@ -1,34 +0,0 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React from 'react' - -const PostsStats = ({ post }) => { - return ( -
- {post.score ? ( - - {' '} - {Math.floor((post.score || 0) * 10000) / 10000}{' '} - Score - - ) : ( - '' - )} - - {post.baseScore || 0}{' '} - Upvotes - - - {post.clickCount || 0}{' '} - Clicks - - - {post.viewCount || 0}{' '} - Views - -
- ) -} - -PostsStats.displayName = 'PostsStats' - -registerComponent('PostsStats', PostsStats) diff --git a/packages/news/lib/components/posts/PostsThumbnail.js b/packages/news/lib/components/posts/PostsThumbnail.js deleted file mode 100644 index 7561ae8c5..000000000 --- a/packages/news/lib/components/posts/PostsThumbnail.js +++ /dev/null @@ -1,19 +0,0 @@ -import { registerComponent } from 'meteor/vulcan:core' -import React from 'react' -import { Posts } from '../../modules/posts/index.js' - -const PostsThumbnail = ({ post }) => ( - - - - - -) - -PostsThumbnail.displayName = 'PostsThumbnail' - -registerComponent('PostsThumbnail', PostsThumbnail) diff --git a/packages/news/lib/components/posts/PostsViews.js b/packages/news/lib/components/posts/PostsViews.js deleted file mode 100644 index 19192c425..000000000 --- a/packages/news/lib/components/posts/PostsViews.js +++ /dev/null @@ -1,69 +0,0 @@ -import { registerComponent, withCurrentUser, Utils } 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 ( -
- - {views.map(view => ( - - - - - - ))} - - - - - - -
- ) -} - -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) diff --git a/packages/news/lib/components/users/UsersAccount.js b/packages/news/lib/components/users/UsersAccount.js deleted file mode 100644 index 8dac7c96c..000000000 --- a/packages/news/lib/components/users/UsersAccount.js +++ /dev/null @@ -1,23 +0,0 @@ -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 -} - -UsersAccount.propTypes = { - currentUser: PropTypes.object -} - -UsersAccount.displayName = 'UsersAccount' - -registerComponent('UsersAccount', UsersAccount, withCurrentUser) diff --git a/packages/news/lib/components/users/UsersAccountMenu.js b/packages/news/lib/components/users/UsersAccountMenu.js deleted file mode 100644 index 9cc86c8ea..000000000 --- a/packages/news/lib/components/users/UsersAccountMenu.js +++ /dev/null @@ -1,24 +0,0 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React, { Component } from 'react' -import PropTypes from 'prop-types' -import { FormattedMessage } from 'meteor/vulcan:i18n' -import Dropdown from 'react-bootstrap/lib/Dropdown' -import { STATES } from 'meteor/vulcan:accounts' - -const UsersAccountMenu = ({ state }) => ( - - - - - - - - - -) - -UsersAccountMenu.displayName = 'UsersAccountMenu' - -registerComponent('UsersAccountMenu', UsersAccountMenu) diff --git a/packages/news/lib/components/users/UsersAvatar.js b/packages/news/lib/components/users/UsersAvatar.js deleted file mode 100644 index 1bd1969cf..000000000 --- a/packages/news/lib/components/users/UsersAvatar.js +++ /dev/null @@ -1,53 +0,0 @@ -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 = ( - {Users.getDisplayName(user)} - ) - const initials = ( - - {Users.avatar.getInitials(user)} - - ) - - const avatar = avatarUrl ? img : initials - - return ( -
- {link ? ( - - {avatar} - - ) : ( - {avatar} - )} -
- ) -} - -UsersAvatar.propTypes = { - user: PropTypes.object.isRequired, - size: PropTypes.string, - link: PropTypes.bool -} - -UsersAvatar.defaultProps = { - size: 'medium', - link: true -} - -UsersAvatar.displayName = 'UsersAvatar' - -registerComponent('UsersAvatar', UsersAvatar) diff --git a/packages/news/lib/components/users/UsersEditForm.js b/packages/news/lib/components/users/UsersEditForm.js deleted file mode 100644 index 7f9e83c92..000000000 --- a/packages/news/lib/components/users/UsersEditForm.js +++ /dev/null @@ -1,74 +0,0 @@ -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 ( - } - > -
-

- -

- -
- - - - } - > - - -
- - { - props.flash( - context.intl.formatMessage( - { id: 'users.edit_success' }, - { name: Users.getDisplayName(user) } - ), - 'success' - ) - }} - showRemove={true} - /> -
-
- ) -} - -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) diff --git a/packages/news/lib/components/users/UsersMenu.js b/packages/news/lib/components/users/UsersMenu.js deleted file mode 100644 index 55cee8ab9..000000000 --- a/packages/news/lib/components/users/UsersMenu.js +++ /dev/null @@ -1,65 +0,0 @@ -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 }) => ( -
- - - -
- {Users.getDisplayName(currentUser)} -
-
- - - - - - - - - - - - - Users.isAdmin(currentUser)}> - - - Admin - - - - Meteor.logout(() => client.resetStore())} - > - - - -
-
-) - -UsersMenu.propsTypes = { - currentUser: PropTypes.object, - client: PropTypes.object -} - -registerComponent('UsersMenu', UsersMenu, withCurrentUser, withApollo) diff --git a/packages/news/lib/components/users/UsersName.js b/packages/news/lib/components/users/UsersName.js deleted file mode 100644 index 11cdf1cae..000000000 --- a/packages/news/lib/components/users/UsersName.js +++ /dev/null @@ -1,19 +0,0 @@ -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 }) => ( - - {Users.getDisplayName(user)} - -) - -UsersName.propTypes = { - user: PropTypes.object.isRequired -} - -UsersName.displayName = 'UsersName' - -registerComponent('UsersName', UsersName) diff --git a/packages/news/lib/components/users/UsersProfile.js b/packages/news/lib/components/users/UsersProfile.js deleted file mode 100644 index ee89be70c..000000000 --- a/packages/news/lib/components/users/UsersProfile.js +++ /dev/null @@ -1,89 +0,0 @@ -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 ( -
- -
- ) - } else if (!props.document) { - // console.log(`// missing user (_id/slug: ${props.documentId || props.slug})`); - return ( -
- -
- ) - } else { - const user = props.document - - const terms = { view: 'userPosts', userId: user._id } - - return ( -
- -

{Users.getDisplayName(user)}

- {user.htmlBio ? ( -
- ) : null} - -

- -

- -
- ) - } -} - -UsersProfile.propTypes = { - // document: PropTypes.object.isRequired, -} - -UsersProfile.displayName = 'UsersProfile' - -const options = { - collection: Users, - queryName: 'usersSingleQuery', - fragmentName: 'UsersProfile' -} - -registerComponent('UsersProfile', UsersProfile, withCurrentUser, [ - withDocument, - options -]) diff --git a/packages/news/lib/components/users/UsersProfileCheck.js b/packages/news/lib/components/users/UsersProfileCheck.js deleted file mode 100644 index 167fb4ccf..000000000 --- a/packages/news/lib/components/users/UsersProfileCheck.js +++ /dev/null @@ -1,104 +0,0 @@ -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 ( - - - - - - - - { - const newUser = { ...currentUser, ...user } - if (Users.hasCompletedProfile(newUser)) { - flash( - context.intl.formatMessage({ - id: 'users.profile_completed' - }), - 'success' - ) - } - }} - /> - - - {' '} - - Meteor.logout( - () => - window.location.reload() /* something is broken here when giving the apollo client as a prop*/ - ) - } - > - - - - - ) - } 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 -]) diff --git a/packages/news/lib/components/users/UsersSingle.js b/packages/news/lib/components/users/UsersSingle.js deleted file mode 100644 index f403550f9..000000000 --- a/packages/news/lib/components/users/UsersSingle.js +++ /dev/null @@ -1,15 +0,0 @@ -import { Components, registerComponent } from 'meteor/vulcan:core' -import React from 'react' - -const UsersSingle = (props, context) => { - return ( - - ) -} - -UsersSingle.displayName = 'UsersSingle' - -registerComponent('UsersSingle', UsersSingle) diff --git a/packages/news/lib/modules/categories/custom_fields.js b/packages/news/lib/modules/categories/custom_fields.js deleted file mode 100644 index 994409a9e..000000000 --- a/packages/news/lib/modules/categories/custom_fields.js +++ /dev/null @@ -1,56 +0,0 @@ -/* - -Custom fields on Posts collection - -*/ - -import { Posts } from '../../modules/posts/index.js' -import { getCategoriesAsOptions } from './schema.js' - -Posts.addField([ - { - fieldName: 'categoriesIds', - fieldSchema: { - type: Array, - control: 'checkboxgroup', - optional: true, - insertableBy: ['members'], - editableBy: ['members'], - viewableBy: ['guests'], - options: props => { - return getCategoriesAsOptions(props.data.CategoriesList) - }, - query: ` - CategoriesList{ - _id - name - slug - order - } - `, - resolveAs: { - fieldName: 'categories', - type: '[Category]', - resolver: async (post, args, { currentUser, Users, Categories }) => { - if (!post.categoriesIds) return [] - const categories = _.compact( - await Categories.loader.loadMany(post.categoriesIds) - ) - return Users.restrictViewableFields( - currentUser, - Categories, - categories - ) - }, - addOriginalField: true - } - } - }, - { - fieldName: 'categoriesIds.$', - fieldSchema: { - type: String, - optional: true - } - } -]) diff --git a/packages/news/lib/modules/categories/helpers.js b/packages/news/lib/modules/categories/helpers.js deleted file mode 100644 index e37f46261..000000000 --- a/packages/news/lib/modules/categories/helpers.js +++ /dev/null @@ -1,72 +0,0 @@ -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' -} diff --git a/packages/news/lib/modules/categories/index.js b/packages/news/lib/modules/categories/index.js deleted file mode 100644 index 6fcfefa8b..000000000 --- a/packages/news/lib/modules/categories/index.js +++ /dev/null @@ -1,8 +0,0 @@ -export * from './collection.js' - -import './fragments.js' -import './views.js' -import './custom_fields.js' -import './helpers.js' -import './permissions.js' -import './parameters.js' diff --git a/packages/news/lib/modules/categories/permissions.js b/packages/news/lib/modules/categories/permissions.js deleted file mode 100644 index fc347f555..000000000 --- a/packages/news/lib/modules/categories/permissions.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - -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) diff --git a/packages/news/lib/modules/comments/collection.js b/packages/news/lib/modules/comments/collection.js deleted file mode 100644 index a0299d7a4..000000000 --- a/packages/news/lib/modules/comments/collection.js +++ /dev/null @@ -1,40 +0,0 @@ -/* - -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 - */ -export 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 - } -} diff --git a/packages/news/lib/modules/comments/helpers.js b/packages/news/lib/modules/comments/helpers.js deleted file mode 100644 index 6014383c2..000000000 --- a/packages/news/lib/modules/comments/helpers.js +++ /dev/null @@ -1,35 +0,0 @@ -/* - -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 -} diff --git a/packages/news/lib/modules/comments/index.js b/packages/news/lib/modules/comments/index.js deleted file mode 100644 index 979a92eb7..000000000 --- a/packages/news/lib/modules/comments/index.js +++ /dev/null @@ -1,7 +0,0 @@ -export * from './collection.js' - -import './fragments.js' -import './custom_fields.js' -import './helpers.js' -import './permissions.js' -import './views.js' diff --git a/packages/news/lib/modules/comments/permissions.js b/packages/news/lib/modules/comments/permissions.js deleted file mode 100644 index 4f46ac442..000000000 --- a/packages/news/lib/modules/comments/permissions.js +++ /dev/null @@ -1,25 +0,0 @@ -/* - -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) diff --git a/packages/news/lib/modules/comments/views.js b/packages/news/lib/modules/comments/views.js deleted file mode 100644 index 22bf27584..000000000 --- a/packages/news/lib/modules/comments/views.js +++ /dev/null @@ -1,21 +0,0 @@ -/* - -Comments views - -*/ - -import { Comments } from './index.js' - -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 } } - } -}) diff --git a/packages/news/lib/modules/components.js b/packages/news/lib/modules/components.js deleted file mode 100644 index 3bb67b6b8..000000000 --- a/packages/news/lib/modules/components.js +++ /dev/null @@ -1,64 +0,0 @@ -// common - -import '../components/common/Footer.js' -import '../components/common/Header.js' -import '../components/common/Layout.js' -import '../components/common/Logo.js' -import '../components/common/Newsletter.js' -import '../components/common/NewsletterButton.js' -import '../components/common/SearchForm.js' -import '../components/common/Vote.js' - -// posts - -import '../components/posts/PostsHome.js' -import '../components/posts/PostsSingle.js' -import '../components/posts/PostsNewButton.js' -import '../components/posts/PostsLoadMore.js' -import '../components/posts/PostsNoMore.js' -import '../components/posts/PostsNoResults.js' -import '../components/posts/PostsItem.js' -import '../components/posts/PostsLoading.js' -import '../components/posts/PostsViews.js' -import '../components/posts/PostsList.js' -import '../components/posts/PostsListHeader.js' -import '../components/posts/PostsCategories.js' -import '../components/posts/PostsCommenters.js' -import '../components/posts/PostsPage.js' -import '../components/posts/PostsStats.js' -import '../components/posts/PostsDaily.js' -import '../components/posts/PostsDailyList.js' -import '../components/posts/PostsDay.js' -import '../components/posts/PostsThumbnail.js' -import '../components/posts/PostsEditForm.js' -import '../components/posts/PostsNewForm.js' -import '../components/posts/PostsCommentsThread.js' - -// comments - -import '../components/comments/CommentsItem.js' -import '../components/comments/CommentsList.js' -import '../components/comments/CommentsNode.js' -import '../components/comments/CommentsNewForm.js' -import '../components/comments/CommentsEditForm.js' -import '../components/comments/CommentsLoadMore.js' - -// categories - -import '../components/categories/CategoriesList.js' -import '../components/categories/CategoriesNode.js' -import '../components/categories/Category.js' -import '../components/categories/CategoriesEditForm.js' -import '../components/categories/CategoriesNewForm.js' - -// users - -import '../components/users/UsersSingle.js' -import '../components/users/UsersAccount.js' -import '../components/users/UsersEditForm.js' -import '../components/users/UsersProfile.js' -import '../components/users/UsersProfileCheck.js' -import '../components/users/UsersAvatar.js' -import '../components/users/UsersName.js' -import '../components/users/UsersMenu.js' -import '../components/users/UsersAccountMenu.js' diff --git a/packages/news/lib/modules/config.js b/packages/news/lib/modules/config.js deleted file mode 100644 index 94fcadcc0..000000000 --- a/packages/news/lib/modules/config.js +++ /dev/null @@ -1,13 +0,0 @@ -import Users from 'meteor/vulcan:users' - -Users.avatar.setOptions({ - gravatarDefault: 'mm', - defaultImageUrl: - 'http://www.gravatar.com/avatar/00000000000000000000000000000000?d=mm&f=y' -}) - -Accounts.ui.config({ - requestPermissions: { - github: ['read:user', 'user:email'] - } -}) diff --git a/packages/news/lib/modules/index.js b/packages/news/lib/modules/index.js deleted file mode 100644 index c7d52625c..000000000 --- a/packages/news/lib/modules/index.js +++ /dev/null @@ -1,13 +0,0 @@ -import './voting.js' -import './fragments.js' -import './components.js' -import './config.js' -import './routes.js' -import './headtags.js' -import './i18n.js' - -export { Posts } from './posts/index.js' -export { Categories } from './categories/index.js' -export { Comments } from './comments/index.js' - -import './notifications/index.js' diff --git a/packages/news/lib/modules/notifications/index.js b/packages/news/lib/modules/notifications/index.js deleted file mode 100644 index f06fc6eec..000000000 --- a/packages/news/lib/modules/notifications/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import './custom_fields.js' -import './emails.js' diff --git a/packages/news/lib/modules/posts/custom_fields.js b/packages/news/lib/modules/posts/custom_fields.js deleted file mode 100644 index 0bc57ad2c..000000000 --- a/packages/news/lib/modules/posts/custom_fields.js +++ /dev/null @@ -1,52 +0,0 @@ -/* - -Custom fields on Users collection - -*/ - -import Users from 'meteor/vulcan:users' - -Users.addField([ - /** - Count of the user's posts - */ - { - fieldName: 'postCount', - fieldSchema: { - type: Number, - optional: true, - defaultValue: 0, - viewableBy: ['guests'] - } - }, - /** - The user's associated posts (GraphQL only) - */ - { - fieldName: 'posts', - fieldSchema: { - type: Array, - optional: true, - viewableBy: ['guests'], - resolveAs: { - arguments: 'limit: Int = 5', - type: '[Post]', - resolver: (user, { limit }, { currentUser, Users, Posts }) => { - const posts = Posts.find({ userId: user._id }, { limit }).fetch() - - // restrict documents fields - const viewablePosts = _.filter(posts, post => - Posts.checkAccess(currentUser, post) - ) - const restrictedPosts = Users.restrictViewableFields( - currentUser, - Posts, - viewablePosts - ) - - return restrictedPosts - } - } - } - } -]) diff --git a/packages/news/lib/modules/posts/helpers.js b/packages/news/lib/modules/posts/helpers.js deleted file mode 100644 index 5ef9c333e..000000000 --- a/packages/news/lib/modules/posts/helpers.js +++ /dev/null @@ -1,212 +0,0 @@ -/* - -Posts helpers - -*/ - -import moment from 'moment' -import { Posts } from './collection.js' -import Users from 'meteor/vulcan:users' -import { Utils, getSetting, registerSetting } from 'meteor/vulcan:core' - -registerSetting( - 'forum.outsideLinksPointTo', - 'link', - 'Whether to point RSS links to the linked URL (“link”) or back to the post page (“page”)' -) -registerSetting( - 'forum.requirePostsApproval', - false, - 'Require posts to be approved manually' -) -registerSetting( - 'twitterAccount', - null, - 'Twitter account associated with the app' -) -registerSetting('siteUrl', null, 'Main site URL') - -////////////////// -// Link Helpers // -////////////////// - -/** - * @summary Return a post's link if it has one, else return its post page URL - * @param {Object} post - */ -Posts.getLink = function(post, isAbsolute = false, isRedirected = true) { - const url = isRedirected ? Utils.getOutgoingUrl(post.url) : post.url - return !!post.url ? url : Posts.getPageUrl(post, isAbsolute) -} - -/** - * @summary Depending on the settings, return either a post's URL link (if it has one) or its page URL. - * @param {Object} post - */ -Posts.getShareableLink = function(post) { - return getSetting('forum.outsideLinksPointTo', 'link') === 'link' - ? Posts.getLink(post) - : Posts.getPageUrl(post, true) -} - -/** - * @summary Whether a post's link should open in a new tab or not - * @param {Object} post - */ -Posts.getLinkTarget = function(post) { - return !!post.url ? '_blank' : '' -} - -/** - * @summary Get URL of a post page. - * @param {Object} post - */ -Posts.getPageUrl = function(post, isAbsolute = false) { - const prefix = isAbsolute ? Utils.getSiteUrl().slice(0, -1) : '' - return `${prefix}/posts/${post._id}/${post.slug}` -} - -/////////////////// -// Other Helpers // -/////////////////// - -/** - * @summary Get a post author's name - * @param {Object} post - */ -Posts.getAuthorName = function(post) { - var user = Users.findOne(post.userId) - if (user) { - return Users.getDisplayName(user) - } else { - return post.author - } -} - -/** - * @summary Get default status for new posts. - * @param {Object} user - */ -Posts.getDefaultStatus = function(user) { - const canPostApproved = - typeof user === 'undefined' - ? false - : Users.canDo(user, 'posts.new.approved') - if (!getSetting('forum.requirePostsApproval', false) || canPostApproved) { - // if user can post straight to 'approved', or else post approval is not required - return Posts.config.STATUS_APPROVED - } else { - return Posts.config.STATUS_PENDING - } -} - -/** - * @summary Get status name - * @param {Object} user - */ -Posts.getStatusName = function(post) { - return Utils.findWhere(Posts.statuses, { value: post.status }).label -} - -/** - * @summary Check if a post is approved - * @param {Object} post - */ -Posts.isApproved = function(post) { - return post.status === Posts.config.STATUS_APPROVED -} - -/** - * @summary Check if a post is pending - * @param {Object} post - */ -Posts.isPending = function(post) { - return post.status === Posts.config.STATUS_PENDING -} - -/** - * @summary Check to see if post URL is unique. - * We need the current user so we know who to upvote the existing post as. - * @param {String} url - */ -Posts.checkForSameUrl = function(url) { - // check that there are no previous posts with the same link in the past 6 months - var sixMonthsAgo = moment() - .subtract(6, 'months') - .toDate() - var postWithSameLink = Posts.findOne({ - url: url, - postedAt: { $gte: sixMonthsAgo } - }) - - return !!postWithSameLink -} - -/** - * @summary When on a post page, return the current post - */ -Posts.current = function() { - return Posts.findOne('foo') -} - -/** - * @summary Check to see if a post is a link to a video - * @param {Object} post - */ -Posts.isVideo = function(post) { - return post.media && post.media.type === 'video' -} - -/** - * @summary Get the complete thumbnail url whether it is hosted on Embedly or on an external website, or locally in the app. - * @param {Object} post - */ -Posts.getThumbnailUrl = post => { - const thumbnailUrl = post.thumbnailUrl - if (!!thumbnailUrl) { - return thumbnailUrl.indexOf('//') > -1 - ? Utils.addHttp(thumbnailUrl) - : Utils.getSiteUrl().slice(0, -1) + thumbnailUrl - } -} - -/** - * @summary Get URL for sharing on Twitter. - * @param {Object} post - */ -Posts.getTwitterShareUrl = post => { - const via = getSetting('twitterAccount', null) - ? `&via=${getSetting('twitterAccount')}` - : '' - return `https://twitter.com/intent/tweet?text=${encodeURIComponent( - post.title - )}%20${encodeURIComponent(Posts.getLink(post, true))}${via}` -} - -/** - * @summary Get URL for sharing on Facebook. - * @param {Object} post - */ -Posts.getFacebookShareUrl = post => { - return `https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent( - Posts.getLink(post, true) - )}` -} - -/** - * @summary Get URL for sharing by Email. - * @param {Object} post - */ -Posts.getEmailShareUrl = post => { - const subject = `Interesting link: ${post.title}` - const body = `I thought you might find this interesting: - -${post.title} -${Posts.getLink(post, true, false)} - -(found via ${getSetting('siteUrl')}) - ` - return `mailto:?subject=${encodeURIComponent( - subject - )}&body=${encodeURIComponent(body)}` -} diff --git a/packages/news/lib/modules/posts/index.js b/packages/news/lib/modules/posts/index.js deleted file mode 100644 index 491e517c8..000000000 --- a/packages/news/lib/modules/posts/index.js +++ /dev/null @@ -1,12 +0,0 @@ -export * from './collection.js' - -import './fragments.js' -import './custom_fields.js' -import './parameters.js' -import './views.js' -import './helpers.js' -import './permissions.js' -import './redux.js' -import './admin.js' -import './newsletter.js' -import './embedly.js' diff --git a/packages/news/lib/modules/posts/permissions.js b/packages/news/lib/modules/posts/permissions.js deleted file mode 100644 index a67c23d5a..000000000 --- a/packages/news/lib/modules/posts/permissions.js +++ /dev/null @@ -1,30 +0,0 @@ -/* - -Posts permissions - -*/ - -import Users from 'meteor/vulcan:users' - -const guestsActions = ['posts.view.approved'] -Users.groups.guests.can(guestsActions) - -const membersActions = [ - 'posts.new', - 'posts.edit.own', - 'posts.remove.own', - 'posts.upvote', - 'posts.downvote' -] -Users.groups.members.can(membersActions) - -const adminActions = [ - 'posts.view.pending', - 'posts.view.rejected', - 'posts.view.spam', - 'posts.view.deleted', - 'posts.new.approved', - 'posts.edit.all', - 'posts.remove.all' -] -Users.groups.admins.can(adminActions) diff --git a/packages/news/lib/modules/posts/redux.js b/packages/news/lib/modules/posts/redux.js deleted file mode 100644 index c66d10168..000000000 --- a/packages/news/lib/modules/posts/redux.js +++ /dev/null @@ -1,26 +0,0 @@ -/* - -Redux - -*/ - -import { addAction, addReducer } from 'meteor/vulcan:core' - -addAction({ - postsViewed: { - setViewed: postId => ({ - type: 'SET_VIEWED', - postId - }) - } -}) - -addReducer({ - postsViewed: (state = [], action) => { - if (action.type === 'SET_VIEWED') { - return [...state, action.postId] - } - - return state - } -}) diff --git a/packages/news/lib/modules/routes.js b/packages/news/lib/modules/routes.js deleted file mode 100644 index d73e996d2..000000000 --- a/packages/news/lib/modules/routes.js +++ /dev/null @@ -1,18 +0,0 @@ -import { addRoute } from 'meteor/vulcan:core' - -addRoute([ - { name: 'posts.list', path: '/', componentName: 'PostsHome' }, // index route - { name: 'posts.daily', path: 'daily', componentName: 'PostsDaily' }, - { - name: 'posts.single', - path: 'posts/:_id(/:slug)', - componentName: 'PostsSingle' - }, - { name: 'users.single', path: 'users/:slug', componentName: 'UsersSingle' }, - { name: 'users.account', path: 'account', componentName: 'UsersAccount' }, - { - name: 'users.edit', - path: 'users/:slug/edit', - componentName: 'UsersAccount' - } -]) diff --git a/packages/news/lib/modules/voting.js b/packages/news/lib/modules/voting.js deleted file mode 100644 index 954647c37..000000000 --- a/packages/news/lib/modules/voting.js +++ /dev/null @@ -1,6 +0,0 @@ -import { makeVoteable } from 'meteor/vulcan:voting' -import { Posts } from './posts/index.js' -import { Comments } from './comments/index.js' - -makeVoteable(Posts) -makeVoteable(Comments) diff --git a/packages/news/lib/server/api.js b/packages/news/lib/server/api.js deleted file mode 100644 index dfb647bbe..000000000 --- a/packages/news/lib/server/api.js +++ /dev/null @@ -1,90 +0,0 @@ -import { Posts } from '../modules/posts/index.js' -import { Comments } from '../modules/comments/index.js' -import Users from 'meteor/vulcan:users' -import { Utils } from 'meteor/vulcan:core' -import { Picker } from 'meteor/meteorhacks:picker' - -export const servePostsApi = terms => { - var posts = [] - - if (!terms.limit) { - terms.limit = 50 - } - - var parameters = Posts.getParameters(terms) - - const postsCursor = Posts.find(parameters.selector, parameters.options) - - postsCursor.forEach(function(post) { - var url = Posts.getLink(post) - var postOutput = { - title: post.title, - headline: post.title, // for backwards compatibility - author: post.author, - date: post.postedAt, - url: url, - pageUrl: Posts.getPageUrl(post, true), - guid: post._id - } - - if (post.body) postOutput.body = post.body - - if (post.url) postOutput.domain = Utils.getDomain(url) - - if (post.thumbnailUrl) { - postOutput.thumbnailUrl = Utils.addHttp(post.thumbnailUrl) - } - - var twitterName = Users.getTwitterNameById(post.userId) - if (twitterName) postOutput.twitterName = twitterName - - var comments = [] - - Comments.find( - { postId: post._id }, - { sort: { postedAt: -1 }, limit: 50 } - ).forEach(function(comment) { - var commentProperties = { - body: comment.body, - author: comment.author, - date: comment.postedAt, - guid: comment._id, - parentCommentId: comment.parentCommentId - } - comments.push(commentProperties) - }) - - var commentsToDelete = [] - - comments.forEach(function(comment, index) { - if (comment.parentCommentId) { - var parent = comments.filter(function(obj) { - return obj.guid === comment.parentCommentId - })[0] - if (parent) { - parent.replies = parent.replies || [] - parent.replies.push(JSON.parse(JSON.stringify(comment))) - commentsToDelete.push(index) - } - } - }) - - commentsToDelete.reverse().forEach(function(index) { - comments.splice(index, 1) - }) - - postOutput.comments = comments - - posts.push(postOutput) - }) - - return JSON.stringify(posts) -} - -// for backwards compatibility's sake, accept a "limit" segment -Picker.route('/api/:limit?', function(params, req, res, next) { - if (typeof params.limit !== 'undefined') { - params.query.limit = params.limit - } - res.end(servePostsApi(params.query)) -}) diff --git a/packages/news/lib/server/categories/index.js b/packages/news/lib/server/categories/index.js deleted file mode 100644 index c45c26401..000000000 --- a/packages/news/lib/server/categories/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import './callbacks.js' -import './indexes.js' diff --git a/packages/news/lib/server/categories/indexes.js b/packages/news/lib/server/categories/indexes.js deleted file mode 100644 index 89cd8cea2..000000000 --- a/packages/news/lib/server/categories/indexes.js +++ /dev/null @@ -1,3 +0,0 @@ -import { Posts } from '../../modules/posts/index.js' - -Posts._ensureIndex({ categories: 1 }) diff --git a/packages/news/lib/server/comments/callbacks/notifications.js b/packages/news/lib/server/comments/callbacks/notifications.js deleted file mode 100644 index e4adcb613..000000000 --- a/packages/news/lib/server/comments/callbacks/notifications.js +++ /dev/null @@ -1,57 +0,0 @@ -/* - -Comment notification callbacks - -*/ - -import Users from 'meteor/vulcan:users' -import { addCallback } from 'meteor/vulcan:core' -import { createNotification } from '../../email/notifications.js' -import { Posts } from '../../../modules/posts/index.js' -import { Comments } from '../../../modules/comments/index.js' - -// 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) diff --git a/packages/news/lib/server/comments/callbacks/validation.js b/packages/news/lib/server/comments/callbacks/validation.js deleted file mode 100644 index 4effa299a..000000000 --- a/packages/news/lib/server/comments/callbacks/validation.js +++ /dev/null @@ -1,30 +0,0 @@ -import Users from 'meteor/vulcan:users' -import { addCallback, getSetting, registerSetting } from 'meteor/vulcan:core' -import { Comments } from '../../../modules/comments/index.js' - -registerSetting( - 'forum.commentInterval', - 15, - 'How long users should wait in between comments (in seconds)' -) - -function CommentsNewRateLimit(comment, user) { - if (!Users.isAdmin(user)) { - const timeSinceLastComment = Users.timeSinceLast(user, Comments) - const commentInterval = Math.abs( - parseInt(getSetting('forum.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) diff --git a/packages/news/lib/server/comments/callbacks/voting.js b/packages/news/lib/server/comments/callbacks/voting.js deleted file mode 100644 index cc85735fc..000000000 --- a/packages/news/lib/server/comments/callbacks/voting.js +++ /dev/null @@ -1,21 +0,0 @@ -import Users from 'meteor/vulcan:users' -import { addCallback } from 'meteor/vulcan:core' -import { Comments } from '../../../modules/comments/index.js' - -import { performVoteServer } from 'meteor/vulcan:voting' - -/** - * @summary Make users upvote their own new comments - */ -function CommentsNewUpvoteOwnComment(comment) { - var commentAuthor = Users.findOne(comment.userId) - const votedComent = performVoteServer({ - document: comment, - voteType: 'upvote', - collection: Comments, - user: commentAuthor - }) - return { ...comment, ...votedComent } -} - -addCallback('comments.new.after', CommentsNewUpvoteOwnComment) diff --git a/packages/news/lib/server/comments/index.js b/packages/news/lib/server/comments/index.js deleted file mode 100644 index 68c73863d..000000000 --- a/packages/news/lib/server/comments/index.js +++ /dev/null @@ -1,4 +0,0 @@ -import './callbacks/notifications.js' -import './callbacks/other.js' -import './callbacks/validation.js' -import './callbacks/voting.js' diff --git a/packages/news/lib/server/config.js b/packages/news/lib/server/config.js deleted file mode 100644 index ff3d62887..000000000 --- a/packages/news/lib/server/config.js +++ /dev/null @@ -1,9 +0,0 @@ -ServiceConfiguration.configurations.upsert( - { service: 'github' }, - { - $set: { - clientId: Meteor.settings.oAuth.github.clientId, - secret: Meteor.settings.oAuth.github.secret - } - } -) diff --git a/packages/news/lib/server/email/notifications.js b/packages/news/lib/server/email/notifications.js deleted file mode 100644 index 464cd44e2..000000000 --- a/packages/news/lib/server/email/notifications.js +++ /dev/null @@ -1,27 +0,0 @@ -import Users from 'meteor/vulcan:users' -import VulcanEmail from 'meteor/vulcan:email' -import { getSetting, registerSetting } from 'meteor/vulcan:core' - -registerSetting('emailNotifications', true, 'Enable email notifications') - -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 - } - }) - } -} diff --git a/packages/news/lib/server/email/templates.js b/packages/news/lib/server/email/templates.js deleted file mode 100644 index a3a754f8b..000000000 --- a/packages/news/lib/server/email/templates.js +++ /dev/null @@ -1,38 +0,0 @@ -import VulcanEmail from 'meteor/vulcan:email' - -VulcanEmail.addTemplates({ - test: Assets.getText('lib/server/email/templates/common/test.handlebars'), - wrapper: Assets.getText( - 'lib/server/email/templates/common/wrapper.handlebars' - ), - newPost: Assets.getText( - 'lib/server/email/templates/posts/newPost.handlebars' - ), - newPendingPost: Assets.getText( - 'lib/server/email/templates/posts/newPendingPost.handlebars' - ), - postApproved: Assets.getText( - 'lib/server/email/templates/posts/postApproved.handlebars' - ), - newComment: Assets.getText( - 'lib/server/email/templates/comments/newComment.handlebars' - ), - newReply: Assets.getText( - 'lib/server/email/templates/comments/newReply.handlebars' - ), - accountApproved: Assets.getText( - 'lib/server/email/templates/users/accountApproved.handlebars' - ), - newUser: Assets.getText( - 'lib/server/email/templates/users/newUser.handlebars' - ), - newsletter: Assets.getText( - 'lib/server/email/templates/newsletter/newsletter.handlebars' - ), - newsletterConfirmation: Assets.getText( - 'lib/server/email/templates/newsletter/newsletterConfirmation.handlebars' - ), - postItem: Assets.getText( - 'lib/server/email/templates/newsletter/postItem.handlebars' - ) -}) diff --git a/packages/news/lib/server/main.js b/packages/news/lib/server/main.js deleted file mode 100644 index 8dd29bacf..000000000 --- a/packages/news/lib/server/main.js +++ /dev/null @@ -1,23 +0,0 @@ -// Modules - -export * from '../modules/index.js' - -export * from './email/notifications.js' - -// Server - -import './email/templates.js' - -import './seed/seed_posts.js' -import './seed/seed_categories.js' - -import './comments/index.js' - -import './categories/index.js' - -import './posts/index.js' - -import './api.js' -import './rss.js' - -import './config.js' diff --git a/packages/news/lib/server/posts/callbacks/embedly.js b/packages/news/lib/server/posts/callbacks/embedly.js deleted file mode 100644 index 296ea4337..000000000 --- a/packages/news/lib/server/posts/callbacks/embedly.js +++ /dev/null @@ -1,78 +0,0 @@ -/* - -Callbacks to add media/thumbnail after submit and on edit - -*/ - -import { addCallback, getSetting } from 'meteor/vulcan:core' -import { Embed } from 'meteor/vulcan:embed' - -const embedProvider = getSetting('embedProvider') - -// For security reason, we make the media property non-modifiable by the client and -// we use a separate server-side API call to set it (and the thumbnail object if it hasn't already been set) - -// Async variant that directly modifies the post object with update() -function AddMediaAfterSubmit(post) { - if (post.url) { - const data = Embed[embedProvider].getData(post.url) - - if (data) { - // only add a thumbnailUrl if there isn't one already - if (!post.thumbnailUrl && data.thumbnailUrl) { - post.thumbnailUrl = data.thumbnailUrl - } - - // add media if necessary - if (data.media && data.media.html) { - post.media = data.media - } - - // add source name & url if they exist - if (data.sourceName && data.sourceUrl) { - post.sourceName = data.sourceName - post.sourceUrl = data.sourceUrl - } - } - } - - return post -} -addCallback('posts.new.sync', AddMediaAfterSubmit) - -function updateMediaOnEdit(modifier, post) { - const newUrl = modifier.$set.url - - if (newUrl && newUrl !== post.url) { - const data = Embed[embedProvider].getData(newUrl) - - if (data) { - if (data.media && data.media.html) { - if (modifier.$unset.media) { - delete modifier.$unset.media - } - modifier.$set.media = data.media - } - - // add source name & url if they exist - if (data.sourceName && data.sourceUrl) { - modifier.$set.sourceName = data.sourceName - modifier.$set.sourceUrl = data.sourceUrl - } - } - } - return modifier -} -addCallback('posts.edit.sync', updateMediaOnEdit) - -const addMediaAfterSubmit = AddMediaAfterSubmit - -const regenerateThumbnail = function(post) { - delete post.thumbnailUrl - delete post.media - delete post.sourceName - delete post.sourceUrl - addMediaAfterSubmit(post) -} - -export { addMediaAfterSubmit, updateMediaOnEdit, regenerateThumbnail } diff --git a/packages/news/lib/server/posts/callbacks/notifications.js b/packages/news/lib/server/posts/callbacks/notifications.js deleted file mode 100644 index 831bed0d7..000000000 --- a/packages/news/lib/server/posts/callbacks/notifications.js +++ /dev/null @@ -1,42 +0,0 @@ -/* - -Notifications for new posts and post approval. - -*/ - -import { Posts } from '../../../modules/posts/index.js' -import Users from 'meteor/vulcan:users' -import { addCallback } from 'meteor/vulcan:core' -import { createNotification } from '../../email/notifications.js' - -/** - * @summary Add notification callback when a post is approved - */ -function PostsApprovedNotification(post) { - createNotification(post.userId, 'postApproved', { documentId: post._id }) -} -addCallback('posts.approve.async', PostsApprovedNotification) - -/** - * @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.new.async', PostsNewNotifications) diff --git a/packages/news/lib/server/posts/callbacks/validation.js b/packages/news/lib/server/posts/callbacks/validation.js deleted file mode 100644 index f1dbf0b85..000000000 --- a/packages/news/lib/server/posts/callbacks/validation.js +++ /dev/null @@ -1,85 +0,0 @@ -/* - -Post validation and rate limiting callbacks - -*/ - -import { Posts } from '../../../modules/posts/index.js' -import Users from 'meteor/vulcan:users' -import { addCallback, getSetting, registerSetting } from 'meteor/vulcan:core' -import { createError } from 'apollo-errors' - -registerSetting( - 'forum.postInterval', - 30, - 'How long users should wait between each posts, in seconds' -) -registerSetting( - 'forum.maxPostsPerDay', - 5, - 'Maximum number of posts a user can create in a day' -) - -/** - * @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('forum.postInterval', 30))), - maxPostsPer24Hours = Math.abs( - parseInt(getSetting('forum.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) - -/** - * @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) - -/** - * @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) diff --git a/packages/news/lib/server/posts/callbacks/voting.js b/packages/news/lib/server/posts/callbacks/voting.js deleted file mode 100644 index 343c35921..000000000 --- a/packages/news/lib/server/posts/callbacks/voting.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - -Voting callbacks - -*/ - -import { Posts } from '../../../modules/posts/index.js' -import Users from 'meteor/vulcan:users' -import { addCallback } from 'meteor/vulcan:core' -import { performVoteServer } from 'meteor/vulcan:voting' - -/** - * @summary Make users upvote their own new posts - */ -function PostsNewUpvoteOwnPost(post) { - var postAuthor = Users.findOne(post.userId) - return { - ...post, - ...performVoteServer({ - document: post, - voteType: 'upvote', - collection: Posts, - user: postAuthor, - updateDocument: true - }) - } -} - -addCallback('posts.new.after', PostsNewUpvoteOwnPost) diff --git a/packages/news/lib/server/posts/cron.js b/packages/news/lib/server/posts/cron.js deleted file mode 100644 index 0b0ca139d..000000000 --- a/packages/news/lib/server/posts/cron.js +++ /dev/null @@ -1,48 +0,0 @@ -import { SyncedCron } from 'meteor/percolatestudio:synced-cron' -// import moment from 'moment'; -import { Posts } from '../../modules/posts/index.js' - -SyncedCron.options = { - log: true, - collectionName: 'cronHistory', - utc: false, - collectionTTL: 172800 -} - -const addJob = function() { - SyncedCron.add({ - name: 'checkScheduledPosts', - schedule(parser) { - return parser.text('every 10 minutes') - }, - job() { - // fetch all posts tagged as future - const scheduledPosts = Posts.find( - { isFuture: true }, - { fields: { _id: 1, status: 1, postedAt: 1, userId: 1, title: 1 } } - ).fetch() - - // filter the scheduled posts to retrieve only the one that should update, considering their schedule - const postsToUpdate = scheduledPosts.filter( - post => post.postedAt <= new Date() - ) - - // update posts found - if (!_.isEmpty(postsToUpdate)) { - const postsIds = _.pluck(postsToUpdate, '_id') - Posts.update( - { _id: { $in: postsIds } }, - { $set: { isFuture: false } }, - { multi: true } - ) - - // log the action - console.log('// Scheduled posts approved:', postsIds) // eslint-disable-line - } - } - }) -} - -Meteor.startup(function() { - addJob() -}) diff --git a/packages/news/lib/server/posts/index.js b/packages/news/lib/server/posts/index.js deleted file mode 100644 index ac14a8b6e..000000000 --- a/packages/news/lib/server/posts/index.js +++ /dev/null @@ -1,10 +0,0 @@ -import './cron.js' -import './out.js' -import './indexes.js' -import './graphql.js' - -import './callbacks/embedly.js' -import './callbacks/notifications.js' -import './callbacks/other.js' -import './callbacks/validation.js' -import './callbacks/voting.js' diff --git a/packages/news/lib/server/posts/indexes.js b/packages/news/lib/server/posts/indexes.js deleted file mode 100644 index ab11945d4..000000000 --- a/packages/news/lib/server/posts/indexes.js +++ /dev/null @@ -1,4 +0,0 @@ -import { Posts } from '../../modules/posts/index.js' - -Posts._ensureIndex({ status: 1, isFuture: 1 }) -Posts._ensureIndex({ status: 1, isFuture: 1, postedAt: 1 }) diff --git a/packages/news/lib/server/posts/out.js b/packages/news/lib/server/posts/out.js deleted file mode 100644 index f0a21cea3..000000000 --- a/packages/news/lib/server/posts/out.js +++ /dev/null @@ -1,46 +0,0 @@ -import { runCallbacksAsync } from 'meteor/vulcan:core' -import escapeStringRegexp from 'escape-string-regexp' -import { Picker } from 'meteor/meteorhacks:picker' -import { Posts } from '../../modules/posts/index.js' - -Picker.route('/out', ({ query }, req, res, next) => { - if (query.url) { - // for some reason, query.url doesn't need to be decoded - /* - If the URL passed to ?url= is in plain text, any hash fragment - will get stripped out. - So we search for any post whose URL contains the current URL to get a match - even without the hash - */ - - try { - const post = Posts.findOne( - { url: { $regex: escapeStringRegexp(query.url) } }, - { sort: { postedAt: -1, createdAt: -1 } } - ) - - if (post) { - const ip = - (req.headers && req.headers['x-forwarded-for']) || - req.connection.remoteAddress - - runCallbacksAsync('posts.click.async', post, ip) - - res.writeHead(301, { Location: query.url }) - res.end() - } else { - // don't redirect if we can't find a post for that link - res.end(`Invalid URL: ${query.url}`) - } - } catch (error) { - // eslint-disable-next-line no-console - console.log('// /out error') - // eslint-disable-next-line no-console - console.log(error) - // eslint-disable-next-line no-console - console.log(query) - } - } else { - res.end('Please provide a URL') - } -}) diff --git a/packages/news/lib/server/rss.js b/packages/news/lib/server/rss.js deleted file mode 100644 index 9d1f8eb1e..000000000 --- a/packages/news/lib/server/rss.js +++ /dev/null @@ -1,116 +0,0 @@ -import RSS from 'rss' -import { Posts } from '../modules/posts/index.js' -import { Comments } from '../modules/comments/index.js' -import { Utils, getSetting, registerSetting } from 'meteor/vulcan:core' -import { Picker } from 'meteor/meteorhacks:picker' - -registerSetting('forum.RSSLinksPointTo', 'link', 'Where to point RSS links to') - -Posts.addView('rss', Posts.views.new) // default to 'new' view for RSS feed - -const getMeta = url => { - const siteUrl = getSetting('siteUrl', Meteor.absoluteUrl()) - - return { - title: getSetting('title'), - description: getSetting('tagline'), - feed_url: siteUrl + url, - site_url: siteUrl, - image_url: siteUrl + 'img/favicon.png' - } -} - -export const servePostRSS = (terms, url) => { - const feed = new RSS(getMeta(url)) - - let parameters = Posts.getParameters(terms) - delete parameters['options']['sort']['sticky'] - - parameters.options.limit = 50 - - const postsCursor = Posts.find(parameters.selector, parameters.options) - - postsCursor.forEach(post => { - const description = !!post.body ? post.body + '

' : '' - const feedItem = { - title: post.title, - description: - description + `Discuss`, - author: post.author, - date: post.postedAt, - guid: post._id, - url: - getSetting('forum.RSSLinksPointTo', 'link') === 'link' - ? Posts.getLink(post) - : Posts.getPageUrl(post, true) - } - - if (post.thumbnailUrl) { - const url = Utils.addHttp(post.thumbnailUrl) - feedItem.custom_elements = [{ imageUrl: url }, { content: url }] - } - - feed.item(feedItem) - }) - - return feed.xml() -} - -export const serveCommentRSS = (terms, url) => { - const feed = new RSS(getMeta(url)) - - const commentsCursor = Comments.find( - { isDeleted: { $ne: true } }, - { sort: { postedAt: -1 }, limit: 20 } - ) - - commentsCursor.forEach(function(comment) { - const post = Posts.findOne(comment.postId) - - feed.item({ - title: 'Comment on ' + post.title, - description: `${comment.body}

Discuss`, - author: comment.author, - date: comment.postedAt, - url: Comments.getPageUrl(comment, true), - guid: comment._id - }) - }) - - return feed.xml() -} - -Picker.route('/feed.xml', function(params, req, res, next) { - if (typeof params.query.view === 'undefined') { - params.query.view = 'rss' - } - res.end(servePostRSS(params.query, 'feed.xml')) -}) - -Picker.route('/rss/posts/new.xml', function(params, req, res, next) { - res.end(servePostRSS({ view: 'new' }, '/rss/posts/new.xml')) -}) - -Picker.route('/rss/posts/top.xml', function(params, req, res, next) { - res.end(servePostRSS({ view: 'top' }, '/rss/posts/top.xml')) -}) - -Picker.route('/rss/posts/best.xml', function(params, req, res, next) { - res.end(servePostRSS({ view: 'best' }, '/rss/posts/best.xml')) -}) - -Picker.route('/rss/category/:slug/feed.xml', function(params, req, res, next) { - res.end( - servePostRSS( - { view: 'new', cat: params.slug }, - '/rss/category/:slug/feed.xml' - ) - ) -}) - -Picker.route('/rss/comments.xml', function(params, req, res, next) { - res.end(serveCommentRSS({}, '/rss/comments.xml')) -}) diff --git a/packages/news/lib/server/seed/seed_posts.js b/packages/news/lib/server/seed/seed_posts.js deleted file mode 100644 index d25246e35..000000000 --- a/packages/news/lib/server/seed/seed_posts.js +++ /dev/null @@ -1,212 +0,0 @@ -/* global Vulcan */ -import moment from 'moment' -import { newMutation, registerSetting, getSetting } from 'meteor/vulcan:core' -import Users from 'meteor/vulcan:users' -import { Promise } from 'meteor/promise' -import { Posts } from '../../modules/posts/index.js' -import { Comments } from '../../modules/comments/index.js' - -registerSetting( - 'forum.seedOnStart', - true, - 'Seed the app with dummy content on startup' -) - -if (getSetting('forum.seedOnStart')) { - const dummyFlag = { - fieldName: 'isDummy', - fieldSchema: { - type: Boolean, - optional: true, - hidden: true - } - } - - Users.addField(dummyFlag) - Posts.addField(dummyFlag) - Comments.addField(dummyFlag) - - Posts.addField({ - fieldName: 'dummySlug', - fieldSchema: { - type: String, - optional: true, - hidden: true // never show this - } - }) - - const toTitleCase = str => { - return str.replace(/\w\S*/g, function(txt) { - return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() - }) - } - - const createPost = async (slug, postedAt, username, thumbnail) => { - const currentUser = await Users.rawCollection().findOne({ - username: username - }) - const document = { - postedAt: postedAt, - body: Assets.getText('lib/assets/content/' + slug + '.md'), - title: toTitleCase(slug.replace(/_/g, ' ')), - dummySlug: slug, - isDummy: true, - userId: currentUser._id - } - - if (typeof thumbnail !== 'undefined') { - document.thumbnailUrl = - '/packages/example-forum/lib/assets/images/' + thumbnail - } - - return newMutation({ - collection: Posts, - document, - currentUser, - validate: false - }) - } - - const createComment = async (slug, username, body, parentBody) => { - const user = await Users.rawCollection().findOne({ username: username }) - const post = await Posts.rawCollection().findOne({ dummySlug: slug }) - const comment = { - postId: post._id, - userId: user._id, - body: body, - isDummy: true, - disableNotifications: true - } - const parentComment = await Comments.rawCollection().findOne({ - body: parentBody - }) - - if (parentComment) { - comment.parentCommentId = parentComment._id - } - - return newMutation({ - collection: Comments, - document: comment, - currentUser: user, - validate: false - }) - } - - const createUser = async (username, email) => { - const document = { - username, - email, - isDummy: true - } - - return newMutation({ - collection: Users, - document, - validate: false - }) - } - - const createDummyUsers = async () => { - // eslint-disable-next-line no-console - console.log('// inserting dummy users…') - return Promise.all([ - createUser('Bruce', 'dummyuser1@telescopeapp.org'), - createUser('Arnold', 'dummyuser2@telescopeapp.org'), - createUser('Julia', 'dummyuser3@telescopeapp.org') - ]) - } - - const createDummyPosts = async () => { - // eslint-disable-next-line no-console - console.log('// inserting dummy posts') - - return Promise.all([ - createPost( - 'read_this_first', - moment().toDate(), - 'Bruce', - 'telescope.png' - ), - createPost( - 'deploying', - moment() - .subtract(10, 'minutes') - .toDate(), - 'Arnold' - ), - createPost( - 'customizing', - moment() - .subtract(3, 'hours') - .toDate(), - 'Julia' - ), - createPost( - 'getting_help', - moment() - .subtract(1, 'days') - .toDate(), - 'Bruce', - 'stackoverflow.png' - ), - createPost( - 'removing_getting_started_posts', - moment() - .subtract(2, 'days') - .toDate(), - 'Julia' - ) - ]) - } - - const createDummyComments = async () => { - // eslint-disable-next-line no-console - console.log('// inserting dummy comments…') - - return Promise.all([ - createComment('read_this_first', 'Bruce', 'What an awesome app!'), - createComment('deploying', 'Arnold', 'Deploy to da choppah!'), - createComment( - 'deploying', - 'Julia', - 'Do you really need to say this all the time?', - 'Deploy to da choppah!' - ), - createComment('customizing', 'Julia', 'This is really cool!'), - createComment( - 'removing_getting_started_posts', - 'Bruce', - 'Yippee ki-yay!' - ), - createComment( - 'removing_getting_started_posts', - 'Arnold', - "I'll be back.", - 'Yippee ki-yay!' - ) - ]) - } - - Vulcan.removeGettingStartedContent = () => { - Users.remove({ 'profile.isDummy': true }) - Posts.remove({ isDummy: true }) - Comments.remove({ isDummy: true }) - // eslint-disable-next-line no-console - console.log('// Getting started content removed from seed_posts') - } - - // Uses Promise.await to await async functions in a Fiber to make them "Meteor synchronous" - Meteor.startup(() => { - // insert dummy content only if createDummyContent hasn't happened and there aren't any posts or users in the db - if (!Users.find().count()) { - Promise.await(createDummyUsers()) - } - if (!Posts.find().count()) { - Promise.await(createDummyPosts()) - } - if (!Comments.find().count()) { - Promise.await(createDummyComments()) - } - }) -} diff --git a/packages/news/package.js b/packages/news/package.js deleted file mode 100644 index 1d620f05d..000000000 --- a/packages/news/package.js +++ /dev/null @@ -1,67 +0,0 @@ -Package.describe({ - name: 'news', - summary: 'GraphQL News app', - version: '0.0.1', - git: 'git@github.com:GraphQLGuide/news.git' -}) - -Package.onUse(function(api) { - api.versionsFrom('METEOR@1.5.2') - - api.use([ - 'promise', - 'fourseven:scss@4.5.0', - - // vulcan core - 'vulcan:core@1.8.9', - - // vulcan packages - 'vulcan:voting@1.8.9', - 'vulcan:accounts@1.8.9', - 'vulcan:email@1.8.9', - 'vulcan:forms@1.8.9', - 'vulcan:newsletter@1.8.9', - 'vulcan:events@1.8.9', - 'vulcan:embed@1.8.9' - ]) - - api.addAssets( - ['lib/assets/images/stackoverflow.png', 'lib/assets/images/telescope.png'], - ['client'] - ) - - api.addAssets( - [ - 'lib/assets/content/read_this_first.md', - 'lib/assets/content/deploying.md', - 'lib/assets/content/customizing.md', - 'lib/assets/content/getting_help.md', - 'lib/assets/content/removing_getting_started_posts.md', - - 'lib/server/email/templates/common/test.handlebars', - 'lib/server/email/templates/common/wrapper.handlebars', - 'lib/server/email/templates/comments/newComment.handlebars', - 'lib/server/email/templates/comments/newReply.handlebars', - 'lib/server/email/templates/posts/newPendingPost.handlebars', - 'lib/server/email/templates/posts/newPost.handlebars', - 'lib/server/email/templates/posts/postApproved.handlebars', - 'lib/server/email/templates/users/accountApproved.handlebars', - 'lib/server/email/templates/users/newUser.handlebars', - 'lib/server/email/templates/newsletter/newsletter.handlebars', - 'lib/server/email/templates/newsletter/newsletterConfirmation.handlebars', - 'lib/server/email/templates/newsletter/postItem.handlebars' - ], - ['server'] - ) - - api.addFiles( - [ - // 'lib/stylesheets/bootstrap.css', - 'lib/stylesheets/main.scss' - ], - ['client'] - ) - - api.mainModule('lib/server/main.js', 'server') - api.mainModule('lib/client/main.js', 'client') -})