├── .gitignore ├── README.md ├── building-a-blog-with-react.md └── code ├── .editorconfig ├── .gitignore ├── .jshintrc ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── cordova-plugins ├── packages ├── platforms ├── release └── versions ├── README.md ├── application.html ├── both ├── methods │ ├── insert │ │ └── collection.js │ ├── read │ │ └── collection.js │ ├── remove │ │ └── collection.js │ └── update │ │ └── collection.js ├── modules │ ├── _modules.js │ └── startup.js ├── routes │ ├── authenticated.jsx │ ├── configure.jsx │ └── public.jsx └── startup.js ├── client ├── components │ ├── generic │ │ ├── alerts │ │ │ ├── alert.jsx │ │ │ ├── danger-alert.jsx │ │ │ ├── info-alert.jsx │ │ │ ├── success-alert.jsx │ │ │ └── warning-alert.jsx │ │ ├── buttons │ │ │ ├── button.jsx │ │ │ ├── danger.jsx │ │ │ ├── default.jsx │ │ │ ├── info.jsx │ │ │ ├── link.jsx │ │ │ ├── primary.jsx │ │ │ ├── success.jsx │ │ │ └── warning.jsx │ │ ├── dropdown-menu.jsx │ │ ├── forms │ │ │ ├── email-input.jsx │ │ │ ├── form-control.jsx │ │ │ ├── form-group.jsx │ │ │ ├── form.jsx │ │ │ ├── password-input.jsx │ │ │ └── repeat-password-input.jsx │ │ ├── grid-column.jsx │ │ ├── grid-row.jsx │ │ ├── jumbotron.jsx │ │ ├── list-group.jsx │ │ ├── navbar-nav.jsx │ │ ├── navbar.jsx │ │ ├── page-header.jsx │ │ ├── panels │ │ │ ├── panel-body.jsx │ │ │ ├── panel-footer.jsx │ │ │ ├── panel-heading.jsx │ │ │ └── panel.jsx │ │ └── post.jsx │ ├── global │ │ ├── app-header.jsx │ │ ├── authenticated-navigation.jsx │ │ ├── loading.jsx │ │ └── public-navigation.jsx │ ├── layout │ │ └── app.jsx │ └── views │ │ ├── editor.jsx │ │ ├── login.jsx │ │ ├── not-found.jsx │ │ ├── posts-index.jsx │ │ ├── posts-list.jsx │ │ ├── recover-password.jsx │ │ ├── reset-password.jsx │ │ └── single-post.jsx ├── helpers │ ├── flow-router.js │ └── react.js ├── modules │ ├── _modules.js │ └── startup.js ├── startup.js └── stylesheets │ ├── application.scss │ ├── components │ ├── generic │ │ ├── _jumbotron.scss │ │ ├── _post.scss │ │ └── forms │ │ │ └── _form-control.scss │ ├── global │ │ └── _loading.scss │ └── views │ │ ├── _editor.scss │ │ └── _login.scss │ └── tools │ └── _extends.scss ├── collections ├── posts.js └── users.js ├── package.json ├── packages.json ├── packages └── npm-container │ ├── index.js │ └── package.js ├── private └── email │ └── templates │ └── .gitkeep ├── public └── favicon.ico ├── server ├── admin │ └── reset-password.js ├── methods │ ├── insert │ │ └── posts.js │ └── update │ │ └── posts.js ├── modules │ ├── _modules.js │ ├── generate-accounts.js │ ├── set-environment-variables.js │ └── startup.js ├── publications │ ├── editor.jsx │ ├── posts-index.js │ ├── posts-list.js │ └── single-post.js └── startup.js └── settings-development.json /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ##### The Meteor Chef 2 | ###### \#018 - Building a Blog with React 3 | 4 | In this recipe, we'll work with HD Buff, a curated video streaming service to create a blog for their website using React. We'll see how to create a simple admin area, retrieve posts, and even handle things like Markdown directly from our React components! 5 | 6 | [Read on The Meteor Chef](http://themeteorchef.com/recipes/building-a-blog-with-react) 7 | 8 | [Demo the Recipe](http://tmc-018-demo.meteor.com) 9 | 10 | [Download the Source](https://github.com/themeteorchef/building-a-blog-with-react/archive/master.zip) 11 | 12 | The code for this recipe is licensed under the [MIT License](http://opensource.org/licenses/MIT). 13 | -------------------------------------------------------------------------------- /building-a-blog-with-react.md: -------------------------------------------------------------------------------- 1 |
2 |

Pre-Written Code

3 |

Heads up: this recipe relies on some code that has been pre-written for you, available in the recipe's repository on GitHub. During this recipe, our focus will only be on implementing a simple blog using React. If you find yourself asking "we didn't cover that, did we?", make sure to check the source on GitHub.

4 |
5 | 6 |
7 |

Additional Packages

8 |

This recipe relies on several other packages that come as part of Base, the boilerplate kit used here on The Meteor Chef. The packages listed below are merely recipe-specific additions to the packages that are included by default in the kit. Make sure to reference the Packages Included list for Base to ensure you have fulfilled all of the dependencies.

9 |
10 | 11 | ### Prep 12 | - **Time**: ~2-3 hours 13 | - **Difficulty**: Advanced 14 | - **Additional knowledge required**: [basic usage of React](https://themeteorchef.com/recipes/getting-started-with-react/), [understanding props and state](https://themeteorchef.com/snippets/understanding-props-and-state-in-react) in React, and using [Flow Router](https://themeteorchef.com/snippets/using-flow-router-with-react/) with React. 15 | 16 | ### What are we building? 17 | HD Buff is a curated video streaming service (like [MUBI.com](https://mubi.com)). It's a really small service built by three enthusiastic film professionals that focuses on all the films you've never seen (as long as they're available in HD). They're looking to start a blog to keep up with their customers and asked us how to get it done. During our initial meetings, they mentioned that they'll want to extend its feature set in the long-run, but for now they just need a quick, simple way to write posts. 18 | 19 | In this recipe, we'll be helping the HD Buff crew to build a simple blog using Meteor and React. In terms of features, they said as long as someone can log in, write a post, and customers can sort those posts by tags later, they'll be happy. Oh, and they said Markdown is a _must-have_. 20 | 21 | Before we get to work, here's a quick example of what we're after: 22 | 23 |
24 | A simple blog for HD Buff. 25 |
A simple blog for HD Buff.
26 |
27 | 28 | Ready to get to work? Let's do it! 29 | 30 | ### Ingredients 31 | Before we start building, make sure that you've installed the following packages and libraries in your application. We'll use these at different points in the recipe, so it's best to install these now so we have access to them later. 32 | 33 | #### Meteor packages 34 | 35 |

Terminal

36 | 37 | ```bash 38 | meteor add ongoworks:speakingurl 39 | ``` 40 | 41 | We'll rely on the `ongoworks:speakingurl` package to help us generate URL friendly slugs based on our `post-titles-like-this`. 42 | 43 |

Terminal

44 | 45 | ```bash 46 | meteor add themeteorchef:commonmark 47 | ``` 48 | We'll use the `themeteorchef:commonmark` package to help us parse the [Markdown](https://daringfireball.net/projects/markdown/) posts will be written with on the client. 49 | 50 |

Terminal

51 | 52 | ```bash 53 | meteor add momentjs:moment 54 | ``` 55 | To help us parse dates on posts, we'll rely on the `momentjs:moment` package. 56 | 57 | ### Setting up an auth flow 58 | To get us up and running, our first task will be to set up a basic authentication workflow. We want to organize our routes so that only members of the HD Buff team can get access to creating new posts and editing existing ones. To do this, we're going to rely on creating an `App` component in React that will determine when and where users should be routed. 59 | 60 | We've already [set up some routes](https://github.com/themeteorchef/building-a-blog-with-react/tree/master/code/both/routes) (we've split these into two groups: `public` and `authenticated`), so let's focus on building out the `App` component each of the routes is using to render the view. If you're not familiar with this pattern, take a peek at [this snippet](https://themeteorchef.com/snippets/authentication-with-react-and-flow-router/) which will walk you through all of the finer details of what we'll cover below. 61 | 62 | To get started, let's create our `App` component now. 63 | 64 |

/client/components/layout/app.jsx

65 | 66 | ```javascript 67 | App = React.createClass({ 68 | mixins: [ ReactMeteorData ], 69 | getMeteorData() { 70 | return { 71 | loggingIn: Meteor.loggingIn(), 72 | hasUser: !!Meteor.user(), 73 | isPublic( route ) { 74 | return [ 75 | 'index', 76 | 'singlePost', 77 | 'tagIndex', 78 | 'login', 79 | 'recoverPassword', 80 | 'resetPassword', 81 | 'notFound' 82 | ].indexOf( route ) > -1; 83 | }, 84 | canView() { 85 | return this.isPublic( FlowRouter.getRouteName() ) || !!Meteor.user(); 86 | } 87 | }; 88 | }, 89 | getView() { 90 | return this.data.canView() ? this.props.yield : ; 91 | }, 92 | render() { 93 | return
94 | 95 |
96 | { this.data.loggingIn ? : this.getView() } 97 |
98 |
; 99 | } 100 | }); 101 | ``` 102 | 103 | Lots of code! Don't panic. Let's focus down in the `render()` method of our component first. Here, we're working to determine _what_ the user should be able to see. As this `App` component is used for every route in the app, we can trust that it will always be the first "stopping point" for users. Because of this, here, we can implement the necessary functionality to decide whether or not they can see a certain page or not. 104 | 105 | The part we want to pay attention to is `{ this.data.loggingIn ? : this.getView() }`. In this one line, we're asking the question "is the app currently logging in a user?" If someone _is_ being logged in, we want to display the [``](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/components/global/loading.jsx) component for them (a simple SVG graphic that we [animate with CSS](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/stylesheets/components/global/_loading.scss)). If not, we want to fire the `getView()` method we've defined up above in the component. 106 | 107 | Up above, `getView()` is doing something similar to what we're doing in our `render()` method. At this point, we're asking _another_ question: "can this person view the page they're trying to access?" If the answer is yes, we reveal the page by returning `this.props.yield` (remember, from the current route, this is where we're telling `ReactLayout.render()` where to render the component being passed). If we went to `/login`, `this.props.yield` would equal the `` component. Make sense? 108 | 109 | Conversely, if we get a negative response from `this.data.canView()`, we simply reveal the `` component in place of the requested route/component. Wait, what? This is a bit confusing at first. While you might expect us to want to perform a redirect to `/login` here, it's much easier to just reveal the `` component directly instead. Why? Well, consider that if we do this, the URL doesn't change in the browser's navigation bar. 110 | 111 | So, if we're trying to access a protected route like `/posts`, when the user logs in (using the form we've revealed via the `` component), they will automatically get the component intended for the `/posts` route. Let that sink in. The basic idea is that by using this pattern, we're removing the need to store _where_ the user was headed if we determine they need to login. Instead, we let the browser do the work and simply "open the gates" once we've authenticated the user. Pretty neat, eh? 112 | 113 | #### Wiring up `canView()` and `isPublic()` 114 | 115 | With all of this in mind, the next question is, "how are `this.data.canView()` and `this.data.isPublic()` working?" This is where everything in our `App` component comes together. In the `canView()` method, we're asking whether or not the current route name is considered "public" (meaning it's accessible to anyone), or, if a user is currently logged in (using `!!` to convert the result of `Meteor.user()`—an object—to a `Boolean`). If either of these return `true`, we return the requested view/component. 116 | 117 | The magic of this happens inside of the `isPublic()` method. Notice that we're passing in the current route name via `FlowRouter.getRouteName()`. With this, we simply take the value and test it against an array of route names (pay attention, these are the `name` values defined on our routes, not the paths), seeing if the passed value exists in the array. If it _does_, this means that the route is public and okay to access. If it _doesn't_ exist, that means the route is protected and we should defer to `Meteor.user()` to see if a user is logged in. If they _are_, they see the protected route as expected. If not, they get the `` component like we outlined above! 118 | 119 | With this in place, we now have a functioning auth flow for our app. This means that anyone from HD Buff can login and get access to the editor we'll build next, but the public is kept out. Nice! 120 | 121 |
122 |

Holy components, Batman!

123 |

We've taken a decidely shorter path to explaining the organization of our authentication flow. You may be wondering, "but what about that <AppHeader /> component?" Throughout this recipe, we may skip over components like this, so make sure to check the source on GitHub if something isn't clear. If the concepts being used are not clear, make sure to defer to the links in the "Additional knowledge required" list in the Prep section of this post up top. These guides will help you to understand what's happening here.

124 |
125 | 126 | ### Building the editor 127 | Next up, we need to implement the editor where the HD Buff team will actually manage content on their blog. This will require three steps: adding the ability to list existing posts, the ability to create new posts, and the ability to edit posts in a form. Before we start our work on these components, though, we need to set up a collection where all of our data will live. To help us out later, we'll be using some Schema-foo to automate the creation of some of the data we'll need. 128 | 129 | #### Setting up a collection and schema 130 | Let's get our collection set up now. To start, let's get a simple definition in place and lock down our allow/deny rules (this will help us to keep the client secure and force all database operations to happen on the server). 131 | 132 |

/collections/posts

133 | 134 | ```javascript 135 | Posts = new Mongo.Collection( 'posts' ); 136 | 137 | Posts.allow({ 138 | insert: () => false, 139 | update: () => false, 140 | remove: () => false 141 | }); 142 | 143 | Posts.deny({ 144 | insert: () => true, 145 | update: () => true, 146 | remove: () => true 147 | }); 148 | ``` 149 | 150 | Simple enough! Here we create our new collection assigning it to the global variable `Posts` and then set up our `allow` and `deny` rules. Again, this is a security practice. Here, we're saying that we want to deny _all_ database operations on the client and we do not want to allow _any_ database operations on the client (specifically for our `Posts`) collection. This means that later, we'll need to use Meteor methods in order to manage our database. We do this because allow and deny rules are [finicky and error prone](https://www.discovermeteor.com/blog/allow-deny-challenge-results/). Using methods does add a little work to our plate, but gives us peace of mind for later. 151 | 152 | Next up, we need to define a schema. For this, we're going to rely on the [aldeed:collection2](https://themeteorchef.com/snippets/using-the-collection2-package/) package that comes with [Base](https://github.com/themeteorchef/base/tree/base-react). Because some of the rules here are a little more complicated than others, let's add the basic ones first and then pepper in the more complicated stuff. 153 | 154 |

/collections/posts.js

155 | 156 | ```javascript 157 | let PostsSchema = new SimpleSchema({ 158 | "published": { 159 | type: Boolean, 160 | label: "Is this post published?", 161 | autoValue() { 162 | if ( this.isInsert ) { 163 | return false; 164 | } 165 | } 166 | }, 167 | "updated": { 168 | type: String, 169 | label: "The date this post was last updated on.", 170 | autoValue() { 171 | return ( new Date() ).toISOString(); 172 | } 173 | }, 174 | "title": { 175 | type: String, 176 | label: "The title of this post.", 177 | defaultValue: "Untitled Post" 178 | }, 179 | "content": { 180 | type: String, 181 | label: "The content of this post.", 182 | optional: true 183 | }, 184 | "tags": { 185 | type: [ String ], 186 | label: "The tags for this post.", 187 | optional: true 188 | } 189 | }); 190 | 191 | Posts.attachSchema( PostsSchema ); 192 | ``` 193 | 194 | Okay! Here, we have the basic parts of our schema. **This is not all of the fields we'll need**, just the ones with simple rulesets. Here, `published` is being used to determine whether or not the current post is published. This is a `Boolean` value, meaning it's either `true` (is published) or `false` (is not published). Notice that protect our authors, when we insert a new post we're automatically setting the value of this field to `false`. Neat, eh? This means that no matter what, if we insert a new post it will have its published value set to `false`. They'll thank us later! 195 | 196 | Next, `updated` is doing something similar, however, setting the current date as an [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) string. A little further down, `title` and `content` are just going to be simple `String` values. For `tags`, this will just be validating as an `Array` of `String`s. Notice that both `content` and `tags` are being made optional. Why's that? Well, technically we'll be allowing our authors to create new posts _without_ any content or tags. While this isn't likely to happen all of the time, we may find authors wanting to save a post idea but not publish it yet (remember, posts are _not_ published by default). Finally, to save us some time, notice that we've already attached our schema to our `Posts` collection. 197 | 198 | Now for the tricky part! Right now the HD Buff team isn't terribly concerned about content owernship. They've alluded to the idea that whoever last touched a post is going to be considered the owner. To make our lives easy, we can piggyback on `autoValue()` again, this time however, setting the author name automatically. Let's add it in: 199 | 200 |

/collections/posts.js

201 | 202 | ```javascript 203 | let PostsSchema = new SimpleSchema({ 204 | "published": { 205 | type: Boolean, 206 | label: "Is this post published?", 207 | autoValue() { 208 | if ( this.isInsert ) { 209 | return false; 210 | } 211 | } 212 | }, 213 | "author": { 214 | type: String, 215 | label: "The ID of the author of this post.", 216 | autoValue() { 217 | let user = Meteor.users.findOne( { _id: this.userId } ); 218 | if ( user ) { 219 | return `${ user.profile.name.first } ${ user.profile.name.last }`; 220 | } 221 | } 222 | }, 223 | [...] 224 | }); 225 | ``` 226 | 227 | Nothing _too_ crazy. Here, we're automatically setting the value of the `author` field on _any_ changes to posts (inserts, updates, etc.) with the name of the current user. To get their name, notice that we take `this.userId` (this is automatically provided by the collection2 package) and pass it to a call to `Meteor.users.findOne()`. From there, if we get a user back we grab the `first` and `last` name of the user from their `profile`'s `name` property and concatenate them into a String using ES2015's [template strings](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/template_strings) feature. Phew! Now, if "Joe Buff" is logged in and creates a new post (or edits an existing one), he will be set as the author. If "Jane Buff" does the same, _she_ will be set as the author! Pretty slick. 228 | 229 | Okay. Just one more of these to knock out and our schema is ready. This may be frustrating, but realize that we're saving ourselves a lot of effort later! Next, we need to account for duplicate `slug` values (`the-title-formatted-for-a-url-like-this`) for our posts automatically. Let's take a look: 230 | 231 |

/collections/posts.js

232 | 233 | ```javascript 234 | let PostsSchema = new SimpleSchema({ 235 | [...] 236 | "title": { 237 | type: String, 238 | label: "The title of this post.", 239 | defaultValue: "Untitled Post" 240 | }, 241 | "slug": { 242 | type: String, 243 | label: "The slug for this post.", 244 | autoValue() { 245 | let slug = this.value, 246 | existingSlugCount = Posts.find( { _id: { $ne: this.docId }, slug: slug } ).count(), 247 | existingUntitled = Posts.find( { slug: { $regex: /untitled-post/i } } ).count(); 248 | 249 | if ( slug ) { 250 | return existingSlugCount > 0 ? `${ slug }-${ existingSlugCount + 1 }` : slug; 251 | } else { 252 | return existingUntitled > 0 ? `untitled-post-${ existingUntitled + 1 }` : 'untitled-post'; 253 | } 254 | } 255 | }, 256 | [...] 257 | }); 258 | ``` 259 | 260 | Don't give up! This is a bit trickier than our other `autoValue()`s but not too scary. Here, our goal is to determine whether or not a post with the same slug as the one currently being managed. This means that if we have a post called `my-great-film-review` and then try to add another with the same title, the slug will be set to `my-great-film-review-1`. This prevents overwriting and collisions in our URLs later and encourages authors to use more unique post titles. 261 | 262 | To do this, we're creating two queries and counting the number of posts returned _by_ those queries. First, we check whether the current slug already exists in the database (we use a [JavaScript RegEx](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions) to do this)—notice we filter out this post from the results to avoid renaming its slug with `{ $ne: this.docId }`—returning the number of existing posts with the same slug. Read that a few times! It's a bit of a brain buster. In essence, this returns us the number of posts that have the same `slug` value as the post being inserted or updated, excluding that post from the count (`$ne = "Does Not Equal"`). 263 | 264 | Next, we do something very similar but this time for posts with the slug `untitled-post`. Wait, what? As we'll see in a little bit, whenever we create a new post we'll be giving it the title "Untitled Post" to start with. Here, we account for this and ensure that the slug value is handled properly. Down below, then, we check whether or not a slug is being passed. If one _is_ passed, we use our `existingSlugCount` suffixing the count plus one of matching posts to the returned value. If no posts exist, we return the value as-is. 265 | 266 | If we _do not_ have a slug passed (meaning we're creating a new post and want this to be automated), we check our `existingUntitled` count and update the slug in the exact same way we handle existing slugs. Woah. This is a pretty powerful chunk of code, so read it over a few times! This will save us and the HD Buff team a lot of frustration later. 267 | 268 | Phew. We're making good progress but we still have a lot to do. Let's keep chuggin'. Next up, we need to implement our ability to insert new posts and then display a list of those posts for editors to select from. 269 | 270 | #### Creating and listing posts for editors 271 | All right! Now we're getting in to the meat of this recipe. Now, we need a way to do two things: create a new post and list all of the posts in the system for the HD Buff team. To start, let's scope out the basic structure of our component along with its listing feature. Pay close attention as we'll be making reference to a few things that we won't cover directly but will link up to the code for. 272 | 273 |

/client/components/views/posts-list.jsx

274 | 275 | ```javascript 276 | PostsList = React.createClass({ 277 | mixins: [ ReactMeteorData ], 278 | getMeteorData() { 279 | Meteor.subscribe( 'postsList' ); 280 | 281 | return { 282 | posts: Posts.find().fetch().map( ( post ) => { 283 | return { uid: post._id, href: `/posts/${ post._id }/edit`, label: post.title }; 284 | }) 285 | }; 286 | }, 287 | renderPostsList() { 288 | if ( this.data.posts.length > 0 ) { 289 | return ; 290 | } else { 291 | return No posts found.; 292 | } 293 | }, 294 | render() { 295 | return 296 | 297 | 298 | 299 | { this.renderPostsList() } 300 | 301 | ; 302 | } 303 | }); 304 | ``` 305 | 306 | A few things to noice. First, down in our `render()` method, in order to render our list of posts we're calling to a method `renderPostsList()` we've defined further up in our component. Our goal here is to conditionally display either a list of posts—if any exist—or, a message that reads "No posts found." Up in the `renderPostsList()` method, we can see this taking place. Inside, we begin by testing the length of `this.data.posts` (we'll cover this in detail soon). 307 | 308 | If it's greater than `0` (meaning posts were found), we go ahead and render the [``](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/components/generic/list-group.jsx) component in [Base](https://github.com/themeteorchef/base/tree/base-react). Alternatively, if we _do not_ find any posts in the database, we display the [``](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/components/generic/alerts/warning-alert.jsx) component (a stylized variation of the [``](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/components/generic/alerts/alert.jsx) component. Review that wiring a few times. All we're doing is rendering out the list of posts, or, display an alert message. 309 | 310 | Let's review how the data is getting into the component to make sense of this. Up in the `getMeteorData()` method (this is the one that we get from Meteor's `react` package and is responsible for reactivity in the component), we begin to fetch data by subscribing to a publication called `postsList`. Real quick, let's see what that's returning. 311 | 312 |

/server/publications/posts-list.js

313 | 314 | ```javascript 315 | Meteor.publish( 'postsList', () => { 316 | return Posts.find(); 317 | }); 318 | ``` 319 | 320 | Un-der-whel-ming! Pretty simple here. Because we're on the admin side of things, we simply want to return _all_ of the posts in the database. This means that all authors should have access to _all_ posts. Remember, ownership isn't a priority for HD Buff right now; they just want to get posts published without a lot of fuss. So far so good? 321 | 322 | Back in our `` component, let's review how we're returning this data from `getMeteorData()`. 323 | 324 |

/client/components/views/posts-list.jsx

325 | 326 | ```javascript 327 | PostsList = React.createClass({ 328 | mixins: [ ReactMeteorData ], 329 | getMeteorData() { 330 | Meteor.subscribe( 'postsList' ); 331 | 332 | return { 333 | posts: Posts.find().fetch().map( ( post ) => { 334 | return { uid: post._id, href: `/posts/${ post._id }/edit`, label: post.title }; 335 | }) 336 | }; 337 | }, 338 | [...] 339 | }); 340 | ``` 341 | 342 | Here, we're returning a `posts` property (this will be accessible via `this.data.posts` elsewhere in the component) and assigning it to a query to find all of the posts returned from our publication. Next, we `fetch()` that list so we get it back as an `Array` (remember, by default we get a MongoDB cursor from `Posts.find()`) and then we `map()` over that array. Phew! Inside of our map, we give each of our returned posts a slightly different structure, including a `uid`, `href`, and `label` property. What gives? 343 | 344 | What we're doing here is formatting the array of posts being returned from `this.data.posts` to match the API of our `` component. It will be expecting `uid`, `href`, and `label` as props, so we take care of this here so the component can just handle the render. A little strange, so spend some time with the connection between this and the `` we're outputting when we have posts. 345 | 346 | Okay, moving right along. Now for something a little easier: creating new posts. 347 | 348 | #### Creating new posts 349 | 350 | This part is super simple. Let's look back at our `render()` method for our posts list. Notice that inside, we have a button with an `onClick` prop that's wired up to a method on our component called `handleNewPost()`. The idea here is that when we click this button, we'll fire this method which will call to the server for us. Real quick, here are the essentials: 351 | 352 |

/client/components/views/posts-list.jsx

353 | 354 | ```javascript 355 | PostsList = React.createClass({ 356 | [...] 357 | handleNewPost() { 358 | Meteor.call( 'newPost', ( error, postId ) => { 359 | if ( error ) { 360 | Bert.alert( error.reason, 'danger' ); 361 | } else { 362 | FlowRouter.go( `/posts/${ postId }/edit` ); 363 | Bert.alert( 'All set! Get to typin\'', 'success' ); 364 | } 365 | }); 366 | }, 367 | [...] 368 | render() { 369 | return 370 | 371 | 372 | 373 | { this.renderPostsList() } 374 | 375 | ; 376 | } 377 | }); 378 | ``` 379 | 380 | For creating a new post, we're not taking in any arguments from the user. Remember all of that "Untitled Post" beeswax from earlier? This is where it comes together. Let's take a peek at this `newPost` method we're dialing up on the server. 381 | 382 |

/server/methods/insert/posts.js

383 | 384 | ```javascript 385 | Meteor.methods({ 386 | newPost() { 387 | return Posts.insert( {} ); 388 | } 389 | }); 390 | ``` 391 | 392 | Wait...hahahaha. Yep. That is seriously _it_. All of that work we did in our schema is paying off right here. How the heck is this working? Well, consider that for all of the fields in our schema, each either has: 393 | 394 | 1. A default value. 395 | 2. An automatically set value. 396 | 3. Is optional. 397 | 398 | Combined, this means that when we insert a "blank" object into our collection, our schema is kicking in and automatically populating the required fields for us! Get outta here. Nope. Serious. Grab a pen and write home about this. You're officially a badass! Isn't this cool? Go ahead, beep your own horn. 399 | 400 | If we look back at our component real quick, we can see that we're taking the returned post ID from our method (remember, when we call `.insert()` on a collection without a callback, Meteor returns the new document's `_id` value) and redirecting to the "editor" view for working on our post FlowRouter.go( '/posts/${ postId }/edit' );. This is our next stop! Now we need to wire up our editor to actually manage and publish posts. 401 | 402 | #### Editing content 403 | This is going to take _a lot_ of work. Don't let that spook you! In honesty, our `` component looks scarier than it actually is because we're using up a lot of vertical space to list out props on our components. To step through everything efficiently, we're going to start by wiring up our data for our component _first_ and then discuss how we're making use of it. There's a lot of repetition in concepts here, so pay close attention to the first few to make sure you have a solid grasp on what we're doing. 404 | 405 |

/client/components/views/editor.jsx

406 | 407 | ```javascript 408 | Editor = React.createClass({ 409 | mixins: [ ReactMeteorData ], 410 | getMeteorData() { 411 | Meteor.subscribe( 'editor', this.props.post ); 412 | 413 | return { 414 | post: Posts.findOne( { _id: this.props.post } ) 415 | }; 416 | }, 417 | [...] 418 | render() { 419 | if ( !this.data.post ) { return
; } 420 | 421 | return [...] 422 | } 423 | }); 424 | ``` 425 | 426 | Easy does it to start! Notice that in terms of loading in data, this is all pretty simplistic. First, up in `getMeteorData()`, notice that we're pulling in the value `this.props.post` in order to subscribe to our data and filter down _which_ post we're currently editing. But wait, where is that coming from? Our route! Let's take a quick peek to see how it's being passed down. 427 | 428 |

/both/routes/authenticated.jsx

429 | 430 | ```javascript 431 | [...] 432 | 433 | authenticatedRoutes.route( '/posts/:_id/edit', { 434 | name: 'editor', 435 | action( params ) { 436 | ReactLayout.render( App, { yield: } ); 437 | } 438 | }); 439 | ``` 440 | 441 | In our [authenticated routes group](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/both/routes/authenticated.jsx), we're defining our path (the one we redirected to in the previous step after creating a post and linked each of our list items to) to our editor. Notice that in the `action()` method, we're pulling in the `params` object for the route and on our invocation of ``, we're passing `post={ params._id }`, or, the `_id` value from our URL! By passing this into our props, whenever our URL changes to a new post, our component will automatically get access to its `_id` straight from the router. Swish. 442 | 443 | Back in our ``, then, we subscribe to our `editor` publication passing in the post ID and then—to compensate for React's speed when moving between views—pass the ID to our `Posts.findOne()` as well. This ensures that when we go to a different post, we don't get stuck on the previous one because that's what our component sees in the minimongo collection (the result we'd get if we left this as a plain `Posts.findOne()`). Safety first! 444 | 445 | Speaking of safety, we also need to account for our data not being ready down in our `render()` method. Notice that we first check to see if `this.data.post` is defined and if it's _not_, we return an empty div until it _is_ ready. Once it _is_ ready, we return our component's actual markup (we'll do this next). So far so good? Okay, **strap on your goggles, we're going downhill at full speed from here**! 446 | 447 |

/client/components/views/editor.jsx

448 | 449 | ```javascript 450 | Editor = React.createClass({ 451 | [...] 452 | generateSlug( event ) { 453 | let { setValue } = ReactHelpers, 454 | form = this.refs.editPostForm.refs.form, 455 | title = event.target.value; 456 | 457 | setValue( form, '[name="postSlug"]', getSlug( title, { custom: { "'": "" } } ) ); 458 | }, 459 | getLastUpdate() { 460 | if ( this.data ) { 461 | let { formatLastUpdate } = ReactHelpers, 462 | post = this.data.post; 463 | 464 | return `${ formatLastUpdate( post.updated ) } by ${ post.author }`; 465 | } 466 | }, 467 | getTags() { 468 | let post = this.data.post; 469 | 470 | if ( post && post.tags ) { 471 | return post.tags.join( ', ' ); 472 | } 473 | }, 474 | handleSubmit( event ) { 475 | event.preventDefault(); 476 | }, 477 | render() { 478 | if ( !this.data.post ) { return
; } 479 | 480 | return 481 | 482 | 483 |
484 |

485 | Last Updated: { this.getLastUpdate() } 486 |

487 | 488 | 495 | 496 | 497 | 506 | 507 | 508 | 517 | 518 | 519 | 526 | 527 | 528 | 536 | 537 | 538 | 539 | 540 |
541 |
542 |
; 543 | } 544 | }); 545 | ``` 546 | 547 | Seriously?! I warned you! We've got a lot going on here so let's work our way through it. First, the basics. Down in our `render()` method, let's inspect each of the [``](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/components/generic/forms/form-control.jsx) components being added. The part we want to pay attention to on each, here, is the `defaultValue` property. Notice that for the first four fields we're rendering, we're setting this equal to `this.data.post && this.data.post.`. What gives? 548 | 549 | This is a trick picked up from reader [Patrick Lewis](https://twitter.com/patrickml1). While we may expect this to return a `Boolean` value, in JSX, the interpretation is to say if both of these return `true` (exist), then _return_ the last value in the chain. This helps us to avoid a bunch of ternary operators littering our code. It's a bit cryptic the first time you see it, but once you start to use it you won't want to go back! 550 | 551 | What this all translates to, then, is that we're setting the default value for each of our input fields _if_ a value exists for that field on `this.data.post`. This covers two scenarios: editing a new post we just created with _some data_ and coming back later to edit a post with everything. If you've been having trouble wrapping your head around the importance of components and React, this is it: extreme frugality in respect to reusing interface components! This is like [extreme couponing](https://www.youtube.com/watch?v=8lFWTGog__I) for developers. 552 | 553 | For our final input—where we'll render a list of tags—we make a call to a method up above `this.getTags()`. It's pretty simple but important, so let's take a closer look. 554 | 555 |

/client/components/views/editor.jsx

556 | 557 | ```javascript 558 | getTags() { 559 | let post = this.data.post; 560 | 561 | if ( post && post.tags ) { 562 | return post.tags.join( ', ' ); 563 | } 564 | } 565 | ``` 566 | 567 | Because our list of tags will be stored as an _array_, when returning it back to the editor, we need to "prettify it" as a string. For example, we're expecting something like this `[ 'tacos', 'burritos', 'sandwiches' ]` but want to set the value of our tags input to `tacos, burritos, sandwiches`. Using this method `getTags()`, we accomplish this by pulling in the post data and if we discoer it has tags set, use a JavaScript `.join( ', ' )` to return our array as a comma-separated string! 568 | 569 |
570 |

Why not use a component?

571 |

As of writing, adding third-party components is tricky. The original scope for this recipe was to include a token input, however, the experience of implementing it was confusing to say the least. Unless you're comfortable getting your hands dirty, usage of third-party components is unadvised until Meteor adds proper support for require in Meteor 1.3.

572 |
573 | 574 | Continuing down a similar path, let's slide up our component a little further and look at the `getLastUpdate()` method we're using toward the top of our component's `render()` method. 575 | 576 |

/client/components/views/editor.jsx

577 | 578 | ```javascript 579 | getLastUpdate() { 580 | if ( this.data ) { 581 | let { formatLastUpdate } = ReactHelpers, 582 | post = this.data.post; 583 | 584 | return `${ formatLastUpdate( post.updated ) } by ${ post.author }`; 585 | } 586 | } 587 | ``` 588 | 589 | A little more involved, but about the same. Here, we check if our data is available and if it is, we assign a few variables. The first is a bit funky. Here, we're using ES2015 object destructuring to say "give us the `formatLastUpdate()` method that's defined in the global `ReactHelpers` object." What's that? This is a collection of helpers that is being [added to the React port of Base](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/helpers/react.js) (the TMC starter kit). 590 | 591 | Here, `formatLastUpdate()` is responsible for taking the date our post was last marked as updated—remember, we set this in our schema to be an ISO8601 string—and returning it as a human readable string like `January 13th, 2016 10:30 am` (behind the scenes we're using the `momentjs:moment` packag we installed earlier). To close the loop here, we concatenate this result with the word `by` and the value of `post.author`, which, again, is the name of the most recent user to update this post (set by `autoValue()` in our schema). Pretty wild stuff! 592 | 593 | Okay. Next up is something really neat! To make the editing experience nice and simple for the folks at HD Buff, we want to auto-generate slugs based on whatever title they set on the post. To do this, notice that in our `render()` method, the `` component for our post's title has an `onChange={ this.generateSlug }` prop. Can you guess what's happening here? Whenever this input changes (meaning a user types in it), we want to grab that value, convert it into a slug `like-this-right-here` and set it on the slug field beneath the title. Let's take a peek at the `generateSlug()` method we're calling. 594 | 595 |

/client/components/views/editor.jsx

596 | 597 | ```javascript 598 | generateSlug( event ) { 599 | let { setValue } = ReactHelpers, 600 | form = this.refs.editPostForm.refs.form, 601 | title = event.target.value; 602 | 603 | setValue( form, '[name="postSlug"]', getSlug( title, { custom: { "'": "" } } ) ); 604 | } 605 | ``` 606 | 607 | We're building up a lot of knowledge here! Notice that again, we're pulling in one of the methods `setValue` from our `ReactHelpers` global (this will help us set the slug we generate on the slug input). Next, we do something funky involving React's refs. Due to the shape of our [`
`](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/components/generic/forms/form.jsx) component, we need to get access to that form by calling `this.refs.editPostForm.refs.form`. If you look down in our `render()` method, you'll notice `` has a prop `ref="editPostForm"` on it. This is React's way of identifying a DOM node within a component. By calling `this.refs.editPostForm.refs.form`, we're essentially grabbing the _nested_ ref of the `` component to access the actual DOM node for the form. _Gasp_. That was long-winded. If that's confusing, take a peek at the wiring of the `` component [here](https://github.com/themeteorchef/building-a-blog-with-react/blob/master/code/client/components/generic/forms/form.jsx). 608 | 609 | Next, we grab the value of `event.target`, or, our title input. This works because the "event" that's taking place is happening on our title input. By proxy, then, `event.target` is equal to our title input. With _all of this_ bundled up, we make a call to `setValue()`, passing the context for where our field will be—`form`—the name attribute of the element we want to set the value on—our slug input—and finally, we use the `getSlug()` method from the `ongoworks:speakingurl` package we installed earlier to generate the `slugified-version-of-our-title`. 610 | 611 | Yikes! That seems like a lot, but step through it and it will make sense. With this in place, whenever we edit the value of our title input, our slug will automatically be generated and set on the slug input! Nice work. That was a big one to solve. 612 | 613 | Last but not least for this form, let's get some validation wired up and then handle saving changes on the server! 614 | 615 | #### Saving the form 616 | Here comes the turkey. This is a big step, but should actually look somewhat familiar if you've worked with validation in the past. What we want to do now is validate the fields in our editor and if they're good to go, push an update of our post to the server. If you're new to validation, take a peek at this post on [jQuery validation](https://themeteorchef.com/snippets/validating-forms-with-jquery-validation/), the underying package we'll use to validate our form here. 617 | 618 |

/client/components/views/editor.jsx

619 | 620 | ```javascript 621 | validations() { 622 | let component = this; 623 | 624 | return { 625 | rules: { 626 | postTitle: { 627 | required: true 628 | } 629 | }, 630 | messages: { 631 | postTitle: { 632 | required: "Hang on there, a post title is required!" 633 | } 634 | }, 635 | submitHandler() { 636 | let { getValue, isChecked } = ReactHelpers; 637 | 638 | let form = component.refs.editPostForm.refs.form, 639 | post = { 640 | _id: component.props.post, 641 | title: getValue( form, '[name="postTitle"]' ), 642 | slug: getValue( form, '[name="postSlug"]' ), 643 | content: getValue( form, '[name="postContent"]' ), 644 | published: isChecked( form, '[name="postPublished"]' ), 645 | tags: getValue( form, '[name="postTags"]' ).split( ',' ).map( ( string ) => { 646 | return string.trim(); 647 | }) 648 | }; 649 | 650 | Meteor.call( 'savePost', post, ( error, response ) => { 651 | if ( error ) { 652 | Bert.alert( error.reason, 'danger' ); 653 | } else { 654 | Bert.alert( 'Post saved!', 'success' ); 655 | } 656 | }); 657 | } 658 | }; 659 | }, 660 | handleSubmit( event ) { 661 | event.preventDefault(); 662 | } 663 | ``` 664 | Woah buddy! A lot going on here but nothing too crazy. Notice that we have two methods output here: `validations()` and `handleSubmit()`. Here, `handleSubmit()` is responsible for "terminating" the default behavior of our form's `onSubmit` method—we can see this being attached to our `` component in our `render()`—and instead, deferring submission to our validation's `submitHandler()` method. This is a bit strange, but allows us to get our form validated and handle the submission without a lot of running around. 665 | 666 | Inside `validations()`—this is also attached to our `` component as a prop—we add a single rule for our `postTitle` input. This is ensuring that `postTitle` is _not_ blank when our user submits the form. If it is, they'll be asked to correct it before they submit the form. Once the form is all green, we get to work in our `submitHandler()`. At this point, we're building up the object we'll send to the server to update our post. 667 | 668 | Here, we're making use of a few more helpers from our `ReactHelpers` object. At this point, all we're doing is fetching the current values from each of the fields in our form. Two to point out: `published` and `tags`. Notice that for published, we're using the helper `isChecked` to determine whether or not the "Published?" box is checked. For `tags`, we're grabbing the current value of the input and then _splitting_ it into an array. To make sure our data is clean, we use a map to return an array of with each value obtained from the string with a `trim()`. This ensures that none of our tags have any trailing spaces (e.g. tag vs. `tag`). 669 | 670 | So far so good? Once we have this, it's up to the server to save the post! 671 | 672 | #### Updating the post on the server 673 | 674 | Let's take a look at our `savePost` method on the server. Hint: it's really simple. 675 | 676 |

/server/methods/update/posts.js

677 | 678 | ```javascript 679 | Meteor.methods({ 680 | savePost( post ) { 681 | check( post, Object ); 682 | 683 | let postId = post._id; 684 | delete post._id; 685 | 686 | Posts.upsert( postId, { $set: post } ); 687 | } 688 | }); 689 | ``` 690 | 691 | Yep, pretty simple! Here, we're not doing much. First, we [check](https://themeteorchef.com/snippets/using-the-check-package/) that the value we get from the client is an `Object`. Next, we assign the value of `post._id` to a variable `postId` and then delete it from the main `post` object (we don't want to include this in the value we pass to our `upsert` below). Finally, we perform an `upsert` on our `Posts` collection, passing in our post's contents via a MongoDB `$set` method. Why use an `upsert` here? 692 | 693 | Well, remember that when creating our new posts, we didn't set a content or tags field. With an upsert, we get the best of doing an update and an insert. If a field doesn't exist on the object we're trying to update, the upsert will create it for us. If it already exists, it will just update the value! This means that we don't have to fight with missing fields conflicting with those that already exist. We just call an `upsert` and let Mongo handle the stick parts for us. 694 | 695 | Awesome. At this point, we've got our posts inserting and everything wired up for admins. Next, let's focus on getting posts output for our readers. This will be much simpler and should go pretty fast! 696 | 697 | ### Listing posts in the index (and tag pages) 698 | Great work so far. We've come a long way, but we're not quite done. Next, we need to wire up a list of posts for HD Buff's readers. This will be the main stream of _published_ posts. In tandem with this, we're also going to wire up a way to make this list filterable by tag. Let's dive in. Our `` component is pretty simple compared to what we've accomplished so far, so let's take a quick tour of how everything is working here (a lot of repeated concepts). 699 | 700 |

/client/components/views/posts-index.jsx

701 | 702 | ```javascript 703 | PostsIndex = React.createClass({ 704 | mixins: [ ReactMeteorData ], 705 | getMeteorData() { 706 | let query = {}; 707 | 708 | if ( this.props.tag ) { 709 | Meteor.subscribe( 'tagsIndex', this.props.tag ); 710 | query = { tags: { $in: [ this.props.tag ] } }; 711 | } else { 712 | Meteor.subscribe( 'postsIndex' ); 713 | } 714 | 715 | return { 716 | posts: Posts.find( query, { sort: { updated: -1 } } ).fetch() 717 | }; 718 | }, 719 | renderHeader() { 720 | if ( this.props.tag ) { 721 | return 722 |

Posts tagged with: { this.props.tag }.

723 |
; 724 | } else { 725 | return 726 |

Get Buff

727 |

A new blog by the HD Buff crew.

728 |
; 729 | } 730 | }, 731 | renderPosts() { 732 | if ( this.data.posts.length > 0 ) { 733 | return this.data.posts.map( ( post ) => { 734 | return ; 735 | }); 736 | } else { 737 | return No posts found.; 738 | } 739 | }, 740 | render() { 741 | return
742 | 743 | 744 | { this.renderHeader() } 745 | { this.renderPosts() } 746 | 747 | 748 |
; 749 | } 750 | }); 751 | ``` 752 | 753 | This should look super familiar. At this point, we're starting to repeat a lot of the patterns we've introduced so far. Let's do a quick breeze through this component, explaining the important pieces. Sound good? 754 | 755 | First, let's call attention to how we're pulling data. Remember, our goal for this component is to pull in data in one of two ways: either filtered by tag, or, with no filter (all published posts on the site). To achieve this, up in our call to `getMeteorData()`, we're doing a little `if`-foo to determine whether or not we're trying to render a list of posts by tag. If we detect the prop `this.props.tag`, we assume that yes, we want to filter by tag. 756 | 757 | In this case, we take the tag and pass it to our publication `tagsIndex` (we'll look at this soon) and then set `query` (the value we'll pass to the query set to the `posts` value of our object returned from `getMeteorData()`) to only gives us back the posts where the current tag is in the `tags` array of the post. Said another way, if we return a list of posts from the server like this: 758 | 759 | ```javascript 760 | { _id: '1', tags: [ 'peanuts', 'butter', 'oil' ] } 761 | { _id: '2', tags: [ 'tacos', 'almonds', 'cookies' ] } 762 | { _id: '3', tags: [ 'peanuts', 'sandwiches', 'political-differences' ] } 763 | ``` 764 | 765 | if we pass the tag `peanuts`, we'd only expect to get back posts `1` and `3` (those are the only posts with `peanuts` in their `tags` array). Making sense? 766 | 767 | A little less complex, if we _do not_ detect `this.props.tag`, we simply want to subscribe to `postsIndex`. Let's take a look at those two side-by-side now. 768 | 769 |

/server/publications/posts-index.js

770 | 771 | ```javascript 772 | Meteor.publish( 'postsIndex', function() { 773 | return Posts.find( { published: true } ); 774 | }); 775 | 776 | Meteor.publish( 'tagsIndex', function( tag ) { 777 | check( tag, String ); 778 | return Posts.find( { published: true, tags: { $in: [ tag ] } } ); 779 | }); 780 | ``` 781 | 782 | About what we'd expect. Notice for `postsIndex` we simply return _all_ of the posts who's `published` flag is set to `true`. Down in `tagsIndex`, we do something similar, however, also passing in the `tag` from the client using an identical `{ $in: [ tag ] }` check like we did on the client. This guarantees that we only get back posts with the matching tag. Neat! 783 | 784 | Back in our `` component, the rest is pretty simple. If we're on a tag page we decide to render a slightly different page header displaying the name of the currently selected tag. For `renderPosts()`, if we have a set of posts we want to map over those returning an instance of `` (we'll look at this next). Otherwise, we return a "No posts found." message just like we did for editors earlier. Boom! Making progress. 785 | 786 | Real quick, let's take a peek at that `` component as it's got a little more going on then meets the eye. 787 | 788 | #### The `` component 789 | 790 | The `` component is reusing a lot of the same techniques as explained above, except for one. Let's take a look at the full component and then talk about the one that stands out. 791 | 792 |

/client/components/generic/post.jsx

793 | 794 | ```javascript 795 | Post = React.createClass({ 796 | getPostTitle() { 797 | let post = this.props.post; 798 | 799 | if ( this.props.singlePost ) { 800 | return

{ post.title }

; 801 | } else { 802 | return

{ post.title }

; 803 | } 804 | }, 805 | getHTML( markdown ) { 806 | if ( markdown ) { 807 | return { __html: parseMarkdown( markdown ) }; 808 | } 809 | }, 810 | renderTags( tags ) { 811 | if ( tags ) { 812 | return
813 | {tags.map( ( tag ) => { 814 | return #{ tag }; 815 | })} 816 |
; 817 | } 818 | }, 819 | render() { 820 | let { formatLastUpdate } = ReactHelpers, 821 | post = this.props.post; 822 | 823 | return
824 | { this.getPostTitle() } 825 |

Last Updated: { formatLastUpdate( post.updated ) } by { post.author }

826 | { this.renderTags( post.tags ) } 827 |
828 |
; 829 | } 830 | }); 831 | ``` 832 | 833 | Okay! A lot of this is pretty straightforward. Here, we're rendering the actual contents of a post out for display. Notice that down in our `render()` method, we're simply grabbing the contents of our post and then displaying them in the UI. Because we'll be using this component conditionally (in either a list of posts or as a single post), we've set up a few methods to help us negotiate that process. For `getPostTitle()`, notice that we're checking whether or not our component has a `this.props.singlePost` value. 834 | 835 | In our last step, we'll wire up a component that makes use of this. If we _do_ detect this value, we want to return a plain `

` tag containing our title _without_ a link. If we're not on a single post, we return an `

` tag with a link _to_ our post. Think about that one! If we're in a list of posts, we'll want a link so we can read the post. If we're on a single post, we're already viewing it so we don't need the link. 836 | 837 | For our tags, there's not much mystery. Inside of `renderTags()`, if we get a list of tags for our post, we simply map over each and return an `` tag with a link to that tag's page (this makes use of the code we wrote in the previous step) along with the name of the tag. Simple! 838 | 839 | The big scary part (not really) of this is Markdown. In React, it's advised to be cautious when setting HTML directly on a component. Most of the time this isn't necessary, however, with Markdown conversion, we _will_ need to set HTML directly. Why? Well, when we get our post back here on the client, what we're actually getting back is a string of Markdown. In order to convert that into HTML, we need a way to do that and then set the value in our component. 840 | 841 | Here, if we look at `
` we can see this taking place in the prop `dangerouslySetInnerHTML`. Spooky! This method is aptly named as outside of scenarios like this, setting this value can leave a component vulnerable to [XSS](https://facebook.github.io/react/tips/dangerously-set-inner-html.html) attacks. Fortunately in this case, we're safe as we know what we're putting into the DOM (at that location) has been properly sanitized. To handle that sanitization, notice that we're making a call to `this.getHTML()`. 842 | 843 | If we look at that method, it's pretty simple. All we're doing here is returning an object with a property `__html`, equal to the result of converting our Markdown into HTML. We get the `parseMarkdown()` method from the `themeteorchef:commonmark` package we installed earlier. In passing our string of Markdown to it, we get back a sanitized, HTML-ified version of our posts contents. We set this on the `__html` propery of the object we're returning and React takes care of the rest! Note: that `__html` property in an object thing is specific to `dangerouslySetInnerHTML`. If we pass our string directly, React will throw a tantrum. 844 | 845 | That's all we need to know for our `` component! One last step: rendering a single post. This is quick and painless, so let's take a look. 846 | 847 | ### Displaying a single post 848 | Fortunately, with our `` component wired up, displaying a single post is pretty easy. Let's dump out the whole component and explain what's happening. 849 | 850 |

/client/components/views/single-post.jsx

851 | 852 | ```javascript 853 | SinglePost = React.createClass({ 854 | mixins: [ ReactMeteorData ], 855 | getMeteorData() { 856 | let sub = Meteor.subscribe( 'singlePost', this.props.slug ); 857 | 858 | return { 859 | post: Posts.findOne( { slug: this.props.slug } ), 860 | ready: sub.ready() 861 | }; 862 | }, 863 | render() { 864 | if ( !this.data ) { return
; } 865 | return 866 | 867 | 868 | 869 | ; 870 | } 871 | }); 872 | ``` 873 | 874 | Pretty close to what we've been up to. Notice that down in our `render()` method, we're wiring up our data source to the `` component we just wrapped up. That's the bulk of this component! The one thing we want to call attention to is how we're loading in our data. Notice that up in `getMeteorData()`, we're assigning our subscription to `sub`, and then making the response to its `.ready()` handle available at `this.data.ready`. We make use of `this.data.ready` down below in our call to ``. 875 | 876 | What's up with this? This is a safeguard. What we want to avoid is React moving too fast and rendering with the wrong data. By tying into `sub.ready()`, we're waiting to return data to our `` component until we're absolutely certain we have the post data we need. We can do this because our call to `Meteor.subscribe( 'singlePost' );` is updating when we change posts (e.g. clicking on another post in our list). Let's take a peek at our publication real quick to understand that. 877 | 878 |

/server/publications/single-post.js

879 | 880 | ```javascript 881 | Meteor.publish( 'singlePost', ( postSlug ) => { 882 | check( postSlug, String ); 883 | 884 | return Posts.find( { slug: postSlug } ); 885 | }); 886 | ``` 887 | 888 | Making sense? When we move to a new post, we grab its slug value from our route and pass it into `` as a prop. Then, in the component we subscribe using that same slug, returning only the post that matches that slug in the database. Nice and tidy! 889 | 890 | Back in our component, then, whenever we make the change to a new post, we're updating the slug and our subscription re-responds to `sub.ready()`. Neat! This means that we avoid any issues with displaying the incorrect post, allowing React's speed to play nicely with Meteor's reactivity. 891 | 892 | That's it! At this point, we've successfully fulfilled HD Buff's request for a simple blog! We have the ability to post, view a list of posts, sort posts by tag, and view a single post. We even added a means for managing posts behind the scenes for HD Buff's team. Great work! 893 | 894 | ### Wrap up & summary 895 | In this recipe we learned how to create a simple blog using Meteor and the React user interface library. We learned how to wire up a large collection of components, working with things like nesting, passing around props, and wiring up a data source with `ReactMeteorData`. Further, we learned how to manage an editing interface for writing posts as well as how to get those posts to display back for users. We even learned how to make use of collection2's `autoValue()` method to do some heavy lifting for us! -------------------------------------------------------------------------------- /code/.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /code/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | settings-production.json 3 | npm-debug.log 4 | -------------------------------------------------------------------------------- /code/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "esnext": true 3 | } 4 | -------------------------------------------------------------------------------- /code/.meteor/.finished-upgraders: -------------------------------------------------------------------------------- 1 | # This file contains information which helps Meteor properly upgrade your 2 | # app when you run 'meteor update'. You should check it into version control 3 | # with your project. 4 | 5 | notices-for-0.9.0 6 | notices-for-0.9.1 7 | 0.9.4-platform-file 8 | notices-for-facebook-graph-api-2 9 | 1.2.0-standard-minifiers-package 10 | 1.2.0-meteor-platform-split 11 | 1.2.0-cordova-changes 12 | 1.2.0-breaking-changes 13 | -------------------------------------------------------------------------------- /code/.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /code/.meteor/.id: -------------------------------------------------------------------------------- 1 | # This file contains a token that is unique to your project. 2 | # Check it into your repository along with the rest of this directory. 3 | # It can be used for purposes such as: 4 | # - ensuring you don't accidentally deploy one app on top of another 5 | # - providing package authors with aggregated statistics 6 | 7 | 1ipv1tp12xzfzhba088c 8 | -------------------------------------------------------------------------------- /code/.meteor/cordova-plugins: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themeteorchef/building-a-blog-with-react/490adbaff18da19be8915ebe4115ea3a083683ad/code/.meteor/cordova-plugins -------------------------------------------------------------------------------- /code/.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # 3 | # 'meteor add' and 'meteor remove' will edit this file for you, 4 | # but you can also edit it by hand. 5 | 6 | accounts-password 7 | accounts-base 8 | jquery 9 | check 10 | audit-argument-checks 11 | themeteorchef:jquery-validation 12 | twbs:bootstrap 13 | browser-policy 14 | meteorhacks:npm 15 | 16 | 17 | themeteorchef:bert 18 | meteorhacks:ssr 19 | standard-minifiers 20 | 21 | 22 | npm-container 23 | ecmascript 24 | digilord:faker 25 | kadira:flow-router 26 | kadira:blaze-layout 27 | meteorhacks:fast-render 28 | meteor-base 29 | session 30 | templating 31 | fourseven:scss 32 | stevezhu:lodash 33 | reactive-var 34 | reactive-dict 35 | aldeed:collection2 36 | tracker 37 | react 38 | random 39 | kadira:react-layout 40 | ongoworks:speakingurl 41 | themeteorchef:commonmark 42 | momentjs:moment 43 | -------------------------------------------------------------------------------- /code/.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /code/.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.2.1 2 | -------------------------------------------------------------------------------- /code/.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.2 2 | accounts-password@1.1.4 3 | aldeed:collection2@2.5.0 4 | aldeed:simple-schema@1.3.3 5 | audit-argument-checks@1.0.4 6 | autoupdate@1.2.4 7 | babel-compiler@5.8.24_1 8 | babel-runtime@0.1.4 9 | base64@1.0.4 10 | binary-heap@1.0.4 11 | blaze@2.1.3 12 | blaze-tools@1.0.4 13 | boilerplate-generator@1.0.4 14 | browser-policy@1.0.5 15 | browser-policy-common@1.0.4 16 | browser-policy-content@1.0.6 17 | browser-policy-framing@1.0.6 18 | caching-compiler@1.0.0 19 | caching-html-compiler@1.0.2 20 | callback-hook@1.0.4 21 | check@1.1.0 22 | chuangbo:cookie@1.1.0 23 | coffeescript@1.0.11 24 | cosmos:browserify@0.9.2 25 | ddp@1.2.2 26 | ddp-client@1.2.1 27 | ddp-common@1.2.2 28 | ddp-rate-limiter@1.0.0 29 | ddp-server@1.2.2 30 | deps@1.0.9 31 | diff-sequence@1.0.1 32 | digilord:faker@1.0.7 33 | ecmascript@0.1.6 34 | ecmascript-runtime@0.2.6 35 | ejson@1.0.7 36 | email@1.0.8 37 | fortawesome:fontawesome@4.4.0 38 | fourseven:scss@3.4.1 39 | geojson-utils@1.0.4 40 | hot-code-push@1.0.0 41 | html-tools@1.0.5 42 | htmljs@1.0.5 43 | http@1.1.1 44 | id-map@1.0.4 45 | jquery@1.11.4 46 | jsx@0.2.3 47 | kadira:blaze-layout@2.2.0 48 | kadira:flow-router@2.7.0 49 | kadira:react-layout@1.5.3 50 | livedata@1.0.15 51 | localstorage@1.0.5 52 | logging@1.0.8 53 | meteor@1.1.10 54 | meteor-base@1.0.1 55 | meteorhacks:async@1.0.0 56 | meteorhacks:fast-render@2.10.0 57 | meteorhacks:inject-data@1.4.1 58 | meteorhacks:npm@1.5.0 59 | meteorhacks:picker@1.0.3 60 | meteorhacks:ssr@2.2.0 61 | minifiers@1.1.7 62 | minimongo@1.0.10 63 | momentjs:moment@2.11.1 64 | mongo@1.1.3 65 | mongo-id@1.0.1 66 | npm-bcrypt@0.7.8_2 67 | npm-container@1.2.0 68 | npm-mongo@1.4.39_1 69 | observe-sequence@1.0.7 70 | ongoworks:speakingurl@6.0.0 71 | ordered-dict@1.0.4 72 | promise@0.5.1 73 | random@1.0.5 74 | rate-limit@1.0.0 75 | react@0.14.3 76 | react-meteor-data@0.2.4 77 | react-runtime@0.14.3 78 | react-runtime-dev@0.14.3 79 | react-runtime-prod@0.14.3 80 | reactive-dict@1.1.3 81 | reactive-var@1.0.6 82 | reload@1.1.4 83 | retry@1.0.4 84 | routepolicy@1.0.6 85 | service-configuration@1.0.5 86 | session@1.1.1 87 | sha@1.0.4 88 | spacebars@1.0.7 89 | spacebars-compiler@1.0.7 90 | srp@1.0.4 91 | standard-minifiers@1.0.2 92 | stevezhu:lodash@3.10.1 93 | templating@1.1.5 94 | templating-tools@1.0.0 95 | themeteorchef:bert@2.1.0 96 | themeteorchef:commonmark@1.1.0 97 | themeteorchef:jquery-validation@1.14.0 98 | tracker@1.0.9 99 | twbs:bootstrap@3.3.5 100 | ui@1.0.8 101 | underscore@1.0.4 102 | url@1.0.5 103 | webapp@1.2.3 104 | webapp-hashing@1.0.5 105 | -------------------------------------------------------------------------------- /code/README.md: -------------------------------------------------------------------------------- 1 | # The Meteor Chef - Base 2 | A starting point for Meteor apps. 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
Base Versionv3.3.0
Meteor Versionv1.2.1
16 | 17 | [Read the Documentation](http://themeteorchef.com/base) 18 | -------------------------------------------------------------------------------- /code/application.html: -------------------------------------------------------------------------------- 1 | 2 | HD Buff 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /code/both/methods/insert/collection.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | insertMethod( argument ) { 3 | check( argument, Object ); 4 | 5 | try { 6 | var documentId = Collection.insert( argument ); 7 | return documentId; 8 | } catch( exception ) { 9 | return exception; 10 | } 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /code/both/methods/read/collection.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | readMethod( argument ) { 3 | check( argument, String ); 4 | 5 | var document = Collection.findOne( argument ); 6 | 7 | if ( !document ) { 8 | throw new Meteor.Error( 'document-not-found', 'No documents found matching this query.' ); 9 | } 10 | 11 | return document; 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /code/both/methods/remove/collection.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | removeMethod( argument ) { 3 | check( argument, String ); 4 | 5 | try { 6 | Collection.remove( argument ); 7 | } catch( exception ) { 8 | return exception; 9 | } 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /code/both/methods/update/collection.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | updateMethod( argument ) { 3 | check( argument, Object ); 4 | 5 | try { 6 | var documentId = Collection.update( argument._id, { 7 | $set: { 'key': argument.key } 8 | }); 9 | return documentId; 10 | } catch( exception ) { 11 | return exception; 12 | } 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /code/both/modules/_modules.js: -------------------------------------------------------------------------------- 1 | Modules = {}; 2 | Modules.both = {}; 3 | -------------------------------------------------------------------------------- /code/both/modules/startup.js: -------------------------------------------------------------------------------- 1 | let startup = () => {}; 2 | 3 | Modules.both.startup = startup; 4 | -------------------------------------------------------------------------------- /code/both/routes/authenticated.jsx: -------------------------------------------------------------------------------- 1 | const authenticatedRoutes = FlowRouter.group({ 2 | name: 'authenticated' 3 | }); 4 | 5 | authenticatedRoutes.route( '/posts', { 6 | name: 'posts', 7 | action() { 8 | ReactLayout.render( App, { yield: } ); 9 | } 10 | }); 11 | 12 | authenticatedRoutes.route( '/posts/:_id/edit', { 13 | name: 'editor', 14 | action( params ) { 15 | ReactLayout.render( App, { yield: } ); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /code/both/routes/configure.jsx: -------------------------------------------------------------------------------- 1 | FlowRouter.notFound = { 2 | name: 'notFound', 3 | action() { 4 | ReactLayout.render( App, { yield: } ); 5 | } 6 | }; 7 | 8 | Accounts.onLogin( () => { 9 | let currentRoute = FlowRouter.current(); 10 | if ( currentRoute && currentRoute.route.group.name === 'public' ) { 11 | FlowRouter.go( 'posts' ); 12 | } 13 | }); 14 | -------------------------------------------------------------------------------- /code/both/routes/public.jsx: -------------------------------------------------------------------------------- 1 | const publicRoutes = FlowRouter.group({ 2 | name: 'public' 3 | }); 4 | 5 | publicRoutes.route( '/', { 6 | name: 'index', 7 | action() { 8 | ReactLayout.render( App, { yield: } ); 9 | } 10 | }); 11 | 12 | publicRoutes.route( '/posts/:slug', { 13 | name: 'singlePost', 14 | action( params ) { 15 | ReactLayout.render( App, { yield: } ); 16 | } 17 | }); 18 | 19 | publicRoutes.route( '/tags/:tag', { 20 | name: 'tagIndex', 21 | action( params ) { 22 | ReactLayout.render( App, { yield: } ); 23 | } 24 | }); 25 | 26 | publicRoutes.route( '/login', { 27 | name: 'login', 28 | action() { 29 | ReactLayout.render( App, { yield: } ); 30 | } 31 | }); 32 | 33 | publicRoutes.route( '/recover-password', { 34 | name: 'recoverPassword', 35 | action() { 36 | ReactLayout.render( App, { yield: } ); 37 | } 38 | }); 39 | 40 | publicRoutes.route( '/reset-password/:token', { 41 | name: 'resetPassword', 42 | action( params ) { 43 | ReactLayout.render( App, { yield: } ); 44 | } 45 | }); 46 | -------------------------------------------------------------------------------- /code/both/startup.js: -------------------------------------------------------------------------------- 1 | Meteor.startup( () => Modules.both.startup() ); 2 | -------------------------------------------------------------------------------- /code/client/components/generic/alerts/alert.jsx: -------------------------------------------------------------------------------- 1 | Alert = React.createClass({ 2 | render() { 3 | return

4 | { this.props.children } 5 |

; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/alerts/danger-alert.jsx: -------------------------------------------------------------------------------- 1 | DangerAlert = React.createClass({ 2 | render() { 3 | return 4 | { this.props.children } 5 | ; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/alerts/info-alert.jsx: -------------------------------------------------------------------------------- 1 | InfoAlert = React.createClass({ 2 | render() { 3 | return 4 | { this.props.children } 5 | ; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/alerts/success-alert.jsx: -------------------------------------------------------------------------------- 1 | SuccessAlert = React.createClass({ 2 | render() { 3 | return 4 | { this.props.children } 5 | ; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/alerts/warning-alert.jsx: -------------------------------------------------------------------------------- 1 | WarningAlert = React.createClass({ 2 | render() { 3 | return 4 | { this.props.children } 5 | ; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/buttons/button.jsx: -------------------------------------------------------------------------------- 1 | Button = React.createClass({ 2 | render() { 3 | if ( this.props.href ) { 4 | return 5 | { this.props.label } 6 | ; 7 | } else { 8 | return ; 15 | } 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /code/client/components/generic/buttons/danger.jsx: -------------------------------------------------------------------------------- 1 | DangerButton = React.createClass({ 2 | render() { 3 | return 12 | { this.props.brand } 13 |
14 |
15 | { this.props.children } 16 |
17 |
18 | ; 19 | } 20 | }); 21 | -------------------------------------------------------------------------------- /code/client/components/generic/page-header.jsx: -------------------------------------------------------------------------------- 1 | PageHeader = React.createClass({ 2 | renderPageHeader() { 3 | let headers = { 4 | h1:

{ this.props.label }

, 5 | h2:

{ this.props.label }

, 6 | h3:

{ this.props.label }

, 7 | h4:

{ this.props.label }

, 8 | h5:
{ this.props.label }
, 9 | h6:
{ this.props.label }
10 | }; 11 | 12 | return headers[ this.props.size ]; 13 | }, 14 | render() { 15 | return this.renderPageHeader(); 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /code/client/components/generic/panels/panel-body.jsx: -------------------------------------------------------------------------------- 1 | PanelBody = React.createClass({ 2 | render() { 3 | return
4 | { this.props.children } 5 |
; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/panels/panel-footer.jsx: -------------------------------------------------------------------------------- 1 | PanelFooter = React.createClass({ 2 | render() { 3 | return
4 | { this.props.children } 5 |
; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/panels/panel-heading.jsx: -------------------------------------------------------------------------------- 1 | PanelHeading = React.createClass({ 2 | render() { 3 | return
4 | { this.props.children } 5 |
; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/generic/panels/panel.jsx: -------------------------------------------------------------------------------- 1 | Panel = React.createClass({ 2 | render() { 3 | let style = this.props.style, 4 | classes = style ? 'panel panel-${ style }' : 'panel panel-default'; 5 | 6 | return
7 | { this.props.children } 8 |
; 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /code/client/components/generic/post.jsx: -------------------------------------------------------------------------------- 1 | Post = React.createClass({ 2 | getPostTitle() { 3 | let post = this.props.post; 4 | 5 | if ( this.props.singlePost ) { 6 | return

{ post.title }

; 7 | } else { 8 | return

{ post.title }

; 9 | } 10 | }, 11 | getHTML( markdown ) { 12 | if ( markdown ) { 13 | return { __html: parseMarkdown( markdown ) }; 14 | } 15 | }, 16 | renderTags( tags ) { 17 | if ( tags ) { 18 | return
19 | {tags.map( ( tag ) => { 20 | return #{ tag }; 21 | })} 22 |
; 23 | } 24 | }, 25 | render() { 26 | let { formatLastUpdate } = ReactHelpers, 27 | post = this.props.post; 28 | 29 | return
30 | { this.getPostTitle() } 31 |

Last Updated: { formatLastUpdate( post.updated ) } by { post.author }

32 | { this.renderTags( post.tags ) } 33 |
34 |
; 35 | } 36 | }); 37 | -------------------------------------------------------------------------------- /code/client/components/global/app-header.jsx: -------------------------------------------------------------------------------- 1 | AppHeader = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | return { 5 | brandLink: !!Meteor.user() ? '/posts' : '/', 6 | user: Meteor.user() 7 | }; 8 | }, 9 | render() { 10 | return 11 | { this.props.hasUser ? : } 12 | ; 13 | } 14 | }); 15 | -------------------------------------------------------------------------------- /code/client/components/global/authenticated-navigation.jsx: -------------------------------------------------------------------------------- 1 | AuthenticatedNavigation = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | let userEmail = Meteor.user().emails[0].address; 5 | 6 | return { 7 | items: { 8 | left: [ 9 | { uid: 'posts', href: '/posts', label: 'Posts' } 10 | ], 11 | right: [ 12 | { 13 | uid: 'currentUser', 14 | href: '#', 15 | label: userEmail, 16 | dropdown: true, 17 | dropdownItems: [ 18 | { uid: 'logout', href: '#', label: 'Logout', action: () => { 19 | return Meteor.logout( () => { 20 | FlowRouter.go( 'index' ); 21 | }); 22 | }} 23 | ] 24 | } 25 | ] 26 | } 27 | }; 28 | }, 29 | render() { 30 | return
31 | 32 | 33 |
; 34 | } 35 | }); 36 | -------------------------------------------------------------------------------- /code/client/components/global/loading.jsx: -------------------------------------------------------------------------------- 1 | Loading = React.createClass({ 2 | render() { 3 | return 4 | 7 | 9 | 10 | ; 11 | } 12 | }); 13 | -------------------------------------------------------------------------------- /code/client/components/global/public-navigation.jsx: -------------------------------------------------------------------------------- 1 | PublicNavigation = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | return { 5 | items: { 6 | right: [ 7 | { uid: 'login', href: '/login', label: 'Log In' } 8 | ] 9 | } 10 | }; 11 | }, 12 | render() { 13 | return
14 | 15 |
; 16 | } 17 | }); 18 | -------------------------------------------------------------------------------- /code/client/components/layout/app.jsx: -------------------------------------------------------------------------------- 1 | App = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | return { 5 | loggingIn: Meteor.loggingIn(), 6 | hasUser: !!Meteor.user(), 7 | isPublic( route ) { 8 | return [ 9 | 'index', 10 | 'singlePost', 11 | 'tagIndex', 12 | 'login', 13 | 'recoverPassword', 14 | 'resetPassword', 15 | 'notFound' 16 | ].indexOf( route ) > -1; 17 | }, 18 | canView() { 19 | return this.isPublic( FlowRouter.getRouteName() ) || !!Meteor.user(); 20 | } 21 | }; 22 | }, 23 | getView() { 24 | return this.data.canView() ? this.props.yield : ; 25 | }, 26 | render() { 27 | return
28 | 29 |
30 | { this.data.loggingIn ? : this.getView() } 31 |
32 |
; 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /code/client/components/views/editor.jsx: -------------------------------------------------------------------------------- 1 | Editor = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | Meteor.subscribe( 'editor', this.props.post ); 5 | 6 | return { 7 | post: Posts.findOne( { _id: this.props.post } ) 8 | }; 9 | }, 10 | validations() { 11 | let component = this; 12 | 13 | return { 14 | rules: { 15 | postTitle: { 16 | required: true 17 | } 18 | }, 19 | messages: { 20 | postTitle: { 21 | required: "Hang on there, a post title is required!" 22 | } 23 | }, 24 | submitHandler() { 25 | let { getValue, isChecked } = ReactHelpers; 26 | 27 | let form = component.refs.editPostForm.refs.form, 28 | post = { 29 | _id: component.props.post, 30 | title: getValue( form, '[name="postTitle"]' ), 31 | slug: getValue( form, '[name="postSlug"]' ), 32 | content: getValue( form, '[name="postContent"]' ), 33 | published: isChecked( form, '[name="postPublished"]' ), 34 | tags: getValue( form, '[name="postTags"]' ).split( ',' ).map( ( string ) => { 35 | return string.trim(); 36 | }) 37 | }; 38 | 39 | Meteor.call( 'savePost', post, ( error, response ) => { 40 | if ( error ) { 41 | Bert.alert( error.reason, 'danger' ); 42 | } else { 43 | Bert.alert( 'Post saved!', 'success' ); 44 | } 45 | }); 46 | } 47 | }; 48 | }, 49 | generateSlug( event ) { 50 | let { setValue } = ReactHelpers, 51 | form = this.refs.editPostForm.refs.form, 52 | title = event.target.value; 53 | 54 | setValue( form, '[name="postSlug"]', getSlug( title, { custom: { "'": "" } } ) ); 55 | }, 56 | getLastUpdate() { 57 | if ( this.data ) { 58 | let { formatLastUpdate } = ReactHelpers, 59 | post = this.data.post; 60 | 61 | return `${ formatLastUpdate( post.updated ) } by ${ post.author }`; 62 | } 63 | }, 64 | getTags() { 65 | let post = this.data.post; 66 | 67 | if ( post && post.tags ) { 68 | return post.tags.join( ', ' ); 69 | } 70 | }, 71 | handleSubmit( event ) { 72 | event.preventDefault(); 73 | }, 74 | render() { 75 | if ( !this.data.post ) { return
; } 76 | 77 | return 78 | 79 | 80 |
81 |

82 | Last Updated: { this.getLastUpdate() } 83 |

84 | 85 | 92 | 93 | 94 | 103 | 104 | 105 | 114 | 115 | 116 | 123 | 124 | 125 | 133 | 134 | 135 | 136 | 137 |
138 |
139 |
; 140 | } 141 | }); 142 | -------------------------------------------------------------------------------- /code/client/components/views/login.jsx: -------------------------------------------------------------------------------- 1 | Login = React.createClass({ 2 | validations() { 3 | let component = this; 4 | 5 | return { 6 | rules: { 7 | emailAddress: { 8 | required: true, 9 | email: true 10 | }, 11 | password: { 12 | required: true 13 | } 14 | }, 15 | messages: { 16 | emailAddress: { 17 | required: 'Need an email address here.', 18 | email: 'Is this email address legit?' 19 | }, 20 | password: { 21 | required: 'Need a password here.' 22 | } 23 | }, 24 | submitHandler() { 25 | let { getValue } = ReactHelpers; 26 | 27 | let form = component.refs.loginForm.refs.form, 28 | email = getValue( form, '[name="emailAddress"]' ), 29 | password = getValue( form, '[name="password"]' ); 30 | 31 | Meteor.loginWithPassword( email, password, ( error ) => { 32 | if ( error ) { 33 | Bert.alert( error.reason, 'danger' ); 34 | } else { 35 | Bert.alert( 'Logged in!', 'success' ); 36 | } 37 | }); 38 | } 39 | }; 40 | }, 41 | handleSubmit( event ) { 42 | event.preventDefault(); 43 | }, 44 | render() { 45 | let passwordLabelLink = { 46 | href: '/recover-password', 47 | label: 'Forget Password?' 48 | }; 49 | 50 | return 51 | 52 | 53 | 54 | To access the demo, you can use the email address admin@admin.com and the password password. 55 | 56 |
57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 |
67 |

Don't have an account? Sign Up.

68 |
69 |
; 70 | } 71 | }); 72 | -------------------------------------------------------------------------------- /code/client/components/views/not-found.jsx: -------------------------------------------------------------------------------- 1 | NotFound = React.createClass({ 2 | render() { 3 | return 4 | Error [404]: { FlowRouter.current().path } does not exist. 5 | ; 6 | } 7 | }); 8 | -------------------------------------------------------------------------------- /code/client/components/views/posts-index.jsx: -------------------------------------------------------------------------------- 1 | PostsIndex = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | let query = {}; 5 | 6 | if ( this.props.tag ) { 7 | Meteor.subscribe( 'tagsIndex', this.props.tag ); 8 | query = { tags: { $in: [ this.props.tag ] } }; 9 | } else { 10 | Meteor.subscribe( 'postsIndex' ); 11 | } 12 | 13 | return { 14 | posts: Posts.find( query, { sort: { updated: -1 } } ).fetch() 15 | }; 16 | }, 17 | renderHeader() { 18 | if ( this.props.tag ) { 19 | return 20 |

Posts tagged with: { this.props.tag }.

21 |
; 22 | } else { 23 | return 24 |

Get Buff

25 |

A new blog by the HD Buff crew.

26 |
; 27 | } 28 | }, 29 | renderPosts() { 30 | if ( this.data.posts.length > 0 ) { 31 | return this.data.posts.map( ( post ) => { 32 | return ; 33 | }); 34 | } else { 35 | return No posts found.; 36 | } 37 | }, 38 | render() { 39 | return
40 | 41 | 42 | { this.renderHeader() } 43 | { this.renderPosts() } 44 | 45 | 46 |
; 47 | } 48 | }); 49 | -------------------------------------------------------------------------------- /code/client/components/views/posts-list.jsx: -------------------------------------------------------------------------------- 1 | PostsList = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | Meteor.subscribe( 'postsList' ); 5 | 6 | return { 7 | posts: Posts.find().fetch().map( ( post ) => { 8 | return { uid: post._id, href: `/posts/${ post._id }/edit`, label: post.title }; 9 | }) 10 | }; 11 | }, 12 | handleNewPost() { 13 | Meteor.call( 'newPost', ( error, postId ) => { 14 | if ( error ) { 15 | Bert.alert( error.reason, 'danger' ); 16 | } else { 17 | FlowRouter.go( `/posts/${ postId }/edit` ); 18 | Bert.alert( 'All set! Get to typin\'', 'success' ); 19 | } 20 | }); 21 | }, 22 | renderPostsList() { 23 | if ( this.data.posts.length > 0 ) { 24 | return ; 25 | } else { 26 | return No posts found.; 27 | } 28 | }, 29 | render() { 30 | return 31 | 32 | 33 | 34 | { this.renderPostsList() } 35 | 36 | ; 37 | } 38 | }); 39 | -------------------------------------------------------------------------------- /code/client/components/views/recover-password.jsx: -------------------------------------------------------------------------------- 1 | RecoverPassword = React.createClass({ 2 | validations() { 3 | let component = this; 4 | 5 | return { 6 | rules: { 7 | emailAddress: { 8 | required: true, 9 | email: true 10 | } 11 | }, 12 | messages: { 13 | emailAddress: { 14 | required: 'Need an email address here.', 15 | email: 'Is this email address legit?' 16 | } 17 | }, 18 | submitHandler() { 19 | let { getValue } = ReactHelpers; 20 | 21 | let form = component.refs.recoverPasswordForm.refs.form, 22 | email = getValue( form, '[name="emailAddress"]' ); 23 | 24 | Accounts.forgotPassword( { email: email }, ( error ) => { 25 | if ( error ) { 26 | Bert.alert( error.reason, 'warning' ); 27 | } else { 28 | Bert.alert( 'Check your inbox for a reset link!', 'success' ); 29 | } 30 | }); 31 | } 32 | }; 33 | }, 34 | handleSubmit( event ) { 35 | event.preventDefault(); 36 | }, 37 | render() { 38 | return 39 | 40 | 41 | 42 | Enter your email address below to receive a link to reset your password. 43 | 44 |
45 | 46 | 47 | 48 | 49 | 50 | 51 |
52 |
53 |
; 54 | } 55 | }); 56 | -------------------------------------------------------------------------------- /code/client/components/views/reset-password.jsx: -------------------------------------------------------------------------------- 1 | ResetPassword = React.createClass({ 2 | validations() { 3 | let component = this; 4 | 5 | return { 6 | rules: { 7 | password: { 8 | required: true, 9 | minlength: 6 10 | }, 11 | repeatPassword: { 12 | required: true, 13 | minlength: 6, 14 | equalTo: '[name="password"]' 15 | } 16 | }, 17 | messages: { 18 | password: { 19 | required: "Enter a new password, please.", 20 | minlength: "Use at least six characters, please." 21 | }, 22 | repeatPassword: { 23 | required: "Repeat your new password, please.", 24 | equalTo: "Hmm, your passwords don't match. Try again?" 25 | } 26 | }, 27 | submitHandler() { 28 | let { getValue } = ReactHelpers; 29 | 30 | let form = component.refs.resetPasswordForm.refs.form, 31 | token = component.props.token, 32 | password = getValue( form, '[name="password"]' ); 33 | 34 | Accounts.resetPassword( token, password, ( error ) => { 35 | if ( error ) { 36 | Bert.alert( error.reason, 'danger' ); 37 | } else { 38 | Bert.alert( 'Password reset!', 'success' ); 39 | } 40 | }); 41 | } 42 | }; 43 | }, 44 | handleSubmit( event ) { 45 | event.preventDefault(); 46 | }, 47 | render() { 48 | return 49 | 50 | 51 | 52 | To reset your password, enter a new one below. You will be logged in with your new password. 53 | 54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 |
65 |
66 |
; 67 | } 68 | }); 69 | -------------------------------------------------------------------------------- /code/client/components/views/single-post.jsx: -------------------------------------------------------------------------------- 1 | SinglePost = React.createClass({ 2 | mixins: [ ReactMeteorData ], 3 | getMeteorData() { 4 | let sub = Meteor.subscribe( 'singlePost', this.props.slug ); 5 | 6 | return { 7 | post: Posts.findOne( { slug: this.props.slug } ), 8 | ready: sub.ready() 9 | }; 10 | }, 11 | render() { 12 | if ( !this.data ) { return
; } 13 | return 14 | 15 | 16 | 17 | ; 18 | } 19 | }); 20 | -------------------------------------------------------------------------------- /code/client/helpers/flow-router.js: -------------------------------------------------------------------------------- 1 | let pathFor = ( path, view ) => { 2 | if ( path.hash ) { 3 | view = path; 4 | path = view.hash.route; 5 | delete view.hash.route; 6 | } 7 | 8 | let query = view.hash.query ? FlowRouter._qs.parse( view.hash.query ) : {}; 9 | return FlowRouter.path( path, view.hash, query ); 10 | }; 11 | 12 | FlowHelpers = { 13 | pathFor: pathFor, 14 | urlFor( path, view ) { 15 | return Meteor.absoluteUrl( pathFor( path, view ).substr( 1 ) ); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /code/client/helpers/react.js: -------------------------------------------------------------------------------- 1 | ReactHelpers = { 2 | getValue( context, selector ) { 3 | return context.querySelector( selector ).value; 4 | }, 5 | setValue( context, selector, value ) { 6 | context.querySelector( selector ).value = value; 7 | }, 8 | isChecked( context, selector ) { 9 | return context.querySelector( selector ).checked; 10 | }, 11 | formatLastUpdate( date ) { 12 | if ( date ) { 13 | return moment( date ).format( 'MMMM Do, YYYY hh:mm a' ); 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /code/client/modules/_modules.js: -------------------------------------------------------------------------------- 1 | Modules.client = {}; 2 | -------------------------------------------------------------------------------- /code/client/modules/startup.js: -------------------------------------------------------------------------------- 1 | let startup = () => {}; 2 | 3 | Modules.client.startup = startup; 4 | -------------------------------------------------------------------------------- /code/client/startup.js: -------------------------------------------------------------------------------- 1 | Meteor.startup( () => Modules.client.startup() ); 2 | -------------------------------------------------------------------------------- /code/client/stylesheets/application.scss: -------------------------------------------------------------------------------- 1 | @import "tools/extends"; 2 | 3 | @import "components/generic/jumbotron"; 4 | @import "components/generic/post"; 5 | 6 | @import "components/generic/forms/form-control"; 7 | 8 | @import "components/global/loading"; 9 | 10 | @import "components/views/login"; 11 | @import "components/views/editor"; 12 | -------------------------------------------------------------------------------- /code/client/stylesheets/components/generic/_jumbotron.scss: -------------------------------------------------------------------------------- 1 | .jumbotron { 2 | padding: 20px; 3 | } 4 | 5 | .jumbotron.blog-header, 6 | .jumbotron.tags-header { 7 | padding: 30px !important; 8 | 9 | h2 { 10 | margin-top: 0px; 11 | } 12 | 13 | h4 { 14 | margin-top: 0px; 15 | margin-bottom: 0px; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /code/client/stylesheets/components/generic/_post.scss: -------------------------------------------------------------------------------- 1 | .post { 2 | border: 1px solid #eee; 3 | padding: 20px; 4 | border-radius: 3px; 5 | margin-bottom: 20px; 6 | 7 | h3 { 8 | margin: 0 0 10px; 9 | } 10 | 11 | .tags { 12 | margin: 15px 0 20px; 13 | } 14 | 15 | .tags .tag:not( :last-child ) { 16 | margin-right: 5px; 17 | } 18 | 19 | .tag { 20 | display: inline-block; 21 | background: #eee; 22 | color: #333; 23 | border-radius: 3px; 24 | padding: 2px 6px; 25 | text-decoration: none; 26 | } 27 | 28 | .tag:hover { 29 | background: #333; 30 | color: #fff; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /code/client/stylesheets/components/generic/forms/_form-control.scss: -------------------------------------------------------------------------------- 1 | .input label.error { 2 | display: block; 3 | margin-top: 10px; 4 | font-weight: normal; 5 | color: lighten( red, 20% ); 6 | } 7 | 8 | .form-control.error { 9 | border-color: #a94442; 10 | box-shadow: inset 0 1px 1px rgba( 0, 0, 0, .075 ), 0 0 6px #ce8483; 11 | } 12 | -------------------------------------------------------------------------------- /code/client/stylesheets/components/global/_loading.scss: -------------------------------------------------------------------------------- 1 | @-webkit-keyframes rotate { 2 | from { 3 | -webkit-transform: rotate( 0deg ); 4 | } 5 | to { 6 | -webkit-transform: rotate( 360deg ); 7 | } 8 | } 9 | 10 | .loading { 11 | -webkit-animation-name: rotate; 12 | -webkit-animation-duration: 0.5s; 13 | -webkit-animation-iteration-count: infinite; 14 | -webkit-animation-timing-function: linear; 15 | } 16 | -------------------------------------------------------------------------------- /code/client/stylesheets/components/views/_editor.scss: -------------------------------------------------------------------------------- 1 | .edit-post .updated-date { 2 | margin-bottom: 20px; 3 | } 4 | 5 | .edit-post textarea { 6 | min-height: 200px; 7 | } 8 | -------------------------------------------------------------------------------- /code/client/stylesheets/components/views/_login.scss: -------------------------------------------------------------------------------- 1 | .login label { 2 | display: block; 3 | @extend %clearfix; 4 | } 5 | -------------------------------------------------------------------------------- /code/client/stylesheets/tools/_extends.scss: -------------------------------------------------------------------------------- 1 | %clearfix { 2 | *zoom: 1; 3 | 4 | &:before, 5 | &:after { 6 | display: table; 7 | content: ""; 8 | } 9 | 10 | &:after { 11 | clear: both; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /code/collections/posts.js: -------------------------------------------------------------------------------- 1 | Posts = new Mongo.Collection( 'posts' ); 2 | 3 | Posts.allow({ 4 | insert: () => false, 5 | update: () => false, 6 | remove: () => false 7 | }); 8 | 9 | Posts.deny({ 10 | insert: () => true, 11 | update: () => true, 12 | remove: () => true 13 | }); 14 | 15 | let PostsSchema = new SimpleSchema({ 16 | "published": { 17 | type: Boolean, 18 | label: "Is this post published?", 19 | autoValue() { 20 | if ( this.isInsert ) { 21 | return false; 22 | } 23 | } 24 | }, 25 | "author": { 26 | type: String, 27 | label: "The ID of the author of this post.", 28 | autoValue() { 29 | let user = Meteor.users.findOne( { _id: this.userId } ); 30 | if ( user ) { 31 | return `${ user.profile.name.first } ${ user.profile.name.last }`; 32 | } 33 | } 34 | }, 35 | "updated": { 36 | type: String, 37 | label: "The date this post was last updated on.", 38 | autoValue() { 39 | return ( new Date() ).toISOString(); 40 | } 41 | }, 42 | "title": { 43 | type: String, 44 | label: "The title of this post.", 45 | defaultValue: "Untitled Post" 46 | }, 47 | "slug": { 48 | type: String, 49 | label: "The slug for this post.", 50 | autoValue() { 51 | let slug = this.value, 52 | existingSlugCount = Posts.find( { _id: { $ne: this.docId }, slug: new RegExp( slug ) } ).count(), 53 | existingUntitled = Posts.find( { slug: { $regex: /untitled-post/i } } ).count(); 54 | 55 | if ( slug ) { 56 | return existingSlugCount > 0 ? `${ slug }-${ existingSlugCount + 1 }` : slug; 57 | } else { 58 | return existingUntitled > 0 ? `untitled-post-${ existingUntitled + 1 }` : 'untitled-post'; 59 | } 60 | } 61 | }, 62 | "content": { 63 | type: String, 64 | label: "The content of this post.", 65 | optional: true 66 | }, 67 | "tags": { 68 | type: [ String ], 69 | label: "The tags for this post.", 70 | optional: true 71 | } 72 | }); 73 | 74 | Posts.attachSchema( PostsSchema ); 75 | -------------------------------------------------------------------------------- /code/collections/users.js: -------------------------------------------------------------------------------- 1 | Meteor.users.allow({ 2 | insert: () => false, 3 | update: () => false, 4 | remove: () => false 5 | }); 6 | 7 | Meteor.users.deny({ 8 | insert: () => true, 9 | update: () => true, 10 | remove: () => true 11 | }); 12 | -------------------------------------------------------------------------------- /code/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "application-name", 3 | "version": "1.0.0", 4 | "description": "Application description.", 5 | "scripts": { 6 | "start": "meteor --settings settings-development.json", 7 | "staging": "meteor deploy staging.meteor.com --settings settings-development.json", 8 | "production": "meteor deploy production.meteor.com --settings settings-production.json" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /code/packages.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /code/packages/npm-container/index.js: -------------------------------------------------------------------------------- 1 | Meteor.npmRequire = function(moduleName) { 2 | var module = Npm.require(moduleName); 3 | return module; 4 | }; 5 | 6 | Meteor.require = function(moduleName) { 7 | console.warn('Meteor.require is deprecated. Please use Meteor.npmRequire instead!'); 8 | return Meteor.npmRequire(moduleName); 9 | }; -------------------------------------------------------------------------------- /code/packages/npm-container/package.js: -------------------------------------------------------------------------------- 1 | var path = Npm.require('path'); 2 | var fs = Npm.require('fs'); 3 | 4 | Package.describe({ 5 | summary: 'Contains all your npm dependencies', 6 | version: '1.2.0', 7 | name: 'npm-container' 8 | }); 9 | 10 | var packagesJsonFile = path.resolve('./packages.json'); 11 | try { 12 | var fileContent = fs.readFileSync(packagesJsonFile); 13 | var packages = JSON.parse(fileContent.toString()); 14 | Npm.depends(packages); 15 | } catch (ex) { 16 | console.error('ERROR: packages.json parsing error [ ' + ex.message + ' ]'); 17 | } 18 | 19 | // Adding the app's packages.json as a used file for this package will get 20 | // Meteor to watch it and reload this package when it changes 21 | Package.onUse(function(api) { 22 | api.addFiles('index.js', 'server'); 23 | if (api.addAssets) { 24 | api.addAssets('../../packages.json', 'server'); 25 | } else { 26 | api.addFiles('../../packages.json', 'server', { 27 | isAsset: true 28 | }); 29 | } 30 | }); -------------------------------------------------------------------------------- /code/private/email/templates/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themeteorchef/building-a-blog-with-react/490adbaff18da19be8915ebe4115ea3a083683ad/code/private/email/templates/.gitkeep -------------------------------------------------------------------------------- /code/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/themeteorchef/building-a-blog-with-react/490adbaff18da19be8915ebe4115ea3a083683ad/code/public/favicon.ico -------------------------------------------------------------------------------- /code/server/admin/reset-password.js: -------------------------------------------------------------------------------- 1 | Accounts.emailTemplates.resetPassword.siteName = () => "Application Name"; 2 | Accounts.emailTemplates.resetPassword.from = () => "Application Name "; 3 | Accounts.emailTemplates.resetPassword.subject = () => "[Application Name] Reset Your Password"; 4 | 5 | Accounts.emailTemplates.resetPassword.text = ( user, url ) => { 6 | let emailAddress = user.emails[0].address, 7 | urlWithoutHash = url.replace( '#/', '' ), 8 | supportEmail = "support@application.com", 9 | emailBody = `A password reset has been requested for the account related to this address (${emailAddress}). To reset the password, visit the following link:\n\n${urlWithoutHash}\n\n If you did not request this reset, please ignore this email. If you feel something is wrong, please contact our support team: ${supportEmail}.`; 10 | 11 | return emailBody; 12 | }; 13 | -------------------------------------------------------------------------------- /code/server/methods/insert/posts.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | newPost() { 3 | return Posts.insert( {} ); 4 | } 5 | }); 6 | -------------------------------------------------------------------------------- /code/server/methods/update/posts.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | savePost( post ) { 3 | check( post, Object ); 4 | 5 | let postId = post._id; 6 | delete post._id; 7 | 8 | Posts.upsert( postId, { $set: post } ); 9 | } 10 | }); 11 | -------------------------------------------------------------------------------- /code/server/modules/_modules.js: -------------------------------------------------------------------------------- 1 | Modules.server = {}; 2 | -------------------------------------------------------------------------------- /code/server/modules/generate-accounts.js: -------------------------------------------------------------------------------- 1 | let administrators = [ 2 | { 3 | name: { first: 'Admin', last: 'McAdmin' }, 4 | email: 'admin@admin.com', 5 | password: 'password' 6 | }, 7 | { 8 | name: { first: 'Joe', last: 'Buff' }, 9 | email: 'joe@hdbuff.com', 10 | password: 'password' 11 | }, 12 | { 13 | name: { first: 'Jane', last: 'Buff' }, 14 | email: 'jane@hdbuff.com', 15 | password: 'password' 16 | } 17 | ]; 18 | 19 | let generateAccounts = () => { 20 | let fakeUserCount = 5, 21 | usersExist = _checkIfAccountsExist( administrators.length + fakeUserCount ); 22 | 23 | if ( !usersExist ) { 24 | _createUsers( administrators ); 25 | _createUsers( _generateFakeUsers( fakeUserCount ) ); 26 | } 27 | }; 28 | 29 | let _checkIfAccountsExist = ( count ) => { 30 | let userCount = Meteor.users.find().count(); 31 | return userCount < count ? false : true; 32 | }; 33 | 34 | let _createUsers = ( users ) => { 35 | for ( let i = 0; i < users.length; i++ ) { 36 | let user = users[ i ], 37 | userExists = _checkIfUserExists( user.email ); 38 | 39 | if ( !userExists ) { 40 | _createUser( user ); 41 | } 42 | } 43 | }; 44 | 45 | let _checkIfUserExists = ( email ) => { 46 | return Meteor.users.findOne( { 'emails.address': email } ); 47 | }; 48 | 49 | let _createUser = ( user ) => { 50 | Accounts.createUser({ 51 | email: user.email, 52 | password: user.password, 53 | profile: { 54 | name: user.name 55 | } 56 | }); 57 | }; 58 | 59 | let _generateFakeUsers = ( count ) => { 60 | let users = []; 61 | 62 | for ( let i = 0; i < count; i++ ) { 63 | users.push({ 64 | name: { first: faker.name.firstName(), last: faker.name.lastName() }, 65 | email: faker.internet.email(), 66 | password: 'password' 67 | }); 68 | } 69 | 70 | return users; 71 | }; 72 | 73 | Modules.server.generateAccounts = generateAccounts; 74 | -------------------------------------------------------------------------------- /code/server/modules/set-environment-variables.js: -------------------------------------------------------------------------------- 1 | let setEnvironmentVariables = () => { 2 | if ( Meteor.settings.private ) { 3 | process.env.MAIL_URL = Meteor.settings.private.MAIL_URL; 4 | } 5 | }; 6 | 7 | Modules.server.setEnvironmentVariables = setEnvironmentVariables; 8 | -------------------------------------------------------------------------------- /code/server/modules/startup.js: -------------------------------------------------------------------------------- 1 | let startup = () => { 2 | _setEnvironmentVariables(); 3 | _setBrowserPolicies(); 4 | _generateAccounts(); 5 | }; 6 | 7 | var _setEnvironmentVariables = () => Modules.server.setEnvironmentVariables(); 8 | 9 | var _setBrowserPolicies = () => {}; 10 | 11 | var _generateAccounts = () => Modules.server.generateAccounts(); 12 | 13 | Modules.server.startup = startup; 14 | -------------------------------------------------------------------------------- /code/server/publications/editor.jsx: -------------------------------------------------------------------------------- 1 | Meteor.publish( 'editor', ( postId ) => { 2 | check( postId, String ); 3 | 4 | return [ 5 | Posts.find( { _id: postId } ), 6 | Meteor.users.find( {}, { fields: { profile: 1 } } ) 7 | ]; 8 | }); 9 | -------------------------------------------------------------------------------- /code/server/publications/posts-index.js: -------------------------------------------------------------------------------- 1 | Meteor.publish( 'postsIndex', function() { 2 | return Posts.find( { published: true } ); 3 | }); 4 | 5 | Meteor.publish( 'tagsIndex', function( tag ) { 6 | check( tag, String ); 7 | return Posts.find( { published: true, tags: { $in: [ tag ] } } ); 8 | }); 9 | -------------------------------------------------------------------------------- /code/server/publications/posts-list.js: -------------------------------------------------------------------------------- 1 | Meteor.publish( 'postsList', () => { 2 | return Posts.find(); 3 | }); 4 | -------------------------------------------------------------------------------- /code/server/publications/single-post.js: -------------------------------------------------------------------------------- 1 | Meteor.publish( 'singlePost', ( postSlug ) => { 2 | check( postSlug, String ); 3 | 4 | return Posts.find( { slug: postSlug } ); 5 | }); 6 | -------------------------------------------------------------------------------- /code/server/startup.js: -------------------------------------------------------------------------------- 1 | Meteor.startup( () => Modules.server.startup() ); 2 | -------------------------------------------------------------------------------- /code/settings-development.json: -------------------------------------------------------------------------------- 1 | { 2 | "public": { 3 | "key": "value" 4 | }, 5 | "private": { 6 | "MAIL_URL": "" 7 | } 8 | } 9 | --------------------------------------------------------------------------------