├── .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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------