├── .eslintrc ├── .gitignore ├── .meteor ├── .finished-upgraders ├── .gitignore ├── .id ├── packages ├── platforms ├── release └── versions ├── CONTRIBUTING.md ├── README.md ├── client ├── lib │ ├── main.js │ ├── spacebars.js │ └── subscriptions.js ├── stylesheets │ ├── base │ │ └── base.import.less │ ├── main.less │ └── modules │ │ ├── footer.import.less │ │ ├── header.import.less │ │ ├── jobs.import.less │ │ ├── messages.import.less │ │ ├── navigation.import.less │ │ ├── posts.import.less │ │ ├── useraccounts.import.less │ │ └── users.import.less └── views │ ├── feed.html │ ├── feed.js │ ├── footer.html │ ├── head.html │ ├── header.html │ ├── header.js │ ├── jobBoard.html │ ├── jobBoard.js │ ├── layout.html │ ├── layout.js │ ├── messages.html │ ├── messages.js │ ├── shared │ ├── _follow_button.html │ ├── _follow_button.js │ ├── _navigation.html │ ├── _navigation.js │ ├── _post.html │ ├── _post.js │ └── _small_profile.html │ └── users │ ├── browse_users.html │ ├── browse_users.js │ ├── follower.html │ ├── follower.js │ ├── following.html │ ├── following.js │ ├── profile.html │ ├── profile.js │ ├── update_profile.html │ └── update_profile.js ├── collections ├── jobs.js ├── messages.js ├── posts.js └── users.js ├── lib ├── accounts │ └── config.js ├── avatars │ └── config.js └── router.js ├── license.md ├── public └── img │ ├── facebook.svg │ ├── github.svg │ ├── googlePlus.svg │ ├── instagram.svg │ ├── linkedin.svg │ ├── logo-gravity.png │ ├── twitter.svg │ └── youtube.svg ├── screenshot-1.png └── server ├── indexes.js ├── publications.js └── startup.js /.eslintrc: -------------------------------------------------------------------------------- 1 | /* Meteor's ES6 Javascript linting configuration 2 | * 3 | * Documentation on rules can be found at: 4 | * http://eslint.org/docs/rules/ <- Optionally append the rulename 5 | * 6 | */ 7 | { 8 | "env": { 9 | "browser": true, 10 | "meteor": true, 11 | "node": true 12 | }, 13 | "ecmaFeatures": { 14 | "arrowFunctions": true, 15 | "blockBindings": true, 16 | "classes": true, 17 | "defaultParams": true, 18 | "destructuring": true, 19 | "forOf": true, 20 | "generators": false, 21 | "modules": true, 22 | "objectLiteralComputedProperties": true, 23 | "objectLiteralDuplicateProperties": false, 24 | "objectLiteralShorthandMethods": true, 25 | "objectLiteralShorthandProperties": true, 26 | "restParams": true, 27 | "spread": true, 28 | "superInFunctions": true, 29 | "templateStrings": true, 30 | "jsx": true 31 | }, 32 | "rules": { 33 | 34 | /** 35 | * Meteor Specific 36 | */ 37 | // babel inserts "use strict"; for us 38 | // http://eslint.org/docs/rules/strict 39 | "strict": [2, "never"], 40 | 41 | // allows certain non-constructor functions to start with a capital letter 42 | "new-cap": [2, { // http://eslint.org/docs/rules/new-cap 43 | "capIsNewExceptions": [ 44 | "Match", "Any", "Object", "ObjectIncluding", "OneOf", "Optional", "Where" 45 | ] 46 | }], 47 | 48 | /** 49 | * ES6 Specific 50 | */ 51 | "arrow-parens": 0, // http://eslint.org/docs/rules/arrow-parens 52 | "arrow-spacing": 2, // http://eslint.org/docs/rules/arrow-spacing 53 | "constructor-super": 2, // http://eslint.org/docs/rules/constructor-super 54 | "generator-star-spacing": 2, // http://eslint.org/docs/rules/generator-star-spacing 55 | "no-class-assign": 2, // http://eslint.org/docs/rules/no-class-assign 56 | "no-const-assign": 2, // http://eslint.org/docs/rules/no-const-assign 57 | /* “no-dupe-class-members": 2,*/ // http://eslint.org/docs/rules/no-dupe-class-members 58 | "no-this-before-super": 2, // http://eslint.org/docs/rules/no-this-before-super 59 | "no-var": 2, // http://eslint.org/docs/rules/no-var 60 | "object-shorthand": 0, // http://eslint.org/docs/rules/object-shorthand 61 | /* “prefer-arrow-callback": 1,*/ // http://eslint.org/docs/rules/prefer-arrow-callback 62 | "prefer-const": 2, // http://eslint.org/docs/rules/prefer-const 63 | "prefer-spread": 2, // http://eslint.org/docs/rules/prefer-spread 64 | /* “prefer-template": 2,*/ // http://eslint.org/docs/rules/prefer-template 65 | "require-yield": 2, // http://eslint.org/docs/rules/require-yield 66 | 67 | /** 68 | * Variables 69 | */ 70 | "no-shadow": 2, // http://eslint.org/docs/rules/no-shadow 71 | "no-shadow-restricted-names": 2, // http://eslint.org/docs/rules/no-shadow-restricted-names 72 | "no-unused-vars": [2, { // http://eslint.org/docs/rules/no-unused-vars 73 | "vars": "local", 74 | "args": "after-used" 75 | }], 76 | "no-use-before-define": [2, "nofunc"], // http://eslint.org/docs/rules/no-use-before-define 77 | 78 | /** 79 | * Possible errors 80 | */ 81 | "block-scoped-var": 0, // http://eslint.org/docs/rules/block-scoped-var 82 | "no-alert": 1, // http://eslint.org/docs/rules/no-alert 83 | "no-cond-assign": [1, "always"], // http://eslint.org/docs/rules/no-cond-assign 84 | "no-console": 1, // http://eslint.org/docs/rules/no-console 85 | "no-constant-condition": 1, // http://eslint.org/docs/rules/no-constant-condition 86 | "no-debugger": 1, // http://eslint.org/docs/rules/no-debugger 87 | "no-dupe-keys": 2, // http://eslint.org/docs/rules/no-dupe-keys 88 | "no-duplicate-case": 2, // http://eslint.org/docs/rules/no-duplicate-case 89 | "no-empty": 2, // http://eslint.org/docs/rules/no-empty 90 | "no-empty-label": 2, // http://eslint.org/docs/rules/no-empty-label 91 | "no-ex-assign": 2, // http://eslint.org/docs/rules/no-ex-assign 92 | "no-extra-boolean-cast": 0, // http://eslint.org/docs/rules/no-extra-boolean-cast 93 | "no-extra-semi": 2, // http://eslint.org/docs/rules/no-extra-semi 94 | "no-func-assign": 2, // http://eslint.org/docs/rules/no-func-assign 95 | "no-inner-declarations": 2, // http://eslint.org/docs/rules/no-inner-declarations 96 | "no-invalid-regexp": 2, // http://eslint.org/docs/rules/no-invalid-regexp 97 | "no-irregular-whitespace": 2, // http://eslint.org/docs/rules/no-irregular-whitespace 98 | "no-obj-calls": 2, // http://eslint.org/docs/rules/no-obj-calls 99 | "no-sparse-arrays": 2, // http://eslint.org/docs/rules/no-sparse-arrays 100 | "no-undef": 2, // http://eslint.org/docs/rules/no-undef 101 | "no-unreachable": 2, // http://eslint.org/docs/rules/no-unreachable 102 | "quote-props": [1, "as-needed"], // http://eslint.org/docs/rules/quote-props 103 | "use-isnan": 2, // http://eslint.org/docs/rules/use-isnan 104 | 105 | /** 106 | * Best practices 107 | */ 108 | "consistent-return": 2, // http://eslint.org/docs/rules/consistent-return 109 | "curly": [2, "multi-line"], // http://eslint.org/docs/rules/curly 110 | "default-case": 2, // http://eslint.org/docs/rules/default-case 111 | "dot-notation": [2, { // http://eslint.org/docs/rules/dot-notation 112 | "allowKeywords": true 113 | }], 114 | "eqeqeq": 2, // http://eslint.org/docs/rules/eqeqeq 115 | "guard-for-in": 2, // http://eslint.org/docs/rules/guard-for-in 116 | "max-len": [1, 100, 2, { // http://eslint.org/docs/rules/max-len 117 | "ignoreUrls": true, "ignorePattern": "['\"]" 118 | }], 119 | "no-caller": 2, // http://eslint.org/docs/rules/no-caller 120 | "no-else-return": 2, // http://eslint.org/docs/rules/no-else-return 121 | "no-eq-null": 2, // http://eslint.org/docs/rules/no-eq-null 122 | "no-eval": 2, // http://eslint.org/docs/rules/no-eval 123 | "no-extend-native": 2, // http://eslint.org/docs/rules/no-extend-native 124 | "no-extra-bind": 2, // http://eslint.org/docs/rules/no-extra-bind 125 | "no-fallthrough": 2, // http://eslint.org/docs/rules/no-fallthrough 126 | "no-floating-decimal": 2, // http://eslint.org/docs/rules/no-floating-decimal 127 | "no-implied-eval": 2, // http://eslint.org/docs/rules/no-implied-eval 128 | "no-iterator": 2, // http://eslint.org/docs/rules/no-iterator 129 | "no-label-var": 2, // http://eslint.org/docs/rules/no-label-var 130 | "no-lone-blocks": 2, // http://eslint.org/docs/rules/no-lone-blocks 131 | "no-loop-func": 2, // http://eslint.org/docs/rules/no-loop-func 132 | "no-multi-spaces": 2, // http://eslint.org/docs/rules/no-multi-spaces 133 | "no-multi-str": 2, // http://eslint.org/docs/rules/no-multi-str 134 | "no-native-reassign": 2, // http://eslint.org/docs/rules/no-native-reassign 135 | "no-new": 2, // http://eslint.org/docs/rules/no-new 136 | "no-new-func": 2, // http://eslint.org/docs/rules/no-new-func 137 | "no-new-wrappers": 2, // http://eslint.org/docs/rules/no-new-wrappers 138 | "no-octal": 2, // http://eslint.org/docs/rules/no-octal 139 | "no-octal-escape": 2, // http://eslint.org/docs/rules/no-octal-escape 140 | "no-param-reassign": 0, // http://eslint.org/docs/rules/no-param-reassign 141 | "no-process-exit": 2, // http://eslint.org/docs/rules/no-process-exit 142 | "no-proto": 2, // http://eslint.org/docs/rules/no-proto 143 | "no-redeclare": 2, // http://eslint.org/docs/rules/no-redeclare 144 | "no-return-assign": 2, // http://eslint.org/docs/rules/no-return-assign 145 | "no-script-url": 2, // http://eslint.org/docs/rules/no-script-url 146 | "no-self-compare": 2, // http://eslint.org/docs/rules/no-self-compare 147 | "no-sequences": 2, // http://eslint.org/docs/rules/no-sequences 148 | "no-throw-literal": 2, // http://eslint.org/docs/rules/no-throw-literal 149 | "no-with": 2, // http://eslint.org/docs/rules/no-with 150 | "radix": 2, // http://eslint.org/docs/rules/radix 151 | "vars-on-top": 0, // http://eslint.org/docs/rules/vars-on-top 152 | "wrap-iife": [2, "any"], // http://eslint.org/docs/rules/wrap-iife 153 | "yoda": 2, // http://eslint.org/docs/rules/yoda 154 | 155 | /** 156 | * Style 157 | */ 158 | "indent": [2, 2], // http://eslint.org/docs/rules/indent 159 | "brace-style": [1, // http://eslint.org/docs/rules/brace-style 160 | "1tbs", { 161 | "allowSingleLine": true 162 | }], 163 | "quotes": [ 164 | 1, "single", "avoid-escape" // http://eslint.org/docs/rules/quotes 165 | ], 166 | "camelcase": [2, { // http://eslint.org/docs/rules/camelcase 167 | "properties": "never" 168 | }], 169 | "comma-spacing": [2, { // http://eslint.org/docs/rules/comma-spacing 170 | "before": false, 171 | "after": true 172 | }], 173 | "comma-style": [2, "last"], // http://eslint.org/docs/rules/comma-style 174 | "eol-last": 2, // http://eslint.org/docs/rules/eol-last 175 | "func-names": 0, // http://eslint.org/docs/rules/func-names 176 | "func-style": 0, // http://eslint.org/docs/rules/func-style 177 | "key-spacing": [2, { // http://eslint.org/docs/rules/key-spacing 178 | "beforeColon": false, 179 | "afterColon": true 180 | }], 181 | "new-parens": 1, // http://eslint.org/docs/rules/new-parens 182 | "no-multiple-empty-lines": [2, { // http://eslint.org/docs/rules/no-multiple-empty-lines 183 | "max": 2 184 | }], 185 | "no-nested-ternary": 2, // http://eslint.org/docs/rules/no-nested-ternary 186 | "no-new-object": 2, // http://eslint.org/docs/rules/no-new-object 187 | "no-array-constructor": 2, // http://eslint.org/docs/rules/no-array-constructor 188 | "no-spaced-func": 2, // http://eslint.org/docs/rules/no-spaced-func 189 | "no-trailing-spaces": 2, // http://eslint.org/docs/rules/no-trailing-spaces 190 | "no-extra-parens": 2, // http://eslint.org/docs/rules/no-extra-parens 191 | "no-underscore-dangle": 0, // http://eslint.org/docs/rules/no-underscore-dangle 192 | "one-var": 0, // http://eslint.org/docs/rules/one-var 193 | "padded-blocks": [2, "never"], // http://eslint.org/docs/rules/padded-blocks 194 | "semi": [2, "always"], // http://eslint.org/docs/rules/semi 195 | "semi-spacing": [2, { // http://eslint.org/docs/rules/semi-spacing 196 | "before": false, 197 | "after": true 198 | }], 199 | "space-after-keywords": 2, // http://eslint.org/docs/rules/space-after-keywords 200 | "space-before-blocks": 2, // http://eslint.org/docs/rules/space-before-blocks 201 | "space-before-function-paren": [ // http://eslint.org/docs/rules/space-before-function-paren 202 | 2, "never" 203 | ], 204 | "space-infix-ops": 2, // http://eslint.org/docs/rules/space-infix-ops 205 | "space-return-throw-case": 2, // http://eslint.org/docs/rules/space-return-throw-case 206 | "space-unary-ops": 2, // http://eslint.org/docs/rules/space-unary-ops 207 | "spaced-comment": 2 // http://eslint.org/docs/rules/spaced-comment 208 | } 209 | } 210 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ################ Meteor ################ 2 | .meteor/local 3 | packages/* 4 | 5 | ################ Sublime Text ################ 6 | # workspace files are user-specific 7 | *.sublime-workspace 8 | 9 | # project files should be checked into the repository, unless a significant 10 | # proportion of contributors will probably not be using SublimeText 11 | # *.sublime-project 12 | 13 | #sftp configuration file 14 | sftp-config.json 15 | 16 | ################ JetBrains ################ 17 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm 18 | 19 | ## Directory-based project format 20 | .idea/ 21 | # if you remove the above rule, at least ignore user-specific stuff: 22 | # .idea/workspace.xml 23 | # .idea/tasks.xml 24 | # and these sensitive or high-churn files: 25 | # .idea/dataSources.ids 26 | # .idea/dataSources.xml 27 | # .idea/sqlDataSources.xml 28 | # .idea/dynamic.xml 29 | 30 | ## File-based project format 31 | *.ipr 32 | *.iws 33 | *.iml 34 | 35 | ## Additional for IntelliJ 36 | out/ 37 | 38 | # generated by mpeltonen/sbt-idea plugin 39 | .idea_modules/ 40 | 41 | # generated by JIRA plugin 42 | atlassian-ide-plugin.xml 43 | 44 | # generated by Crashlytics plugin (for Android Studio and Intellij) 45 | com_crashlytics_export_strings.xml 46 | 47 | ################ Mac OS X ################ 48 | .DS_Store 49 | .AppleDouble 50 | .LSOverride 51 | 52 | # Icon must end with two \r 53 | Icon 54 | 55 | 56 | # Thumbnails 57 | ._* 58 | 59 | # Files that might appear on external disk 60 | .Spotlight-V100 61 | .Trashes 62 | 63 | # Directories potentially created on remote AFP share 64 | .AppleDB 65 | .AppleDesktop 66 | Network Trash Folder 67 | Temporary Items 68 | .apdisk 69 | 70 | ################ Linux ################ 71 | *~ 72 | 73 | # KDE directory preferences 74 | .directory 75 | 76 | ################ Windows ################ 77 | # Windows image file caches 78 | Thumbs.db 79 | ehthumbs.db 80 | 81 | # Folder config file 82 | Desktop.ini 83 | 84 | # Recycle Bin used on file shares 85 | $RECYCLE.BIN/ 86 | 87 | # Windows Installer files 88 | *.cab 89 | *.msi 90 | *.msm 91 | *.msp 92 | -------------------------------------------------------------------------------- /.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 | -------------------------------------------------------------------------------- /.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 | 16aaz3z194r0rr8u8tk9 8 | -------------------------------------------------------------------------------- /.meteor/packages: -------------------------------------------------------------------------------- 1 | # Meteor packages used by this project, one per line. 2 | # Check this file (and the other files in this directory) into your repository. 3 | # 4 | # 'meteor add' and 'meteor remove' will edit this file for you, 5 | # but you can also edit it by hand. 6 | 7 | meteor-base # Packages every Meteor app needs to have 8 | mobile-experience # Packages for a great mobile UX 9 | mongo # The database Meteor supports right now 10 | blaze-html-templates # Compile .html files into Meteor Blaze views 11 | session # Client-side reactive dictionary for your app 12 | jquery # Helpful client-side library 13 | tracker # Meteor's client-side reactive programming library 14 | 15 | standard-minifiers # JS/CSS minifiers run for production mode 16 | es5-shim # ECMAScript 5 compatibility for older browsers. 17 | ecmascript # Enable ECMAScript2015+ syntax in app code 18 | 19 | check 20 | audit-argument-checks 21 | skeleton:skeleton 22 | accounts-base 23 | accounts-password 24 | useraccounts:unstyled 25 | useraccounts:flow-routing 26 | kadira:flow-router 27 | kadira:blaze-layout 28 | arillo:flow-router-helpers 29 | themeteorchef:bert 30 | reactive-var 31 | momentjs:moment 32 | reywood:publish-composite 33 | fortawesome:fontawesome 34 | utilities:avatar 35 | tmeasday:publish-counts 36 | verron:autosize 37 | mizzao:user-status 38 | dburles:eslint 39 | kevohagan:sweetalert 40 | mizzao:autocomplete 41 | markdown 42 | simple:highlight.js 43 | less 44 | -------------------------------------------------------------------------------- /.meteor/platforms: -------------------------------------------------------------------------------- 1 | server 2 | browser 3 | -------------------------------------------------------------------------------- /.meteor/release: -------------------------------------------------------------------------------- 1 | METEOR@1.2.1 2 | -------------------------------------------------------------------------------- /.meteor/versions: -------------------------------------------------------------------------------- 1 | accounts-base@1.2.2 2 | accounts-password@1.1.4 3 | arillo:flow-router-helpers@0.4.5 4 | audit-argument-checks@1.0.4 5 | autoupdate@1.2.4 6 | babel-compiler@5.8.24_1 7 | babel-runtime@0.1.4 8 | base64@1.0.4 9 | binary-heap@1.0.4 10 | blaze@2.1.3 11 | blaze-html-templates@1.0.1 12 | blaze-tools@1.0.4 13 | boilerplate-generator@1.0.4 14 | caching-compiler@1.0.0 15 | caching-html-compiler@1.0.2 16 | callback-hook@1.0.4 17 | check@1.1.0 18 | coffeescript@1.0.11 19 | cosmos:browserify@0.5.1 20 | dandv:caret-position@2.1.1 21 | dburles:eslint@1.0.1 22 | ddp@1.2.2 23 | ddp-client@1.2.1 24 | ddp-common@1.2.2 25 | ddp-rate-limiter@1.0.0 26 | ddp-server@1.2.2 27 | deps@1.0.9 28 | diff-sequence@1.0.1 29 | ecmascript@0.1.6 30 | ecmascript-runtime@0.2.6 31 | ejson@1.0.7 32 | email@1.0.8 33 | es5-shim@4.1.14 34 | fastclick@1.0.7 35 | fortawesome:fontawesome@4.4.0 36 | fourseven:scss@3.3.3_3 37 | geojson-utils@1.0.4 38 | hot-code-push@1.0.0 39 | html-tools@1.0.5 40 | htmljs@1.0.5 41 | http@1.1.1 42 | id-map@1.0.4 43 | jparker:crypto-core@0.1.0 44 | jparker:crypto-md5@0.1.1 45 | jparker:gravatar@0.3.1 46 | jquery@1.11.4 47 | kadira:blaze-layout@2.2.0 48 | kadira:flow-router@2.8.0 49 | kevohagan:sweetalert@1.0.0 50 | launch-screen@1.0.4 51 | less@2.5.1 52 | livedata@1.0.15 53 | localstorage@1.0.5 54 | logging@1.0.8 55 | markdown@1.0.5 56 | meteor@1.1.10 57 | meteor-base@1.0.1 58 | meteorhacks:inject-initial@1.0.3 59 | minifiers@1.1.7 60 | minimongo@1.0.10 61 | mizzao:autocomplete@0.5.1 62 | mizzao:timesync@0.3.4 63 | mizzao:user-status@0.6.6 64 | mobile-experience@1.0.1 65 | mobile-status-bar@1.0.6 66 | momentjs:moment@2.10.6 67 | mongo@1.1.3 68 | mongo-id@1.0.1 69 | npm-bcrypt@0.7.8_2 70 | npm-mongo@1.4.39_1 71 | observe-sequence@1.0.7 72 | ordered-dict@1.0.4 73 | promise@0.5.1 74 | random@1.0.5 75 | rate-limit@1.0.0 76 | reactive-dict@1.1.3 77 | reactive-var@1.0.6 78 | reload@1.1.4 79 | retry@1.0.4 80 | reywood:publish-composite@1.4.2 81 | routepolicy@1.0.6 82 | service-configuration@1.0.5 83 | session@1.1.1 84 | sha@1.0.4 85 | simple:highlight.js@1.2.0 86 | skeleton:skeleton@2.0.4 87 | softwarerero:accounts-t9n@1.1.4 88 | spacebars@1.0.7 89 | spacebars-compiler@1.0.7 90 | srp@1.0.4 91 | standard-minifiers@1.0.2 92 | templating@1.1.5 93 | templating-tools@1.0.0 94 | themeteorchef:bert@2.1.0 95 | tmeasday:publish-counts@0.7.2 96 | tracker@1.0.9 97 | ui@1.0.8 98 | underscore@1.0.4 99 | url@1.0.5 100 | useraccounts:core@1.12.4 101 | useraccounts:flow-routing@1.12.4 102 | useraccounts:unstyled@1.12.4 103 | utilities:avatar@0.9.2 104 | verron:autosize@3.0.8 105 | webapp@1.2.3 106 | webapp-hashing@1.0.5 107 | zimme:active-route@2.3.2 108 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | Please review this guideline when contributing to the Gravity repository. 3 | 4 | ## Issues 5 | Issues should include a descriptive title and a description which briefly describes what the issue is all about. 6 | Issues can be anything such as bugs, features, enhancements, discussions... 7 | 8 | Before opening a new issue please search through all the existing ones so that we don't introduce duplicates. 9 | 10 | ## Pull Requests 11 | Each pull request should close / fix an issue so that the community can discuss the functionality 12 | before a line of code is written. 13 | 14 | We need to define some standards in order to keep code quality high. 15 | Pull requests who don't match the following standards won't be merged. 16 | 17 | We stick to the [Meteor Style Guides](https://github.com/meteor/meteor/wiki/Meteor-Style-Guide) as close as possible. 18 | 19 | Just read through the code a little bit to get an idea of the used coding conventions. 20 | 21 | Here are some important examples you should keep an eye on: 22 | - Use the new ES6 syntax as often as possible (especially try to avoid `var`) 23 | - camelCase syntax for variables (e.g. `userProfile`) 24 | - underscores for file names (e.g. `example_file.html`) 25 | - Each file should have an empty newline at the bottom ("Ensure line feed at end of file") 26 | - Use two level space indentation (no tab indentation) 27 | - Indent your code so that it's readable and sticks to the indentation of the overall project 28 | - Remove all `console.log` statements / unnecessary code 29 | - Try to avoid committing commented out code 30 | - Remove dead code 31 | - Useful variable names (e.g. `let user;` instead of `let u;`) 32 | - Shared partials should have an underscore in front of them and be placed in a folder called `shared` (e.g. `profiles/shared/_button.html`) 33 | -2 space indents (setq js-indent-level 2) 34 | - Spaces, not literal tabs (setq-default indent-tabs-mode nil) 35 | - No trailing whitespace (setq-default show-trailing-whitespace t) 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gravity 2 | [Gravity](http://joingravity.herokuapp.com/) is an open source social network built with Meteor. 3 | 4 | ![Gravity screenshot](screenshot-1.png) 5 | 6 | The most recent version is deployed at [http://joingravity.herokuapp.com/](http://joingravity.herokuapp.com/). Meet us there! 7 | 8 | ## Setup 9 | 1. Clone / fork the repository 10 | 2. `cd` into the downloaded folder 11 | 3. run `meteor` 12 | 4. head over to `http://localhost:3000` 13 | 14 | ## How can I help? 15 | Any kind of contribution is welcomed! 16 | Take a look at our [issues](https://github.com/GravityProject/gravity/issues) to get an overview where you can help. 17 | 18 | ## Contributors 19 | [Here](https://github.com/GravityProject/gravity/graphs/contributors) is the list of all the contributors so far. Thank you! 20 | -------------------------------------------------------------------------------- /client/lib/main.js: -------------------------------------------------------------------------------- 1 | setTitle = (title) => { 2 | let base = 'Gravity'; 3 | if (title) { 4 | document.title = title + ' - ' + base; 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /client/lib/spacebars.js: -------------------------------------------------------------------------------- 1 | UI.registerHelper('simpleFormat', (text) => { 2 | if (!text) { 3 | return; 4 | } 5 | var carriage_returns, linkify, newline, paragraphs; 6 | linkify = (string) => { 7 | var re; 8 | re = [ 9 | "\\b((?:https?|ftp)://[^\\s\"'<>]+)\\b", 10 | "\\b(www\\.[^\\s\"'<>]+)\\b", 11 | "\\b(\\w[\\w.+-]*@[\\w.-]+\\.[a-z]{2,6})\\b", 12 | "@([a-z0-9]+)" 13 | ]; 14 | re = new RegExp(re.join('|'), 'gi'); 15 | return string.replace(re, (match, url, www, mail, username) => { 16 | if (url) { 17 | return '' + url + ''; 18 | } 19 | if (www) { 20 | return '' + www + ''; 21 | } 22 | if (mail) { 23 | return '' + mail + ''; 24 | } 25 | if (username) { 26 | if (Meteor.user().username === username) { 27 | return '@' + username + ''; 28 | } 29 | return '@' + username; 30 | } 31 | return match; 32 | }); 33 | }; 34 | text = linkify(text); 35 | carriage_returns = /\r\n?/g; 36 | paragraphs = /\n\n+/g; 37 | newline = /([^\n]\n)(?=[^\n])/g; 38 | text = text.replace(carriage_returns, '\n'); 39 | text = text.replace(paragraphs, '

\n\n

'); 40 | text = text.replace(newline, '$1
'); 41 | text = '

' + text + '

'; 42 | return new Spacebars.SafeString(text); 43 | }); 44 | 45 | UI.registerHelper('formatDate', (date) => { 46 | return moment(date).format('LL'); 47 | }); 48 | 49 | /* 50 | * Full time format 51 | */ 52 | UI.registerHelper('fullTimeDate', (date) => { 53 | return moment(date).format('HH:mm:ss, LL'); 54 | }); 55 | 56 | Template.registerHelper('instance', () => Template.instance()); 57 | -------------------------------------------------------------------------------- /client/lib/subscriptions.js: -------------------------------------------------------------------------------- 1 | Meteor.subscribe('userStatus'); 2 | -------------------------------------------------------------------------------- /client/stylesheets/base/base.import.less: -------------------------------------------------------------------------------- 1 | a { 2 | text-decoration: none; 3 | &:hover { 4 | text-decoration: underline; 5 | } 6 | } 7 | 8 | .text-center { 9 | text-align: center; 10 | } 11 | 12 | .no-button { 13 | border: 0; 14 | padding: 0; 15 | margin-bottom: 0; 16 | margin-left: 10px; 17 | } 18 | 19 | .alert { 20 | padding: 15px; 21 | text-align: center; 22 | margin-bottom: 20px; 23 | &.info { 24 | background: #D9EDF7; 25 | color: #31708F; 26 | } 27 | &.danger { 28 | background: #F2DEDE; 29 | color: #A94442; 30 | } 31 | &.success { 32 | background: #DFF0D8; 33 | color: #3C763D; 34 | } 35 | &.warning { 36 | background: #FCF8E3; 37 | color: #8A6D3B; 38 | } 39 | } 40 | 41 | .button-danger { 42 | background: #DB1E1E; 43 | color: #FFFFFF; 44 | border-color: #DB1E1E; 45 | &:hover, &:focus { 46 | color: #FFFFFF; 47 | background: darken(#DB1E1E, 10%); 48 | border-color: darken(#DB1E1E, 10%); 49 | } 50 | } 51 | 52 | .clear { 53 | clear: both; 54 | } 55 | -------------------------------------------------------------------------------- /client/stylesheets/main.less: -------------------------------------------------------------------------------- 1 | @import 'base/base.import.less'; 2 | 3 | @import 'modules/header.import.less'; 4 | @import 'modules/navigation.import.less'; 5 | @import 'modules/posts.import.less'; 6 | @import 'modules/users.import.less'; 7 | @import 'modules/footer.import.less'; 8 | @import 'modules/messages.import.less'; 9 | @import 'modules/jobs.import.less'; 10 | 11 | // keep it at the bottom 12 | @import 'modules/useraccounts.import.less'; 13 | -------------------------------------------------------------------------------- /client/stylesheets/modules/footer.import.less: -------------------------------------------------------------------------------- 1 | footer.footer { 2 | border-top: 1px solid #EEEEEE; 3 | padding: 10px; 4 | margin-top: 20px; 5 | } 6 | -------------------------------------------------------------------------------- /client/stylesheets/modules/header.import.less: -------------------------------------------------------------------------------- 1 | header.main-header { 2 | display: block; 3 | width: 100%; 4 | height: 6.5rem; 5 | background: #FFFFFF; 6 | z-index: 99; 7 | border-top: 1px solid #EEEEEE; 8 | border-bottom: 1px solid #EEEEEE; 9 | margin-bottom: 20px; 10 | ul { 11 | list-style: none; 12 | margin-bottom: 0; 13 | li { 14 | position: relative; 15 | float: left; 16 | margin-bottom: 0; 17 | margin-right: 10px; 18 | button { 19 | border: 0; 20 | padding: 0; 21 | line-height: 6.5rem; 22 | } 23 | a { 24 | text-transform: uppercase; 25 | font-size: 11px; 26 | font-weight: 600; 27 | letter-spacing: .2rem; 28 | text-decoration: none; 29 | line-height: 6.5rem; 30 | color: #222222; 31 | } 32 | } 33 | li.u-pull-right { 34 | float: right; 35 | } 36 | li.sign-out { 37 | margin-right: 0; 38 | } 39 | } 40 | .logo { 41 | float: left; 42 | padding: 11px 0; 43 | height: 43px; 44 | width: auto; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/stylesheets/modules/jobs.import.less: -------------------------------------------------------------------------------- 1 | [data-id="addJob-form"] { 2 | .addJob-single-line { 3 | height: 38px; 4 | min-height: 38px; 5 | } 6 | .addJob-multi-line { 7 | height: 110px; 8 | min-height: 110px; 9 | } 10 | [data-id="addJob-submit"].disabled, 11 | [data-id="addJob-submit"].disabled:hover { 12 | opacity: 0.3; 13 | cursor: default; 14 | background-color: #33C3F0; 15 | border-color: #33C3F0; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/stylesheets/modules/messages.import.less: -------------------------------------------------------------------------------- 1 | #allMessagesArea, 2 | #singleMessageContainer { 3 | border: 1px solid #D1D1D1; 4 | border-radius: 4px; 5 | box-shadow: none; 6 | min-width: 200px; 7 | min-height: 100px; 8 | height: 450px; 9 | max-height: 450px; 10 | } 11 | 12 | #allMessagesArea { 13 | overflow: scroll; 14 | tr { 15 | td:first-child { 16 | padding-left: 10px; 17 | } 18 | td:nth-child(4) { 19 | padding-right: 10px; 20 | } 21 | } 22 | } 23 | 24 | #singleMessageContainer { 25 | overflow: hidden; 26 | } 27 | 28 | .emptyMessage { 29 | display: flex; 30 | justify-content: center; 31 | align-items: center; 32 | height: 100%; 33 | opacity: 0.8; 34 | } 35 | 36 | #messagesButtons { 37 | display: flex; 38 | justify-content: space-between; 39 | } 40 | 41 | #singleMessageHeaderArea { 42 | height: 8%; 43 | var:first-child { 44 | margin-left: 20px; 45 | height: 100%; 46 | display: flex; 47 | align-items: center; 48 | } 49 | hr { 50 | margin-top: 0px; 51 | margin-bottom: 15px; 52 | } 53 | } 54 | 55 | #singleMessageArea { 56 | overflow: scroll; 57 | height: 59%; 58 | 59 | } 60 | 61 | .smallCol { 62 | width: 20%; 63 | max-width: 120px; 64 | overflow: hidden; 65 | white-space: nowrap; 66 | text-overflow: ellipsis; 67 | } 68 | 69 | .largeCol { 70 | width: 50%; 71 | max-width: 275px; 72 | overflow: hidden; 73 | white-space: nowrap; 74 | text-overflow: ellipsis; 75 | } 76 | 77 | .individualMessage { 78 | p { 79 | border-radius: 10%; 80 | font-style: normal; 81 | font-size: 14px; 82 | padding: 4px; 83 | margin-bottom: 0px; 84 | display: block; 85 | position: relative; 86 | clear: both; 87 | margin: 0px 20px; 88 | } 89 | &.left { 90 | p { 91 | border: 1px solid #cfcfcf; 92 | float: left; 93 | } 94 | } 95 | &.right { 96 | p { 97 | background-color: #cfcfcf; 98 | float: right; 99 | } 100 | } 101 | } 102 | 103 | .individualMessageDate { 104 | font-size: 12px; 105 | display: block; 106 | position: relative; 107 | clear: both; 108 | margin: 0px 25px 10px 25px; 109 | &.left { 110 | float: left; 111 | } 112 | &.right { 113 | float: right; 114 | } 115 | } 116 | 117 | #allMessagesButton.active, 118 | #composeButton.active { 119 | background: rgba(0, 0, 0, 0.1); 120 | } 121 | 122 | #allMessagesArea table { 123 | width: 100%; 124 | } 125 | 126 | td { 127 | padding: 0px; 128 | } 129 | 130 | .notRead { 131 | font-weight: bold; 132 | } 133 | 134 | #singleMessageReplyArea { 135 | [data-id="reply-body"] { 136 | min-height: 100px; 137 | height: 100px; 138 | margin-bottom: 0px; 139 | } 140 | [data-id="reply-submit"] { 141 | margin-bottom: 0px; 142 | } 143 | [data-id="reply-submit"].disabled, 144 | [data-id="reply-submit"].disabled:hover { 145 | opacity: 0.3; 146 | cursor: default; 147 | background-color: #33C3F0; 148 | border-color: #33C3F0; 149 | } 150 | } 151 | 152 | .sweet-alert { 153 | button { 154 | padding-top: 0px; 155 | } 156 | } 157 | 158 | [data-id="send-message-form"] { 159 | .-autocomplete-container { 160 | margin-top: 15px; 161 | } 162 | [data-id="message-submit"].disabled, 163 | [data-id="message-submit"].disabled:hover { 164 | opacity: 0.3; 165 | cursor: default; 166 | background-color: #33C3F0; 167 | border-color: #33C3F0; 168 | } 169 | } -------------------------------------------------------------------------------- /client/stylesheets/modules/navigation.import.less: -------------------------------------------------------------------------------- 1 | .navigation { 2 | ul { 3 | margin-bottom: 0; 4 | list-style: none; 5 | a:hover { 6 | text-decoration: none; 7 | } 8 | li { 9 | padding: 5px; 10 | &.active { 11 | background: #EEEEEE; 12 | font-weight: bold; 13 | a { 14 | text-decoration: none; 15 | } 16 | } 17 | } 18 | } 19 | hr { 20 | margin-top: 20px; 21 | margin-bottom: 20px; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /client/stylesheets/modules/posts.import.less: -------------------------------------------------------------------------------- 1 | .post { 2 | padding: 10px; 3 | border: 1px solid #EEEEEE; 4 | overflow-wrap: break-word; 5 | word-wrap: break-word; 6 | hr { 7 | margin-top: 15px; 8 | margin-bottom: 10px; 9 | } 10 | p.formatted-date, a.author { 11 | color: #D5D5D5; 12 | } 13 | p { 14 | margin-bottom: 0; 15 | } 16 | .avatar { 17 | margin-right: 5px; 18 | } 19 | .highlight-username { 20 | background: #FFEDCA; 21 | } 22 | margin-bottom: 20px; 23 | .post-line-two { 24 | display: flex; 25 | flex-direction: row; 26 | justify-content: space-between; 27 | .post-line-two-left, 28 | .post-line-two-right { 29 | display: flex; 30 | align-items: center; 31 | } 32 | .fa-thumbs-o-up { 33 | margin-right: 2px; 34 | &.liked { 35 | color: #1EAEDB; 36 | } 37 | } 38 | .remove-post { 39 | margin-left: 0px; 40 | margin-right: 10px; 41 | } 42 | [data-id="remove-post"], 43 | [data-id="like-post"] { 44 | font-size: 14px; 45 | } 46 | } 47 | 48 | } 49 | 50 | form.search-posts-form { 51 | margin-bottom: 0; 52 | input { 53 | margin-bottom: 0; 54 | } 55 | } 56 | 57 | ul.feed-filter { 58 | list-style: none; 59 | margin-bottom: 0; 60 | display: inline-block; 61 | li { 62 | float: left; 63 | margin-bottom: 0; 64 | margin-right: 10px; 65 | button { 66 | margin-bottom: 0; 67 | &.active { 68 | background: rgba(0, 0, 0, .1); 69 | } 70 | } 71 | &:last-child { 72 | margin-right: 0; 73 | } 74 | } 75 | } 76 | 77 | textarea { 78 | &.insert-post-form { 79 | min-height: 39px; 80 | } 81 | &.message-body-form { 82 | min-height: 200px; 83 | } 84 | } 85 | 86 | [data-id="insert-post-form"] { 87 | .-autocomplete-container { 88 | margin-top: 15px; 89 | } 90 | input[type="submit"].disabled, 91 | input[type="submit"].disabled:hover { 92 | opacity: 0.3; 93 | cursor: default; 94 | background-color: #33C3F0; 95 | border-color: #33C3F0; 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /client/stylesheets/modules/useraccounts.import.less: -------------------------------------------------------------------------------- 1 | .at-title h3 { 2 | font-size: 5rem; 3 | font-weight: 300; 4 | line-height: 1.2; 5 | letter-spacing: -0.1rem; 6 | text-align: center; 7 | } 8 | 9 | .at-input input { 10 | width: 100%; 11 | } 12 | 13 | .at-btn.submit { 14 | color: #FFF; 15 | background-color: #33C3F0 !important; 16 | outline: 0px none; 17 | display: inline-block; 18 | width: 100%; 19 | height: 38px; 20 | padding: 0px 30px; 21 | text-align: center; 22 | font-size: 11px; 23 | font-weight: 600; 24 | line-height: 38px; 25 | letter-spacing: 0.1rem; 26 | text-transform: uppercase; 27 | text-decoration: none; 28 | white-space: nowrap; 29 | background-color: transparent; 30 | border-radius: 4px; 31 | border: 1px solid #33C3F0; cursor: pointer; 32 | box-sizing: border-box; 33 | } 34 | .at-error { 35 | text-align: center; 36 | color: #D43939; 37 | } 38 | -------------------------------------------------------------------------------- /client/stylesheets/modules/users.import.less: -------------------------------------------------------------------------------- 1 | .profile { 2 | .avatar-center { 3 | margin: 0 auto; 4 | } 5 | 6 | h1.username { 7 | margin-top: 20px; 8 | margin-bottom: 5px; 9 | font-size: 3.0rem; 10 | } 11 | 12 | #socialMediaAccounts { 13 | display: flex; 14 | justify-content: space-around; 15 | .smAccount { 16 | &:hover { 17 | transform: scale(1.2); 18 | } 19 | img { 20 | width: 75px; 21 | height: 75px; 22 | margin-top: -15px; 23 | } 24 | } 25 | } 26 | 27 | textarea.socialMedia { 28 | min-height: 40px; 29 | height: 40px; 30 | margin-bottom: 10px; 31 | } 32 | 33 | textarea.biography { 34 | height: 200px; 35 | } 36 | 37 | input[type="submit"].disabled, 38 | input[type="submit"].disabled:hover { 39 | opacity: 0.3; 40 | cursor: default; 41 | background-color: #33C3F0; 42 | border-color: #33C3F0; 43 | } 44 | } 45 | 46 | .small-profile { 47 | padding: 10px; 48 | border: 1px solid #EEEEEE; 49 | margin-bottom: 20px; 50 | .avatar, p { 51 | margin-right: 20px; 52 | } 53 | } 54 | 55 | .presence { 56 | &.online { 57 | margin-left: 5px; 58 | color: green; 59 | } 60 | 61 | &.offline { 62 | margin-left: 5px; 63 | color: gray; 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/views/feed.html: -------------------------------------------------------------------------------- 1 | 47 | -------------------------------------------------------------------------------- /client/views/feed.js: -------------------------------------------------------------------------------- 1 | Template.feed.onCreated(function() { 2 | this.searchQuery = new ReactiveVar(''); 3 | this.filter = new ReactiveVar('all'); 4 | this.limit = new ReactiveVar(20); 5 | this.postsCount = new ReactiveVar(0); 6 | 7 | this.autorun(() => { 8 | this.subscribe('posts.all', this.searchQuery.get(), this.filter.get(), this.limit.get()); 9 | this.subscribe('users.all', this.searchQuery.get(), this.limit.get()); 10 | this.postsCount.set(Counts.get('posts.all')); 11 | }); 12 | }); 13 | 14 | Template.feed.onRendered(() => { 15 | autosize($('[data-id=body]')); 16 | 17 | // Set submit button to disabled since text field is empty 18 | $('input[type=submit]').addClass('disabled'); 19 | }); 20 | 21 | Template.feed.helpers({ 22 | posts: () => { 23 | const instance = Template.instance(); 24 | if (instance.searchQuery.get()) { 25 | return Posts.find({}, { sort: [['score', 'desc']] }); 26 | } 27 | return Posts.find({}, { sort: { createdAt: -1 } }); 28 | }, 29 | 30 | activeIfFilterIs: (filter) => { 31 | if (filter === Template.instance().filter.get()) { 32 | return 'active'; 33 | } 34 | }, 35 | 36 | hasMorePosts: () => { 37 | return Template.instance().limit.get() <= Template.instance().postsCount.get(); 38 | }, 39 | // Settings for autocomplete in post field 40 | settings: () => { 41 | return { 42 | position: 'bottom', 43 | limit: 5, 44 | rules: [{ 45 | token: '@', 46 | collection: Meteor.users, 47 | field: 'username', 48 | template: Template.userList, 49 | filter: { _id: { $ne: Meteor.userId() }} 50 | }] 51 | }; 52 | } 53 | }); 54 | 55 | Template.feed.events({ 56 | 'keyup [data-id=body]': (event, template) => { 57 | // If body section has text enable the submit button, else disable it 58 | if (template.find('[data-id=body]').value.toString().trim() !== '') { 59 | $('input[type=submit]').removeClass('disabled'); 60 | } else { 61 | $('input[type=submit]').addClass('disabled'); 62 | } 63 | 64 | // When shift and enter are pressed, submit form 65 | if (event.shiftKey && event.keyCode === 13) { 66 | $('[data-id=insert-post-form]').submit(); 67 | } 68 | }, 69 | 70 | 'submit [data-id=insert-post-form]': (event, template) => { 71 | event.preventDefault(); 72 | 73 | // Only continue if button isn't disabled 74 | if (!$('input[type=submit]').hasClass('disabled')) { 75 | let body = template.find('[data-id=body]').value; 76 | 77 | // If a user is mentioned in the post add span with class to highlight their username 78 | if(body.indexOf('@') !== -1) { 79 | for(let x = 0; x < body.length; x++) { 80 | if(body[x] === '@') { 81 | let u = body.slice(x + 1, body.indexOf(' ', x)); 82 | let mentionedUser = Meteor.users.findOne({username: u}); 83 | 84 | // If a valid user 85 | if(mentionedUser) { 86 | // Add opening and closing span tags 87 | body = body.slice(0, x) + '' + body.slice(x, body.indexOf(' ', x)) + '' + 88 | body.slice(body.indexOf(' ', x)); 89 | 90 | // Increment by number of characters in openeing span tag 91 | // so the same mention doesn't get evaluated multiple times 92 | x+= 16; 93 | } 94 | } 95 | } 96 | } 97 | 98 | Meteor.call('posts.insert', body, (error, result) => { 99 | if (error) { 100 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 101 | } else { 102 | Bert.alert('Post successfully submitted', 'success', 'growl-top-right'); 103 | template.find('[data-id=body]').value = ''; 104 | $('[data-id=body]').css('height', '39px'); 105 | $('input[type=submit]').addClass('disabled'); 106 | } 107 | }); 108 | } 109 | }, 110 | 111 | 'click [data-id=all]': (event, template) => { 112 | template.filter.set('all'); 113 | }, 114 | 115 | 'click [data-id=following]': (events, template) => { 116 | template.filter.set('following'); 117 | }, 118 | 119 | 'click [data-id=load-more]': (event, template) => { 120 | template.limit.set(template.limit.get() + 20); 121 | }, 122 | 123 | 'keyup [data-id=search-query]': _.debounce((event, template) => { 124 | event.preventDefault(); 125 | template.searchQuery.set(template.find('[data-id=search-query]').value); 126 | template.limit.set(20); 127 | }, 300), 128 | 129 | 'submit [data-id=search-posts-form]': (event, template) => { 130 | event.preventDefault(); 131 | } 132 | }); 133 | -------------------------------------------------------------------------------- /client/views/footer.html: -------------------------------------------------------------------------------- 1 | 17 | -------------------------------------------------------------------------------- /client/views/head.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Gravity 4 | 5 | -------------------------------------------------------------------------------- /client/views/header.html: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /client/views/header.js: -------------------------------------------------------------------------------- 1 | Template.header.events({ 2 | 'click [data-id=sign-out]': function() { 3 | Meteor.logout(function(error) { 4 | if (error) { 5 | alert(error.reason); 6 | } else { 7 | FlowRouter.go('/sign-in'); 8 | } 9 | }); 10 | } 11 | }); 12 | -------------------------------------------------------------------------------- /client/views/jobBoard.html: -------------------------------------------------------------------------------- 1 | 48 | 49 | 67 | 68 | 96 | -------------------------------------------------------------------------------- /client/views/jobBoard.js: -------------------------------------------------------------------------------- 1 | /* Function to check if view passed in is the current view */ 2 | function isCurrentView(v) { 3 | if (Session.get('currentView') === v) { 4 | return true; 5 | } else { 6 | return false; 7 | } 8 | } 9 | 10 | /* jobBoard template onCreated */ 11 | Template.jobBoard.onCreated(function() { 12 | this.searchQuery = new ReactiveVar(''); 13 | this.limit = new ReactiveVar(10); 14 | this.jobsCount = new ReactiveVar(0); 15 | this.usersCount = new ReactiveVar(0); 16 | 17 | this.autorun(() => { 18 | this.subscribe('jobs.all', this.searchQuery.get(), this.limit.get()); 19 | this.jobsCount.set(Counts.get('jobs.all')); 20 | 21 | this.subscribe('users.all', this.searchQuery.get(), this.limit.get()); 22 | this.usersCount.set(Counts.get('users.all')); 23 | 24 | // Show allJobs view 25 | Session.set('currentView', 'allJobs'); 26 | Session.set('selectedJob', ''); 27 | }); 28 | }); 29 | 30 | /* jobBoard template helpers */ 31 | Template.jobBoard.helpers({ 32 | showAllJobs: () => { 33 | return isCurrentView('allJobs'); 34 | }, 35 | showSingleJob: () => { 36 | return isCurrentView('singleJob'); 37 | }, 38 | showAddJob: () => { 39 | return isCurrentView('addJob') || isCurrentView('editJob'); 40 | }, 41 | jobs: () => { 42 | // Get all jobs, recently posted ones first 43 | return Jobs.find({}, {sort: {createdOn: -1 } }); 44 | }, 45 | hasMoreJobs: () => { 46 | return Template.instance().limit.get() <= Template.instance().jobsCount.get(); 47 | }, 48 | formatDate: (date) => { 49 | let currDate = moment(new Date()), 50 | postedDate = moment(new Date(date)); 51 | 52 | let diff = currDate.diff(postedDate, 'days'); 53 | 54 | if (diff === 0 && currDate.day() === postedDate.day()) { 55 | return 'Today'; 56 | } else if (diff < 30) { 57 | if (diff <= 1) { 58 | return '1 day ago'; 59 | } else { 60 | return diff + ' days ago'; 61 | } 62 | } else { 63 | return '30+ days ago'; 64 | } 65 | }, 66 | isAuthor: (author) => { 67 | return Meteor.userId() === author; 68 | } 69 | }); 70 | 71 | /* jobBoard template events */ 72 | Template.jobBoard.events({ 73 | 'click [data-id=load-more]': (event, template) => { 74 | template.limit.set(template.limit.get() + 20); 75 | }, 76 | 'keyup [data-id=search-jobs-query]': _.debounce((event, template) => { 77 | event.preventDefault(); 78 | 79 | template.searchQuery.set(template.find('[data-id=search-jobs-query]').value); 80 | template.limit.set(20); 81 | }, 300), 82 | 'submit [data-id=search-jobs-form]': (event, template) => { 83 | event.preventDefault(); 84 | }, 85 | 'click #postJobButton': (event, template) => { 86 | Session.set('currentView', 'addJob'); 87 | }, 88 | 'click .small-profile': (event, template) => { 89 | Session.set('selectedJob', event.currentTarget.id); 90 | Session.set('currentView', 'singleJob'); 91 | }, 92 | 'click [data-id=edit-job]': (event, template) => { 93 | event.stopPropagation(); 94 | 95 | Session.set('selectedJob', event.currentTarget.parentNode.id); 96 | Session.set('currentView', 'editJob'); 97 | }, 98 | 'click [data-id=remove-job]': (event, template) => { 99 | event.stopPropagation(); 100 | 101 | // Sweet Alert delete confirmation 102 | swal({ 103 | title: 'Delete job?', 104 | text: 'Are you sure that you want to delete this job?', 105 | type: 'error', 106 | showCancelButton: true, 107 | closeOnConfirm: true, 108 | cancelButtonText: 'No', 109 | confirmButtonText: 'Yes, delete it!', 110 | confirmButtonColor: '#da5347' 111 | }, function() { 112 | // Get the id of the job to be deleted 113 | let jobId = event.currentTarget.parentNode.id; 114 | 115 | // Make sure message exists 116 | let job = Jobs.findOne({ _id: jobId } ); 117 | 118 | // If message exists 119 | if (job) { 120 | // Remove selected job 121 | Meteor.call('jobs.remove', jobId, (error, result) => { 122 | if (error) { 123 | Bert.alert('Job couldn\'t be deleted.', 'danger', 'growl-top-right'); 124 | } else { 125 | Bert.alert('Job deleted', 'success', 'growl-top-right'); 126 | } 127 | }); 128 | } else { 129 | Bert.alert('Job couldn\'t be deleted.', 'danger', 'growl-top-right'); 130 | } 131 | }); 132 | } 133 | }); 134 | 135 | /* singleJob template helpers */ 136 | Template.singleJob.helpers({ 137 | getSingleJob: () => { 138 | return Jobs.findOne({ _id: Session.get('selectedJob') } ); 139 | }, 140 | formatDate: (date) => { 141 | return moment(date).format('MM/DD/YY'); 142 | }, 143 | notAuthor: (author) => { 144 | return Meteor.userId() !== author; 145 | } 146 | }); 147 | 148 | /* singleJob template events */ 149 | Template.singleJob.events({ 150 | 'click .allJobsButton': (event, template) => { 151 | Session.set('currentView', 'allJobs'); 152 | }, 153 | 'click #applyNowButton': (event, template) => { 154 | // Get job details 155 | let currJob = Jobs.findOne({ _id: Session.get('selectedJob') } ); 156 | 157 | if (currJob.externalLink && currJob.externalLink !== '') { 158 | // Proceed to external link 159 | let link = currJob.externalLink; 160 | 161 | if (link.toString().indexOf('http://') === -1) { 162 | link = 'http://' + link; 163 | } 164 | 165 | window.open(link); 166 | } else { 167 | // Send message to job poster 168 | let authorName = Meteor.users.findOne({_id: currJob.author.toString()}).username, 169 | msg = "Hi, I'm interested in the job you posted titled \"" + currJob.title + ".\""; 170 | 171 | if (currJob.author && authorName) { 172 | Meteor.call('messages.insert', currJob.author, authorName, msg, (error, result) => { 173 | if (error) { 174 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 175 | } else { 176 | // Display success message and reset form values 177 | Bert.alert('Message sent to the job poster.', 'success', 'growl-top-right'); 178 | 179 | Session.set('currentView', 'allJobs'); 180 | } 181 | }); 182 | } else { 183 | Bert.alert('There was a problem sending the message.', 'danger', 'growl-top-right'); 184 | } 185 | } 186 | } 187 | }); 188 | 189 | /* addJob template onRendered */ 190 | Template.addJob.onRendered(function() { 191 | $('[data-id=addJob-submit]').addClass('disabled'); 192 | Session.set('showExternalLink', false); 193 | 194 | if (isCurrentView('editJob')) { 195 | let jobToEdit = Jobs.findOne({ _id: Session.get('selectedJob') } ); 196 | 197 | if (jobToEdit.externalLink && jobToEdit.externalLink !== '') { 198 | Session.set('showExternalLink', true); 199 | $('[data-id=addJob-interestedBehavior]').val('goToLink'); 200 | } else { 201 | Session.set('showExternalLink', false); 202 | $('[data-id=addJob-interestedBehavior]').val('directMessage'); 203 | } 204 | 205 | // Fill in fields with existing data 206 | $('[data-id=addJob-title]').val(jobToEdit.title); 207 | $('[data-id=addJob-location]').val(jobToEdit.location); 208 | $('[data-id=addJob-description]').val(jobToEdit.description); 209 | $('[data-id=addJob-responsibilities]').val(jobToEdit.responsibilities); 210 | $('[data-id=addJob-qualifications]').val(jobToEdit.qualifications); 211 | $('[data-id=addJob-schedule]').val(jobToEdit.schedule); 212 | $('[data-id=addJob-externalLink]').val(jobToEdit.externalLink); 213 | 214 | // Keep track of original values 215 | Session.set('startingTitle', $('[data-id=addJob-title]').val()); 216 | Session.set('startingLocation', $('[data-id=addJob-location]').val()); 217 | Session.set('startingDescription', $('[data-id=addJob-description]').val()); 218 | Session.set('startingResponsibilities', $('[data-id=addJob-responsibilities]').val()); 219 | Session.set('startingQualifications', $('[data-id=addJob-qualifications]').val()); 220 | Session.set('startingSchedule', $('[data-id=addJob-schedule]').val()); 221 | Session.set('startingLink', $('[data-id=addJob-externalLink]').val()); 222 | 223 | // Change button text 224 | $('[data-id=addJob-submit]').prop('value', 'Save'); 225 | } else if (isCurrentView('addJob')) { 226 | // Change button text 227 | $('[data-id=addJob-submit]').prop('value', 'Post'); 228 | } 229 | }); 230 | 231 | /* addJob template helpers */ 232 | Template.addJob.helpers({ 233 | showExternalLink: () => { 234 | return Session.get('showExternalLink'); 235 | } 236 | }); 237 | 238 | /* addJob template events */ 239 | Template.addJob.events({ 240 | 'change [data-id=addJob-interestedBehavior]': (event, template) => { 241 | if (template.find('[data-id=addJob-interestedBehavior] option:selected').value === 'goToLink') { 242 | Session.set('showExternalLink', true); 243 | } else { 244 | Session.set('showExternalLink', false); 245 | } 246 | }, 247 | 'click .allJobsButton': (event, template) => { 248 | Session.set('currentView', 'allJobs'); 249 | }, 250 | 'keyup [data-id=addJob-title], keyup [data-id=addJob-location], keyup [data-id=addJob-description], keyup [data-id=addJob-responsibilities], keyup [data-id=addJob-qualifications], change [data-id=addJob-schedule], change [data-id=addJob-interestedBehavior], keyup [data-id=addJob-externalLink]': (event, template) => { 251 | if (isCurrentView('addJob')) { 252 | try { 253 | // If job title, location, and description sections have text enable the submit button, else disable it 254 | if (template.find('[data-id=addJob-title]').value.toString().trim() !== '' && 255 | template.find('[data-id=addJob-location]').value.toString().trim() !== '' && 256 | template.find('[data-id=addJob-description]').value.toString().trim() !== '' && 257 | (template.find('[data-id=addJob-interestedBehavior] option:selected').value.toString() === 'directMessage' || 258 | (template.find('[data-id=addJob-interestedBehavior] option:selected').value.toString() === 'goToLink' && 259 | template.find('[data-id=addJob-externalLink]').value.toString().trim() !== ''))) { 260 | $('[data-id=addJob-submit]').removeClass('disabled'); 261 | } else { 262 | $('[data-id=addJob-submit]').addClass('disabled'); 263 | } 264 | } catch(err) { 265 | $('[data-id=addJob-submit]').addClass('disabled'); 266 | } 267 | } else if (isCurrentView('editJob')) { 268 | // If any of the values have changed enable the save button, else disable it 269 | if (template.find('[data-id=addJob-title]').value.toString().trim() !== Session.get('startingTitle') || 270 | template.find('[data-id=addJob-location]').value.toString().trim() !== Session.get('startingLocation') || 271 | template.find('[data-id=addJob-description]').value.toString().trim() !== Session.get('startingDescription') || 272 | template.find('[data-id=addJob-responsibilities]').value.toString().trim() !== Session.get('startingResponsibilities') || 273 | template.find('[data-id=addJob-qualifications]').value.toString().trim() !== Session.get('startingQualifications') || 274 | template.find('[data-id=addJob-schedule]').value !== Session.get('startingSchedule') || 275 | template.find('[data-id=addJob-externalLink]').value !== Session.get('startingLink')) { 276 | $('[data-id=addJob-submit]').removeClass('disabled'); 277 | } else { 278 | $('[data-id=addJob-submit]').addClass('disabled'); 279 | } 280 | } 281 | }, 282 | 'submit [data-id=addJob-form]': (event, template) => { 283 | event.preventDefault(); 284 | 285 | // Only continue if button isn't disabled 286 | if (!$('[data-id=addJob-submit]').hasClass('disabled')) { 287 | // Get values 288 | let title = template.find('[data-id=addJob-title]').value.toString().trim(), 289 | location = template.find('[data-id=addJob-location]').value.toString().trim(), 290 | schedule = template.find('[data-id=addJob-schedule] option:selected').text.trim(), 291 | description = template.find('[data-id=addJob-description]').value.toString().trim(), 292 | responsibilities = template.find('[data-id=addJob-responsibilities]').value.toString().trim(), 293 | qualifications = template.find('[data-id=addJob-qualifications]').value.toString().trim(), 294 | externalLink = ''; 295 | 296 | if (template.find('[data-id=addJob-interestedBehavior] option:selected').value.toString() === 'goToLink') { 297 | externalLink = template.find('[data-id=addJob-externalLink]').value.toString().trim(); 298 | } 299 | 300 | if (isCurrentView('addJob')) { 301 | // Title, location and description should have text 302 | if (title && location && description) { 303 | Meteor.call('jobs.post', title, location, schedule, description, responsibilities, 304 | qualifications, externalLink, (error, result) => { 305 | if (error) { 306 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 307 | } else { 308 | // Display success message 309 | Bert.alert('Job posted', 'success', 'growl-top-right'); 310 | 311 | // Switch the to allJobs view 312 | Session.set('currentView', 'allJobs'); 313 | } 314 | }); 315 | } else { 316 | Bert.alert('Please enter a job title, location and description.', 'danger', 'growl-top-right'); 317 | } 318 | } else if (isCurrentView('editJob')) { 319 | // Update existing job 320 | Meteor.call('jobs.update', Session.get('selectedJob'), title, location, schedule, description, 321 | responsibilities, qualifications, externalLink, (error, result) => { 322 | if (error) { 323 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 324 | } else { 325 | // Display success message 326 | Bert.alert('Job updated', 'success', 'growl-top-right'); 327 | 328 | // Switch the to allJobs view 329 | Session.set('currentView', 'allJobs'); 330 | } 331 | }); 332 | } 333 | } 334 | } 335 | }); 336 | -------------------------------------------------------------------------------- /client/views/layout.html: -------------------------------------------------------------------------------- 1 | 23 | -------------------------------------------------------------------------------- /client/views/layout.js: -------------------------------------------------------------------------------- 1 | let getHeight = () => { 2 | let windowHeight = $(window).height(), 3 | headerHeight = $('.main-header').outerHeight(true), 4 | footerHeight = $('.footer').outerHeight(true); 5 | 6 | return `${windowHeight - headerHeight - footerHeight}px`; 7 | }; 8 | 9 | Template.layout.onCreated(function() { 10 | this.minHeight = new ReactiveVar(0); 11 | }); 12 | 13 | Template.layout.onRendered(function() { 14 | this.minHeight.set(getHeight()); 15 | 16 | $(window).resize(() => { 17 | this.minHeight.set(getHeight()); 18 | }); 19 | }); 20 | -------------------------------------------------------------------------------- /client/views/messages.html: -------------------------------------------------------------------------------- 1 | 2 | 18 | 19 | 20 | 40 | 41 | 42 | 75 | 76 | 77 | 86 | 87 | 88 | 91 | 92 | -------------------------------------------------------------------------------- /client/views/messages.js: -------------------------------------------------------------------------------- 1 | /* Function to check if view passed in is the current view */ 2 | function isCurrentView(v) { 3 | if(Session.get('currentView') === v) { 4 | return true; 5 | } else { 6 | return false; 7 | } 8 | } 9 | 10 | /* Function to get the userId of the other user (not current user) */ 11 | function getOtherUserId(fromId, toId) { 12 | if(fromId === Meteor.userId()) 13 | { 14 | return toId; 15 | } else { 16 | return fromId; 17 | } 18 | } 19 | 20 | /* Function to get the username of the other user (not current user) */ 21 | function getOtherUsername(fromName, toName) { 22 | if(fromName === Meteor.user().username) 23 | { 24 | Session.set('originating', 'from'); 25 | return toName; 26 | } else { 27 | Session.set('originating', 'to'); 28 | return fromName; 29 | } 30 | } 31 | 32 | /* Function to format the passed in date */ 33 | function performFormatting(date) { 34 | let currDate = moment(new Date()), 35 | msgDate = moment(new Date(date)); 36 | 37 | let diff = currDate.diff(msgDate, 'days'); 38 | 39 | if(diff === 0 && currDate.day() === msgDate.day()) { 40 | return moment(date).format('h:mm a'); 41 | } else if(diff < 7 && currDate.day() !== msgDate.day()) { 42 | return moment(date).format('dddd'); 43 | } else { 44 | if(currDate.year() != msgDate.year()) { 45 | return moment(date).format('MM/DD/YY'); 46 | } else { 47 | return moment(date).format('MMM DD'); 48 | } 49 | } 50 | } 51 | 52 | /* Helper function available to all templates that calls getOtherUsername */ 53 | Template.registerHelper('getOtherUsername', function(fromName, toName) { 54 | return getOtherUsername(fromName, toName); 55 | }); 56 | 57 | /* On messages template created */ 58 | Template.messages.onCreated(function () { 59 | this.searchQuery = new ReactiveVar(''); 60 | this.limit = new ReactiveVar(20); 61 | this.usersCount = new ReactiveVar(0); 62 | 63 | this.autorun(() => { 64 | //Set subscriptions 65 | this.subscribe('users.all', this.searchQuery.get(), this.limit.get()); 66 | this.usersCount.set(Counts.get('users.all')); 67 | this.subscribe('messages.all'); 68 | 69 | //Set session variables 70 | Session.set('currentView', 'allMessages'); 71 | Session.set('selectedMsg', ''); 72 | Session.set('originating', ''); 73 | $('#allMessagesButton').addClass('active'); 74 | }); 75 | }); 76 | 77 | /* messages template helpers */ 78 | Template.messages.helpers({ 79 | showAllMessages: () => { 80 | return isCurrentView('allMessages'); 81 | }, 82 | showSingleMessage: () => { 83 | return isCurrentView('singleMessage'); 84 | }, 85 | showCompose: () => { 86 | return isCurrentView('compose'); 87 | } 88 | }); 89 | 90 | /* messages template events */ 91 | Template.messages.events({ 92 | 'click #allMessagesButton, click #composeButton': (event, template) => { 93 | //Set template based on button that was clicked 94 | $('button').removeClass('active'); 95 | $('#' + event.target.id).addClass('active'); 96 | Session.set('currentView', event.target.id.toString().replace('Button', '')); 97 | } 98 | }); 99 | 100 | /* allMessages helpers */ 101 | Template.allMessages.helpers({ 102 | getAllMessages: () => { 103 | //Find all messages that current user is involved in 104 | return Messages.find({$or: [{ 'originatingFromId': Meteor.userId(), 'conversation.originatingFromDeleted': false }, {'originatingToId': Meteor.userId(), 'conversation.originatingToDeleted': false}]}, {sort: {'conversation.sentOn': -1} }); 105 | }, 106 | messagesEmpty: () => { 107 | //Check if current user has any messages 108 | if(Messages.find({$or: [{ 'originatingFromId': Meteor.userId(), 'conversation.originatingFromDeleted': false }, {'originatingToId': Meteor.userId(), 'conversation.originatingToDeleted': false}]}).count() === 0) { 109 | return true; 110 | } else { 111 | return false; 112 | } 113 | }, 114 | formatDate: (conversation) => { 115 | if(conversation.length > 0) 116 | { 117 | let date = conversation[conversation.length - 1].sentOn; 118 | 119 | return performFormatting(date); 120 | } 121 | }, 122 | getMostRecentBody: (conversation) => { 123 | if(conversation.length > 0) { 124 | return conversation[conversation.length - 1].body; 125 | } 126 | }, 127 | getMostRecentRead: (conversation) => { 128 | //Determine if message has been read 129 | if(conversation.length > 0) { 130 | if(conversation[conversation.length - 1].to.userId === Meteor.userId()) { 131 | if(!conversation[conversation.length - 1].to.read) { 132 | return 'notRead'; 133 | } 134 | } 135 | } 136 | 137 | return ''; 138 | } 139 | }); 140 | 141 | /* allMessages template events */ 142 | Template.allMessages.events({ 143 | 'click .msg': (event, template) => { 144 | //Make sure selected message exists 145 | let selectedMsg = Messages.findOne({_id: event.currentTarget.id}); 146 | if(selectedMsg) { 147 | if(selectedMsg.conversation[selectedMsg.conversation.length - 1].to.userId === Meteor.userId()) { 148 | //Check if most recent message is TO the current user 149 | if(!selectedMsg.conversation[selectedMsg.conversation.length - 1].to.read) { 150 | //Update read field to true 151 | Meteor.call('messages.updateRead', selectedMsg._id, true, (error, result) => { 152 | //Do nothing 153 | }); 154 | } 155 | 156 | } 157 | } 158 | 159 | //Set current view to singleMssage template 160 | $('button').removeClass('active'); 161 | Session.set('selectedMsg', event.currentTarget.id); 162 | Session.set('currentView', 'singleMessage'); 163 | }, 164 | 'click .remove-message': (event, template) => { 165 | event.stopPropagation(); 166 | 167 | //Sweet Alert delete confirmation 168 | swal({ 169 | title: 'Delete message?', 170 | text: 'Are you sure that you want to delete this message?', 171 | type: 'error', 172 | showCancelButton: true, 173 | closeOnConfirm: true, 174 | cancelButtonText: 'No', 175 | confirmButtonText: 'Yes, delete it!', 176 | confirmButtonColor: '#da5347' 177 | }, function() { 178 | //Get the id of the message to be deleted 179 | let msgId = event.currentTarget.parentNode.parentNode.id; 180 | 181 | //Make sure message exists 182 | let msg = Messages.findOne({ '_id': msgId } ); 183 | let whoDeleted; 184 | 185 | //If message exists 186 | if(msg) { 187 | //Set deleted correctly 188 | if(msg.originatingFromId === Meteor.userId()) { 189 | whoDeleted = 'from'; 190 | } else { 191 | whoDeleted = 'to'; 192 | } 193 | 194 | //Remove selected message 195 | Meteor.call('messages.remove', msgId, whoDeleted, (error, result) => { 196 | if(error) { 197 | Bert.alert('Message couldn\'t be deleted.', 'danger', 'growl-top-right'); 198 | } else { 199 | Bert.alert('Message deleted', 'success', 'growl-top-right'); 200 | } 201 | }); 202 | } else { 203 | Bert.alert('Message couldn\'t be deleted.', 'danger', 'growl-top-right'); 204 | } 205 | }); 206 | } 207 | }); 208 | 209 | /* singleMessage template on rendered */ 210 | Template.singleMessage.onRendered(function() { 211 | //Move to bottom of div so most recent message is shown 212 | $('#singleMessageArea').scrollTop($('#singleMessageArea')[0].scrollHeight); 213 | 214 | //Set focus to the reply body text area 215 | $('[data-id=reply-body]').focus(); 216 | 217 | //Set submit button to disabled since text field is empty 218 | $('[data-id=reply-submit]').addClass('disabled'); 219 | }); 220 | 221 | /* singleMessage template helpers */ 222 | Template.singleMessage.helpers({ 223 | getSingleMessage: () => { 224 | return Messages.findOne({ '_id': Session.get('selectedMsg') } ); 225 | }, 226 | showMessage: (originatingFromDeleted, originatingToDeleted) => { 227 | //Don't show messages that have been previously deleted 228 | if(Session.equals('originating', 'from')) { 229 | return !originatingFromDeleted; 230 | } else { 231 | return !originatingToDeleted; 232 | } 233 | }, 234 | fromOtherUser: (from, to) => { 235 | if(from.userId !== Meteor.userId()) { 236 | return true; 237 | } else { 238 | return false; 239 | } 240 | }, 241 | formatDate: (date) => { 242 | return performFormatting(date); 243 | } 244 | }); 245 | 246 | /* singleMessage template events */ 247 | Template.singleMessage.events({ 248 | 'keyup [data-id=reply-body]': (event, template) => { 249 | //If reply section has text enable the submit button, else disable it 250 | if(template.find('[data-id=reply-body]').value.toString().trim() !== '') { 251 | $('[data-id=reply-submit]').removeClass('disabled'); 252 | } else { 253 | $('[data-id=reply-submit]').addClass('disabled'); 254 | } 255 | 256 | // When shift and enter are pressed, submit form 257 | if (event.shiftKey && event.keyCode === 13) { 258 | $('[data-id=reply-message-form]').submit(); 259 | } 260 | }, 261 | 'submit [data-id=reply-message-form]': (event, template) => { 262 | event.preventDefault(); 263 | 264 | //Only continue if button isn't disabled 265 | if(!$('[data-id=reply-submit]').hasClass('disabled')) { 266 | let body = template.find('[data-id=reply-body]').value, 267 | currMessage = Messages.findOne({_id: Session.get('selectedMsg')}), 268 | toUserId = getOtherUserId(currMessage.originatingFromId, currMessage.originatingToId), 269 | fieldEmpty = false; 270 | 271 | //Make sure fields arent empty 272 | if(!body.toString().trim()) { 273 | fieldEmpty = true; 274 | Bert.alert('Please enter a message.', 'danger', 'growl-top-right'); 275 | } 276 | if(!fieldEmpty && !toUserId) { 277 | fieldEmpty = true; 278 | Bert.alert('Error sending message.', 'danger', 'growl-top-right'); 279 | } 280 | 281 | //Continue if the fields aren't empty 282 | if(!fieldEmpty) { 283 | //Add reply to the conversation array of the existing message 284 | Meteor.call('messages.addMessage', Session.get('selectedMsg'), toUserId, body, (error, result) => { 285 | if(error) { 286 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 287 | } else { 288 | //Display success message and reset form values 289 | Bert.alert('Message sent', 'success', 'growl-top-right'); 290 | template.find('[data-id=reply-body]').value = ''; 291 | 292 | //Scroll to bottom of message area, set focus back to reply text area, and disable submit button 293 | $('#singleMessageArea').scrollTop($('#singleMessageArea')[0].scrollHeight); 294 | $('[data-id=reply-body]').focus(); 295 | $('[data-id=reply-submit]').addClass('disabled'); 296 | } 297 | }); 298 | } 299 | } 300 | } 301 | }); 302 | 303 | /* compose template on rendered */ 304 | Template.compose.onRendered(function() { 305 | //Set focus to the to text area 306 | $('[data-id=message-to]').focus(); 307 | 308 | //Set submit button to disabled since text fields are empty 309 | $('[data-id=message-submit]').addClass('disabled'); 310 | }); 311 | 312 | /* compose template helpers */ 313 | Template.compose.helpers({ 314 | //Settings for autocomplete To field 315 | settings: () => { 316 | return { 317 | position: 'bottom', 318 | limit: 5, 319 | rules: [ 320 | { 321 | collection: Meteor.users, 322 | field: 'username', 323 | template: Template.userList, 324 | filter: { _id: { $ne: Meteor.userId() }} 325 | } 326 | ] 327 | }; 328 | } 329 | }); 330 | 331 | /* compose template events */ 332 | Template.compose.events({ 333 | 'keyup [data-id=message-to], keyup [data-id=message-body]': (event, template) => { 334 | // If to and body sections have text enable the submit button, else disable it 335 | if (template.find('[data-id=message-to]').value.toString().trim() !== '' && 336 | template.find('[data-id=message-body]').value.toString().trim() !== '') { 337 | $('[data-id=message-submit]').removeClass('disabled'); 338 | } else { 339 | $('[data-id=message-submit]').addClass('disabled'); 340 | } 341 | }, 342 | 'keyup [data-id=message-body]': (event, template) => { 343 | // When shift and enter are pressed, submit form 344 | if (event.shiftKey && event.keyCode === 13) { 345 | $('[data-id=send-message-form]').submit(); 346 | } 347 | }, 348 | 'submit [data-id=send-message-form]': (event, template) => { 349 | event.preventDefault(); 350 | 351 | //Only continue if button isn't disabled 352 | if(!$('[data-id=message-submit]').hasClass('disabled')) { 353 | //Get text from To and Body fields 354 | let to = template.find('[data-id=message-to]').value.toString().trim(), 355 | body = template.find('[data-id=message-body]').value, 356 | fieldEmpty = false; 357 | 358 | //Verify that both fields contain text 359 | if(!to) { 360 | fieldEmpty = true; 361 | Bert.alert('Please enter a username in the To field.', 'danger', 'growl-top-right'); 362 | } 363 | if(!fieldEmpty && !body.toString().trim()) { 364 | fieldEmpty = true; 365 | Bert.alert('Please enter a message.', 'danger', 'growl-top-right'); 366 | } 367 | 368 | //Continue if the fields aren't empty 369 | if(!fieldEmpty) { 370 | //Try to find user in Users collection 371 | let toUser = Meteor.users.findOne({username: to}); 372 | 373 | //If user was found then it is a valid user 374 | if(toUser) { 375 | //If user is not the current user then send the message 376 | if(toUser._id != Meteor.userId()) 377 | { 378 | //If message between these two users already exists, add this message to the current conversation, else create a new message 379 | let existingMessage = Messages.findOne({$or: [{'originatingFromId': Meteor.userId()}, {'originatingToId': Meteor.userId()}], $or: [{'originatingFromId': toUser._id}, {'originatingToId': toUser._id}]}); 380 | 381 | if(existingMessage) { 382 | //Add message to existing conversation 383 | Meteor.call('messages.addMessage', existingMessage._id, toUser._id, body, (error, result) => { 384 | if(error) { 385 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 386 | } else { 387 | //Display success message and reset form values 388 | Bert.alert('Message sent', 'success', 'growl-top-right'); 389 | template.find('[data-id=message-to]').value = ''; 390 | template.find('[data-id=message-body]').value = ''; 391 | 392 | //Switch the to allMessages view 393 | $('button').removeClass('active'); 394 | $('#allMessagesButton').addClass('active'); 395 | Session.set('currentView', 'allMessages'); 396 | } 397 | }); 398 | } else { 399 | //Create new message 400 | Meteor.call('messages.insert', toUser._id, toUser.username, body, (error, result) => { 401 | if(error) { 402 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 403 | } else { 404 | //Display success message and reset form values 405 | Bert.alert('Message sent', 'success', 'growl-top-right'); 406 | 407 | //Switch the to allMessages view 408 | $('button').removeClass('active'); 409 | $('#allMessagesButton').addClass('active'); 410 | Session.set('currentView', 'allMessages'); 411 | } 412 | }); 413 | } 414 | } else { 415 | //Can't send a message to yourself, display error 416 | Bert.alert('Sorry, you can only send messages to other users.', 'danger', 'growl-top-right'); 417 | } 418 | } else { 419 | //Wasn't a valid user, display error 420 | Bert.alert('Sorry, we can\'t find that user. Please verify the username and try again.', 'danger', 'growl-top-right'); 421 | } 422 | } 423 | } 424 | } 425 | }); 426 | 427 | -------------------------------------------------------------------------------- /client/views/shared/_follow_button.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /client/views/shared/_follow_button.js: -------------------------------------------------------------------------------- 1 | Template.followButton.events({ 2 | 'click [data-id=follow]': function(event, template) { 3 | Meteor.call('users.follow', this.user._id, (error, result) => { 4 | if (error) { 5 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 6 | } else { 7 | Bert.alert(`You are now following @${this.user.username}`, 'success', 'growl-top-right'); 8 | } 9 | }); 10 | }, 11 | 12 | 'click [data-id=unfollow]': function(event, template) { 13 | Meteor.call('users.unfollow', this.user._id, (error, result) => { 14 | if (error) { 15 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 16 | } else { 17 | Bert.alert(`You have unfollowed @${this.user.username}`, 'success', 'growl-top-right'); 18 | } 19 | }); 20 | } 21 | }); 22 | 23 | Template.followButton.helpers({ 24 | isThisUserNotCurrentUser: function() { 25 | return this.user._id !== Meteor.userId(); 26 | }, 27 | 28 | isCurrentUserFollowingThisUser: function() { 29 | return Meteor.user().followingIds && Meteor.user().followingIds.indexOf(this.user._id) > -1; 30 | } 31 | }); 32 | -------------------------------------------------------------------------------- /client/views/shared/_navigation.html: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /client/views/shared/_navigation.js: -------------------------------------------------------------------------------- 1 | /* On navigation template created */ 2 | Template.navigation.onCreated(function() { 3 | this.autorun(() => { 4 | // Set subscriptions 5 | this.subscribe('messages.all'); 6 | }); 7 | }); 8 | 9 | Template.navigation.helpers({ 10 | activeIfRouteNameIs: (routeName) => { 11 | if (FlowRouter.getRouteName() === routeName) { 12 | return 'active'; 13 | } 14 | return ''; 15 | }, 16 | getUnreadCount: () => { 17 | let unreadMessageCount = 0; 18 | let messages = Messages.find({$or: [{ originatingFromId: Meteor.userId(), 'conversation.originatingFromDeleted': false }, {originatingToId: Meteor.userId(), 'conversation.originatingToDeleted': false}]}).forEach(function(msg) { 19 | for (let x = 0; x < msg.conversation.length; x++) { 20 | if (msg.conversation[x].to.userId === Meteor.userId() && !msg.conversation[x].to.read) { 21 | unreadMessageCount++; 22 | } 23 | } 24 | }); 25 | 26 | if (unreadMessageCount > 0) { 27 | return ('(' + unreadMessageCount + ')'); 28 | } else { 29 | return ''; 30 | } 31 | } 32 | }); 33 | -------------------------------------------------------------------------------- /client/views/shared/_post.html: -------------------------------------------------------------------------------- 1 | 35 | -------------------------------------------------------------------------------- /client/views/shared/_post.js: -------------------------------------------------------------------------------- 1 | Template.post.onCreated(function() { 2 | this.searchQuery = new ReactiveVar(''); 3 | this.filter = new ReactiveVar('all'); 4 | this.limit = new ReactiveVar(20); 5 | this.autorun(() => { 6 | this.subscribe('posts.all', this.searchQuery.get(), this.filter.get(), this.limit.get()); 7 | }); 8 | }); 9 | 10 | Template.post.events({ 11 | 'click [data-id=remove-post]': function(event, template) { 12 | let self = this; 13 | 14 | // Sweet Alert delete confirmation 15 | swal({ 16 | title: 'Delete post?', 17 | text: 'Are you sure that you want to delete this post?', 18 | type: 'error', 19 | showCancelButton: true, 20 | closeOnConfirm: true, 21 | cancelButtonText: 'No', 22 | confirmButtonText: 'Yes, delete it!', 23 | confirmButtonColor: '#da5347' 24 | }, function() { 25 | Meteor.call('posts.remove', self._id, (error, result) => { 26 | if (error) { 27 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 28 | } else { 29 | Bert.alert('Post successfully removed', 'success', 'growl-top-right'); 30 | } 31 | }); 32 | }); 33 | }, 34 | 'click [data-id=like-post]': function(event, template) { 35 | let self = this; 36 | 37 | Meteor.call('posts.like', self._id, (error, result) => { 38 | if (error) { 39 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 40 | } 41 | }); 42 | } 43 | }); 44 | 45 | Template.post.helpers({ 46 | author: function() { 47 | return Meteor.users.findOne({ _id: this.authorId }); 48 | }, 49 | belongsPostToUser: function() { 50 | return this.authorId === Meteor.userId(); 51 | }, 52 | formatDate: function(date) { 53 | let currDate = moment(new Date()), 54 | msgDate = moment(new Date(date)); 55 | 56 | let diff = currDate.diff(msgDate, 'days'); 57 | 58 | if (diff === 0 && currDate.day() === msgDate.day()) { 59 | let hourDiff = currDate.diff(msgDate, 'hours'), 60 | minDiff = currDate.diff(msgDate, 'minutes'); 61 | if (hourDiff > 0) { 62 | if (hourDiff === 1) { 63 | return (hourDiff + ' hr'); 64 | } else { 65 | return (hourDiff + ' hrs'); 66 | } 67 | } else if (minDiff > 0) { 68 | if (minDiff === 1) { 69 | return (minDiff + ' min'); 70 | } else { 71 | return (minDiff + ' mins'); 72 | } 73 | } else { 74 | return 'Just now'; 75 | } 76 | } else if (diff <= 1 && currDate.day() !== msgDate.day()) { 77 | return ('Yesterday at ' + moment(date).format('h:mm a')); 78 | } else { 79 | if (currDate.year() !== msgDate.year()) { 80 | return moment(date).format('MMMM DD, YYYY'); 81 | } else { 82 | return (moment(date).format('MMMM DD') + ' at ' + moment(date).format('h:mm a')); 83 | } 84 | } 85 | }, 86 | isLiked: function() { 87 | if (Posts.find( { _id: this._id, already_voted: { $in: [Meteor.userId()]} }).count() === 1) { 88 | return 'liked'; 89 | } 90 | return ''; 91 | } 92 | }); 93 | -------------------------------------------------------------------------------- /client/views/shared/_small_profile.html: -------------------------------------------------------------------------------- 1 | 14 | -------------------------------------------------------------------------------- /client/views/users/browse_users.html: -------------------------------------------------------------------------------- 1 | 27 | -------------------------------------------------------------------------------- /client/views/users/browse_users.js: -------------------------------------------------------------------------------- 1 | Template.browseUsers.events({ 2 | 'click [data-id=load-more]': (event, template) => { 3 | template.limit.set(template.limit.get() + 20); 4 | }, 5 | 6 | 'keyup [data-id=search-query]': _.debounce((event, template) => { 7 | event.preventDefault(); 8 | template.searchQuery.set(template.find('[data-id=search-query]').value); 9 | template.limit.set(20); 10 | }, 300), 11 | 12 | 'submit [data-id=search-users-form]': (event, template) => { 13 | event.preventDefault(); 14 | } 15 | }); 16 | 17 | Template.browseUsers.helpers({ 18 | users: () => { 19 | return Meteor.users.find({ _id: { $ne: Meteor.userId() } }, { sort: { createdAt: -1 } }); 20 | }, 21 | 22 | hasMoreUsers: () => { 23 | return Template.instance().limit.get() <= Template.instance().usersCount.get(); 24 | } 25 | }); 26 | 27 | Template.browseUsers.onCreated(function() { 28 | this.searchQuery = new ReactiveVar(''); 29 | this.limit = new ReactiveVar(20); 30 | this.usersCount = new ReactiveVar(0); 31 | 32 | this.autorun(() => { 33 | this.subscribe('users.all', this.searchQuery.get(), this.limit.get()); 34 | this.usersCount.set(Counts.get('users.all')); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /client/views/users/follower.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /client/views/users/follower.js: -------------------------------------------------------------------------------- 1 | Template.follower.helpers({ 2 | users: () => { 3 | return Meteor.users.find({ _id: { $ne: Meteor.userId() } }, { sort: { username: 1 } }); 4 | } 5 | }); 6 | 7 | Template.follower.onCreated(function() { 8 | this.autorun(() => { 9 | this.subscribe('users.follower'); 10 | }); 11 | }); 12 | -------------------------------------------------------------------------------- /client/views/users/following.html: -------------------------------------------------------------------------------- 1 | 18 | -------------------------------------------------------------------------------- /client/views/users/following.js: -------------------------------------------------------------------------------- 1 | Template.following.helpers({ 2 | users: () => { 3 | if (Meteor.user().followingIds && Meteor.user().followingIds.length !== 0) { 4 | return Meteor.users.find({ _id: { $in: Meteor.user().followingIds } }, { sort: { username: 1 } }); 5 | } else { 6 | return []; 7 | } 8 | } 9 | }); 10 | 11 | Template.following.onCreated(function() { 12 | this.autorun(() => { 13 | this.subscribe('users.following'); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /client/views/users/profile.html: -------------------------------------------------------------------------------- 1 | 33 | -------------------------------------------------------------------------------- /client/views/users/profile.js: -------------------------------------------------------------------------------- 1 | Template.profile.events({ 2 | 'click [data-id=load-more]': (event, template) => { 3 | template.limit.set(template.limit.get() + 20); 4 | } 5 | }); 6 | 7 | Template.profile.helpers({ 8 | user: () => { 9 | return Meteor.users.findOne({ _id: FlowRouter.getParam('_id') }); 10 | }, 11 | 12 | posts: function() { 13 | return Posts.find({}, { sort: { createdAt: -1 } }); 14 | }, 15 | 16 | hasMorePosts: () => { 17 | return Template.instance().limit.get() <= Template.instance().userPostsCount.get(); 18 | } 19 | }); 20 | 21 | Template.profile.onCreated(function () { 22 | this.limit = new ReactiveVar(20); 23 | this.userPostsCount = new ReactiveVar(0); 24 | 25 | this.autorun(() => { 26 | this.subscribe('users.profile', FlowRouter.getParam('_id'), this.limit.get()); 27 | this.userPostsCount.set(Counts.get('users.profile')); 28 | 29 | // Get current user's social media accounts 30 | let profileUser = Meteor.users.findOne({_id: FlowRouter.getParam('_id')}); 31 | 32 | // Display social media links 33 | if (profileUser && profileUser.socialMedia) { 34 | $('#socialMediaAccounts').empty(); 35 | for (var prop in profileUser.socialMedia) { 36 | let smLink = ''; 37 | $(smLink).appendTo('#socialMediaAccounts'); 38 | } 39 | } 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /client/views/users/update_profile.html: -------------------------------------------------------------------------------- 1 | 19 | -------------------------------------------------------------------------------- /client/views/users/update_profile.js: -------------------------------------------------------------------------------- 1 | Template.updateProfile.onCreated(function() { 2 | this.searchQuery = new ReactiveVar(''); 3 | this.limit = new ReactiveVar(20); 4 | this.usersCount = new ReactiveVar(0); 5 | 6 | this.autorun(() => { 7 | // Set subscriptions 8 | this.subscribe('users.all', this.searchQuery.get(), this.limit.get()); 9 | this.usersCount.set(Counts.get('users.all')); 10 | 11 | // Get current user's social media accounts 12 | let currentUser = Meteor.users.findOne({_id: Meteor.userId()}); 13 | 14 | // Display social media links 15 | if (currentUser && currentUser.socialMedia) { 16 | $('#socialMediaAccounts').empty(); 17 | for (var prop in currentUser.socialMedia) { 18 | let smLink = ''; 19 | $(smLink).appendTo('#socialMediaAccounts'); 20 | } 21 | } 22 | }); 23 | }); 24 | 25 | Template.updateProfile.onRendered(function() { 26 | $('input[type=submit]').addClass('disabled'); 27 | Session.set('startingBio', $('[data-id=biography]').val()); 28 | }); 29 | 30 | Template.updateProfile.events({ 31 | 'keyup [data-id=biography], keyup [data-id=addSocialMedia]': (event, template) => { 32 | // If bio section has changed or social media section has text enable the submit button, else disable it 33 | if (template.find('[data-id=biography]').value !== Session.get('startingBio') || 34 | template.find('[data-id=addSocialMedia]').value.toString().trim() !== '') { 35 | $('input[type=submit]').removeClass('disabled'); 36 | } else { 37 | $('input[type=submit]').addClass('disabled'); 38 | } 39 | }, 40 | 41 | 'submit [data-id=update-profile-form]': (event, template) => { 42 | event.preventDefault(); 43 | 44 | // Only continue if button isn't disabled 45 | if (!$('input[type=submit]').hasClass('disabled')) { 46 | let user = { 47 | biography: template.find('[data-id=biography]').value, 48 | socialMedia: { } 49 | }; 50 | 51 | // Get current social media accounts for this user 52 | user.socialMedia = Meteor.users.findOne({_id: Meteor.userId()}).socialMedia || {}; 53 | 54 | let bioChanged = false, 55 | numSmAdded = 0, 56 | numInvalidSm = 0; 57 | if (user.biography !== Session.get('startingBio')) { 58 | bioChanged = true; 59 | } 60 | 61 | // Add social media accounts if any were entered 62 | let newSocialMedia = template.find('[data-id=addSocialMedia]').value.toString().split(','); 63 | for (let x = 0; x < newSocialMedia.length; x++) { 64 | newSocialMedia[x] = newSocialMedia[x].trim(); 65 | 66 | if (newSocialMedia[x] !== '') { 67 | if (newSocialMedia[x].indexOf('http://') === -1 && newSocialMedia[x].indexOf('https://') === -1) { 68 | newSocialMedia[x] = 'http://' + newSocialMedia[x]; 69 | } 70 | 71 | if (newSocialMedia[x].toLowerCase().indexOf('facebook') !== -1) { 72 | user.socialMedia.facebook = newSocialMedia[x]; 73 | numSmAdded++; 74 | } else if (newSocialMedia[x].toLowerCase().indexOf('twitter') !== -1) { 75 | user.socialMedia.twitter = newSocialMedia[x]; 76 | numSmAdded++; 77 | } else if (newSocialMedia[x].toLowerCase().indexOf('linkedin') !== -1) { 78 | user.socialMedia.linkedin = newSocialMedia[x]; 79 | numSmAdded++; 80 | } else if (newSocialMedia[x].toLowerCase().indexOf('instagram') !== -1) { 81 | user.socialMedia.instagram = newSocialMedia[x]; 82 | numSmAdded++; 83 | } else if (newSocialMedia[x].toLowerCase().indexOf('plus.google') !== -1) { 84 | user.socialMedia.googlePlus = newSocialMedia[x]; 85 | numSmAdded++; 86 | } else if (newSocialMedia[x].toLowerCase().indexOf('github') !== -1) { 87 | user.socialMedia.github = newSocialMedia[x]; 88 | numSmAdded++; 89 | } else if (newSocialMedia[x].toLowerCase().indexOf('youtube') !== -1) { 90 | user.socialMedia.youtube = newSocialMedia[x]; 91 | numSmAdded++; 92 | } else { 93 | numInvalidSm++; 94 | } 95 | } 96 | } 97 | 98 | if (bioChanged || numSmAdded > 0) { 99 | Meteor.call('users.updateProfile', user, (error, result) => { 100 | if (error) { 101 | Bert.alert(error.reason, 'danger', 'growl-top-right'); 102 | } else { 103 | if (numInvalidSm > 0) { 104 | Bert.alert('Profile updated but some social media links were invalid', 'success', 'growl-top-right'); 105 | } else { 106 | Bert.alert('Profile successfully updated', 'success', 'growl-top-right'); 107 | } 108 | 109 | template.find('[data-id=addSocialMedia]').value = ''; 110 | } 111 | }); 112 | } else { 113 | Bert.alert('Invalid social media links', 'danger', 'growl-top-right'); 114 | } 115 | } 116 | } 117 | }); 118 | -------------------------------------------------------------------------------- /collections/jobs.js: -------------------------------------------------------------------------------- 1 | Jobs = new Mongo.Collection('jobs'); 2 | 3 | Meteor.methods({ 4 | 'jobs.post': (title, location, schedule, description, responsibilities, qualifications, externalLink) => { 5 | check(title, String); 6 | check(location, String); 7 | check(schedule, String); 8 | check(description, String); 9 | check(responsibilities, String); 10 | check(qualifications, String); 11 | check(externalLink, String); 12 | 13 | // Verify that user is logged in 14 | if (!Meteor.user()) { 15 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 16 | } 17 | 18 | // Verify that required fields are empty 19 | if (!title) { 20 | throw new Meteor.Error(422, 'Job title should not be blank'); 21 | } 22 | if (!location) { 23 | throw new Meteor.Error(422, 'Job location should not be blank'); 24 | } 25 | if (!description) { 26 | throw new Meteor.Error(422, 'Job description should not be blank'); 27 | } 28 | 29 | // Create job object to be inserted into DB 30 | let job = { 31 | title: title, 32 | location: location, 33 | schedule: schedule, 34 | description: description, 35 | responsibilities: responsibilities, 36 | qualifications: qualifications, 37 | externalLink: externalLink, 38 | author: Meteor.userId(), 39 | createdOn: new Date() 40 | }; 41 | 42 | // Insert new job 43 | return Jobs.insert(job); 44 | }, 45 | 'jobs.remove': (jobId) => { 46 | check(jobId, String); 47 | 48 | // Verify that user is logged in 49 | if (!Meteor.user()) { 50 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 51 | } 52 | 53 | // Verify that job exists 54 | if (Jobs.find({_id: jobId}).count() === 0) { 55 | throw new Meteor.Error(111, 'Not a valid job'); 56 | } 57 | 58 | // Remove job by jobId 59 | return Jobs.remove({_id: jobId}); 60 | }, 61 | 'jobs.update': (jobId, title, location, schedule, description, responsibilities, qualifications, externalLink) => { 62 | check(jobId, String); 63 | check(title, String); 64 | check(location, String); 65 | check(schedule, String); 66 | check(description, String); 67 | check(responsibilities, String); 68 | check(qualifications, String); 69 | check(externalLink, String); 70 | 71 | // Verify that user is logged in 72 | if (!Meteor.user()) { 73 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 74 | } 75 | 76 | // Verify that job exists 77 | if (Jobs.find({_id: jobId}).count() === 0) { 78 | throw new Meteor.Error(111, 'Not a valid job'); 79 | } 80 | 81 | // Update job by jobId 82 | return Jobs.update({_id: jobId}, {$set: {title: title, location: location, schedule: schedule, 83 | description: description, responsibilities: responsibilities, 84 | qualifications: qualifications, externalLink: externalLink}}); 85 | } 86 | }); 87 | -------------------------------------------------------------------------------- /collections/messages.js: -------------------------------------------------------------------------------- 1 | Messages = new Mongo.Collection('messages'); 2 | 3 | Meteor.methods({ 4 | 'messages.insert': (toUserId, toUsername, body) => { 5 | check(toUserId, String); 6 | check(toUsername, String); 7 | check(body, String); 8 | 9 | // Verify that user is logged in 10 | if (!Meteor.user()) { 11 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 12 | } 13 | 14 | if (!toUserId) { 15 | throw new Meteor.Error(422, 'To field should not be blank'); 16 | } 17 | 18 | if (!body) { 19 | throw new Meteor.Error(422, 'Body should not be blank'); 20 | } 21 | 22 | // Verify that to is an existing user 23 | if (Meteor.users.find({_id: toUserId}).count() === 0) { 24 | throw new Meteor.Error(111, 'Not a valid user'); 25 | } 26 | 27 | let message = { 28 | originatingFromId: Meteor.userId(), 29 | originatingFromName: Meteor.user().username, 30 | originatingToId: toUserId, 31 | originatingToName: toUsername, 32 | conversation: [{ 33 | from: { 34 | userId: Meteor.userId() 35 | }, 36 | to: { 37 | userId: toUserId, 38 | read: false 39 | }, 40 | body: body, 41 | sentOn: new Date(), 42 | originatingFromDeleted: false, 43 | originatingToDeleted: false 44 | }] 45 | }; 46 | 47 | return Messages.insert(message); 48 | }, 49 | 'messages.remove': (messageId, whoDeleted) => { 50 | check(messageId, String); 51 | check(whoDeleted, String); 52 | 53 | // Verify that user is logged in 54 | if (!Meteor.user()) { 55 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 56 | } 57 | 58 | // Verify that message exists 59 | if (Messages.find({_id: messageId}).count() === 0) { 60 | throw new Meteor.Error(111, 'Not a valid message'); 61 | } 62 | 63 | // Get conversation array and store in variable 64 | let conversation = Messages.findOne({_id: messageId}).conversation; 65 | 66 | if (whoDeleted === 'from') { 67 | for (let x = 0; x < conversation.length; x++) { 68 | conversation[x].originatingFromDeleted = true; 69 | } 70 | } else if (whoDeleted === 'to') { 71 | for (let x = 0; x < conversation.length; x++) { 72 | conversation[x].originatingToDeleted = true; 73 | } 74 | } else { 75 | throw new Meteor.Error(211, 'Message could not be deleted'); 76 | } 77 | 78 | Messages.update({_id: messageId}, {$set: {conversation: conversation}}); 79 | }, 80 | 'messages.updateRead': (messageId, val) => { 81 | check(messageId, String); 82 | check(val, Boolean); 83 | 84 | // Verify that user is logged in 85 | if (!Meteor.user()) { 86 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 87 | } 88 | 89 | // Verify that message exists 90 | if (Messages.find({_id: messageId}).count() === 0) { 91 | throw new Meteor.Error(111, 'Not a valid message'); 92 | } 93 | 94 | // Get conversation array and store in variable 95 | let conversation = Messages.findOne({_id: messageId}).conversation; 96 | 97 | for (let x = 0; x < conversation.length; x++) { 98 | if (conversation[x].to.userId === Meteor.userId()) { 99 | conversation[x].to.read = val; 100 | } 101 | } 102 | 103 | // Update entire conversation array in Messages 104 | Messages.update({_id: messageId}, {$set: {conversation: conversation}}); 105 | }, 106 | 'messages.addMessage': (messageId, toUserId, body) => { 107 | check(messageId, String); 108 | check(toUserId, String); 109 | check(body, String); 110 | 111 | // Verify that user is logged in 112 | if (!Meteor.user()) { 113 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 114 | } 115 | 116 | if (!body) { 117 | throw new Meteor.Error(422, 'Body should not be blank'); 118 | } 119 | 120 | if (!toUserId) { 121 | throw new Meteor.Error(422, 'Error finding other user'); 122 | } 123 | 124 | // Verify that message exists 125 | if (Messages.find({_id: messageId}).count() === 0) { 126 | throw new Meteor.Error(111, 'Not a valid message'); 127 | } 128 | 129 | let newMessage = { 130 | from: { 131 | userId: Meteor.userId() 132 | }, 133 | to: { 134 | userId: toUserId, 135 | read: false 136 | }, 137 | body: body, 138 | sentOn: new Date(), 139 | originatingFromDeleted: false, 140 | originatingToDeleted: false 141 | }; 142 | 143 | // Add item to array 144 | Messages.update({_id: messageId}, {$push: {conversation: newMessage}}); 145 | } 146 | }); 147 | -------------------------------------------------------------------------------- /collections/posts.js: -------------------------------------------------------------------------------- 1 | Posts = new Mongo.Collection('posts'); 2 | 3 | Meteor.methods({ 4 | 'posts.insert': (body) => { 5 | check(body, String); 6 | 7 | if (!Meteor.user()) { 8 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 9 | } 10 | if (!body) { 11 | throw new Meteor.Error(422, 'Body should not be blank'); 12 | } 13 | 14 | let post = { 15 | body: body, 16 | authorId: Meteor.userId(), 17 | createdAt: new Date(), 18 | updatedAt: new Date(), 19 | likecount: 0, 20 | already_voted: [] 21 | }; 22 | 23 | return Posts.insert(post); 24 | }, 25 | 'posts.remove': (_id) => { 26 | check(_id, String); 27 | 28 | if (!Meteor.user()) { 29 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 30 | } 31 | if (!_id) { 32 | throw new Meteor.Error(422, '_id should not be blank'); 33 | } 34 | if (Meteor.userId() !== Posts.findOne({ _id: _id }).authorId) { 35 | throw new Meteor.Error(422, 'You can only remove your own posts'); 36 | } 37 | 38 | Posts.remove({ _id: _id }); 39 | }, 40 | 'posts.like': (_id) => { 41 | check(_id, String); 42 | 43 | if (!Meteor.user()) { 44 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 45 | } 46 | if (!_id) { 47 | throw new Meteor.Error(422, '_id should not be blank'); 48 | } 49 | 50 | if (Posts.find( { _id: _id, already_voted: { $in: [Meteor.userId()]} }).count() === 0) { 51 | Posts.update( { _id: _id }, { $push: { already_voted: Meteor.userId() } }); 52 | Posts.update({ _id: _id }, { $inc: {likecount: 1} }); 53 | } else if (Posts.find( { _id: _id, already_voted: { $in: [Meteor.userId()]} }).count() === 1) { 54 | Posts.update( { _id: _id }, { $pull: { already_voted: Meteor.userId() } }); 55 | Posts.update({ _id: _id }, { $inc: { likecount: -1} }); 56 | } 57 | } 58 | }); 59 | -------------------------------------------------------------------------------- /collections/users.js: -------------------------------------------------------------------------------- 1 | Meteor.methods({ 2 | 'users.updateProfile': (user) => { 3 | check(user, { 4 | biography: String, 5 | socialMedia: Object 6 | }); 7 | 8 | if (!Meteor.user()) { 9 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 10 | } 11 | 12 | Meteor.users.update({ _id: Meteor.userId() }, { $set: { biography: user.biography, socialMedia: user.socialMedia } } ); 13 | }, 14 | 15 | 'users.follow': (_id) => { 16 | check(_id, String); 17 | 18 | if (!Meteor.user()) { 19 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 20 | } 21 | if (Meteor.userId() === _id) { 22 | throw new Meteor.Error(422, 'You can not follow yourself'); 23 | } 24 | 25 | Meteor.users.update({ _id: Meteor.userId() }, { $addToSet: { followingIds: _id } }); 26 | }, 27 | 28 | 'users.unfollow': (_id) => { 29 | check(_id, String); 30 | 31 | if (!Meteor.user()) { 32 | throw new Meteor.Error(401, 'You need to be signed in to continue'); 33 | } 34 | if (Meteor.userId() === _id) { 35 | throw new Meteor.Error(422, 'You can not unfollow yourself'); 36 | } 37 | 38 | Meteor.users.update({ _id: Meteor.userId() }, { $pull: { followingIds: _id } }); 39 | } 40 | }); 41 | -------------------------------------------------------------------------------- /lib/accounts/config.js: -------------------------------------------------------------------------------- 1 | AccountsTemplates.configure({ 2 | defaultLayout: 'layout', 3 | defaultLayoutRegions: { 4 | header: 'header'// , 5 | // footer: '_footer' 6 | }, 7 | defaultContentRegion: 'main', 8 | showForgotPasswordLink: true, 9 | overrideLoginErrors: true, 10 | enablePasswordChange: true, 11 | sendVerificationEmail: true, 12 | lowercaseUsername: false, 13 | 14 | // enforceEmailVerification: true, 15 | confirmPassword: true, 16 | continuousValidation: true, 17 | // displayFormLabels: true, 18 | // forbidClientAccountCreation: false, 19 | homeRoutePath: '/', 20 | // showAddRemoveServices: false, 21 | showPlaceholders: true, 22 | 23 | negativeValidation: true, 24 | positiveValidation: true, 25 | negativeFeedback: false, 26 | positiveFeedback: false, 27 | 28 | // privacyUrl: 'privacy', 29 | // termsUrl: 'terms', 30 | 31 | texts: { 32 | title: { 33 | changePwd: 'Change password', 34 | forgotPwd: 'Forgot password?', 35 | resetPwd: 'Reset password', 36 | signIn: 'Sign in', 37 | signUp: 'Sign up', 38 | verifyEmail: 'Verify Email' 39 | }, 40 | button: { 41 | changePwd: 'Change password', 42 | enrollAccount: 'Enroll Text', 43 | forgotPwd: 'Send reset link', 44 | resetPwd: 'Reset Password', 45 | signIn: 'Sign in', 46 | signUp: 'Sign up' 47 | } 48 | } 49 | }); 50 | 51 | // configuring useraccounts for login with both username or email 52 | let pwd = AccountsTemplates.removeField('password'); 53 | AccountsTemplates.removeField('email'); 54 | AccountsTemplates.addFields([ 55 | { 56 | _id: 'email', 57 | type: 'email', 58 | required: true, 59 | displayName: 'Email', 60 | re: /.+@(.+){2,}\.(.+){2,}/, 61 | errStr: 'Invalid email' 62 | }, 63 | { 64 | _id: 'username', 65 | type: 'text', 66 | displayName: 'Username', 67 | required: true, 68 | minLength: 4 69 | }, 70 | { 71 | _id: 'username_and_email', 72 | placeholder: 'Username or email', 73 | type: 'text', 74 | required: true, 75 | displayName: 'Login' 76 | }, 77 | pwd 78 | ]); 79 | 80 | // enable preconfigured Flow-Router routes by useraccounts:flow-router. 81 | AccountsTemplates.configureRoute('changePwd'); 82 | AccountsTemplates.configureRoute('forgotPwd'); 83 | AccountsTemplates.configureRoute('resetPwd'); 84 | AccountsTemplates.configureRoute('signIn'); 85 | AccountsTemplates.configureRoute('signUp'); 86 | AccountsTemplates.configureRoute('verifyEmail'); 87 | // AccountsTemplates.configureRoute('enrollAccount'); // for creating passwords after logging first time 88 | -------------------------------------------------------------------------------- /lib/avatars/config.js: -------------------------------------------------------------------------------- 1 | Avatar.setOptions({ 2 | imageSizes: { 3 | 'extra-large': 200, 4 | 'extra-small': 20 5 | } 6 | }); 7 | -------------------------------------------------------------------------------- /lib/router.js: -------------------------------------------------------------------------------- 1 | publicAccessible = FlowRouter.group({}); 2 | 3 | signInRequired = FlowRouter.group({ 4 | triggersEnter: [AccountsTemplates.ensureSignedIn] 5 | }); 6 | 7 | signInRequired.route('/', { 8 | name: 'feed', 9 | action: () => { 10 | BlazeLayout.render('layout', { 11 | main: 'feed' 12 | }); 13 | setTitle('Feed'); 14 | } 15 | }); 16 | 17 | signInRequired.route('/update-profile', { 18 | name: 'updateProfile', 19 | action: () => { 20 | BlazeLayout.render('layout', { 21 | main: 'updateProfile' 22 | }); 23 | setTitle('Update profile'); 24 | } 25 | }); 26 | 27 | signInRequired.route('/users/:_id', { 28 | name: 'profile', 29 | action: () => { 30 | BlazeLayout.render('layout', { 31 | main: 'profile' 32 | }); 33 | setTitle('Profile'); 34 | } 35 | }); 36 | 37 | signInRequired.route('/browse-users', { 38 | name: 'browseUsers', 39 | action: () => { 40 | BlazeLayout.render('layout', { 41 | main: 'browseUsers' 42 | }); 43 | setTitle('Browse users'); 44 | } 45 | }); 46 | 47 | signInRequired.route('/following', { 48 | name: 'following', 49 | action: () => { 50 | BlazeLayout.render('layout', { 51 | main: 'following' 52 | }); 53 | setTitle('Following'); 54 | } 55 | }); 56 | 57 | signInRequired.route('/follower', { 58 | name: 'follower', 59 | action: () => { 60 | BlazeLayout.render('layout', { 61 | main: 'follower' 62 | }); 63 | setTitle('Follower'); 64 | } 65 | }); 66 | 67 | signInRequired.route('/messages', { 68 | name: 'messages', 69 | action: () => { 70 | BlazeLayout.render('layout', { 71 | main: 'messages' 72 | }); 73 | setTitle('Messages'); 74 | } 75 | }); 76 | 77 | signInRequired.route('/jobBoard', { 78 | name: 'jobBoard', 79 | action: () => { 80 | BlazeLayout.render('layout', { 81 | main: 'jobBoard' 82 | }); 83 | setTitle('Job board'); 84 | } 85 | }); 86 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Gravity 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /public/img/logo-gravity.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GravityProject/gravity/07d5295d7e7ef3cff9d4cc289abc10328fe80dda/public/img/logo-gravity.png -------------------------------------------------------------------------------- /screenshot-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/GravityProject/gravity/07d5295d7e7ef3cff9d4cc289abc10328fe80dda/screenshot-1.png -------------------------------------------------------------------------------- /server/indexes.js: -------------------------------------------------------------------------------- 1 | Posts._ensureIndex({ 2 | body: 'text' 3 | }); 4 | 5 | Meteor.users._ensureIndex({ 6 | username: 'text' 7 | }); 8 | -------------------------------------------------------------------------------- /server/publications.js: -------------------------------------------------------------------------------- 1 | Meteor.publish(null, function() { 2 | if (this.userId) { 3 | return Meteor.users.find({ _id: this.userId }, { fields: { biography: 1, followingIds: 1 } }); 4 | } 5 | }); 6 | 7 | Meteor.publish('userStatus', function() { 8 | return Meteor.users.find({'status.online': true }); 9 | }); 10 | 11 | Meteor.publishComposite('posts.all', function(query, filter, limit) { 12 | check(query, String); 13 | check(filter, String); 14 | check(limit, Number); 15 | 16 | if (this.userId) { 17 | let currentUser = Meteor.users.findOne({ _id: this.userId }); 18 | 19 | let parameters = { 20 | find: {}, 21 | options: {} 22 | }; 23 | 24 | if (filter === 'following') { 25 | if (currentUser.followingIds && currentUser.followingIds.length !== 0) { 26 | parameters.find.authorId = { $in: currentUser.followingIds }; 27 | } else { 28 | parameters.find.authorId = { $in: [] }; 29 | } 30 | } 31 | 32 | return { 33 | find: () => { 34 | if (query) { 35 | parameters.find.$text = { $search: query }; 36 | parameters.options = { 37 | fields: { score: { $meta: 'textScore' } }, 38 | sort: { score: { $meta: 'textScore' } }, 39 | limit: limit 40 | }; 41 | } else { 42 | parameters.options = { sort: { createdAt: -1 }, limit: limit }; 43 | } 44 | Counts.publish(this, 'posts.all', Posts.find(parameters.find), { noReady: true }); 45 | return Posts.find(parameters.find, parameters.options); 46 | }, 47 | children: [ 48 | { 49 | find: (post) => { 50 | return Meteor.users.find({ _id: post.authorId }, { fields: { emails: 1, username: 1 } }); 51 | } 52 | } 53 | ] 54 | }; 55 | } else { 56 | return []; 57 | } 58 | }); 59 | 60 | Meteor.publishComposite('users.profile', function(_id, limit) { 61 | check(_id, String); 62 | check(limit, Number); 63 | 64 | if (this.userId) { 65 | return { 66 | find: () => { 67 | return Meteor.users.find({ _id: _id }); 68 | }, 69 | children: [ 70 | { 71 | find: (user) => { 72 | Counts.publish(this, 'users.profile', Posts.find({ authorId: user._id }), { noReady: true }); 73 | return Posts.find({ authorId: user._id }, { sort: { createdAt: -1 }, limit: limit }); 74 | } 75 | } 76 | ] 77 | }; 78 | } else { 79 | return []; 80 | } 81 | }); 82 | 83 | Meteor.publish('users.all', function(query, limit) { 84 | check(query, String); 85 | check(limit, Number); 86 | 87 | if (this.userId) { 88 | if (query) { 89 | Counts.publish(this, 'users.all', Meteor.users.find({ $text: { $search: query } }), { noReady: true }); 90 | return Meteor.users.find( 91 | { 92 | $text: { 93 | $search: query 94 | } 95 | }, 96 | { 97 | fields: { 98 | score: { 99 | $meta: 'textScore' 100 | } 101 | }, 102 | sort: { 103 | score: { 104 | $meta: 'textScore' 105 | } 106 | }, 107 | limit: limit 108 | } 109 | ); 110 | } else { 111 | Counts.publish(this, 'users.all', Meteor.users.find(), { noReady: true }); 112 | return Meteor.users.find({}, { sort: { createdAt: -1 }, limit: limit }); 113 | } 114 | } else { 115 | return []; 116 | } 117 | }); 118 | 119 | Meteor.publish('users.following', function() { 120 | if (this.userId) { 121 | let currentUser = Meteor.users.findOne({ _id: this.userId }); 122 | 123 | if (currentUser.followingIds && currentUser.followingIds.length !== 0) { 124 | return Meteor.users.find({ _id: { $in: currentUser.followingIds } }, { sort: { username: 1 } }); 125 | } else { 126 | return []; 127 | } 128 | } else { 129 | return []; 130 | } 131 | }); 132 | 133 | Meteor.publish('users.follower', function() { 134 | if (this.userId) { 135 | let currentUser = Meteor.users.findOne({ _id: this.userId }); 136 | 137 | return Meteor.users.find({ followingIds: { $in: [currentUser._id] } }, { sort: { username: 1 } }); 138 | } else { 139 | return []; 140 | } 141 | }); 142 | 143 | Meteor.publish('messages.all', function() { 144 | if (this.userId) { 145 | let currentUser = Meteor.users.findOne({_id: this.userId}); 146 | 147 | return Messages.find({ $or: [{ originatingFromId: currentUser._id }, {originatingToId: currentUser._id }] }); 148 | } else { 149 | return []; 150 | } 151 | }); 152 | 153 | Meteor.publish('jobs.all', function(query, limit) { 154 | check(query, String); 155 | check(limit, Number); 156 | 157 | if (this.userId) { 158 | if (query) { 159 | Counts.publish(this, 'jobs.all', Jobs.find({title: { $regex: '.*' + query + '.*', $options: 'i' } }), { noReady: true }); 160 | return Jobs.find({ title: { $regex: '.*' + query + '.*', $options: 'i' } }, { sort: { createdOn: -1 }, limit: limit }); 161 | } else { 162 | Counts.publish(this, 'jobs.all', Jobs.find({}), { noReady: true }); 163 | return Jobs.find({}, { sort: { createdOn: -1 }, limit: limit }); 164 | } 165 | } else { 166 | return []; 167 | } 168 | }); 169 | -------------------------------------------------------------------------------- /server/startup.js: -------------------------------------------------------------------------------- 1 | Meteor.startup(() => { 2 | Accounts.validateNewUser((user) => { 3 | if (user.emails && user.emails[0].address.length !== 0) { 4 | return true; 5 | } 6 | throw new Meteor.Error(403, 'E-Mail address should not be blank'); 7 | }); 8 | Accounts.validateNewUser((user) => { 9 | if (user.username && user.username.length >= 3) { 10 | return true; 11 | } 12 | throw new Meteor.Error(403, 'Username must have at least 3 characters'); 13 | }); 14 | }); 15 | --------------------------------------------------------------------------------