├── .gitignore
├── .jshintrc
├── README.md
├── common.js
├── package.json
├── public
├── assets
│ └── images
│ │ ├── github-retina.png
│ │ ├── github.png
│ │ ├── twitter-retina.png
│ │ └── twitter.png
└── favicon.ico
├── server
├── app.js
├── bin
│ └── www
├── common
│ └── helpers.js
├── routes.js
└── views
│ ├── error.ejs
│ ├── layout.ejs
│ ├── markup.ejs
│ └── partials
│ └── analytics.ejs
├── src
├── bootstrap.jsx
├── components
│ ├── About.jsx
│ ├── App.jsx
│ ├── Footer.jsx
│ ├── Github.jsx
│ ├── GithubUser.jsx
│ ├── Header.jsx
│ ├── Home.jsx
│ ├── common
│ │ ├── DisplayInfosPanel.jsx
│ │ ├── DisplayStars.jsx
│ │ ├── Panel.jsx
│ │ ├── Spinner.jsx
│ │ ├── Tr.jsx
│ │ └── TwitterButton.jsx
│ ├── github
│ │ ├── IntroBox.jsx
│ │ ├── ProfileBox.jsx
│ │ ├── ProfileList.jsx
│ │ └── SearchBox.jsx
│ └── githubUser
│ │ ├── Profile.jsx
│ │ ├── Repos.jsx
│ │ └── ReposPaginator.jsx
├── routes.jsx
├── services
│ ├── github.js
│ ├── httpService.js
│ ├── httpService
│ │ ├── config
│ │ │ └── environment
│ │ │ │ ├── config.build.js
│ │ │ │ ├── config.dev.js
│ │ │ │ └── config.mock.js
│ │ ├── http.js
│ │ ├── http.mocks
│ │ │ ├── followers.json
│ │ │ ├── repos.json
│ │ │ ├── user.json
│ │ │ └── users.search.json
│ │ └── http.stub.js
│ └── localStorageWrapper.js
└── style
│ ├── footer.scss
│ ├── header.scss
│ ├── main.scss
│ └── spinner.scss
├── webpack.config.js
└── yarn.lock
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | .idea
3 | build
4 | *.log
--------------------------------------------------------------------------------
/.jshintrc:
--------------------------------------------------------------------------------
1 | {
2 | "debug": true,
3 | "devel": true,
4 | "unused": false
5 | }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | react-es6-isomorphic
2 | ====================
3 |
4 | 
5 |
6 | *UPDATE :* Facebook released [react@0.14.0](https://facebook.github.io/react/blog/2015/10/07/react-v0.14.html), so I upgraded [topheman/react-es6](https://github.com/topheman/react-es6) and this project with the latest version (also upgraded react-router - which was the most of the work).
7 |
8 | This project is the isomorphic version of [topheman/react-es6](https://github.com/topheman/react-es6), which contains all the front-end part (UI in React ...) and was coded so that it could be executed in both client and server side (shared components, routes, services ...)
9 |
10 | The backend is based on [topheman/topheman-apis-proxy](https://github.com/topheman/topheman-apis-proxy) which provides the github API.
11 |
12 | Techs involved :
13 |
14 | * React (client and server side rendering)
15 | * ES6 (using Webpack for bundling)
16 | * ExpressJS as node server framework
17 |
18 | If you want to go further, read the [blog post](http://dev.topheman.com/react-es6-isomorphic/) about this project.
19 |
20 | ### Setup
21 |
22 | #### Install
23 |
24 | ```shell
25 | git clone https://github.com/topheman/react-es6-isomorphic.git
26 | cd react-es6-isomorphic
27 | npm install
28 | ```
29 |
30 | You'll have to install the [topheman-apis-proxy](https://github.com/topheman/topheman-apis-proxy) backend, follow the [installation steps](https://github.com/topheman/topheman-apis-proxy#installation) README section.
31 |
32 | #### Run
33 |
34 | A set of npm tasks are configured in the package.json to help you.
35 |
36 | I'll try to automate even more, but with that, you'll be able to have your API server (topheman-apis-proxy), your expressJS server AND the webpack dev server managing client side assets (js, css) with sourceMaps.
37 |
38 | Those npm task are based on the env var `NODE_ENV` which accepts `PROD`/`MOCK`/`DEV`, but this var is abstracted by the tasks.
39 |
40 | ##### Dev mode
41 |
42 | Open three terminal tabs : two in the react-es6-isomorphic project folder, one in the topheman-apis-proxy project folder.
43 |
44 | * Tab 1 react-es6-isomorphic : `npm run webpack-dev` (will launch the webpack dev server, serving the front assets in live reloading)
45 | * Tab 2 react-es6-isomorphic : `npm run server-dev` (will launch the express server on port 9000, monitored by nodemon which will restart the server on pretty much any file changes - since there is server side rendering)
46 | * Tab 3 topheman-apis-proxy : `grunt serve` (see more in the [run in local](https://github.com/topheman/topheman-apis-proxy#run-in-local) README section)
47 |
48 | ##### Mock mode
49 |
50 | You can run the project offline (without topheman-apis-proxy), a set of requests are mocked (not all of them, still wip), it will also be usefull for unit tests in continuous integration :
51 |
52 | * Tab 1 react-es6-isomorphic : `npm run webpack-mock` (will launch webpack dev server, serving the front assets in live reloading)
53 | * Tab 2 react-es6-isomorphic : `npm run server-mock` (will launch the express server on port 9000, monitored by nodemon)
54 |
55 | ##### Prod mode
56 |
57 | `npm run server-prod` will build the assets via webpack in production mode and then launch the express server in production mode.
58 |
59 | Use it to check if the project behaves OK in production mode before delivering (note that my production server of topheman-apis-proxy won't accept xhr because of cors constraints in this case).
60 |
61 | ##### Finally
62 |
63 | Open [http://localhost:9000](http://localhost:9000/)
64 |
65 |
66 | #### Build
67 |
68 | The client-side code is written in ES6 with modules that work as well on the client as on the server (like React, superagent ...).
69 |
70 | * client-side : a bundle is made with webpack and accessible either via the webpack dev server, or written inside `/build` folder
71 | * server-side : `require('babel/register')` lets me use those es6 modules without any transpiling step (babel does it itself on the fly, I don't have to worry about it)
72 |
73 | I also made a set of npm tasks for the build step, that creates a bundle inside the `/build` folder (including js, sass to css, images ...)
74 |
75 | `npm run webpack-build` will build the assets to `/build` (with sourcemaps and all), you can `npm start` after if you want, to check the project without webpack involved.
76 |
77 | This task is triggered at `npm postinstall`, since it's based on the `NODE_ENV` variable, it will create the bundle in the correct mode.
78 |
79 | ### Deploy
80 |
81 | I deploy on heroku, if you don't, you shouldn't have to tweak a lot my routine.
82 |
83 | Assuming you've logged in to heroku on the command line and have setup your heroku remote repo :
84 |
85 | * Since I make the client-side bundle (js, css builds) at deploy time, the hosting platform will also need the devDependencies (such as webpack) : `heroku config:set NPM_CONFIG_PRODUCTION=false`
86 | * Configure your app in production mode : `heroku config:set NODE_ENV=PROD`
87 | * Then : `git push heroku master`
88 |
89 | A tricks I discovered : if you're on a branch and want to deploy to heroku (which ignores anything which is not the master branch) : `git push -f heroku name-of-your-branch:master`
90 |
91 | ### Contributing
92 |
93 | As I stated before, [topheman/react-es6](https://github.com/topheman/react-es6), is the original project, where I'll continue to implement the client-side features - which I will merge into [topheman/react-es6-isomorphic](https://github.com/topheman/react-es6-isomorphic), which is [only about server-side rendering](#contributing).
94 |
95 | So, any **client-side related pull-requests** should be done against [topheman/react-es6](https://github.com/topheman/react-es6).
96 |
97 | **This repo is only about server-side rendering.** I won't accept pull-requests about client-side features.
98 |
99 | ### FAQ
100 |
101 | This project is still a work in progress, I'm documenting as I'm moving forward.
102 |
103 | ### License
104 |
105 | This software is distributed under an MIT licence.
106 |
107 | Copyright 2015 © Christophe Rosset
108 |
109 | > Permission is hereby granted, free of charge, to any person obtaining a copy of this software
110 | > and associated documentation files (the "Software"), to deal in the Software without
111 | > restriction, including without limitation the rights to use, copy, modify, merge, publish,
112 | > distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the
113 | > Software is furnished to do so, subject to the following conditions:
114 | > The above copyright notice and this permission notice shall be included in all copies or
115 | > substantial portions of the Software.
116 | > The Software is provided "as is", without warranty of any kind, express or implied, including
117 | > but not limited to the warranties of merchantability, fitness for a particular purpose and
118 | > noninfringement. In no event shall the authors or copyright holders be liable for any claim,
119 | > damages or other liability, whether in an action of contract, tort or otherwise, arising from,
120 | > out of or in connection with the software or the use or other dealings in the Software.
--------------------------------------------------------------------------------
/common.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | function projectIsGitManaged(){
4 | var fs = require('fs');
5 | var path = require('path');
6 | try {
7 | // Query the entry
8 | var stats = fs.lstatSync(path.join(__dirname,'.git'));
9 |
10 | // Is it a directory?
11 | if (stats.isDirectory()) {
12 | return true;
13 | }
14 | return false;
15 | }
16 | catch (e) {
17 | return false;
18 | }
19 | }
20 |
21 | /**
22 | * Return the git revision if the project is git managed
23 | * Also returns the git revision for a project just shipped to heroku at compile time
24 | * If no git involved, returns null
25 | * @param {String} mode 'short'|'long'
26 | * @returns {String|null}
27 | */
28 | function getGitRevision(mode){
29 | var gitRev = require('git-rev-sync');
30 | mode = mode || 'long';
31 | if(['short','long'].indexOf(mode) === -1){
32 | throw new Error("Only accepts 'short' or 'long' as argument");
33 | }
34 | if(projectIsGitManaged()){
35 | return gitRev[mode]();
36 | }
37 | //case we are on heroku, process.env.SOURCE_VERSION contains the hash of the git revision (only at compile time)
38 | else if(process.env.SOURCE_VERSION){
39 | if(mode === 'short'){
40 | return process.env.SOURCE_VERSION.slice(0,7);
41 | }
42 | return process.env.SOURCE_VERSION
43 | }
44 | return;
45 | }
46 |
47 | function getInfos(){
48 | var moment = require('moment');
49 | var pkg = require('./package.json');
50 | var infos = {
51 | pkg: pkg,
52 | today: moment(new Date()).format('DD/MM/YYYY'),
53 | year: new Date().toISOString().substr(0, 4),
54 | gitRevisionShort: getGitRevision('short'),
55 | gitRevisionLong: getGitRevision('long'),
56 | urlToCommit: undefined
57 | };
58 | infos.urlToCommit = infos.gitRevisionLong ? _getUrlToCommit(pkg, infos.gitRevisionLong) : undefined;
59 | return infos;
60 | }
61 |
62 | /**
63 | * Called in default mode by webpack (will format it correctly in comments)
64 | * Called in formatted mode by gulp (for html comments)
65 | * @param {String} mode default/formatted
66 | * @param {Object} overrideInfos pass an object to override the properties to render
67 | * @returns {String}
68 | */
69 | function getBanner(mode, overrideInfos){
70 | overrideInfos = overrideInfos || {};
71 | var _ = require('lodash');
72 | var infos = _.extend(getInfos(), overrideInfos);
73 | var compiled = _.template([
74 | '<%= pkg.name %>',
75 | '',
76 | '<%= pkg.description %>',
77 | '',
78 | '@version v<%= pkg.version %> - <%= today %>',
79 | '<% if(gitRevisionShort) { %>@revision #<%= gitRevisionShort %><% if (urlToCommit) { %> - <%= urlToCommit %><% } %><% } %>',
80 | '@author <%= (pkg.author && pkg.author.name) ? pkg.author.name : pkg.author %>',
81 | '@copyright <%= year %>(c) <%= (pkg.author && pkg.author.name) ? pkg.author.name : pkg.author %>',
82 | '@license <%= pkg.license %>',
83 | ''
84 | ].join(mode === 'formatted' ? '\n * ' : '\n'));
85 | return compiled(infos);
86 | }
87 |
88 | function getBannerHtml(overrideInfos){
89 | overrideInfos = overrideInfos || {};
90 | return '\n';
91 | }
92 |
93 | function _getUrlToCommit(pkg, gitRevisionLong){
94 | var urlToCommit;
95 | //retrieve and reformat repo url from package.json
96 | if (typeof(pkg.repository) === 'string') {
97 | urlToCommit = pkg.repository;
98 | }
99 | else if (typeof(pkg.repository.url) === 'string') {
100 | urlToCommit = pkg.repository.url;
101 | }
102 | //check that there is a git repo specified in package.json & it is a github one
103 | if (urlToCommit && /^https:\/\/github.com/.test(urlToCommit)) {
104 | urlToCommit = urlToCommit.replace(/.git$/, '/tree/' + gitRevisionLong);//remove the .git at the end
105 | }
106 | return urlToCommit;
107 | }
108 |
109 | module.exports.getInfos = getInfos;
110 | module.exports.getBanner = getBanner;
111 | module.exports.getBannerHtml = getBannerHtml;
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-es6-isomorphic",
3 | "version": "2.2.0",
4 | "description": "A simple app to try React and ES6, using topheman-apis-proxy as data api backend NOW with isomorphism",
5 | "main": "index.js",
6 | "scripts": {
7 | "start": "node ./server/bin/www",
8 | "nodemon": "node_modules/.bin/nodemon -e js,jsx,json,ejs ./server/bin/www",
9 | "test": "echo \"Error: no test specified\" && exit 1",
10 | "server-dev": "DEBUG=http,errors npm run nodemon",
11 | "server-mock": "NODE_ENV=MOCK npm run server-dev",
12 | "server-prod": "echo 'Building assets before launching server' && NODE_ENV=PROD npm run webpack-build && NODE_ENV=PROD DEBUG=http,errors npm start",
13 | "webpack-dev": "./node_modules/.bin/webpack-dev-server --progress --colors",
14 | "webpack-mock": "NODE_ENV=MOCK npm run webpack-dev",
15 | "webpack-build": "./node_modules/.bin/webpack --progress",
16 | "postinstall": "npm run webpack-build"
17 | },
18 | "repository": {
19 | "type": "git",
20 | "url": "https://github.com/topheman/react-es6-isomorphic.git"
21 | },
22 | "keywords": [
23 | "react",
24 | "ES6",
25 | "isomorphism",
26 | "server-side rendering",
27 | "express"
28 | ],
29 | "author": "Christophe Rosset",
30 | "license": "MIT",
31 | "bugs": {
32 | "url": "https://github.com/topheman/react-es6-isomorphic/issues"
33 | },
34 | "homepage": "https://github.com/topheman/react-es6-isomorphic",
35 | "engines": {
36 | "node": "^6.11.1"
37 | },
38 | "devDependencies": {
39 | "babel-core": "^5.8.25",
40 | "babel-loader": "^5.3.2",
41 | "css-loader": "^0.19.0",
42 | "del": "^2.0.2",
43 | "extract-text-webpack-plugin": "^0.8.2",
44 | "file-loader": "^0.8.4",
45 | "git-rev-sync": "^1.4.0",
46 | "json-loader": "^0.5.3",
47 | "lodash": "^3.10.1",
48 | "moment": "^2.10.6",
49 | "node-sass": "^3.3.3",
50 | "nodemon": "^1.3.7",
51 | "react-hot-loader": "^1.3.0",
52 | "run-sequence": "^1.1.4",
53 | "sass-loader": "^3.0.0",
54 | "style-loader": "^0.12.4",
55 | "url-loader": "^0.5.6",
56 | "webpack": "^1.12.2",
57 | "webpack-dev-server": "^1.12.0"
58 | },
59 | "dependencies": {
60 | "bootstrap-sass": "^3.3.5",
61 | "history": "^1.12.4",
62 | "babel": "^5.1.10",
63 | "body-parser": "~1.12.0",
64 | "cookie-parser": "~1.3.4",
65 | "debug": "~2.1.1",
66 | "ejs": "^2.3.1",
67 | "express": "~4.12.2",
68 | "express-ejs-layouts": "^1.1.0",
69 | "lscache": "^1.0.5",
70 | "react": "^0.14.0",
71 | "react-addons-update": "^0.14.0",
72 | "react-dom": "^0.14.0",
73 | "react-router": "^1.0.0-rc3",
74 | "superagent": "^1.1.0",
75 | "morgan": "~1.5.1",
76 | "serve-favicon": "~2.2.0"
77 | },
78 | "private": true
79 | }
80 |
--------------------------------------------------------------------------------
/public/assets/images/github-retina.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/github-retina.png
--------------------------------------------------------------------------------
/public/assets/images/github.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/github.png
--------------------------------------------------------------------------------
/public/assets/images/twitter-retina.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/twitter-retina.png
--------------------------------------------------------------------------------
/public/assets/images/twitter.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/assets/images/twitter.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/topheman/react-es6-isomorphic/e8cd9c9ad6bb962f025bd6dc69c9817e11efd574/public/favicon.ico
--------------------------------------------------------------------------------
/server/app.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('babel/register');//to be able to mix and match ES5 CommonJS / ES6 modules / jsx
4 | var express = require('express');
5 | var path = require('path');
6 | var favicon = require('serve-favicon');
7 | var logger = require('morgan');
8 | var cookieParser = require('cookie-parser');
9 | var bodyParser = require('body-parser');
10 | var helpers = require('./common/helpers');
11 |
12 | var routes = require('./routes');
13 |
14 | var app = express();
15 | var expressLayouts = require('express-ejs-layouts');
16 |
17 | // view engine setup
18 | app.set('views', path.join(__dirname, 'views'));
19 | app.set('view engine', 'ejs');
20 |
21 | // set the layout part
22 | app.set('layout', 'layout');
23 | app.use(expressLayouts);
24 |
25 | // uncomment after placing your favicon in /public
26 | app.use(favicon(path.join(__dirname ,'../public/favicon.ico')));
27 | app.use(logger('dev'));
28 | app.use(bodyParser.json());
29 | app.use(bodyParser.urlencoded({ extended: false }));
30 | app.use(cookieParser());
31 |
32 | // available vars in all templates
33 | app.locals.hash = '';
34 | app.locals.bannerHtml = require('../common').getBannerHtml( app.get('env') === 'PROD' ? require('../build/bannerInfos.json') : {});
35 |
36 | //@note don't really need to serve /public since every assets are bundle with webpack
37 | //app.use(express.static(path.join(__dirname, 'public'))); //keeping it if any assets come out
38 | if (app.get('env') === 'PROD') {
39 | app.use(express.static(path.join(__dirname, '../build')));
40 | try{
41 | app.locals.hash = '-' + require('../build/stats.json').hash;
42 | console.log('Using hash:', app.locals.hash);
43 | }
44 | catch(e){
45 | throw new Error("Couldn't retrieve hash from /build/stats.json, please rebuild", e);
46 | }
47 | }
48 |
49 | // catch 404 and forward to error handler (must be before routes declaration which catches all)
50 | app.use('/:url(assets)/*',function(req, res, next) {
51 | var err = new Error('Not Found');
52 | err.status = 404;
53 | next(err);
54 | });
55 |
56 | app.use('/', routes);
57 |
58 | // error handlers
59 |
60 | // development error handler
61 | // will print stacktrace
62 | if (app.get('env') !== 'PROD') {
63 | app.use(function(err, req, res, next) {
64 | res.status(err.status || 500);
65 | console.log(err, err.message);
66 | res.render(path.join(__dirname,'views/error.ejs'), {
67 | layout: 'layout',
68 | message: err.message,
69 | status: err.status || 500,
70 | error: err,
71 | env: req.app.get('env')
72 | });
73 | });
74 | }
75 |
76 | // production error handler
77 | // no stacktraces leaked to user
78 | app.use(function(err, req, res, next) {
79 | res.status(err.status || 500);
80 | res.render(path.join(__dirname,'views/error.ejs'), {
81 | layout: 'layout',
82 | message: err.message,
83 | status: err.status || 500,
84 | error: {},
85 | env: req.app.get('env')
86 | });
87 | });
88 |
89 | /** httpService init - also runs client side with the same code, both in ES6 */
90 | var httpServiceConfiguration = helpers.getHttpServiceConfiguration(app.get('env'));
91 | var httpService = require('../src/services/httpService').getInstance(httpServiceConfiguration);
92 |
93 | module.exports = app;
94 |
--------------------------------------------------------------------------------
/server/bin/www:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env node
2 |
3 | /**
4 | * Module dependencies.
5 | */
6 |
7 | var app = require('../app');
8 | var debug = require('debug')('http');
9 | var http = require('http');
10 |
11 | var env = process.env.NODE_ENV ? process.env.NODE_ENV.toLowerCase() : 'dev';
12 | if(env === 'prod'){
13 | console.log('PRODUCTION mode');
14 | }
15 | else if(env === 'test'){
16 | console.log('TEST mode');
17 | }
18 | else if(env === 'mock'){
19 | console.log('MOCK mode');
20 | }
21 | else{
22 | console.log('DEVELOPMENT mode');
23 | }
24 |
25 | /**
26 | * Get port from environment and store in Express.
27 | */
28 |
29 | var port = normalizePort(process.env.PORT || '9000');
30 | app.set('port', port);
31 |
32 | /**
33 | * Create HTTP server.
34 | */
35 |
36 | var server = http.createServer(app);
37 |
38 | /**
39 | * Listen on provided port, on all network interfaces.
40 | */
41 |
42 | server.listen(port);
43 | server.on('error', onError);
44 | server.on('listening', onListening);
45 |
46 | /**
47 | * Normalize a port into a number, string, or false.
48 | */
49 |
50 | function normalizePort(val) {
51 | var port = parseInt(val, 10);
52 |
53 | if (isNaN(port)) {
54 | // named pipe
55 | return val;
56 | }
57 |
58 | if (port >= 0) {
59 | // port number
60 | return port;
61 | }
62 |
63 | return false;
64 | }
65 |
66 | /**
67 | * Event listener for HTTP server "error" event.
68 | */
69 |
70 | function onError(error) {
71 | if (error.syscall !== 'listen') {
72 | throw error;
73 | }
74 |
75 | var bind = typeof port === 'string'
76 | ? 'Pipe ' + port
77 | : 'Port ' + port;
78 |
79 | // handle specific listen errors with friendly messages
80 | switch (error.code) {
81 | case 'EACCES':
82 | console.error(bind + ' requires elevated privileges');
83 | process.exit(1);
84 | break;
85 | case 'EADDRINUSE':
86 | console.error(bind + ' is already in use');
87 | process.exit(1);
88 | break;
89 | default:
90 | throw error;
91 | }
92 | }
93 |
94 | /**
95 | * Event listener for HTTP server "listening" event.
96 | */
97 |
98 | function onListening() {
99 | var addr = server.address();
100 | var bind = typeof addr === 'string'
101 | ? 'pipe ' + addr
102 | : 'port ' + addr.port;
103 | debug('Listening on ' + bind);
104 | }
105 |
--------------------------------------------------------------------------------
/server/common/helpers.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | /**
5 | *
6 | * @param env from process.env or app.get('env')
7 | * @returns {Object} the Configuration that will let possible to init the httpService that runs client-side
8 | * to also run server-side
9 | */
10 | getHttpServiceConfiguration: function(env){
11 | var suffix;
12 | switch(env){
13 | case 'development':
14 | case 'DEV':
15 | suffix = 'dev';
16 | break;
17 | case 'PROD':
18 | suffix = 'build';
19 | break;
20 | case 'MOCK':
21 | suffix = 'mock';
22 | break;
23 | }
24 | return require('../../src/services/httpService/config/environment/config.'+suffix+'.js');
25 | }
26 | };
--------------------------------------------------------------------------------
/server/routes.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | var express = require('express');
4 | var router = express.Router();
5 | var path = require('path');
6 | var debug = require('debug')('http');
7 |
8 | var React = require('react');
9 | var ReactDOMServer = require('react-dom/server');
10 | var ReactRouter = require('react-router');
11 | var RoutingContext = ReactRouter.RoutingContext;
12 |
13 | var clientSideRoutes = require('../src/routes.jsx');
14 |
15 | var github = require('../src/services/github');
16 |
17 | /**
18 | * Helper that makes async request when needed to feed data to the router
19 | * for later rendering by react components
20 | *
21 | * @param {Object} req
22 | * @param {Object} renderProps (from react-router)
23 | * @param {Function} cb containing the application to serialize and pass in the params of the router
24 | */
25 | var fetchDataByRoute = function(req, renderProps, cb){
26 | if(req.url.indexOf('/github/user/') > -1){
27 | var state = {
28 | data: {}
29 | };
30 | Promise.all([
31 | github.getUser(renderProps.params.username),
32 | github.getUserRepos(renderProps.params.username,{
33 | page: 1,
34 | sort: "updated",
35 | per_page: 15
36 | })
37 | ]).then(results => {
38 | state.username = renderProps.params.username;
39 | state.data.profile = results[0];
40 | state.data.repositories = results[1];
41 | cb(state);
42 | },() => {
43 | delete state.data;
44 | cb(state);
45 | });
46 | }
47 | else{
48 | cb(state);
49 | }
50 | };
51 |
52 | /**
53 | * The server side-rendering works but there are caveats when rendering from dynamic data
54 | * using the new version of react-router.
55 | * Main reason: it doesn't seem to pass mutated routeInfos (renderProps).
56 | * This bug is a good oportunity to setup some flux-like implementation,
57 | * in order to have clearer management of state.
58 | */
59 | router.get('/*', function(req, res, next) {
60 | ReactRouter.match({routes: clientSideRoutes, location: req.url}, function(err, redirectLocation, renderProps) {
61 | if(err){
62 | debug('[ReactRouter][Error]', req.url, err.message);
63 | return next(err);
64 | }
65 | else if(redirectLocation){
66 | debug('[ReactRouter][Redirection]', req.url, redirectLocation.pathname, redirectLocation.search);
67 | return res.redirect(302, redirectLocation.pathname + redirectLocation.search);
68 | }
69 | else if(renderProps){
70 | debug('[ReactRouter][Found]', req.url);
71 | fetchDataByRoute(req, renderProps, function(appState){
72 | //pass the state of the app via the params, so that the router dispatches to the props of the right component
73 | Object.assign(renderProps.params, appState);
74 | var markup = ReactDOMServer.renderToString( );
75 | //serialize the state of the app so that the client can catch up
76 | var serializedState = JSON.stringify(appState);
77 | return res.render(path.resolve(__dirname, 'views/markup.ejs'), {
78 | layout: 'layout',
79 | markup: markup,
80 | serializedState: serializedState,
81 | env: req.app.get('env')
82 | });
83 | });
84 | }
85 | else{
86 | //@todo correctly render errors
87 | console.log('[ReactRouter][Not Found]', req.url);
88 | var error404 = new Error('Not Found');
89 | error404.status = 404;
90 | next(error404);
91 | }
92 | });
93 | })
94 |
95 | /* if no route matched, redirect home */
96 | router.get('/*', function(req, res, next) {
97 | res.redirect('/');
98 | });
99 |
100 | module.exports = router;
--------------------------------------------------------------------------------
/server/views/error.ejs:
--------------------------------------------------------------------------------
1 |
ERROR <%= status %> : <%= message %>
2 |
3 | <% if (status != 404 && error.stack) { %>
4 | <%= error.stack %>
5 | <% } %>
--------------------------------------------------------------------------------
/server/views/layout.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Topheman - react-es6-isomorphic
6 | <% if(env === 'PROD') { %>
7 |
8 | <% } else { %>
9 |
10 | <% } %>
11 |
12 |
13 |
14 | <% include ./partials/analytics %>
15 | <% if(typeof markup !== 'undefined') { %>
16 | <%- markup %>
17 | <% } %>
18 | <% if(typeof serializedState !== 'undefined') { %>
19 |
22 | <% } %>
23 | <% if(typeof error === 'undefined') { %>
24 | <% if(env === 'PROD') { %>
25 |
26 | <% } else { %>
27 |
28 |
29 | <% } %>
30 | <% } %>
31 |
32 | <%- bannerHtml %>
--------------------------------------------------------------------------------
/server/views/markup.ejs:
--------------------------------------------------------------------------------
1 | <%- markup %>
--------------------------------------------------------------------------------
/server/views/partials/analytics.ejs:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/bootstrap.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import { Router } from 'react-router';
4 | import createBrowserHistory from 'history/lib/createBrowserHistory'
5 | import routes from './routes.jsx';
6 |
7 | //init httpService config
8 | import httpServiceConfiguration from 'httpServiceConfiguration';
9 | import httpService from './services/httpService.js';
10 | httpService.getInstance(httpServiceConfiguration);//will keep config in singleton
11 | //this way, instead of using resolve.alias of webpack (and having the require of module messed up by webpack when they'll be executed server-side)
12 | //I use dependency injection, in the one place that won't be executed in node : the client side bootstrap
13 |
14 | const history = createBrowserHistory();
15 | ReactDOM.render({routes} , document.getElementById('app-container'))
16 |
--------------------------------------------------------------------------------
/src/components/About.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | const About = () => (
6 |
7 |
Update: You may now use "universal" instead of isomorphic (see this post ).
8 |
9 | The difference with regular SPA is that any page you access directly will be initially server-side rendered .
10 | You can check that by accessing directly a page and displaying its source.
11 | Even if server-side rendered, the SPA keeps working as usual (front-end router, template rendering ...).
12 |
13 |
14 | But ... why bother about that ...?
15 |
16 |
I won't go into specifics but here are two main upsides :
17 |
18 | Initial load is faster : Since markup is present, no API request from the client nor re-rendering needed.
19 | More SEO friendly : Since markup is present, crawlers can read your site.
20 |
21 |
22 | TLDR; from stackoverflow
23 |
24 |
25 | Isomorphic web sites can be run on both the server and in the browser. They grant code reuse, seo, and page load speed boosts while still having an interface written in JS. node.js is most often used for the server javascript-engine.
26 |
27 |
Learn more about that and this project on my blog post .
28 |
29 | );
30 |
31 | export default About;
--------------------------------------------------------------------------------
/src/components/App.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | //import Router from 'react-router';
5 | //var RouteHandler = Router.RouteHandler;
6 |
7 | import Header from './Header.jsx';
8 | import Footer from './Footer.jsx';
9 |
10 | const App = ({children}) => (
11 |
12 |
13 |
14 | {children}
15 |
16 |
17 |
18 | );
19 |
20 | export default App;
--------------------------------------------------------------------------------
/src/components/Footer.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | import TwitterButton from './common/TwitterButton.jsx';
6 |
7 | const Footer = () => (
8 |
14 | );
15 |
16 | export default Footer;
--------------------------------------------------------------------------------
/src/components/Github.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | import IntroBox from './github/IntroBox.jsx';
6 | import SearchBox from './github/SearchBox.jsx';
7 |
8 | const Github = () => (
9 |
10 |
11 |
12 |
13 | );
14 |
15 | export default Github;
--------------------------------------------------------------------------------
/src/components/GithubUser.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import update from 'react-addons-update';
5 |
6 | import github from '../services/github.js';
7 | import Spinner from './common/Spinner.jsx';
8 | import Profile from './githubUser/Profile.jsx';
9 | import Repos from './githubUser/Repos.jsx';
10 |
11 | const ORIGINAL_REPOS_PER_PAGE = 15;
12 |
13 | export default class GithubUser extends React.Component {
14 | constructor(props){
15 |
16 | super(props);
17 |
18 | //init context bindings - due to diff between React.createClass and ES6 class
19 | this._getInitialState = this._getInitialState.bind(this);
20 | this.reposGotoPage = this.reposGotoPage.bind(this);
21 | this.init = this.init.bind(this);
22 | this.componentWillMount = this.componentWillMount.bind(this);
23 |
24 | //init state
25 | this.state = this._getInitialState();
26 |
27 | //init state client-side - when the page was already rendered server-side, to share the state (passed serialized)
28 | if(typeof __DATA__ !== 'undefined' && __DATA__ !== null){
29 | this.state = __DATA__.data;
30 | __DATA__ = null;
31 | }
32 |
33 | //init state server-side - to render based on data previously retrieved and passed through the router params
34 | if(props.params.data){
35 | this.state.profile = props.params.data.profile;
36 | this.state.profile.pristineLogin = props.params.username;
37 | this.state.repositories = props.params.data.repositories;
38 | this.state.repositories.pristineLogin = props.params.username;
39 | }
40 |
41 | }
42 |
43 | /**
44 | * If the component's state not yet initialized with data (from server-side rendering),
45 | * will trigger an xhr, a state change and a re-render
46 | *
47 | * Should never be triggered on server-side rendering (even if componentDidMount only fires on client,
48 | * this one fires before the first render - see http://facebook.github.io/react/docs/component-specs.html#lifecycle-methods )
49 | */
50 | componentWillMount(){
51 | if(!this.state.profile.data) {
52 | this.init(this.state.profile.pristineLogin);
53 | }
54 | }
55 | _getInitialState(){
56 | return{
57 | profile: {
58 | pristineLogin: this.props.params.username
59 | },
60 | repositories: {
61 | pristineLogin: this.props.params.username
62 | }
63 | };
64 | }
65 | init(userName){
66 | //init the state as fetching
67 | var newState = update(this.state,{
68 | profile:{
69 | fetching: {$set: true}
70 | },
71 | repositories:{
72 | fetching: {$set: true}
73 | }
74 | });
75 | this.setState(newState);
76 | //client-side fetching of the profile via xhr based on username
77 | github.getUser(userName)
78 | .then((result) => {
79 | this.setState({
80 | profile: {
81 | data: result.data,
82 | fetching: false
83 | }
84 | });
85 | })
86 | .catch((error) => {
87 | this.setState({
88 | profile: {
89 | error : error.humanMessage,
90 | fetching: false
91 | }
92 | });
93 | });
94 | //client-side fetching of the repositories via xhr based on the username
95 | github.getUserRepos(userName,{
96 | page: 1,
97 | sort: "updated",
98 | per_page: ORIGINAL_REPOS_PER_PAGE
99 | })
100 | .then((result) => {
101 | this.setState({
102 | repositories: {
103 | pristineLogin: userName,//pass again (since it was erased)
104 | data: result.data,
105 | infos: result.infos,
106 | fetching: false
107 | }
108 | });
109 | })
110 | .catch((error) => {
111 | this.setState({
112 | repositories: {
113 | error : error.humanMessage,
114 | fetching: false
115 | }
116 | });
117 | });
118 | }
119 | reposGotoPage(pageNum){
120 | //client-side fetching of the repositories via xhr based on the username
121 | var newState = update(this.state,{
122 | repositories:{
123 | fetching: {$set: true}
124 | }
125 | });
126 | this.setState(newState);
127 | github.getUserRepos(this.state.repositories.pristineLogin,{
128 | page: pageNum,
129 | sort: "updated",
130 | per_page: this.state.repositories.infos.per_page
131 | })
132 | .then((result) => {
133 | this.setState({
134 | repositories: {
135 | pristineLogin: this.state.repositories.pristineLogin,//pass again (since it was erased)
136 | data: result.data,
137 | infos: result.infos,
138 | fetching: false
139 | }
140 | });
141 | })
142 | .catch((error) => {
143 | this.setState({
144 | repositories: {
145 | error : error.humanMessage,
146 | fetching: false
147 | }
148 | });
149 | });
150 | }
151 | reposChangePerPage(perPage){
152 |
153 | }
154 | render(){
155 | var profile = this.state.profile;
156 | var repositories = this.state.repositories;
157 | return (
158 |
162 | );
163 | }
164 | }
--------------------------------------------------------------------------------
/src/components/Header.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import { IndexLink, Link } from 'react-router';
5 |
6 | export default class Header extends React.Component {
7 | constructor(props){
8 |
9 | super(props);
10 |
11 | //init context bindings - due to diff between React.createClass and ES6 class
12 | this._getInitialState = this._getInitialState.bind(this);
13 | this.handleClick = this.handleClick.bind(this);
14 |
15 | //init state
16 | this.state = this._getInitialState();
17 |
18 | }
19 | _getInitialState(){
20 | return {collapsed: true};
21 | }
22 | handleClick(e){
23 | var collapsed = this.state.collapsed;
24 | this.setState({collapsed:!collapsed});
25 | }
26 | render() {
27 |
28 | var collapsedMenuClassName = "collapse navbar-collapse" + (this.state.collapsed === true ? "" : " in");
29 |
30 | return (
31 |
32 |
33 |
34 |
35 |
36 | Toggle navigation
37 |
38 |
39 |
40 |
41 |
42 | {this.props.title}
43 |
44 |
45 |
46 |
47 |
48 | Search Github Users
49 | About
50 | Blog post
51 |
52 |
53 |
54 |
55 |
56 |
70 |
71 | );
72 | }
73 | }
--------------------------------------------------------------------------------
/src/components/Home.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import { Link } from 'react-router';
5 |
6 | const Home = () => (
7 |
8 |
This project is the isomorphic version of topheman/react-es6 . It's based on React and Express and relies on topheman-apis-proxy for the backend (providing the github API).
9 |
It's running on react v0.14.0 - read the blog post about the upgrade .
10 |
Please check out the github repo or read original blog post for further informations.
11 |
TL;DR : click on the button to try it !
12 |
TRY the DAMN thing !
13 |
Isomorphic , you say ? You may now use the term "universal" ... Take a quick look at the about page if you don't see the difference with the front-only version.
14 |
15 | );
16 |
17 | export default Home;
18 |
--------------------------------------------------------------------------------
/src/components/common/DisplayInfosPanel.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | import Panel from '../common/Panel.jsx';
6 | import Spinner from '../common/Spinner.jsx';
7 |
8 | const DisplayInfosPanel = ({infos, originalTitle}) => {
9 | var fetching = infos ? infos.fetching : false;
10 | if(infos && infos.error){
11 | var error = infos.error;
12 | return (
13 |
14 |
15 |
16 |
17 | {error}
18 |
19 |
20 |
21 |
22 | );
23 | }
24 | else{
25 | //initial case before xhr
26 | //better speed loading perception if username already present
27 | return(
28 |
29 |
34 |
35 | );
36 | }
37 | };
38 |
39 | export default DisplayInfosPanel;
--------------------------------------------------------------------------------
/src/components/common/DisplayStars.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | const DisplayStars = ({number}) => {
6 | if(number > 0){
7 | return(
8 |
9 | {number}
10 |
11 | )
12 | }
13 | else{
14 | return
15 | }
16 | };
17 |
18 | export default DisplayStars;
--------------------------------------------------------------------------------
/src/components/common/Panel.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | const Panel = ({title, children}) => (
6 |
7 |
8 |
9 |
{title}
10 |
11 | {children}
12 |
13 |
14 | );
15 |
16 | export default Panel;
--------------------------------------------------------------------------------
/src/components/common/Spinner.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | const Spinner = ({fetching}) => {
6 | if(fetching == true){
7 | return (
8 |
13 | )
14 | }
15 | else{
16 | return (
17 |
18 | )
19 | }
20 | }
21 |
22 | export default Spinner;
--------------------------------------------------------------------------------
/src/components/common/Tr.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | const Tr = ({label, value, type, display}) => {
6 | if(typeof value !== 'undefined' && !!value){
7 | if(value instanceof Date){
8 | value = value.toString().split(' ').slice(0,4).join(' ');//ok I took a very simple way ;-)
9 | }
10 | if(type === 'link'){
11 | value = {value} ;
12 | }
13 |
14 | if(display !== 'colspan'){
15 | return (
16 |
17 | {label}
18 | {value}
19 |
20 | )
21 | }
22 | else{
23 | return (
24 |
25 | {value}
26 |
27 | )
28 | }
29 | }
30 | else{
31 | //won't return if so, getting following : Warning: validateDOMNesting(...): cannot appear as a child of . See Profile > tbody > Tr > noscript.
32 | return ;
33 | }
34 | };
35 |
36 | export default Tr;
--------------------------------------------------------------------------------
/src/components/common/TwitterButton.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | /**
6 | * This component renders directly the iframe of twitter without running external script
7 | * to avoid messing up with react's internal DOM and break react hot loader
8 | *
9 | * @todo make a more generic version
10 | * @note : Tweet
11 | */
12 | export default class TwitterButton extends React.Component {
13 | render(){
14 | return (
15 |
22 | );
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/components/github/IntroBox.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | const IntroBox = () => (
6 |
7 |
This is only a simple feature, search Github users ...
8 |
All the UI is coded in React and ES6, using only isomorphic techs like superagent for the AJAX request (so that it could also work server-side).
9 |
10 | );
11 |
12 | export default IntroBox;
--------------------------------------------------------------------------------
/src/components/github/ProfileBox.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | import { Link } from 'react-router';
6 |
7 | const ProfileBox = ({user}) => {
8 | var link = '/github/user/' + user.login;
9 | return (
10 |
11 | {user.login}
12 |
13 | )
14 | }
15 |
16 | export default ProfileBox;
--------------------------------------------------------------------------------
/src/components/github/ProfileList.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import { Link } from 'react-router';
5 |
6 | import ProfileBox from './ProfileBox.jsx';
7 |
8 | const ProfileList = ({results}) => {
9 | if (results === null) {
10 | return (
11 |
12 | Just search for a Github user or organization ... or access directly to my profile.
13 |
14 | )
15 | }
16 | else if(results.error){
17 | return (
18 |
19 | {results.error}
20 |
21 | )
22 | }
23 | else if(results.total_count === 0){
24 | return (
25 |
26 | No results.
27 |
28 | )
29 | }
30 | else {
31 | return (
32 |
33 |
Total result : {results.total_count} / showing : {results.items.length}
34 |
35 | {results.items.map(function (user) {
36 | user.$avatar_url = user.avatar_url+"&s=40";
37 | return
38 | })}
39 |
40 |
41 | )
42 | }
43 | }
44 |
45 | export default ProfileList;
--------------------------------------------------------------------------------
/src/components/github/SearchBox.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import ProfileList from './ProfileList.jsx';
5 | import Spinner from '../common/Spinner.jsx';
6 |
7 | import github from '../../services/github.js';
8 |
9 | import localStorageWrapper from '../../services/localStorageWrapper.js';
10 |
11 | export default class SearchBox extends React.Component {
12 | constructor(props){
13 |
14 | super(props);
15 |
16 | //init context bindings - due to diff between React.createClass and ES6 class
17 | this.handleSubmit = this.handleSubmit.bind(this);
18 | this.handleChange = this.handleChange.bind(this);
19 | this._getInitialState = this._getInitialState.bind(this);
20 |
21 | //init state
22 | this.state = this._getInitialState();
23 |
24 | //if results are cached in storage, recache for X mins
25 | localStorageWrapper.extend('github.search.userName');
26 | localStorageWrapper.extend('github.search.results');
27 |
28 | }
29 | _getInitialState(){
30 | return {
31 | userName : localStorageWrapper.get('github.search.userName'),
32 | results : localStorageWrapper.get('github.search.results') || null,
33 | fetching: false
34 | };
35 | }
36 | handleFocus(e) {
37 | var target = e.target;
38 | //dirty but curiously in React this is a known bug and workaround ...
39 | setTimeout(function() {
40 | target.select();
41 | }, 0);
42 | }
43 | handleSubmit(e) {
44 | e.preventDefault();
45 | document.getElementById('user-name').blur();
46 | var currentUser = this.state.userName;
47 | //prevent submiting empty user
48 | if (currentUser !== "") {
49 | this.setState({fetching: true});
50 | github.searchUser(currentUser)
51 | .then((result) => {
52 | localStorageWrapper.set('github.search.results',result.data);
53 | localStorageWrapper.set('github.search.userName',currentUser);
54 | this.setState({
55 | results: result.data,
56 | fetching: false
57 | });
58 | })
59 | .catch((error) => {
60 | this.setState({
61 | results: {
62 | error: error.humanMessage
63 | },
64 | fetching: false
65 | });
66 | });
67 | }
68 | }
69 | handleChange(e){
70 | //not sure it's the best way because it will trigger a render on something handled by the browser
71 | //but have to keep track of this value anyway ...
72 | this.setState({userName:e.target.value});
73 | }
74 | render() {
75 | var userName = this.state.userName;
76 | var results = this.state.results;
77 | var fetching = this.state.fetching;
78 | return (
79 |
96 | )
97 | }
98 | }
--------------------------------------------------------------------------------
/src/components/githubUser/Profile.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | import Panel from '../common/Panel.jsx';
6 | import Tr from '../common/Tr.jsx';
7 | import DisplayInfosPanel from '../common/DisplayInfosPanel.jsx';
8 |
9 | const Profile = ({profile}) => {
10 | var fetching = profile.fetching;
11 | if (profile && profile.data){
12 | var user = profile.data;
13 | user.$githubProfileHref = user.html_url;
14 | user.$githubProfileHrefTitle = "Visit " + user.login + " profile on Github";
15 | user.$avatar_url = user.avatar_url+"&s=130";
16 | return (
17 |
18 |
19 |
20 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | }
43 | else {
44 | return(
45 |
46 | )
47 | }
48 | };
49 |
50 | export default Profile;
--------------------------------------------------------------------------------
/src/components/githubUser/Repos.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | import Panel from '../common/Panel.jsx';
6 | import DisplayInfosPanel from '../common/DisplayInfosPanel.jsx';
7 | import DisplayStars from '../common/DisplayStars.jsx';
8 | import ReposPaginator from './ReposPaginator.jsx';
9 |
10 | //@todo cache the last 10 profiles accessed
11 |
12 | export default class Repos extends React.Component {
13 | constructor(props){
14 |
15 | super(props);
16 |
17 | //init context bindings - due to diff between React.createClass and ES6 class
18 | this.reposGotoPage = this.reposGotoPage.bind(this);
19 |
20 | }
21 | reposGotoPage(pageNum){
22 | this.props.reposGotoPage(pageNum);
23 | }
24 | render(){
25 | var repositories = this.props.repositories;
26 | var fetching = repositories.fetching;
27 | var originalTitle = repositories.pristineLogin + "'s repositories";
28 | if (repositories && repositories.data){
29 | var repos = repositories.data;
30 | return (
31 |
32 |
48 |
49 | );
50 | }
51 | else {
52 | return(
53 |
54 | )
55 | }
56 | }
57 | }
--------------------------------------------------------------------------------
/src/components/githubUser/ReposPaginator.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 |
5 | import Spinner from '../common/Spinner.jsx';
6 |
7 | export default class ReposPaginator extends React.Component {
8 | constructor(props) {
9 | super(props);
10 | this.gotoPage = this.gotoPage.bind(this);
11 | this.gotoNextPage = this.gotoNextPage.bind(this);
12 | this.gotoPreviousPage = this.gotoPreviousPage.bind(this);
13 | this.gotoFirstPage = this.gotoFirstPage.bind(this);
14 | this.gotoLastPage = this.gotoLastPage.bind(this);
15 | this.getClickGotoPageHandler = this.getClickGotoPageHandler.bind(this);
16 | }
17 |
18 | gotoPage(pageNum) {
19 | this.props.reposGotoPage(pageNum);
20 | }
21 |
22 | gotoNextPage() {
23 | this.gotoPage(this.props.infos.page + 1);
24 | }
25 |
26 | gotoPreviousPage() {
27 | this.gotoPage(this.props.infos.page - 1);
28 | }
29 |
30 | gotoFirstPage() {
31 | this.gotoPage(1);
32 | }
33 |
34 | gotoLastPage() {
35 | this.gotoPage(this.props.infos.totalPages);
36 | }
37 |
38 | getClickGotoPageHandler(methodName) {
39 | return function(e){
40 | e.preventDefault();
41 | this[methodName]();
42 | }.bind(this);
43 | }
44 |
45 | render() {
46 | var infos = this.props.infos;
47 | var fetching = this.props.fetching;
48 | if (infos.totalPages > 1) {
49 | var pages, currentPage;
50 | var firstPage,
51 | previousPage,
52 | nextPage,
53 | lastPage;
54 | if (infos.page > 1) {
55 | firstPage = (
56 |
57 |
58 | ←←
59 |
60 |
61 | );
62 | }
63 | if (infos.page > 2) {
64 | previousPage = (
65 |
66 |
67 | ←
68 |
69 |
70 | );
71 | }
72 | if (infos.page < (infos.totalPages - 1) ) {
73 | nextPage = (
74 |
75 |
76 | →
77 |
78 |
79 | );
80 | }
81 | if (infos.page <= (infos.totalPages - 1) ) {
82 | lastPage = (
83 |
84 |
85 | →→
86 |
87 |
88 | );
89 | }
90 | return (
91 |
92 |
93 |
94 |
95 | {firstPage}
96 | {previousPage}
97 | {lastPage}
98 | {nextPage}
99 |
100 |
101 |
102 | );
103 | }
104 | else {
105 | return (
106 |
107 | );
108 | }
109 | }
110 | }
--------------------------------------------------------------------------------
/src/routes.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import React from 'react';
4 | import {Route, IndexRoute} from 'react-router';
5 | import App from './components/App.jsx';
6 | import Home from './components/Home.jsx';
7 | import About from './components/About.jsx';
8 | import Github from './components/Github.jsx';
9 | import GithubUser from './components/GithubUser.jsx';
10 |
11 | export default (
12 |
13 |
14 |
15 |
16 |
17 |
18 | );
--------------------------------------------------------------------------------
/src/services/github.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import httpService from './httpService.js';
4 |
5 | //the following var keeps the httpService singleton which has been first instanciated in the bootstrap
6 | //don't make the getInstance here, webpack will require it before the bootstrap (since it's deeper in the modules)
7 | var http;
8 |
9 | export default {
10 | searchUser(userName){
11 | http = httpService.getInstance();//the config is not passed here, it was passed in the bootstrap
12 | return http.get('/github/search/users',{q:userName});
13 | },
14 | getUser(userName){
15 | http = httpService.getInstance();//the config is not passed here, it was passed in the bootstrap
16 | return http.get('/github/users/'+userName);
17 | },
18 | getUserRepos(userName,options = {per_page: 30, page: 1, sort: "updated"}){
19 | http = httpService.getInstance();//the config is not passed here, it was passed in the bootstrap
20 | return http.get('/github/users/'+userName+'/repos',{
21 | page: options.page,
22 | per_page: options.per_page,
23 | sort: options.sort
24 | });
25 | }
26 | }
--------------------------------------------------------------------------------
/src/services/httpService.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * To avoid having to involve webpack into requiring modules when we'll execute any code server-side
5 | * I dropped the resolve.alias solution in favor of dependency injection.
6 | */
7 |
8 | var instance = null;
9 |
10 | class HttpService{
11 | constructor(configuration){
12 | this.service = configuration.injectHttp;
13 | this.service.configuration = {};
14 | //expose rest of the config to the service (usefull for backendBaseUrl, timeOut ...)
15 | for(let name in configuration){
16 | if(name !== 'injectHttp'){
17 | this.service.configuration[name] = configuration[name];
18 | }
19 | }
20 | }
21 | get(relativeUrl,params){
22 | return this.service.get(relativeUrl,params);
23 | }
24 | }
25 |
26 | export default {
27 | getInstance(configuration){
28 | if(instance === null){
29 | if(typeof configuration === 'undefined'){
30 | throw new Error("First time you instantiate the httpService singleton, you must pass the configuration");
31 | }
32 | instance = new HttpService(configuration);
33 | }
34 | return instance;
35 | }
36 | }
--------------------------------------------------------------------------------
/src/services/httpService/config/environment/config.build.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import http from '../../http.js';
4 |
5 | export default {
6 | backendBaseUrl: 'https://topheman-apis-proxy.herokuapp.com',
7 | injectHttp: http
8 | };
--------------------------------------------------------------------------------
/src/services/httpService/config/environment/config.dev.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import http from '../../http.js';
4 |
5 | export default {
6 | backendBaseUrl: 'http://localhost:8000',
7 | injectHttp: http
8 | };
--------------------------------------------------------------------------------
/src/services/httpService/config/environment/config.mock.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import http from '../../http.stub.js';
4 |
5 | export default {
6 | backendBaseUrl: 'http://localhost:8000',
7 | timeOut: 400,
8 | injectHttp: http
9 | };
--------------------------------------------------------------------------------
/src/services/httpService/http.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import request from 'superagent';
4 |
5 | export default {
6 | get(relativeUrl,params){
7 | var promise = new Promise((resolve, reject) => {
8 |
9 | var url = this.configuration.backendBaseUrl+relativeUrl;
10 |
11 | //add query params
12 | if(typeof params === 'object' && params !== null){
13 | if(Object.keys(params).length > 0){
14 | var query = '';
15 | for(let name in params){
16 | if(typeof params[name] !== 'object') {
17 | query += query === '' ? '' : '&';
18 | query += name + '=' + params[name];
19 | }
20 | }
21 | url += (query !== '') ? ('?'+query) : '';
22 | }
23 | }
24 | //init the object param, not to have to check it bellow
25 | else{
26 | params = {};
27 | }
28 |
29 | request.get(url).set('Accept', 'application/json')
30 | .end(function(err, res){
31 | if(err || !res){
32 | if(typeof res !== 'undefined' && res.headers && typeof res.headers['x-ratelimit-remaining'] !== 'undefined'){
33 | return reject({
34 | kind: 'rateLimit',
35 | message: err,
36 | humanMessage: "The server is very crowded, please try again in a few seconds.",
37 | status: res.status,
38 | type: res.type
39 | })
40 | }
41 | else {
42 | return reject({
43 | kind: "error",
44 | message: err ? err : 'No response',
45 | humanMessage: "An error occured, please try again."
46 | });
47 | }
48 | }
49 | var objectToResolve = {
50 | data: res.body,
51 | status: res.status,
52 | type: res.type,
53 | infos: {
54 | ratelimitRemaining: res.headers['x-ratelimit-remaining'],
55 | etag:res.headers['etag']
56 | }
57 | };
58 | //passing some relevant infos from the params in the request to the response
59 | if(typeof params.page !== 'undefined'){
60 | objectToResolve.infos.page = params.page;
61 | }
62 | if(typeof params.per_page !== 'undefined'){
63 | objectToResolve.infos.per_page = params.per_page;
64 | }
65 | //adding metas infos
66 | if(res.headers['link']){
67 | let result = {};
68 | var toProcess = res.headers['link'].split(' ');
69 | for(let i=0; i;','');//@todo cleaner way with one regexp ?
72 | }
73 | }
74 | objectToResolve.infos.link = result;
75 | if(result.last) {
76 | let totalPages = result.last.match(/page=([0-9]+)/);
77 | if(totalPages[1]){
78 | objectToResolve.infos.totalPages = parseInt(totalPages[1]);
79 | }
80 | }
81 | else{
82 | let totalPages = result.prev.match(/page=([0-9]+)/);
83 | if(totalPages[1]){
84 | objectToResolve.infos.totalPages = parseInt(totalPages[1])+1;
85 | }
86 | }
87 | }
88 | else {
89 | objectToResolve.infos.totalPages = 1;
90 | }
91 | return resolve(objectToResolve);
92 | });
93 | });
94 | return promise;
95 | }
96 | }
--------------------------------------------------------------------------------
/src/services/httpService/http.mocks/followers.json:
--------------------------------------------------------------------------------
1 | [{"login":"JT5D","id":391299,"avatar_url":"https://avatars.githubusercontent.com/u/391299?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/JT5D","html_url":"https://github.com/JT5D","followers_url":"http://localhost:8000/github/users/JT5D/followers","following_url":"http://localhost:8000/github/users/JT5D/following{/other_user}","gists_url":"http://localhost:8000/github/users/JT5D/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/JT5D/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/JT5D/subscriptions","organizations_url":"http://localhost:8000/github/users/JT5D/orgs","repos_url":"http://localhost:8000/github/users/JT5D/repos","events_url":"http://localhost:8000/github/users/JT5D/events{/privacy}","received_events_url":"http://localhost:8000/github/users/JT5D/received_events","type":"User","site_admin":false},{"login":"omobili","id":3448120,"avatar_url":"https://avatars.githubusercontent.com/u/3448120?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/omobili","html_url":"https://github.com/omobili","followers_url":"http://localhost:8000/github/users/omobili/followers","following_url":"http://localhost:8000/github/users/omobili/following{/other_user}","gists_url":"http://localhost:8000/github/users/omobili/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/omobili/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/omobili/subscriptions","organizations_url":"http://localhost:8000/github/users/omobili/orgs","repos_url":"http://localhost:8000/github/users/omobili/repos","events_url":"http://localhost:8000/github/users/omobili/events{/privacy}","received_events_url":"http://localhost:8000/github/users/omobili/received_events","type":"User","site_admin":false},{"login":"thierrymarianne","id":1053622,"avatar_url":"https://avatars.githubusercontent.com/u/1053622?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/thierrymarianne","html_url":"https://github.com/thierrymarianne","followers_url":"http://localhost:8000/github/users/thierrymarianne/followers","following_url":"http://localhost:8000/github/users/thierrymarianne/following{/other_user}","gists_url":"http://localhost:8000/github/users/thierrymarianne/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/thierrymarianne/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/thierrymarianne/subscriptions","organizations_url":"http://localhost:8000/github/users/thierrymarianne/orgs","repos_url":"http://localhost:8000/github/users/thierrymarianne/repos","events_url":"http://localhost:8000/github/users/thierrymarianne/events{/privacy}","received_events_url":"http://localhost:8000/github/users/thierrymarianne/received_events","type":"User","site_admin":false},{"login":"yoanmarchal","id":605313,"avatar_url":"https://avatars.githubusercontent.com/u/605313?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/yoanmarchal","html_url":"https://github.com/yoanmarchal","followers_url":"http://localhost:8000/github/users/yoanmarchal/followers","following_url":"http://localhost:8000/github/users/yoanmarchal/following{/other_user}","gists_url":"http://localhost:8000/github/users/yoanmarchal/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/yoanmarchal/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/yoanmarchal/subscriptions","organizations_url":"http://localhost:8000/github/users/yoanmarchal/orgs","repos_url":"http://localhost:8000/github/users/yoanmarchal/repos","events_url":"http://localhost:8000/github/users/yoanmarchal/events{/privacy}","received_events_url":"http://localhost:8000/github/users/yoanmarchal/received_events","type":"User","site_admin":false},{"login":"AMorgaut","id":49318,"avatar_url":"https://avatars.githubusercontent.com/u/49318?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/AMorgaut","html_url":"https://github.com/AMorgaut","followers_url":"http://localhost:8000/github/users/AMorgaut/followers","following_url":"http://localhost:8000/github/users/AMorgaut/following{/other_user}","gists_url":"http://localhost:8000/github/users/AMorgaut/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/AMorgaut/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/AMorgaut/subscriptions","organizations_url":"http://localhost:8000/github/users/AMorgaut/orgs","repos_url":"http://localhost:8000/github/users/AMorgaut/repos","events_url":"http://localhost:8000/github/users/AMorgaut/events{/privacy}","received_events_url":"http://localhost:8000/github/users/AMorgaut/received_events","type":"User","site_admin":false},{"login":"arnonate","id":880025,"avatar_url":"https://avatars.githubusercontent.com/u/880025?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/arnonate","html_url":"https://github.com/arnonate","followers_url":"http://localhost:8000/github/users/arnonate/followers","following_url":"http://localhost:8000/github/users/arnonate/following{/other_user}","gists_url":"http://localhost:8000/github/users/arnonate/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/arnonate/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/arnonate/subscriptions","organizations_url":"http://localhost:8000/github/users/arnonate/orgs","repos_url":"http://localhost:8000/github/users/arnonate/repos","events_url":"http://localhost:8000/github/users/arnonate/events{/privacy}","received_events_url":"http://localhost:8000/github/users/arnonate/received_events","type":"User","site_admin":false},{"login":"bravesomesh","id":2386333,"avatar_url":"https://avatars.githubusercontent.com/u/2386333?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/bravesomesh","html_url":"https://github.com/bravesomesh","followers_url":"http://localhost:8000/github/users/bravesomesh/followers","following_url":"http://localhost:8000/github/users/bravesomesh/following{/other_user}","gists_url":"http://localhost:8000/github/users/bravesomesh/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/bravesomesh/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/bravesomesh/subscriptions","organizations_url":"http://localhost:8000/github/users/bravesomesh/orgs","repos_url":"http://localhost:8000/github/users/bravesomesh/repos","events_url":"http://localhost:8000/github/users/bravesomesh/events{/privacy}","received_events_url":"http://localhost:8000/github/users/bravesomesh/received_events","type":"User","site_admin":false},{"login":"albhardy","id":1732018,"avatar_url":"https://avatars.githubusercontent.com/u/1732018?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/albhardy","html_url":"https://github.com/albhardy","followers_url":"http://localhost:8000/github/users/albhardy/followers","following_url":"http://localhost:8000/github/users/albhardy/following{/other_user}","gists_url":"http://localhost:8000/github/users/albhardy/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/albhardy/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/albhardy/subscriptions","organizations_url":"http://localhost:8000/github/users/albhardy/orgs","repos_url":"http://localhost:8000/github/users/albhardy/repos","events_url":"http://localhost:8000/github/users/albhardy/events{/privacy}","received_events_url":"http://localhost:8000/github/users/albhardy/received_events","type":"User","site_admin":false},{"login":"nicodinh","id":7486270,"avatar_url":"https://avatars.githubusercontent.com/u/7486270?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/nicodinh","html_url":"https://github.com/nicodinh","followers_url":"http://localhost:8000/github/users/nicodinh/followers","following_url":"http://localhost:8000/github/users/nicodinh/following{/other_user}","gists_url":"http://localhost:8000/github/users/nicodinh/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/nicodinh/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/nicodinh/subscriptions","organizations_url":"http://localhost:8000/github/users/nicodinh/orgs","repos_url":"http://localhost:8000/github/users/nicodinh/repos","events_url":"http://localhost:8000/github/users/nicodinh/events{/privacy}","received_events_url":"http://localhost:8000/github/users/nicodinh/received_events","type":"User","site_admin":false},{"login":"timlewis500","id":7833792,"avatar_url":"https://avatars.githubusercontent.com/u/7833792?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/timlewis500","html_url":"https://github.com/timlewis500","followers_url":"http://localhost:8000/github/users/timlewis500/followers","following_url":"http://localhost:8000/github/users/timlewis500/following{/other_user}","gists_url":"http://localhost:8000/github/users/timlewis500/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/timlewis500/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/timlewis500/subscriptions","organizations_url":"http://localhost:8000/github/users/timlewis500/orgs","repos_url":"http://localhost:8000/github/users/timlewis500/repos","events_url":"http://localhost:8000/github/users/timlewis500/events{/privacy}","received_events_url":"http://localhost:8000/github/users/timlewis500/received_events","type":"User","site_admin":false}]
--------------------------------------------------------------------------------
/src/services/httpService/http.mocks/user.json:
--------------------------------------------------------------------------------
1 | {"login":"topheman","id":985982,"avatar_url":"https://avatars.githubusercontent.com/u/985982?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topheman","html_url":"https://github.com/topheman","followers_url":"http://localhost:8000/github/users/topheman/followers","following_url":"http://localhost:8000/github/users/topheman/following{/other_user}","gists_url":"http://localhost:8000/github/users/topheman/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topheman/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topheman/subscriptions","organizations_url":"http://localhost:8000/github/users/topheman/orgs","repos_url":"http://localhost:8000/github/users/topheman/repos","events_url":"http://localhost:8000/github/users/topheman/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topheman/received_events","type":"User","site_admin":false,"name":"Christophe Rosset","company":null,"blog":"http://labs.topheman.com/","location":"Paris","email":null,"hireable":false,"bio":null,"public_repos":30,"public_gists":1,"followers":10,"following":1,"created_at":"2011-08-17T11:59:42Z","updated_at":"2015-04-02T22:51:16Z"}
--------------------------------------------------------------------------------
/src/services/httpService/http.mocks/users.search.json:
--------------------------------------------------------------------------------
1 | {"total_count":212,"incomplete_results":false,"items":[{"login":"tophe","id":55964,"avatar_url":"https://avatars.githubusercontent.com/u/55964?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/tophe","html_url":"https://github.com/tophe","followers_url":"http://localhost:8000/github/users/tophe/followers","following_url":"http://localhost:8000/github/users/tophe/following{/other_user}","gists_url":"http://localhost:8000/github/users/tophe/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/tophe/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/tophe/subscriptions","organizations_url":"http://localhost:8000/github/users/tophe/orgs","repos_url":"http://localhost:8000/github/users/tophe/repos","events_url":"http://localhost:8000/github/users/tophe/events{/privacy}","received_events_url":"http://localhost:8000/github/users/tophe/received_events","type":"User","site_admin":false,"score":20.812012},{"login":"topherwhite","id":1895255,"avatar_url":"https://avatars.githubusercontent.com/u/1895255?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherwhite","html_url":"https://github.com/topherwhite","followers_url":"http://localhost:8000/github/users/topherwhite/followers","following_url":"http://localhost:8000/github/users/topherwhite/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherwhite/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherwhite/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherwhite/subscriptions","organizations_url":"http://localhost:8000/github/users/topherwhite/orgs","repos_url":"http://localhost:8000/github/users/topherwhite/repos","events_url":"http://localhost:8000/github/users/topherwhite/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherwhite/received_events","type":"User","site_admin":false,"score":17.489758},{"login":"topher23","id":6018870,"avatar_url":"https://avatars.githubusercontent.com/u/6018870?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topher23","html_url":"https://github.com/topher23","followers_url":"http://localhost:8000/github/users/topher23/followers","following_url":"http://localhost:8000/github/users/topher23/following{/other_user}","gists_url":"http://localhost:8000/github/users/topher23/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topher23/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topher23/subscriptions","organizations_url":"http://localhost:8000/github/users/topher23/orgs","repos_url":"http://localhost:8000/github/users/topher23/repos","events_url":"http://localhost:8000/github/users/topher23/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topher23/received_events","type":"User","site_admin":false,"score":17.489758},{"login":"topher6345","id":911359,"avatar_url":"https://avatars.githubusercontent.com/u/911359?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topher6345","html_url":"https://github.com/topher6345","followers_url":"http://localhost:8000/github/users/topher6345/followers","following_url":"http://localhost:8000/github/users/topher6345/following{/other_user}","gists_url":"http://localhost:8000/github/users/topher6345/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topher6345/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topher6345/subscriptions","organizations_url":"http://localhost:8000/github/users/topher6345/orgs","repos_url":"http://localhost:8000/github/users/topher6345/repos","events_url":"http://localhost:8000/github/users/topher6345/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topher6345/received_events","type":"User","site_admin":false,"score":17.489758},{"login":"topheman","id":985982,"avatar_url":"https://avatars.githubusercontent.com/u/985982?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topheman","html_url":"https://github.com/topheman","followers_url":"http://localhost:8000/github/users/topheman/followers","following_url":"http://localhost:8000/github/users/topheman/following{/other_user}","gists_url":"http://localhost:8000/github/users/topheman/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topheman/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topheman/subscriptions","organizations_url":"http://localhost:8000/github/users/topheman/orgs","repos_url":"http://localhost:8000/github/users/topheman/repos","events_url":"http://localhost:8000/github/users/topheman/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topheman/received_events","type":"User","site_admin":false,"score":17.489758},{"login":"topher515","id":24050,"avatar_url":"https://avatars.githubusercontent.com/u/24050?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topher515","html_url":"https://github.com/topher515","followers_url":"http://localhost:8000/github/users/topher515/followers","following_url":"http://localhost:8000/github/users/topher515/following{/other_user}","gists_url":"http://localhost:8000/github/users/topher515/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topher515/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topher515/subscriptions","organizations_url":"http://localhost:8000/github/users/topher515/orgs","repos_url":"http://localhost:8000/github/users/topher515/repos","events_url":"http://localhost:8000/github/users/topher515/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topher515/received_events","type":"User","site_admin":false,"score":17.489758},{"login":"tophermade","id":630933,"avatar_url":"https://avatars.githubusercontent.com/u/630933?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/tophermade","html_url":"https://github.com/tophermade","followers_url":"http://localhost:8000/github/users/tophermade/followers","following_url":"http://localhost:8000/github/users/tophermade/following{/other_user}","gists_url":"http://localhost:8000/github/users/tophermade/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/tophermade/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/tophermade/subscriptions","organizations_url":"http://localhost:8000/github/users/tophermade/orgs","repos_url":"http://localhost:8000/github/users/tophermade/repos","events_url":"http://localhost:8000/github/users/tophermade/events{/privacy}","received_events_url":"http://localhost:8000/github/users/tophermade/received_events","type":"User","site_admin":false,"score":15.303537},{"login":"topherfangio","id":54370,"avatar_url":"https://avatars.githubusercontent.com/u/54370?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherfangio","html_url":"https://github.com/topherfangio","followers_url":"http://localhost:8000/github/users/topherfangio/followers","following_url":"http://localhost:8000/github/users/topherfangio/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherfangio/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherfangio/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherfangio/subscriptions","organizations_url":"http://localhost:8000/github/users/topherfangio/orgs","repos_url":"http://localhost:8000/github/users/topherfangio/repos","events_url":"http://localhost:8000/github/users/topherfangio/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherfangio/received_events","type":"User","site_admin":false,"score":15.303537},{"login":"topher1kenobe","id":341412,"avatar_url":"https://avatars.githubusercontent.com/u/341412?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topher1kenobe","html_url":"https://github.com/topher1kenobe","followers_url":"http://localhost:8000/github/users/topher1kenobe/followers","following_url":"http://localhost:8000/github/users/topher1kenobe/following{/other_user}","gists_url":"http://localhost:8000/github/users/topher1kenobe/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topher1kenobe/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topher1kenobe/subscriptions","organizations_url":"http://localhost:8000/github/users/topher1kenobe/orgs","repos_url":"http://localhost:8000/github/users/topher1kenobe/repos","events_url":"http://localhost:8000/github/users/topher1kenobe/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topher1kenobe/received_events","type":"User","site_admin":false,"score":15.303537},{"login":"topherjaynes","id":482683,"avatar_url":"https://avatars.githubusercontent.com/u/482683?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherjaynes","html_url":"https://github.com/topherjaynes","followers_url":"http://localhost:8000/github/users/topherjaynes/followers","following_url":"http://localhost:8000/github/users/topherjaynes/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherjaynes/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherjaynes/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherjaynes/subscriptions","organizations_url":"http://localhost:8000/github/users/topherjaynes/orgs","repos_url":"http://localhost:8000/github/users/topherjaynes/repos","events_url":"http://localhost:8000/github/users/topherjaynes/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherjaynes/received_events","type":"User","site_admin":false,"score":15.303537},{"login":"topherh","id":71855,"avatar_url":"https://avatars.githubusercontent.com/u/71855?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherh","html_url":"https://github.com/topherh","followers_url":"http://localhost:8000/github/users/topherh/followers","following_url":"http://localhost:8000/github/users/topherh/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherh/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherh/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherh/subscriptions","organizations_url":"http://localhost:8000/github/users/topherh/orgs","repos_url":"http://localhost:8000/github/users/topherh/repos","events_url":"http://localhost:8000/github/users/topherh/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherh/received_events","type":"User","site_admin":false,"score":13.117318},{"login":"topherauyeung","id":1114632,"avatar_url":"https://avatars.githubusercontent.com/u/1114632?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherauyeung","html_url":"https://github.com/topherauyeung","followers_url":"http://localhost:8000/github/users/topherauyeung/followers","following_url":"http://localhost:8000/github/users/topherauyeung/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherauyeung/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherauyeung/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherauyeung/subscriptions","organizations_url":"http://localhost:8000/github/users/topherauyeung/orgs","repos_url":"http://localhost:8000/github/users/topherauyeung/repos","events_url":"http://localhost:8000/github/users/topherauyeung/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherauyeung/received_events","type":"User","site_admin":false,"score":13.117318},{"login":"topher1120","id":223179,"avatar_url":"https://avatars.githubusercontent.com/u/223179?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topher1120","html_url":"https://github.com/topher1120","followers_url":"http://localhost:8000/github/users/topher1120/followers","following_url":"http://localhost:8000/github/users/topher1120/following{/other_user}","gists_url":"http://localhost:8000/github/users/topher1120/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topher1120/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topher1120/subscriptions","organizations_url":"http://localhost:8000/github/users/topher1120/orgs","repos_url":"http://localhost:8000/github/users/topher1120/repos","events_url":"http://localhost:8000/github/users/topher1120/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topher1120/received_events","type":"User","site_admin":false,"score":13.117318},{"login":"topherCantrell","id":5549889,"avatar_url":"https://avatars.githubusercontent.com/u/5549889?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherCantrell","html_url":"https://github.com/topherCantrell","followers_url":"http://localhost:8000/github/users/topherCantrell/followers","following_url":"http://localhost:8000/github/users/topherCantrell/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherCantrell/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherCantrell/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherCantrell/subscriptions","organizations_url":"http://localhost:8000/github/users/topherCantrell/orgs","repos_url":"http://localhost:8000/github/users/topherCantrell/repos","events_url":"http://localhost:8000/github/users/topherCantrell/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherCantrell/received_events","type":"User","site_admin":false,"score":13.117318},{"login":"vivae","id":6749838,"avatar_url":"https://avatars.githubusercontent.com/u/6749838?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/vivae","html_url":"https://github.com/vivae","followers_url":"http://localhost:8000/github/users/vivae/followers","following_url":"http://localhost:8000/github/users/vivae/following{/other_user}","gists_url":"http://localhost:8000/github/users/vivae/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/vivae/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/vivae/subscriptions","organizations_url":"http://localhost:8000/github/users/vivae/orgs","repos_url":"http://localhost:8000/github/users/vivae/repos","events_url":"http://localhost:8000/github/users/vivae/events{/privacy}","received_events_url":"http://localhost:8000/github/users/vivae/received_events","type":"User","site_admin":false,"score":11.099739},{"login":"topherez","id":911053,"avatar_url":"https://avatars.githubusercontent.com/u/911053?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherez","html_url":"https://github.com/topherez","followers_url":"http://localhost:8000/github/users/topherez/followers","following_url":"http://localhost:8000/github/users/topherez/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherez/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherez/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherez/subscriptions","organizations_url":"http://localhost:8000/github/users/topherez/orgs","repos_url":"http://localhost:8000/github/users/topherez/repos","events_url":"http://localhost:8000/github/users/topherez/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherez/received_events","type":"User","site_admin":false,"score":10.931099},{"login":"Topher-the-Geek","id":588423,"avatar_url":"https://avatars.githubusercontent.com/u/588423?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/Topher-the-Geek","html_url":"https://github.com/Topher-the-Geek","followers_url":"http://localhost:8000/github/users/Topher-the-Geek/followers","following_url":"http://localhost:8000/github/users/Topher-the-Geek/following{/other_user}","gists_url":"http://localhost:8000/github/users/Topher-the-Geek/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/Topher-the-Geek/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/Topher-the-Geek/subscriptions","organizations_url":"http://localhost:8000/github/users/Topher-the-Geek/orgs","repos_url":"http://localhost:8000/github/users/Topher-the-Geek/repos","events_url":"http://localhost:8000/github/users/Topher-the-Geek/events{/privacy}","received_events_url":"http://localhost:8000/github/users/Topher-the-Geek/received_events","type":"User","site_admin":false,"score":10.931099},{"login":"TopherBlair","id":9954014,"avatar_url":"https://avatars.githubusercontent.com/u/9954014?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/TopherBlair","html_url":"https://github.com/TopherBlair","followers_url":"http://localhost:8000/github/users/TopherBlair/followers","following_url":"http://localhost:8000/github/users/TopherBlair/following{/other_user}","gists_url":"http://localhost:8000/github/users/TopherBlair/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/TopherBlair/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/TopherBlair/subscriptions","organizations_url":"http://localhost:8000/github/users/TopherBlair/orgs","repos_url":"http://localhost:8000/github/users/TopherBlair/repos","events_url":"http://localhost:8000/github/users/TopherBlair/events{/privacy}","received_events_url":"http://localhost:8000/github/users/TopherBlair/received_events","type":"User","site_admin":false,"score":10.931099},{"login":"topher200","id":206988,"avatar_url":"https://avatars.githubusercontent.com/u/206988?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topher200","html_url":"https://github.com/topher200","followers_url":"http://localhost:8000/github/users/topher200/followers","following_url":"http://localhost:8000/github/users/topher200/following{/other_user}","gists_url":"http://localhost:8000/github/users/topher200/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topher200/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topher200/subscriptions","organizations_url":"http://localhost:8000/github/users/topher200/orgs","repos_url":"http://localhost:8000/github/users/topher200/repos","events_url":"http://localhost:8000/github/users/topher200/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topher200/received_events","type":"User","site_admin":false,"score":10.931099},{"login":"TopherJonesy","id":9932814,"avatar_url":"https://avatars.githubusercontent.com/u/9932814?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/TopherJonesy","html_url":"https://github.com/TopherJonesy","followers_url":"http://localhost:8000/github/users/TopherJonesy/followers","following_url":"http://localhost:8000/github/users/TopherJonesy/following{/other_user}","gists_url":"http://localhost:8000/github/users/TopherJonesy/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/TopherJonesy/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/TopherJonesy/subscriptions","organizations_url":"http://localhost:8000/github/users/TopherJonesy/orgs","repos_url":"http://localhost:8000/github/users/TopherJonesy/repos","events_url":"http://localhost:8000/github/users/TopherJonesy/events{/privacy}","received_events_url":"http://localhost:8000/github/users/TopherJonesy/received_events","type":"User","site_admin":false,"score":10.931099},{"login":"topherbullock","id":1643321,"avatar_url":"https://avatars.githubusercontent.com/u/1643321?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherbullock","html_url":"https://github.com/topherbullock","followers_url":"http://localhost:8000/github/users/topherbullock/followers","following_url":"http://localhost:8000/github/users/topherbullock/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherbullock/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherbullock/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherbullock/subscriptions","organizations_url":"http://localhost:8000/github/users/topherbullock/orgs","repos_url":"http://localhost:8000/github/users/topherbullock/repos","events_url":"http://localhost:8000/github/users/topherbullock/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherbullock/received_events","type":"User","site_admin":false,"score":10.931099},{"login":"TopherEllis","id":5521212,"avatar_url":"https://avatars.githubusercontent.com/u/5521212?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/TopherEllis","html_url":"https://github.com/TopherEllis","followers_url":"http://localhost:8000/github/users/TopherEllis/followers","following_url":"http://localhost:8000/github/users/TopherEllis/following{/other_user}","gists_url":"http://localhost:8000/github/users/TopherEllis/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/TopherEllis/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/TopherEllis/subscriptions","organizations_url":"http://localhost:8000/github/users/TopherEllis/orgs","repos_url":"http://localhost:8000/github/users/TopherEllis/repos","events_url":"http://localhost:8000/github/users/TopherEllis/events{/privacy}","received_events_url":"http://localhost:8000/github/users/TopherEllis/received_events","type":"User","site_admin":false,"score":10.931099},{"login":"topherredden","id":1283957,"avatar_url":"https://avatars.githubusercontent.com/u/1283957?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherredden","html_url":"https://github.com/topherredden","followers_url":"http://localhost:8000/github/users/topherredden/followers","following_url":"http://localhost:8000/github/users/topherredden/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherredden/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherredden/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherredden/subscriptions","organizations_url":"http://localhost:8000/github/users/topherredden/orgs","repos_url":"http://localhost:8000/github/users/topherredden/repos","events_url":"http://localhost:8000/github/users/topherredden/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherredden/received_events","type":"User","site_admin":false,"score":8.744879},{"login":"tophertech","id":5945266,"avatar_url":"https://avatars.githubusercontent.com/u/5945266?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/tophertech","html_url":"https://github.com/tophertech","followers_url":"http://localhost:8000/github/users/tophertech/followers","following_url":"http://localhost:8000/github/users/tophertech/following{/other_user}","gists_url":"http://localhost:8000/github/users/tophertech/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/tophertech/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/tophertech/subscriptions","organizations_url":"http://localhost:8000/github/users/tophertech/orgs","repos_url":"http://localhost:8000/github/users/tophertech/repos","events_url":"http://localhost:8000/github/users/tophertech/events{/privacy}","received_events_url":"http://localhost:8000/github/users/tophertech/received_events","type":"User","site_admin":false,"score":8.744879},{"login":"topherlafata","id":104543,"avatar_url":"https://avatars.githubusercontent.com/u/104543?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherlafata","html_url":"https://github.com/topherlafata","followers_url":"http://localhost:8000/github/users/topherlafata/followers","following_url":"http://localhost:8000/github/users/topherlafata/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherlafata/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherlafata/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherlafata/subscriptions","organizations_url":"http://localhost:8000/github/users/topherlafata/orgs","repos_url":"http://localhost:8000/github/users/topherlafata/repos","events_url":"http://localhost:8000/github/users/topherlafata/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherlafata/received_events","type":"User","site_admin":false,"score":8.744879},{"login":"topherdavis","id":1301399,"avatar_url":"https://avatars.githubusercontent.com/u/1301399?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherdavis","html_url":"https://github.com/topherdavis","followers_url":"http://localhost:8000/github/users/topherdavis/followers","following_url":"http://localhost:8000/github/users/topherdavis/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherdavis/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherdavis/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherdavis/subscriptions","organizations_url":"http://localhost:8000/github/users/topherdavis/orgs","repos_url":"http://localhost:8000/github/users/topherdavis/repos","events_url":"http://localhost:8000/github/users/topherdavis/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherdavis/received_events","type":"User","site_admin":false,"score":8.744879},{"login":"TopherLee513","id":1878231,"avatar_url":"https://avatars.githubusercontent.com/u/1878231?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/TopherLee513","html_url":"https://github.com/TopherLee513","followers_url":"http://localhost:8000/github/users/TopherLee513/followers","following_url":"http://localhost:8000/github/users/TopherLee513/following{/other_user}","gists_url":"http://localhost:8000/github/users/TopherLee513/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/TopherLee513/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/TopherLee513/subscriptions","organizations_url":"http://localhost:8000/github/users/TopherLee513/orgs","repos_url":"http://localhost:8000/github/users/TopherLee513/repos","events_url":"http://localhost:8000/github/users/TopherLee513/events{/privacy}","received_events_url":"http://localhost:8000/github/users/TopherLee513/received_events","type":"User","site_admin":false,"score":8.744879},{"login":"topherchristie","id":1136349,"avatar_url":"https://avatars.githubusercontent.com/u/1136349?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topherchristie","html_url":"https://github.com/topherchristie","followers_url":"http://localhost:8000/github/users/topherchristie/followers","following_url":"http://localhost:8000/github/users/topherchristie/following{/other_user}","gists_url":"http://localhost:8000/github/users/topherchristie/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topherchristie/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topherchristie/subscriptions","organizations_url":"http://localhost:8000/github/users/topherchristie/orgs","repos_url":"http://localhost:8000/github/users/topherchristie/repos","events_url":"http://localhost:8000/github/users/topherchristie/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topherchristie/received_events","type":"User","site_admin":false,"score":7.6517687},{"login":"topher6835","id":6319881,"avatar_url":"https://avatars.githubusercontent.com/u/6319881?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/topher6835","html_url":"https://github.com/topher6835","followers_url":"http://localhost:8000/github/users/topher6835/followers","following_url":"http://localhost:8000/github/users/topher6835/following{/other_user}","gists_url":"http://localhost:8000/github/users/topher6835/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/topher6835/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/topher6835/subscriptions","organizations_url":"http://localhost:8000/github/users/topher6835/orgs","repos_url":"http://localhost:8000/github/users/topher6835/repos","events_url":"http://localhost:8000/github/users/topher6835/events{/privacy}","received_events_url":"http://localhost:8000/github/users/topher6835/received_events","type":"User","site_admin":false,"score":7.6517687},{"login":"TopherMan","id":2914275,"avatar_url":"https://avatars.githubusercontent.com/u/2914275?v=3","gravatar_id":"","url":"http://localhost:8000/github/users/TopherMan","html_url":"https://github.com/TopherMan","followers_url":"http://localhost:8000/github/users/TopherMan/followers","following_url":"http://localhost:8000/github/users/TopherMan/following{/other_user}","gists_url":"http://localhost:8000/github/users/TopherMan/gists{/gist_id}","starred_url":"http://localhost:8000/github/users/TopherMan/starred{/owner}{/repo}","subscriptions_url":"http://localhost:8000/github/users/TopherMan/subscriptions","organizations_url":"http://localhost:8000/github/users/TopherMan/orgs","repos_url":"http://localhost:8000/github/users/TopherMan/repos","events_url":"http://localhost:8000/github/users/TopherMan/events{/privacy}","received_events_url":"http://localhost:8000/github/users/TopherMan/received_events","type":"User","site_admin":false,"score":7.6517687}]}
--------------------------------------------------------------------------------
/src/services/httpService/http.stub.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * This module is here to replace http.js for test purposes (or offline development)
5 | * to provide data from the json files referenced bellow which are snapshots of results from the github API
6 | *
7 | * In /src/config.mock.js you can configure the timeOut (to emulate network traffic)
8 | */
9 |
10 | import _mockGithubUser from './http.mocks/user.json';
11 | import _mockGithubRepos from './http.mocks/repos.json';
12 | import _mockGithubFollowers from './http.mocks/followers.json';
13 | import _mockGithubUsersSearch from './http.mocks/users.search.json';
14 |
15 | const mockJsonFiles = {
16 | '/github/users/*/repos': _mockGithubRepos,
17 | '/github/users/*/followers': _mockGithubFollowers,
18 | '/github/users/': _mockGithubUser,
19 | '/github/search/users?': _mockGithubUsersSearch
20 | };
21 |
22 | export default {
23 | get(relativeUrl){
24 | var promise = new Promise((resolve, reject) => {
25 | var result = null;
26 | for(let endpoint in mockJsonFiles){
27 | if(endpoint.indexOf('*') === -1 && relativeUrl.indexOf(endpoint) > -1){
28 | result = mockJsonFiles[endpoint];
29 | break;
30 | }
31 | else{
32 | let regexp = new RegExp(endpoint.replace('*','\\w+') + '.*');
33 | if(regexp.test(relativeUrl) === true){
34 | result = mockJsonFiles[endpoint];
35 | break;
36 | }
37 | }
38 | }
39 | if(result !== null){
40 | var resolvedData = {
41 | data: result,
42 | status: 200,
43 | type: 2,
44 | infos: {
45 | ratelimitRemaining: 123456,//@todo mock it ?
46 | etag: (new Date()).getTime().toString()//@todo mock it ?
47 | }
48 | };
49 | if(typeof this.configuration.timeOut === 'number'){
50 | setTimeout(function(){
51 | console.info('http.stub.js',relativeUrl,resolvedData);
52 | return resolve(resolvedData);
53 | },this.configuration.timeOut);
54 | }
55 | else{
56 | console.info('http.stub.js',relativeUrl,resolvedData);
57 | return resolve(resolvedData);
58 | }
59 | }
60 | else{
61 | var resolvedData = {
62 | kind: "error",
63 | message: "No endpoint matched in available mocks",
64 | humanMessage: "An error occured, please try again."
65 | }
66 | if(typeof this.configuration.timeOut === 'number'){
67 | setTimeout(function(){
68 | console.error('http.stub.js',relativeUrl,resolvedData);
69 | return reject(resolvedData);
70 | },this.configuration.timeOut);
71 | }
72 | else{
73 | console.error('http.stub.js',relativeUrl,resolvedData);
74 | return reject(resolvedData);
75 | }
76 | }
77 | });
78 | return promise;
79 | }
80 | }
--------------------------------------------------------------------------------
/src/services/localStorageWrapper.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | import lscache from 'lscache';
4 |
5 | const cacheLimit = 2;//2 minutes
6 |
7 | export default{
8 | get: lscache.get,
9 | set: function(key, value){
10 | return lscache.set(key, value, cacheLimit);
11 | },
12 | //extend of cacheLimit mins
13 | extend: function(key){
14 | var value = lscache.get(key);
15 | if(typeof value !== 'undefined'){
16 | this.set(key,value);
17 | }
18 | }
19 | };
--------------------------------------------------------------------------------
/src/style/footer.scss:
--------------------------------------------------------------------------------
1 | footer.footer {
2 | opacity:0.8;
3 | font-size:85%;
4 | }
5 |
6 | .footer {
7 | text-align: center;
8 | padding: 15px 0;
9 | margin-top: 30px;
10 | border-top: 1px solid #E5E5E5;
11 | }
--------------------------------------------------------------------------------
/src/style/header.scss:
--------------------------------------------------------------------------------
1 | /** github logo from my other sites**/
2 | /** networks header */
3 | .site-networks{
4 | position:absolute;
5 | right:20px;
6 | top:10px;
7 | margin:0;
8 | padding:0;
9 | }
10 | ul.site-networks{
11 | list-style: none;
12 | text-align: center;
13 | padding:0px 0px 10px 0px;
14 | }
15 | ul.site-networks li{
16 | position: relative;
17 | display: inline-block;
18 | vertical-align: middle;
19 | margin-left:15px;
20 | }
21 | ul.site-networks li a{
22 | display: block;
23 | width:32px;
24 | height:32px;
25 | text-decoration: none;
26 | padding-top:0px;
27 | -webkit-transition : all 0.5s;
28 | -moz-transition : all 0.5s;
29 | -ms-transition : all 0.5s;
30 | -o-transition : all 0.5s;
31 | transition : all 0.5s;
32 | }
33 | ul.site-networks li a span.icon{
34 | position: absolute;
35 | display: block;
36 | width:32px;
37 | height: 32px;
38 | -webkit-transition : all 0.5s;
39 | -moz-transition : all 0.5s;
40 | -ms-transition : all 0.5s;
41 | -o-transition : all 0.5s;
42 | transition : all 0.5s;
43 | }
44 | ul.site-networks li a span.desc{
45 | display: none;
46 | }
47 | ul.site-networks li a:hover{
48 |
49 | }
50 | ul.site-networks li a:hover span.icon{
51 | left:0px;
52 | -webkit-transform: rotate(360deg);
53 | -moz-transform: rotate(360deg);
54 | -ms-transform: rotate(360deg);
55 | -o-transform: rotate(360deg);
56 | transform: rotate(360deg);
57 | }
58 | /** since logos are included with the css in base64, we don't bother about pixel ratio media query (everybody gets the retina version)*/
59 | ul.site-networks li.twitter a span.icon{
60 | background-image: url(../../public/assets/images/twitter-retina.png);
61 | background-size: 32px 32px;
62 | }
63 | ul.site-networks li.github a span.icon{
64 | background-image: url(../../public/assets/images/github-retina.png);
65 | background-size: 32px 32px;
66 | }
67 | @media only screen
68 | and (max-width: $grid-float-breakpoint) and (min-width : 370px)
69 | {
70 | ul.site-networks{
71 | right:70px;
72 | }
73 | ul.site-networks li{
74 | margin-left:0px;
75 | }
76 | .site-title {
77 | font-size: 26px;
78 | }
79 | .content {
80 | padding: 0 10px;
81 | }
82 | img{
83 | max-width: 100%;
84 | }
85 | }
86 | /** for small devices */
87 | @media only screen
88 | and (max-width : 370px) {
89 | ul.site-networks{
90 | display: none;
91 | }
92 | }
93 |
94 | .navbar-default button.navbar-toggle {
95 | border-color : #900000;
96 | }
97 | .navbar-default .navbar-toggle span.icon-bar{
98 | background-color: #900000;
99 | }
--------------------------------------------------------------------------------
/src/style/main.scss:
--------------------------------------------------------------------------------
1 | $brand-primary: #900000;
2 | $input-border-focus: $brand-primary;
3 |
4 | $icon-font-path: "~bootstrap-sass/assets/fonts/bootstrap/";
5 | @import "bootstrap-sass/assets/stylesheets/_bootstrap.scss";
6 | @import "header.scss";
7 | @import "footer.scss";
8 | @import "spinner.scss";
9 |
10 | .glyphicon-star {
11 | color: darken(yellow,15%);
12 | }
13 | .repos-list .pager{
14 | margin: 0 0 10px 0;
15 | }
16 | .repos-list{
17 | width: 100%;
18 | }
19 | .repos-list .spinner{
20 | position: absolute;
21 | left: 50%;
22 | margin-left: -35px;
23 | }
--------------------------------------------------------------------------------
/src/style/spinner.scss:
--------------------------------------------------------------------------------
1 | .panel-body .spinner{
2 | margin: 0 auto;
3 | }
4 |
5 | /** from http://tobiasahlin.com/spinkit/ */
6 | .spinner {
7 | width: 70px;
8 | }
9 |
10 | .spinner > div {
11 | width: 18px;
12 | height: 18px;
13 | background-color: $brand-primary;
14 |
15 | border-radius: 100%;
16 | display: inline-block;
17 | -webkit-animation: bouncedelay 1.4s infinite ease-in-out;
18 | animation: bouncedelay 1.4s infinite ease-in-out;
19 | /* Prevent first frame from flickering when animation starts */
20 | -webkit-animation-fill-mode: both;
21 | animation-fill-mode: both;
22 | }
23 |
24 | .spinner .bounce1 {
25 | -webkit-animation-delay: -0.32s;
26 | animation-delay: -0.32s;
27 | }
28 |
29 | .spinner .bounce2 {
30 | -webkit-animation-delay: -0.16s;
31 | animation-delay: -0.16s;
32 | }
33 |
34 | @-webkit-keyframes bouncedelay {
35 | 0%, 80%, 100% { -webkit-transform: scale(0.0) }
36 | 40% { -webkit-transform: scale(1.0) }
37 | }
38 |
39 | @keyframes bouncedelay {
40 | 0%, 80%, 100% {
41 | transform: scale(0.0);
42 | -webkit-transform: scale(0.0);
43 | } 40% {
44 | transform: scale(1.0);
45 | -webkit-transform: scale(1.0);
46 | }
47 | }
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 | var path = require('path');
3 | var webpack = require('webpack');
4 | var ExtractTextPlugin = require("extract-text-webpack-plugin");
5 | var mockObjects = false;
6 | var common = require('./common');
7 | var plugins = [];
8 | var MODE_DEV_SERVER = process.argv[1].indexOf('webpack-dev-server') > -1 ? true : false;
9 |
10 | console.log('Launched in ' + (MODE_DEV_SERVER ? 'dev-server' : 'build') + ' mode');
11 |
12 | /** environment setup */
13 |
14 | var env = process.env.NODE_ENV ? process.env.NODE_ENV.toLowerCase() : 'dev';
15 | if(env === 'prod'){
16 | console.log('PRODUCTION mode');
17 | }
18 | else if(env === 'test'){
19 | console.log('TEST mode');
20 | mockObjects = true;
21 | }
22 | else if(env === 'mock'){
23 | console.log('MOCK mode');
24 | mockObjects = true;
25 | }
26 | else{
27 | console.log('DEVELOPMENT mode');
28 | }
29 |
30 | /** before build */
31 |
32 | var hash = env === 'prod' ? '-[hash]' : '';
33 |
34 | //in build mode, cleanup build folder before
35 | if(MODE_DEV_SERVER === false){
36 | console.log('Cleaning ...');
37 | var deleted = require('del').sync(['build/*','build/**/*',"!.git/**/*"]);
38 | deleted.forEach(function(e){
39 | console.log(e);
40 | });
41 | }
42 |
43 | /** plugins setup */
44 |
45 | plugins.push(new webpack.NoErrorsPlugin());
46 | // extract css into one main.css file
47 | plugins.push(new ExtractTextPlugin('css/main' + hash + '.css',{
48 | disable: false,
49 | allChunks: true
50 | }));
51 | plugins.push(new webpack.BannerPlugin(common.getBanner()));
52 |
53 | if(env === 'prod'){
54 | plugins.push(new webpack.optimize.UglifyJsPlugin({
55 | compress: {
56 | warnings: true
57 | }
58 | }));
59 | }
60 |
61 | if(MODE_DEV_SERVER === false){
62 | //write infos about the build (to retrieve the hash) https://webpack.github.io/docs/long-term-caching.html#get-filenames-from-stats
63 | plugins.push(function() {
64 | this.plugin("done", function(stats) {
65 | console.log('');//break line
66 |
67 | require("fs").writeFileSync(
68 | path.join(__dirname, "build", "stats.json"),
69 | JSON.stringify(stats.toJson()));
70 | console.log('Created /build/stats.json file');
71 |
72 | //save the git revision hash (on heroku, it can only be retrieved at compile time), that way, it will be available to express after
73 | var bannerInfos = require('./common').getInfos();
74 | require("fs").writeFileSync(
75 | path.join(__dirname, "build", "bannerInfos.json"),
76 | JSON.stringify(bannerInfos));
77 | console.log('Created /build/bannerInfos.json file');
78 | });
79 | });
80 | }
81 |
82 | /** webpack config */
83 |
84 | var resolve = {
85 | alias : {}
86 | };
87 | //only used browser side
88 | resolve.alias['httpServiceConfiguration'] = path.resolve(__dirname, './src/services/httpService/config/environment/config' + (env === 'prod' ? '.build' : (env === 'mock' ? '.mock' : '.dev' ) ) + '.js');
89 |
90 | var config = {
91 | entry: {
92 | "js/bundle": [
93 | "./src/bootstrap.jsx"
94 | ],
95 | "css/main": "./src/style/main.scss"
96 | },
97 | output: {
98 | publicPath: "/assets/",
99 | filename: "[name]" + hash + ".js",
100 | path: "./build/assets"
101 | },
102 | cache: true,
103 | debug: env === 'prod' ? false : true,
104 | devtool: env === 'prod' ? false : "sourcemap",
105 | devServer: {
106 | contentBase: './public',
107 | inline: true
108 | },
109 | module: {
110 | loaders: [
111 | {
112 | test: /\.js(x?)$/,
113 | exclude: /node_modules/,
114 | loader: 'react-hot!babel-loader'
115 | },
116 | {
117 | test: /\.json$/,
118 | loader: 'json-loader'
119 | },
120 | {
121 | test: /\.scss/,
122 | loader: ExtractTextPlugin.extract("style-loader",
123 | "css-loader?sourceMap!sass-loader?sourceMap=true&sourceMapContents=true&outputStyle=expanded&" +
124 | "includePaths[]=" + (path.resolve(__dirname, "./node_modules"))
125 | )
126 | },
127 | {
128 | test: /\.css/,
129 | loader: 'style-loader!css-loader'
130 | },
131 | {test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/, loader: "file-loader" },
132 | { test: /\.(png|woff|woff2|eot|ttf|svg)$/, loader: 'url-loader?limit=100000' }
133 | ]
134 | },
135 | resolve: resolve,
136 | plugins: plugins
137 | };
138 |
139 | module.exports = config;
--------------------------------------------------------------------------------