├── .gitignore ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── packages ├── platforms ├── release └── versions ├── LICENSE.txt ├── README.markdown ├── client ├── components │ ├── comments.jsx │ ├── errors.jsx │ ├── header.jsx │ ├── notifications.jsx │ ├── posts.jsx │ └── root.jsx ├── helpers │ ├── config.js │ ├── errors.js │ └── handlebars.js ├── main.html └── stylesheets │ └── style.css ├── lib ├── collections │ ├── comments.js │ ├── notifications.js │ └── posts.js ├── permissions.js └── router.jsx └── server ├── fixtures.js └── publications.js /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | -------------------------------------------------------------------------------- /.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 | 18zl2uo1h0c4tg18itxnm 8 | -------------------------------------------------------------------------------- /.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 | standard-app-packages 7 | twbs:bootstrap 8 | underscore 9 | sacha:spin 10 | accounts-password 11 | ian:accounts-ui-bootstrap-3 12 | audit-argument-checks 13 | react 14 | meteorhacks:flow-router 15 | reactive-var 16 | kadira:react-layout 17 | dpraburaj:react-spin 18 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.1.0.2 2 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.0 2 | accounts-password@1.1.1 3 | anti:i18n@0.4.3 4 | audit-argument-checks@1.0.3 5 | autoupdate@1.2.1 6 | babel-compiler@5.8.3_1 7 | babel-runtime@0.1.2 8 | base64@1.0.3 9 | binary-heap@1.0.3 10 | blaze@2.1.2 11 | blaze-tools@1.0.3 12 | boilerplate-generator@1.0.3 13 | callback-hook@1.0.3 14 | check@1.0.5 15 | coffeescript@1.0.6 16 | cosmos:browserify@0.4.0 17 | ddp@1.1.0 18 | deps@1.0.7 19 | dpraburaj:react-spin@2.3.1 20 | ejson@1.0.6 21 | email@1.0.6 22 | fastclick@1.0.3 23 | geojson-utils@1.0.3 24 | html-tools@1.0.4 25 | htmljs@1.0.4 26 | http@1.1.0 27 | ian:accounts-ui-bootstrap-3@1.2.76 28 | id-map@1.0.3 29 | jquery@1.11.3_2 30 | json@1.0.3 31 | jsx@0.1.5 32 | kadira:react-layout@1.2.0 33 | launch-screen@1.0.2 34 | livedata@1.0.13 35 | localstorage@1.0.3 36 | logging@1.0.7 37 | meteor@1.1.6 38 | meteor-platform@1.2.2 39 | meteorhacks:flow-router@1.18.0 40 | minifiers@1.1.5 41 | minimongo@1.0.8 42 | mobile-status-bar@1.0.3 43 | mongo@1.1.0 44 | npm-bcrypt@0.7.8_2 45 | observe-sequence@1.0.6 46 | ordered-dict@1.0.3 47 | random@1.0.3 48 | react@0.1.4 49 | react-meteor-data@0.1.2 50 | react-runtime@0.13.3_2 51 | react-runtime-dev@0.13.3_2 52 | react-runtime-prod@0.13.3_1 53 | reactive-dict@1.1.0 54 | reactive-var@1.0.5 55 | reload@1.1.3 56 | retry@1.0.3 57 | routepolicy@1.0.5 58 | sacha:spin@2.3.1 59 | service-configuration@1.0.4 60 | session@1.1.0 61 | sha@1.0.3 62 | spacebars@1.0.6 63 | spacebars-compiler@1.0.6 64 | srp@1.0.3 65 | standard-app-packages@1.0.5 66 | stylus@1.0.7 67 | templating@1.1.1 68 | tracker@1.0.7 69 | twbs:bootstrap@3.3.5 70 | ui@1.0.6 71 | underscore@1.0.3 72 | url@1.0.4 73 | webapp@1.2.0 74 | webapp-hashing@1.0.3 75 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (C) 2012--2014 Discover Meteor 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # React Microscope 2 | 3 | React Microscope is [Microscope](https://github.com/DiscoverMeteor/Microscope) implemented 4 | in React.js instead of Blaze(Meteor's rendering library). It uses the recently announced [React.js support packages](http://react-in-meteor.readthedocs.org). 5 | 6 | [FlowRouter](https://github.com/kadirahq/flow-router) is used for routing. The existing server-side code and the stylesheets have been used without any modifications. A good deal of the client-side code including 7 | helpers, templates were copied almost verbatim with a few minor modifications. 8 | 9 | [Demo](http://react-microscope.meteor.com) 10 | 11 | #Todo 12 | 13 | - ~~Loading animations~~ 14 | - Post animations 15 | - Server-side rendering 16 | -------------------------------------------------------------------------------- /client/components/comments.jsx: -------------------------------------------------------------------------------- 1 | CommentItem = React.createClass({ 2 | submittedText () { 3 | return this.props.comment.submitted.toString(); 4 | }, 5 | 6 | render() { 7 | return ( 8 |
  • 9 |

    10 | {this.props.comment.author} 11 | on {this.submittedText()} 12 |

    13 |

    {this.props.comment.body}

    14 |
  • 15 | ) 16 | } 17 | }) 18 | 19 | CommentSubmit = React.createClass({ 20 | mixins: [React.addons.LinkedStateMixin], 21 | 22 | getInitialState() { 23 | return { body: '', commentSubmitErrors: {} } 24 | }, 25 | 26 | errorMessage (field) { 27 | return this.state.commentSubmitErrors[field]; 28 | }, 29 | 30 | errorClass(field) { 31 | return !!this.state.commentSubmitErrors[field] ? 'has-error' : ''; 32 | }, 33 | 34 | onSubmit(e) { 35 | e.preventDefault(); 36 | 37 | var comment = { 38 | body: this.state.body, 39 | postId: this.props.post._id 40 | }; 41 | 42 | var errors = {}; 43 | if (!comment.body) { 44 | errors.body = "Please write some content"; 45 | return this.setState({commentSubmitErrors: errors}); 46 | } 47 | 48 | Meteor.call('commentInsert', comment, (error, commentId) => { 49 | if (error){ 50 | throwError(error.reason); 51 | } else { 52 | this.setState({body: '', commentSubmitErrors: {}}) 53 | } 54 | }); 55 | }, 56 | 57 | render() { 58 | return ( 59 |
    60 |
    61 |
    62 | 63 | 65 | {this.errorMessage('body')} 66 |
    67 |
    68 | 69 |
    70 | ) 71 | } 72 | }) -------------------------------------------------------------------------------- /client/components/errors.jsx: -------------------------------------------------------------------------------- 1 | ErrorsView = React.createClass({ 2 | mixins: [ReactMeteorData], 3 | 4 | getMeteorData() { 5 | return { errors: Errors.find().fetch() } 6 | }, 7 | 8 | render() { 9 | let errorItems = _.map(this.data.errors, (err) => { 10 | return 11 | }) 12 | return
    {errorItems}
    13 | } 14 | }) 15 | 16 | ErrorItem = React.createClass({ 17 | componentDidMount() { 18 | Meteor.setTimeout(function () { 19 | Errors.remove(this.props.error._id); 20 | }, 3000); 21 | }, 22 | 23 | render() { 24 | return
    25 | 26 | {this.props.error.message} 27 |
    28 | } 29 | }) 30 | -------------------------------------------------------------------------------- /client/components/header.jsx: -------------------------------------------------------------------------------- 1 | NavItem = React.createClass({ 2 | render() { 3 | let isActive = FlowRouter.current().path === this.props.href ? ' active' : '' 4 | return (
  • 5 | {this.props.text} 6 |
  • ); 7 | } 8 | }); 9 | 10 | Header = React.createClass({ 11 | mixins: [ReactMeteorData], 12 | 13 | getMeteorData() { 14 | return { 15 | currentUser: Meteor.user() 16 | } 17 | }, 18 | 19 | iconSpans() { 20 | return _.map([1,2,3], (num) => { 21 | return 22 | }) 23 | }, 24 | 25 | componentDidMount() { 26 | var elt = $("#loginButtons")[0] 27 | Blaze.render(Template._loginButtons, elt) 28 | }, 29 | 30 | render() { 31 | return 51 | } 52 | }) 53 | -------------------------------------------------------------------------------- /client/components/notifications.jsx: -------------------------------------------------------------------------------- 1 | NotificationItem = React.createClass({ 2 | 3 | notificationPostPath() { 4 | return FlowRouter.path('postPage', {_id: this.props.notification.postId}); 5 | }, 6 | 7 | onNotificationClicked() { 8 | Notifications.update(this.props.notification._id, {$set: {read: true}}); 9 | }, 10 | 11 | render() { 12 | return ( 13 |
  • 14 | 15 | {this.props.notification.commenterName} 16 | commented on your post 17 | 18 |
  • 19 | ) 20 | } 21 | }) 22 | 23 | // Cannot name it Notifications, since it'll clash with the collection Notifications 24 | NotificationsView = React.createClass({ 25 | mixins: [ReactMeteorData], 26 | 27 | getMeteorData() { 28 | let sub = Meteor.subscribe('notifications') 29 | return { 30 | loaded: sub.ready(), 31 | notifications: Notifications.find({userId: Meteor.userId(), read: false}).fetch(), 32 | notificationsCount: Notifications.find({userId: Meteor.userId(), read: false}).count() 33 | } 34 | }, 35 | badge() { 36 | if(this.data.notificationsCount) 37 | return   38 | {this.data.notificationsCount} 39 |   40 | 41 | }, 42 | notificationItems() { 43 | if(this.data.notificationsCount) { 44 | return _.map(this.data.notifications, (notification) => { 45 | return 46 | }) 47 | } 48 | else { 49 | return
  • No Notifications
  • 50 | } 51 | }, 52 | 53 | render() { 54 | return (
  • 55 | 56 | Notifications 57 | {this.badge()} 58 | 59 | 60 |
      61 | {this.notificationItems()} 62 |
    63 |
  • ) 64 | } 65 | }) -------------------------------------------------------------------------------- /client/components/posts.jsx: -------------------------------------------------------------------------------- 1 | PostItem = React.createClass({ 2 | ownPost() { 3 | return this.props.post.userId == Meteor.userId(); 4 | }, 5 | 6 | domain() { 7 | var a = document.createElement('a'); 8 | a.href = this.props.post.url; 9 | return a.hostname; 10 | }, 11 | 12 | upvotedClass() { 13 | var userId = Meteor.userId(); 14 | if (userId && !_.include(this.props.post.upvoters, userId)) { 15 | return 'btn-primary upvotable'; 16 | } else { 17 | return 'disabled'; 18 | } 19 | }, 20 | 21 | onUpvote(e) { 22 | if(this.upvotedClass().indexOf('upvotable') > -1) 23 | Meteor.call('upvote', this.props.post._id); 24 | }, 25 | 26 | pluralize(n, thing) { 27 | if (n === 1) { 28 | return '1 ' + thing; 29 | } else { 30 | return n + ' ' + thing + 's'; 31 | } 32 | }, 33 | 34 | votesCount() { 35 | return this.pluralize(this.props.post.votes, 'Vote') 36 | }, 37 | 38 | commentsCount() { 39 | return this.pluralize(this.props.post.commentsCount, 'Comment') 40 | }, 41 | 42 | editButton() { 43 | return this.ownPost() ? Edit : '' 44 | }, 45 | 46 | render() { 47 | let upvoteButtonClasses = "upvote btn btn-default " + this.upvotedClass() 48 | let postPagePath = FlowRouter.path('postPage', this.props.post) 49 | return ( 50 |
    51 | 52 |
    53 |

    54 | {this.props.post.title} 55 | {this.domain()} 56 |

    57 |

    58 | {this.votesCount()} submitted by {this.props.post.author},   59 | {this.commentsCount()} 60 | 61 |   {this.editButton()} 62 |

    63 |
    64 | Discuss 65 |
    66 | ) 67 | } 68 | }) 69 | 70 | PostForm = React.createClass({ 71 | mixins: [React.addons.LinkedStateMixin], 72 | 73 | componentWillReceiveProps(nextProps) { 74 | let post = nextProps.post 75 | this.setState({ url: post.url, title: post.title }) 76 | }, 77 | 78 | getInitialState() { 79 | if(this.props.post) 80 | return { url: this.props.post.url, title: this.props.post.title, postSubmitErrors: {}} 81 | return { url: '', title: '', postSubmitErrors: {}} 82 | }, 83 | 84 | onDelete(e) { 85 | e.preventDefault(); 86 | 87 | if (confirm("Delete this post?")) { 88 | var currentPostId = this.props.postId; 89 | Posts.remove(currentPostId); 90 | FlowRouter.go('home'); 91 | } 92 | }, 93 | 94 | submit(e) { 95 | e.stopPropagation(); 96 | e.preventDefault(); 97 | 98 | let postProperties = { 99 | url: this.state.url, 100 | title: this.state.title 101 | } 102 | 103 | let errors = validatePost(postProperties); 104 | if (errors.title || errors.url) 105 | return this.setState({postSubmitErrors: errors}); 106 | 107 | if(this.props.action === 'edit') { 108 | Posts.update(this.props.post._id, {$set: postProperties}, (error) => { 109 | if (error) { 110 | throwError(error.reason); 111 | } else { 112 | FlowRouter.go('postPage', {_id: this.props.post._id}); 113 | } 114 | }); 115 | } 116 | 117 | else { 118 | Meteor.call('postInsert', postProperties, (err, result) => { 119 | if (err) 120 | return throwError(err.reason); 121 | 122 | //show this result but route anyway 123 | if (result.postExists) 124 | throwError('This link has already been posted'); 125 | 126 | FlowRouter.go('postPage', {_id: result._id}); 127 | }) 128 | } 129 | }, 130 | 131 | onInputChange(e) { 132 | this.setState({title: e.target.value }) 133 | }, 134 | 135 | deletePostButton() { 136 | if(this.props.action === 'edit') 137 | return Delete post 138 | }, 139 | 140 | errorMessage(field) { 141 | return this.state.postSubmitErrors[field]; 142 | }, 143 | 144 | errorClass(field) { 145 | return !!this.state.postSubmitErrors[field] ? 'has-error' : ''; 146 | }, 147 | 148 | render() { 149 | return ( 150 |
    151 |
    152 | 153 |
    154 | 156 | {this.errorMessage('url')} 157 |
    158 |
    159 |
    160 | 161 |
    162 | 164 | {this.errorMessage('title')} 165 |
    166 |
    167 | 168 |
    169 | {this.deletePostButton()} 170 |
    171 | ) 172 | } 173 | }) 174 | 175 | NewPost = React.createClass({ 176 | render() { 177 | return 178 | } 179 | }) 180 | 181 | EditPost = React.createClass({ 182 | mixins: [ReactMeteorData], 183 | 184 | getMeteorData() { 185 | Meteor.subscribe('singlePost', this.props.postId) 186 | return { 187 | post: Posts.findOne(this.props.postId) 188 | } 189 | }, 190 | 191 | render() { 192 | return 193 | } 194 | }) 195 | 196 | PostPage = React.createClass({ 197 | mixins: [ReactMeteorData], 198 | 199 | getMeteorData() { 200 | Meteor.subscribe('singlePost', this.props.postId) 201 | Meteor.subscribe('comments', this.props.postId) 202 | return { 203 | post: Posts.findOne(this.props.postId), 204 | comments: Comments.find({postId: this.props.postId }).fetch() 205 | } 206 | }, 207 | 208 | render() { 209 | let commentItems = this.data.comments.map((c) => { 210 | return 211 | }) 212 | 213 | let submitForm 214 | if(Meteor.user()) 215 | submitForm = 216 | else 217 | submitForm =

    Please log in to leave a comment

    218 | 219 | return ( 220 |
    221 | 222 | 223 |
      224 | {commentItems} 225 |
    226 | 227 | {submitForm} 228 |
    229 | ) 230 | } 231 | }) 232 | 233 | PostsList = React.createClass({ 234 | mixins: [ReactMeteorData, SpinnerMixin], 235 | 236 | getInitialState() { 237 | return { postLimit: parseInt(this.props.limit) || this.increment() } 238 | }, 239 | 240 | spinnerWrapper(spinner) { 241 | return
    242 | {spinner} 243 |
    244 | }, 245 | 246 | getMeteorData() { 247 | let modifier = {} 248 | if(this.props.filter === 'new') 249 | _.extend(modifier, { submitted: -1, _id: -1 }) 250 | else 251 | _.extend(modifier, { votes: -1, submitted: -1, _id: -1 }) 252 | 253 | let postsSubscription = Meteor.subscribe('posts', 254 | {sort: modifier, limit: this.state.postLimit }) 255 | return { 256 | subscriptions: [postsSubscription], 257 | posts: Posts.find({}, {sort: modifier}).fetch() 258 | } 259 | }, 260 | 261 | increment() { 262 | return 5 263 | }, 264 | 265 | postsCount() { 266 | return Posts.find().count() 267 | }, 268 | 269 | componentWillReceiveProps(nextProps) { 270 | if(nextProps.filter !== this.props.filter) 271 | this.setState({postLimit: nextProps.limit}) 272 | }, 273 | 274 | postItems() { 275 | return _.map(this.data.posts, (post) => { 276 | return ; 277 | }); 278 | }, 279 | 280 | loadMore() { 281 | if(Posts.find({}, {limit: this.postLimit }).count() === this.state.postLimit) { 282 | return Load more 284 | } 285 | }, 286 | 287 | onLoadMore() { 288 | this.setState({postLimit: this.state.postLimit + this.increment() }) 289 | }, 290 | 291 | render() { 292 | return
    293 |
    294 | {this.postItems()} 295 |
    296 | {this.loadMore()} 297 |
    298 | } 299 | }) 300 | -------------------------------------------------------------------------------- /client/components/root.jsx: -------------------------------------------------------------------------------- 1 | RootView = React.createClass({ 2 | render() { 3 | return
    4 |
    5 |
    6 | 7 |
    8 | {this.props.mainComponent} 9 |
    10 |
    11 |
    12 | } 13 | }) 14 | -------------------------------------------------------------------------------- /client/helpers/config.js: -------------------------------------------------------------------------------- 1 | Accounts.ui.config({ 2 | passwordSignupFields: 'USERNAME_ONLY' 3 | }); -------------------------------------------------------------------------------- /client/helpers/errors.js: -------------------------------------------------------------------------------- 1 | // Local (client-only) collection 2 | Errors = new Mongo.Collection(null); 3 | 4 | throwError = function(message) { 5 | Errors.insert({message: message}) 6 | } -------------------------------------------------------------------------------- /client/helpers/handlebars.js: -------------------------------------------------------------------------------- 1 | Template.registerHelper('pluralize', function(n, thing) { 2 | // fairly stupid pluralizer 3 | if (n === 1) { 4 | return '1 ' + thing; 5 | } else { 6 | return n + ' ' + thing + 's'; 7 | } 8 | }); -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | React Microscope 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | .grid-block, .main, .post, .comments li, .comment-form { 2 | background: #fff; 3 | border-radius: 3px; 4 | padding: 10px; 5 | margin-bottom: 10px; 6 | -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); 7 | -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); 8 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); } 9 | 10 | body { 11 | background: #eee; 12 | color: #666666; } 13 | 14 | #main { 15 | position: relative; 16 | } 17 | .page { 18 | position: absolute; 19 | top: 0px; 20 | width: 100%; 21 | } 22 | 23 | .navbar { 24 | margin-bottom: 10px; } 25 | /* line 32, ../sass/style.scss */ 26 | .navbar .navbar-inner { 27 | border-radius: 0px 0px 3px 3px; } 28 | 29 | #spinner { 30 | height: 300px; } 31 | 32 | .post { 33 | /* For modern browsers */ 34 | /* For IE 6/7 (trigger hasLayout) */ 35 | *zoom: 1; 36 | position: relative; 37 | opacity: 1; } 38 | .post:before, .post:after { 39 | content: ""; 40 | display: table; } 41 | .post:after { 42 | clear: both; } 43 | .post.invisible { 44 | opacity: 0; } 45 | .post.instant { 46 | -webkit-transition: none; 47 | -moz-transition: none; 48 | -o-transition: none; 49 | transition: none; } 50 | .post.animate{ 51 | -webkit-transition: all 300ms 0ms; 52 | -moz-transition: all 300ms 0ms ease-in; 53 | -o-transition: all 300ms 0ms ease-in; 54 | transition: all 300ms 0ms ease-in; } 55 | .post .upvote { 56 | display: block; 57 | margin: 7px 12px 0 0; 58 | float: left; } 59 | .post .post-content { 60 | float: left; } 61 | .post .post-content h3 { 62 | margin: 0; 63 | line-height: 1.4; 64 | font-size: 18px; } 65 | .post .post-content h3 a { 66 | display: inline-block; 67 | margin-right: 5px; } 68 | .post .post-content h3 span { 69 | font-weight: normal; 70 | font-size: 14px; 71 | display: inline-block; 72 | color: #aaaaaa; } 73 | .post .post-content p { 74 | margin: 0; } 75 | .post .discuss { 76 | display: block; 77 | float: right; 78 | margin-top: 7px; } 79 | 80 | .comments { 81 | list-style-type: none; 82 | margin: 0; } 83 | .comments li h4 { 84 | font-size: 16px; 85 | margin: 0; } 86 | .comments li h4 .date { 87 | font-size: 12px; 88 | font-weight: normal; } 89 | .comments li h4 a { 90 | font-size: 12px; } 91 | .comments li p:last-child { 92 | margin-bottom: 0; } 93 | 94 | .dropdown-menu span { 95 | display: block; 96 | padding: 3px 20px; 97 | clear: both; 98 | line-height: 20px; 99 | color: #bbb; 100 | white-space: nowrap; } 101 | 102 | .load-more { 103 | display: block; 104 | border-radius: 3px; 105 | background: rgba(0, 0, 0, 0.05); 106 | text-align: center; 107 | height: 60px; 108 | line-height: 60px; 109 | margin-bottom: 10px; } 110 | .load-more:hover { 111 | text-decoration: none; 112 | background: rgba(0, 0, 0, 0.1); } 113 | 114 | .posts .spinner-container{ 115 | position: relative; 116 | height: 100px; 117 | } 118 | 119 | .jumbotron{ 120 | text-align: center; 121 | } 122 | .jumbotron h2{ 123 | font-size: 60px; 124 | font-weight: 100; 125 | } 126 | 127 | @-webkit-keyframes fadeOut { 128 | 0% {opacity: 0;} 129 | 10% {opacity: 1;} 130 | 90% {opacity: 1;} 131 | 100% {opacity: 0;} 132 | } 133 | 134 | @keyframes fadeOut { 135 | 0% {opacity: 0;} 136 | 10% {opacity: 1;} 137 | 90% {opacity: 1;} 138 | 100% {opacity: 0;} 139 | } 140 | 141 | .errors{ 142 | position: fixed; 143 | z-index: 10000; 144 | padding: 10px; 145 | top: 0px; 146 | left: 0px; 147 | right: 0px; 148 | bottom: 0px; 149 | pointer-events: none; 150 | } 151 | .alert { 152 | animation: fadeOut 2700ms ease-in 0s 1 forwards; 153 | -webkit-animation: fadeOut 2700ms ease-in 0s 1 forwards; 154 | -moz-animation: fadeOut 2700ms ease-in 0s 1 forwards; 155 | width: 250px; 156 | float: right; 157 | clear: both; 158 | margin-bottom: 5px; 159 | pointer-events: auto; 160 | } 161 | -------------------------------------------------------------------------------- /lib/collections/comments.js: -------------------------------------------------------------------------------- 1 | Comments = new Mongo.Collection('comments'); 2 | 3 | Meteor.methods({ 4 | commentInsert: function(commentAttributes) { 5 | check(this.userId, String); 6 | check(commentAttributes, { 7 | postId: String, 8 | body: String 9 | }); 10 | 11 | var user = Meteor.user(); 12 | var post = Posts.findOne(commentAttributes.postId); 13 | 14 | if (!post) 15 | throw new Meteor.Error('invalid-comment', 'You must comment on a post'); 16 | 17 | comment = _.extend(commentAttributes, { 18 | userId: user._id, 19 | author: user.username, 20 | submitted: new Date() 21 | }); 22 | 23 | // update the post with the number of comments 24 | Posts.update(comment.postId, {$inc: {commentsCount: 1}}); 25 | 26 | // create the comment, save the id 27 | comment._id = Comments.insert(comment); 28 | 29 | // now create a notification, informing the user that there's been a comment 30 | createCommentNotification(comment); 31 | 32 | return comment._id; 33 | } 34 | }); 35 | -------------------------------------------------------------------------------- /lib/collections/notifications.js: -------------------------------------------------------------------------------- 1 | Notifications = new Mongo.Collection('notifications'); 2 | 3 | Notifications.allow({ 4 | update: function(userId, doc, fieldNames) { 5 | return ownsDocument(userId, doc) && 6 | fieldNames.length === 1 && fieldNames[0] === 'read'; 7 | } 8 | }); 9 | 10 | createCommentNotification = function(comment) { 11 | var post = Posts.findOne(comment.postId); 12 | if (comment.userId !== post.userId) { 13 | Notifications.insert({ 14 | userId: post.userId, 15 | postId: post._id, 16 | commentId: comment._id, 17 | commenterName: comment.author, 18 | read: false 19 | }); 20 | } 21 | }; -------------------------------------------------------------------------------- /lib/collections/posts.js: -------------------------------------------------------------------------------- 1 | Posts = new Mongo.Collection('posts'); 2 | 3 | Posts.allow({ 4 | update: function(userId, post) { return ownsDocument(userId, post); }, 5 | remove: function(userId, post) { return ownsDocument(userId, post); }, 6 | }); 7 | 8 | Posts.deny({ 9 | update: function(userId, post, fieldNames) { 10 | // may only edit the following two fields: 11 | return (_.without(fieldNames, 'url', 'title').length > 0); 12 | } 13 | }); 14 | 15 | Posts.deny({ 16 | update: function(userId, post, fieldNames, modifier) { 17 | var errors = validatePost(modifier.$set); 18 | return errors.title || errors.url; 19 | } 20 | }); 21 | 22 | validatePost = function (post) { 23 | var errors = {}; 24 | 25 | if (!post.title) 26 | errors.title = "Please fill in a headline"; 27 | 28 | if (!post.url) 29 | errors.url = "Please fill in a URL"; 30 | 31 | return errors; 32 | } 33 | 34 | Meteor.methods({ 35 | postInsert: function(postAttributes) { 36 | // check(this.userId, String); 37 | check(postAttributes, { 38 | title: String, 39 | url: String 40 | }); 41 | 42 | var errors = validatePost(postAttributes); 43 | if (errors.title || errors.url) 44 | throw new Meteor.Error('invalid-post', "You must set a title and URL for your post"); 45 | 46 | var postWithSameLink = Posts.findOne({url: postAttributes.url}); 47 | if (postWithSameLink) { 48 | return { 49 | postExists: true, 50 | _id: postWithSameLink._id 51 | } 52 | } 53 | 54 | var user = Meteor.user(); 55 | var post = _.extend(postAttributes, { 56 | userId: user._id, 57 | author: user.username, 58 | submitted: new Date(), 59 | commentsCount: 0, 60 | upvoters: [], 61 | votes: 0 62 | }); 63 | 64 | var postId = Posts.insert(post); 65 | 66 | return { 67 | _id: postId 68 | }; 69 | }, 70 | 71 | upvote: function(postId) { 72 | check(this.userId, String); 73 | check(postId, String); 74 | 75 | var affected = Posts.update({ 76 | _id: postId, 77 | upvoters: {$ne: this.userId} 78 | }, { 79 | $addToSet: {upvoters: this.userId}, 80 | $inc: {votes: 1} 81 | }); 82 | 83 | if (! affected) 84 | throw new Meteor.Error('invalid', "You weren't able to upvote that post"); 85 | } 86 | }); 87 | -------------------------------------------------------------------------------- /lib/permissions.js: -------------------------------------------------------------------------------- 1 | // check that the userId specified owns the documents 2 | ownsDocument = function(userId, doc) { 3 | return doc && doc.userId === userId; 4 | } -------------------------------------------------------------------------------- /lib/router.jsx: -------------------------------------------------------------------------------- 1 | setMain = (c) => { 2 | ReactLayout.render(RootView, { 3 | mainComponent: c 4 | }) 5 | } 6 | 7 | FlowRouter.route('/', { 8 | name: 'home', 9 | action: (params) => { 10 | setMain() 11 | } 12 | }); 13 | 14 | FlowRouter.route('/new', { 15 | action: (params) => { 16 | setMain() 17 | } 18 | }); 19 | 20 | FlowRouter.route('/best', { 21 | action: (params) => { 22 | setMain() 23 | } 24 | }); 25 | 26 | FlowRouter.route('/submit', { 27 | action: (params) => { 28 | setMain() 29 | } 30 | }); 31 | 32 | FlowRouter.route('/posts/:_id', { 33 | name: 'postPage', 34 | action: (params) => { 35 | setMain() 36 | } 37 | }) 38 | 39 | FlowRouter.route('/posts/:_id/edit', { 40 | name: 'postEdit', 41 | action: (params) => { 42 | setMain() 43 | } 44 | }); -------------------------------------------------------------------------------- /server/fixtures.js: -------------------------------------------------------------------------------- 1 | // Fixture data 2 | if (Posts.find().count() === 0) { 3 | var now = new Date().getTime(); 4 | 5 | // create two users 6 | var tomId = Meteor.users.insert({ 7 | profile: { name: 'Tom Coleman' } 8 | }); 9 | var tom = Meteor.users.findOne(tomId); 10 | var sachaId = Meteor.users.insert({ 11 | profile: { name: 'Sacha Greif' } 12 | }); 13 | var sacha = Meteor.users.findOne(sachaId); 14 | 15 | var telescopeId = Posts.insert({ 16 | title: 'Introducing Telescope', 17 | userId: sacha._id, 18 | author: sacha.profile.name, 19 | url: 'http://sachagreif.com/introducing-telescope/', 20 | submitted: new Date(now - 7 * 3600 * 1000), 21 | commentsCount: 2, 22 | upvoters: [], votes: 0 23 | }); 24 | 25 | Comments.insert({ 26 | postId: telescopeId, 27 | userId: tom._id, 28 | author: tom.profile.name, 29 | submitted: new Date(now - 5 * 3600 * 1000), 30 | body: 'Interesting project Sacha, can I get involved?' 31 | }); 32 | 33 | Comments.insert({ 34 | postId: telescopeId, 35 | userId: sacha._id, 36 | author: sacha.profile.name, 37 | submitted: new Date(now - 3 * 3600 * 1000), 38 | body: 'You sure can Tom!' 39 | }); 40 | 41 | Posts.insert({ 42 | title: 'Meteor', 43 | userId: tom._id, 44 | author: tom.profile.name, 45 | url: 'http://meteor.com', 46 | submitted: new Date(now - 10 * 3600 * 1000), 47 | commentsCount: 0, 48 | upvoters: [], votes: 0 49 | }); 50 | 51 | Posts.insert({ 52 | title: 'The Meteor Book', 53 | userId: tom._id, 54 | author: tom.profile.name, 55 | url: 'http://themeteorbook.com', 56 | submitted: new Date(now - 12 * 3600 * 1000), 57 | commentsCount: 0, 58 | upvoters: [], votes: 0 59 | }); 60 | 61 | for (var i = 0; i < 10; i++) { 62 | Posts.insert({ 63 | title: 'Test post #' + i, 64 | author: sacha.profile.name, 65 | userId: sacha._id, 66 | url: 'http://google.com/?q=test-' + i, 67 | submitted: new Date(now - i * 3600 * 1000 + 1), 68 | commentsCount: 0, 69 | upvoters: [], votes: 0 70 | }); 71 | } 72 | } -------------------------------------------------------------------------------- /server/publications.js: -------------------------------------------------------------------------------- 1 | Meteor.publish('posts', function(options) { 2 | check(options, { 3 | sort: Object, 4 | limit: Number 5 | }); 6 | return Posts.find({}, options); 7 | }); 8 | 9 | Meteor.publish('singlePost', function(id) { 10 | check(id, String); 11 | return Posts.find(id); 12 | }); 13 | 14 | 15 | Meteor.publish('comments', function(postId) { 16 | check(postId, String); 17 | return Comments.find({postId: postId}); 18 | }); 19 | 20 | Meteor.publish('notifications', function() { 21 | return Notifications.find({userId: this.userId, read: false}); 22 | }); 23 | --------------------------------------------------------------------------------