├── .meteor ├── .gitignore ├── packages └── release ├── README.markdown ├── client ├── helpers │ ├── config.js │ ├── errors.js │ ├── handlebars.js │ └── router.js ├── main.html ├── main.js ├── stylesheets │ └── style.css └── views │ ├── comments │ ├── comment.html │ ├── comment.js │ ├── comment_submit.html │ └── comment_submit.js │ ├── includes │ ├── access_denied.html │ ├── errors.html │ ├── errors.js │ ├── header.html │ ├── header.js │ └── loading.html │ ├── notifications │ ├── notifications.html │ └── notifications.js │ └── posts │ ├── post_edit.html │ ├── post_edit.js │ ├── post_item.html │ ├── post_item.js │ ├── post_page.html │ ├── post_page.js │ ├── post_submit.html │ ├── post_submit.js │ ├── posts_list.html │ └── posts_list.js ├── collections ├── comments.js ├── notifications.js └── posts.js ├── lib └── permissions.js ├── server ├── fixtures.js └── publications.js ├── smart.json └── smart.lock /.meteor/.gitignore: -------------------------------------------------------------------------------- 1 | local 2 | meteorite 3 | -------------------------------------------------------------------------------- /.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 | preserve-inputs 7 | bootstrap 8 | router 9 | accounts-ui-bootstrap-dropdown 10 | accounts-password 11 | spin 12 | paginated-subscription 13 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | none 2 | -------------------------------------------------------------------------------- /README.markdown: -------------------------------------------------------------------------------- 1 | # Microscope 2 | 3 | Microscope is a simple social news app that lets you share links, comment, and vote on them. 4 | 5 | It was built with [Meteor](http://meteor.com) as a companion app to [The Meteor Book](http://themeteorbook.com), and is the "little brother" of [Telescope](http://telesc.pe), the (much more complex) open-source social news app that was the inspiration for the book. 6 | 7 | Microscope itself is free and open-source, and is a good example of common Meteor app patterns such as: 8 | 9 | - Routing 10 | - User Accounts 11 | - Notifications 12 | - Errors 13 | - Publications/Subscriptions 14 | - Permissions 15 | 16 | ## This Repository 17 | 18 | The commits to this repository are organized in a very linear fashion, corresponding to progress throughout the book. Commits are tagged in the format `chapterX-Y`, indicating the `Y`th commit of chapter `X`. 19 | 20 | Also, note that as the book focuses on _development_, all CSS is committed in a single commit early on. 21 | 22 | ### Branches 23 | 24 | There are 2 branches in this repository which correspond to advanced code that is covered in sidebars of the book, and outside of the main code progression. They are tagged `sidebarX-Y`, corresponding to the sidebar number in the book. -------------------------------------------------------------------------------- /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 Meteor.Collection(null); 3 | 4 | throwError = function(message) { 5 | Errors.insert({message: message, seen: false}) 6 | } 7 | 8 | clearErrors = function() { 9 | Errors.remove({seen: true}); 10 | } -------------------------------------------------------------------------------- /client/helpers/handlebars.js: -------------------------------------------------------------------------------- 1 | Handlebars.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/helpers/router.js: -------------------------------------------------------------------------------- 1 | Meteor.Router.add({ 2 | '/': {to: 'newPosts', as: 'home'}, 3 | '/best': 'bestPosts', 4 | '/new': 'newPosts', 5 | 6 | '/posts/:_id': { 7 | to: 'postPage', 8 | and: function(id) { Session.set('currentPostId', id); } 9 | }, 10 | 11 | '/posts/:_id/edit': { 12 | to: 'postEdit', 13 | and: function(id) { Session.set('currentPostId', id); } 14 | }, 15 | 16 | '/submit': 'postSubmit' 17 | }); 18 | 19 | Meteor.Router.filters({ 20 | 'requireLogin': function(page) { 21 | if (Meteor.user()) 22 | return page; 23 | else if (Meteor.loggingIn()) 24 | return 'loading'; 25 | else 26 | return 'accessDenied'; 27 | }, 28 | 'clearErrors': function(page) { 29 | clearErrors(); 30 | return page; 31 | } 32 | }); 33 | 34 | Meteor.Router.filter('requireLogin', {only: 'postSubmit'}); 35 | Meteor.Router.filter('clearErrors'); 36 | -------------------------------------------------------------------------------- /client/main.html: -------------------------------------------------------------------------------- 1 | 2 | Microscope 3 | 4 | 5 |
6 | {{> header}} 7 | {{> errors}} 8 |
9 | {{renderPage}} 10 |
11 |
12 | -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | newPostsHandle = Meteor.subscribeWithPagination('newPosts', 10); 2 | bestPostsHandle = Meteor.subscribeWithPagination('bestPosts', 10); 3 | 4 | Deps.autorun(function() { 5 | Meteor.subscribe('singlePost', Session.get('currentPostId')); 6 | 7 | Meteor.subscribe('comments', Session.get('currentPostId')); 8 | }) 9 | 10 | Meteor.subscribe('notifications'); 11 | -------------------------------------------------------------------------------- /client/stylesheets/style.css: -------------------------------------------------------------------------------- 1 | .grid-block, .main, .post, .comments li, .comment-form { 2 | background: #fff; 3 | -webkit-border-radius: 3px; 4 | -moz-border-radius: 3px; 5 | -ms-border-radius: 3px; 6 | -o-border-radius: 3px; 7 | border-radius: 3px; 8 | padding: 10px; 9 | margin-bottom: 10px; 10 | -webkit-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); 11 | -moz-box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); 12 | box-shadow: 0 1px 1px rgba(0, 0, 0, 0.15); } 13 | 14 | body { 15 | background: #eee; 16 | color: #666666; } 17 | 18 | .navbar { 19 | margin-bottom: 10px; } 20 | /* line 32, ../sass/style.scss */ 21 | .navbar .navbar-inner { 22 | -webkit-border-radius: 0px 0px 3px 3px; 23 | -moz-border-radius: 0px 0px 3px 3px; 24 | -ms-border-radius: 0px 0px 3px 3px; 25 | -o-border-radius: 0px 0px 3px 3px; 26 | border-radius: 0px 0px 3px 3px; } 27 | 28 | #spinner { 29 | height: 300px; } 30 | 31 | .post { 32 | /* For modern browsers */ 33 | /* For IE 6/7 (trigger hasLayout) */ 34 | *zoom: 1; 35 | -webkit-transition: all 300ms 0ms; 36 | -webkit-transition-delay: ease-in; 37 | -moz-transition: all 300ms 0ms ease-in; 38 | -o-transition: all 300ms 0ms ease-in; 39 | transition: all 300ms 0ms ease-in; 40 | position: relative; 41 | opacity: 1; } 42 | .post:before, .post:after { 43 | content: ""; 44 | display: table; } 45 | .post:after { 46 | clear: both; } 47 | .post.invisible { 48 | opacity: 0; } 49 | .post .upvote { 50 | display: block; 51 | margin: 7px 12px 0 0; 52 | float: left; } 53 | .post .post-content { 54 | float: left; } 55 | .post .post-content h3 { 56 | margin: 0; 57 | line-height: 1.4; 58 | font-size: 18px; } 59 | .post .post-content h3 a { 60 | display: inline-block; 61 | margin-right: 5px; } 62 | .post .post-content h3 span { 63 | font-weight: normal; 64 | font-size: 14px; 65 | display: inline-block; 66 | color: #aaaaaa; } 67 | .post .post-content p { 68 | margin: 0; } 69 | .post .discuss { 70 | display: block; 71 | float: right; 72 | margin-top: 7px; } 73 | 74 | .comments { 75 | list-style-type: none; 76 | margin: 0; } 77 | .comments li h4 { 78 | font-size: 16px; 79 | margin: 0; } 80 | .comments li h4 .date { 81 | font-size: 12px; 82 | font-weight: normal; } 83 | .comments li h4 a { 84 | font-size: 12px; } 85 | .comments li p:last-child { 86 | margin-bottom: 0; } 87 | 88 | .dropdown-menu span { 89 | display: block; 90 | padding: 3px 20px; 91 | clear: both; 92 | line-height: 20px; 93 | color: #bbb; 94 | white-space: nowrap; } 95 | 96 | .load-more { 97 | display: block; 98 | -webkit-border-radius: 3px; 99 | -moz-border-radius: 3px; 100 | -ms-border-radius: 3px; 101 | -o-border-radius: 3px; 102 | border-radius: 3px; 103 | background: rgba(0, 0, 0, 0.05); 104 | text-align: center; 105 | height: 60px; 106 | line-height: 60px; 107 | margin-bottom: 10px; } 108 | .load-more:hover { 109 | text-decoration: none; 110 | background: rgba(0, 0, 0, 0.1); } 111 | -------------------------------------------------------------------------------- /client/views/comments/comment.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/comments/comment.js: -------------------------------------------------------------------------------- 1 | Template.comment.helpers({ 2 | submittedText: function() { 3 | return new Date(this.submitted).toString(); 4 | } 5 | }); -------------------------------------------------------------------------------- /client/views/comments/comment_submit.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/comments/comment_submit.js: -------------------------------------------------------------------------------- 1 | Template.commentSubmit.events({ 2 | 'submit form': function(event, template) { 3 | event.preventDefault(); 4 | 5 | var comment = { 6 | body: $(event.target).find('[name=body]').val(), 7 | postId: template.data._id 8 | }; 9 | 10 | Meteor.call('comment', comment, function(error, commentId) { 11 | error && throwError(error.reason); 12 | }); 13 | } 14 | }); -------------------------------------------------------------------------------- /client/views/includes/access_denied.html: -------------------------------------------------------------------------------- 1 | 4 | -------------------------------------------------------------------------------- /client/views/includes/errors.html: -------------------------------------------------------------------------------- 1 | 8 | 9 | -------------------------------------------------------------------------------- /client/views/includes/errors.js: -------------------------------------------------------------------------------- 1 | Template.errors.helpers({ 2 | errors: function() { 3 | return Errors.find(); 4 | } 5 | }); 6 | 7 | Template.error.rendered = function() { 8 | var error = this.data; 9 | Meteor.defer(function() { 10 | Errors.update(error._id, {$set: {seen: true}}); 11 | }); 12 | }; -------------------------------------------------------------------------------- /client/views/includes/header.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/includes/header.js: -------------------------------------------------------------------------------- 1 | Template.header.helpers({ 2 | activeRouteClass: function(/* route names */) { 3 | var args = Array.prototype.slice.call(arguments, 0); 4 | args.pop(); 5 | 6 | var active = _.any(args, function(name) { 7 | return location.pathname === Meteor.Router[name + 'Path'](); 8 | }); 9 | 10 | return active && 'active'; 11 | } 12 | }); -------------------------------------------------------------------------------- /client/views/includes/loading.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/notifications/notifications.html: -------------------------------------------------------------------------------- 1 | 19 | 20 | -------------------------------------------------------------------------------- /client/views/notifications/notifications.js: -------------------------------------------------------------------------------- 1 | Template.notifications.helpers({ 2 | notifications: function() { 3 | return Notifications.find({userId: Meteor.userId(), read: false}); 4 | }, 5 | notificationCount: function(){ 6 | return Notifications.find({userId: Meteor.userId(), read: false}).count(); 7 | } 8 | }); 9 | 10 | Template.notification.events({ 11 | 'click a': function() { 12 | Notifications.update(this._id, {$set: {read: true}}); 13 | } 14 | }) -------------------------------------------------------------------------------- /client/views/posts/post_edit.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/posts/post_edit.js: -------------------------------------------------------------------------------- 1 | Template.postEdit.helpers({ 2 | post: function() { 3 | return Posts.findOne(Session.get('currentPostId')); 4 | } 5 | }); 6 | 7 | Template.postEdit.events({ 8 | 'submit form': function(e) { 9 | e.preventDefault(); 10 | 11 | var currentPostId = Session.get('currentPostId'); 12 | 13 | var postProperties = { 14 | url: $(e.target).find('[name=url]').val(), 15 | title: $(e.target).find('[name=title]').val() 16 | } 17 | 18 | Posts.update(currentPostId, {$set: postProperties}, function(error) { 19 | if (error) { 20 | // display the error to the user 21 | throwError(error.reason); 22 | } else { 23 | Meteor.Router.to('postPage', currentPostId); 24 | } 25 | }); 26 | }, 27 | 28 | 'click .delete': function(e) { 29 | e.preventDefault(); 30 | 31 | if (confirm("Delete this post?")) { 32 | var currentPostId = Session.get('currentPostId'); 33 | Posts.remove(currentPostId); 34 | Meteor.Router.to('postsList'); 35 | } 36 | } 37 | }); 38 | -------------------------------------------------------------------------------- /client/views/posts/post_item.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/posts/post_item.js: -------------------------------------------------------------------------------- 1 | Template.postItem.helpers({ 2 | ownPost: function() { 3 | return this.userId == Meteor.userId(); 4 | }, 5 | domain: function() { 6 | var a = document.createElement('a'); 7 | a.href = this.url; 8 | return a.hostname; 9 | }, 10 | upvotedClass: function() { 11 | var userId = Meteor.userId(); 12 | if (userId && !_.include(this.upvoters, userId)) { 13 | return 'btn-primary upvoteable'; 14 | } else { 15 | return 'disabled'; 16 | } 17 | } 18 | }); 19 | 20 | Template.postItem.rendered = function(){ 21 | // animate post from previous position to new position 22 | var instance = this; 23 | var rank = instance.data._rank; 24 | var $this = $(this.firstNode); 25 | var postHeight = 80; 26 | var newPosition = rank * postHeight; 27 | 28 | // if element has a currentPosition (i.e. it's not the first ever render) 29 | if (typeof(instance.currentPosition) !== 'undefined') { 30 | var previousPosition = instance.currentPosition; 31 | // calculate difference between old position and new position and send element there 32 | var delta = previousPosition - newPosition; 33 | $this.css("top", delta + "px"); 34 | } else { 35 | // it's the first ever render, so hide element 36 | $this.addClass("invisible"); 37 | } 38 | 39 | // let it draw in the old position, then.. 40 | Meteor.defer(function() { 41 | instance.currentPosition = newPosition; 42 | // bring element back to its new original position 43 | $this.css("top", "0px").removeClass("invisible"); 44 | }); 45 | }; 46 | 47 | Template.postItem.events({ 48 | 'click .upvoteable': function(event) { 49 | event.preventDefault(); 50 | Meteor.call('upvote', this._id); 51 | } 52 | }); -------------------------------------------------------------------------------- /client/views/posts/post_page.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /client/views/posts/post_page.js: -------------------------------------------------------------------------------- 1 | Template.postPage.helpers({ 2 | currentPost: function() { 3 | return Posts.findOne(Session.get('currentPostId')); 4 | }, 5 | comments: function() { 6 | return Comments.find({postId: this._id}); 7 | } 8 | }); -------------------------------------------------------------------------------- /client/views/posts/post_submit.html: -------------------------------------------------------------------------------- 1 |