├── Procfile ├── .gitattributes ├── README.md ├── app ├── robots.txt ├── styles │ ├── _auth.scss │ ├── _resets.scss │ ├── main.scss │ ├── _mixins.scss │ ├── _variables.scss │ ├── _settings.scss │ ├── _globals.scss │ ├── _chart.scss │ ├── _modal.scss │ ├── _layout.scss │ ├── _tooltip.scss │ └── _storyPanel.scss ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── mstile-70x70.png ├── apple-touch-icon.png ├── favicon-160x160.png ├── favicon-196x196.png ├── apple-touch-icon-114x114.png ├── apple-touch-icon-120x120.png ├── apple-touch-icon-144x144.png ├── apple-touch-icon-152x152.png ├── apple-touch-icon-57x57.png ├── apple-touch-icon-60x60.png ├── apple-touch-icon-72x72.png ├── apple-touch-icon-76x76.png ├── apple-touch-icon-precomposed.png ├── test.js ├── browserconfig.xml ├── scripts │ ├── app.js │ ├── init.js │ ├── nav.js │ ├── utils.js │ ├── main.js │ ├── favs.js │ ├── layout.js │ ├── actions.js │ ├── auth.js │ ├── storyModel.js │ ├── events.js │ ├── settings.js │ ├── storyPanel.js │ ├── comments.js │ ├── data.js │ └── chart.js ├── test.html ├── images │ └── gearIcon.svg ├── 404.html └── about.html ├── dist ├── robots.txt ├── favicon.ico ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon-96x96.png ├── mstile-70x70.png ├── favicon-160x160.png ├── favicon-196x196.png ├── mstile-144x144.png ├── mstile-150x150.png ├── mstile-310x150.png ├── mstile-310x310.png ├── apple-touch-icon.png ├── apple-touch-icon-57x57.png ├── apple-touch-icon-60x60.png ├── apple-touch-icon-72x72.png ├── apple-touch-icon-76x76.png ├── apple-touch-icon-114x114.png ├── apple-touch-icon-120x120.png ├── apple-touch-icon-144x144.png ├── apple-touch-icon-152x152.png ├── apple-touch-icon-precomposed.png ├── test.js ├── browserconfig.xml ├── test.html ├── 404.html ├── about.html └── styles │ └── main-dd5e0326.css ├── .bowerrc ├── test ├── .bowerrc ├── bower.json ├── spec │ └── test.js └── index.html ├── artifacts ├── icon.png ├── icon.psd ├── reddit7.png ├── facebook43.png ├── google109.png ├── twitter35.png ├── reddit-alien.jpeg ├── Bubble Reader Design.pptx └── star.svg ├── .gitignore ├── strongloop.json ├── server ├── models │ ├── Token.model.js │ ├── Story.model.js │ └── User.model.js ├── readability.js ├── new-hxncrawler.js ├── config.js ├── utils.js ├── server.js ├── routes.js ├── workers.js ├── controllers │ ├── user.controller.js │ └── story.controller.js ├── rdtCrawler.js ├── hxnCrawler.js └── auth.js ├── bower.json ├── .jshintrc ├── start.js ├── .editorconfig ├── LICENSE ├── package.json ├── npm-debug.log └── gulpfile.js /Procfile: -------------------------------------------------------------------------------- 1 | web: node start -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | #News Bubbles 2 | Shows news. As bubbles. -------------------------------------------------------------------------------- /app/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org/ 2 | 3 | User-agent: * 4 | -------------------------------------------------------------------------------- /app/styles/_auth.scss: -------------------------------------------------------------------------------- 1 | .modal-wrapper.settings { 2 | 3 | } -------------------------------------------------------------------------------- /dist/robots.txt: -------------------------------------------------------------------------------- 1 | # robotstxt.org/ 2 | 3 | User-agent: * 4 | -------------------------------------------------------------------------------- /.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "app/bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /test/.bowerrc: -------------------------------------------------------------------------------- 1 | { 2 | "directory": "bower_components" 3 | } 4 | -------------------------------------------------------------------------------- /app/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/favicon.ico -------------------------------------------------------------------------------- /dist/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/favicon.ico -------------------------------------------------------------------------------- /artifacts/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/icon.png -------------------------------------------------------------------------------- /artifacts/icon.psd: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/icon.psd -------------------------------------------------------------------------------- /app/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/favicon-16x16.png -------------------------------------------------------------------------------- /app/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/favicon-32x32.png -------------------------------------------------------------------------------- /app/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/favicon-96x96.png -------------------------------------------------------------------------------- /app/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/mstile-144x144.png -------------------------------------------------------------------------------- /app/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/mstile-150x150.png -------------------------------------------------------------------------------- /app/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/mstile-310x150.png -------------------------------------------------------------------------------- /app/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/mstile-310x310.png -------------------------------------------------------------------------------- /app/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/mstile-70x70.png -------------------------------------------------------------------------------- /artifacts/reddit7.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/reddit7.png -------------------------------------------------------------------------------- /dist/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/favicon-16x16.png -------------------------------------------------------------------------------- /dist/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/favicon-32x32.png -------------------------------------------------------------------------------- /dist/favicon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/favicon-96x96.png -------------------------------------------------------------------------------- /dist/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/mstile-70x70.png -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .tmp 3 | .idea 4 | .sass-cache 5 | app/bower_components 6 | test/bower_components 7 | -------------------------------------------------------------------------------- /app/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon.png -------------------------------------------------------------------------------- /app/favicon-160x160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/favicon-160x160.png -------------------------------------------------------------------------------- /app/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/favicon-196x196.png -------------------------------------------------------------------------------- /artifacts/facebook43.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/facebook43.png -------------------------------------------------------------------------------- /artifacts/google109.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/google109.png -------------------------------------------------------------------------------- /artifacts/twitter35.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/twitter35.png -------------------------------------------------------------------------------- /dist/favicon-160x160.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/favicon-160x160.png -------------------------------------------------------------------------------- /dist/favicon-196x196.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/favicon-196x196.png -------------------------------------------------------------------------------- /dist/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/mstile-144x144.png -------------------------------------------------------------------------------- /dist/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/mstile-150x150.png -------------------------------------------------------------------------------- /dist/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/mstile-310x150.png -------------------------------------------------------------------------------- /dist/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/mstile-310x310.png -------------------------------------------------------------------------------- /artifacts/reddit-alien.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/reddit-alien.jpeg -------------------------------------------------------------------------------- /dist/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon.png -------------------------------------------------------------------------------- /app/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /app/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /app/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /app/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /app/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /app/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /app/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /app/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-57x57.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-57x57.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-72x72.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-72x72.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-114x114.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-114x114.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-144x144.png -------------------------------------------------------------------------------- /dist/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /app/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/app/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /artifacts/Bubble Reader Design.pptx: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/artifacts/Bubble Reader Design.pptx -------------------------------------------------------------------------------- /dist/apple-touch-icon-precomposed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidgilbertson/news-bubbles/HEAD/dist/apple-touch-icon-precomposed.png -------------------------------------------------------------------------------- /test/bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hb", 3 | "private": true, 4 | "dependencies": { 5 | "chai": "~1.8.0", 6 | "mocha": "~1.14.0" 7 | }, 8 | "devDependencies": {} 9 | } 10 | -------------------------------------------------------------------------------- /app/styles/_resets.scss: -------------------------------------------------------------------------------- 1 | *, *:before, *:after { 2 | box-sizing: border-box; 3 | } 4 | 5 | hr { 6 | display: block; 7 | height: 1px; 8 | border: 0; 9 | border-top: 1px solid #ccc; 10 | margin: 1em 0; 11 | padding: 0; 12 | } 13 | 14 | -------------------------------------------------------------------------------- /strongloop.json: -------------------------------------------------------------------------------- 1 | { 2 | "id": "17c200c04bd6cf974f3e7c697abaf67e", 3 | "userId": 100006775, 4 | "name": "David Gilbertson", 5 | "userKey": "17c200c04bd6cf974f3e7c697abaf67e", 6 | "email": "gilbertson.david@gmail.com", 7 | "created": "2014-09-20T05:44:53.000Z" 8 | } 9 | -------------------------------------------------------------------------------- /app/test.js: -------------------------------------------------------------------------------- 1 | var circle1 = d3.select('#circle-1'); 2 | var circle2 = d3.select('#circle-2'); 3 | var circle3 = d3.select('#circle-3'); 4 | 5 | circle3.node(0).parentNode.insertBefore(circle3.node(0), circle3.node(0).parentNode.firstChild); 6 | 7 | console.log('circle test loaded'); -------------------------------------------------------------------------------- /dist/test.js: -------------------------------------------------------------------------------- 1 | var circle1 = d3.select('#circle-1'); 2 | var circle2 = d3.select('#circle-2'); 3 | var circle3 = d3.select('#circle-3'); 4 | 5 | circle3.node(0).parentNode.insertBefore(circle3.node(0), circle3.node(0).parentNode.firstChild); 6 | 7 | console.log('circle test loaded'); -------------------------------------------------------------------------------- /server/models/Token.model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var mongoose = require('mongoose'); 3 | 4 | var tokenSchema = mongoose.Schema({ 5 | token: String, 6 | userId: String 7 | }); 8 | 9 | var Token = mongoose.model('token', tokenSchema); 10 | 11 | module.exports = Token; -------------------------------------------------------------------------------- /app/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import 2 | '../bower_components/normalize-scss/_normalize.scss', 3 | 'resets', 4 | 'variables', 5 | 'mixins', 6 | 'layout', 7 | 'globals', 8 | 9 | 'modal', 10 | 'settings', 11 | 'auth', 12 | 13 | 'tooltip', 14 | 'chart', 15 | 'storyPanel' 16 | ; 17 | -------------------------------------------------------------------------------- /bower.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hb", 3 | "private": true, 4 | "dependencies": { 5 | "jquery": "~1.11.0", 6 | "d3": "~3.4.11", 7 | "knockout": "~3.2.0", 8 | "moment": "~2.8.3", 9 | "firebase": "~1.1.0" 10 | }, 11 | "devDependencies": { 12 | "normalize-scss": "~3.0.1" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /app/styles/_mixins.scss: -------------------------------------------------------------------------------- 1 | @mixin shadow { 2 | box-shadow: 2px 2px 10px rgba(0, 0, 0, 0.5); 3 | } 4 | @mixin no-style-button { 5 | border: none; 6 | outline: none; 7 | padding: 0; 8 | margin: 0; 9 | background: white; 10 | } 11 | @mixin no-style-ul { 12 | list-style-type: none; 13 | margin: 0; 14 | padding: 0; 15 | } -------------------------------------------------------------------------------- /test/spec/test.js: -------------------------------------------------------------------------------- 1 | /* global describe, it */ 2 | 3 | (function () { 4 | 'use strict'; 5 | 6 | describe('Give it some context', function () { 7 | describe('maybe a bit more context here', function () { 8 | it('should run here few assertions', function () { 9 | 10 | }); 11 | }); 12 | }); 13 | })(); 14 | -------------------------------------------------------------------------------- /app/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #da532c 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /dist/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | #da532c 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /app/scripts/app.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.App = (function() { 5 | var App = {}; 6 | 7 | App.maxiTooltipVis = ko.observable(false); 8 | App.user = NB.Auth.userModel; 9 | App.settings = NB.Settings; 10 | App.nav = NB.Nav.navModel; 11 | App.storyModel = NB.StoryModel.storyModel; 12 | 13 | App.view = { 14 | showMaxiTooltip: ko.observable(false) 15 | }; 16 | 17 | 18 | function init() { 19 | ko.applyBindings(App, document.body); 20 | 21 | } 22 | 23 | init(); 24 | return App; 25 | 26 | })(); 27 | -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "node": true, 3 | "browser": true, 4 | "esnext": true, 5 | "bitwise": true, 6 | "camelcase": false, 7 | "curly": true, 8 | "eqeqeq": true, 9 | "immed": true, 10 | "indent": 4, 11 | "latedef": true, 12 | "laxcomma": true, 13 | "newcap": true, 14 | "noarg": true, 15 | "quotmark": "single", 16 | "undef": true, 17 | "unused": true, 18 | "strict": true, 19 | "trailing": true, 20 | "smarttabs": true, 21 | "jquery": true, 22 | "predef": ["$", "d3", "io", "ko", "moment", "socket", "RegExp", "config"] 23 | } 24 | -------------------------------------------------------------------------------- /start.js: -------------------------------------------------------------------------------- 1 | //start.js is kicked off by Heroku. 2 | //it creates an express app, serves out of dist and passes app on to server/server.js 3 | //For dev, gulp is creating an express app instance, serving out of app and .tmp and passing it to server.js 4 | //The magic happens in server.js and beyond. 5 | 6 | var path = require('path') 7 | , express = require('express') 8 | , app = express() 9 | ; 10 | 11 | 12 | app.use(require('compression')()); 13 | 14 | app.use(express.static('dist')); 15 | 16 | var server = require(path.join(__dirname, 'server', 'server.js')); 17 | 18 | server.start(app); 19 | 20 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | 8 | [*] 9 | 10 | # Change these settings to your own preference 11 | indent_style = space 12 | indent_size = 4 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | 23 | [package.json] 24 | indent_style = space 25 | indent_size = 2 26 | 27 | [bower.json] 28 | indent_style = space 29 | indent_size = 2 30 | -------------------------------------------------------------------------------- /app/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /dist/test.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /server/readability.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var request = require('request') 3 | , readabilityUrl = 'https://readability.com/api/content/v1/parser' 4 | , readabilityToken = '10b42cde5b1b55ef7c219a98834c8c823a2b0cc3' 5 | ; 6 | 7 | //use the readability API to scrape a web page and return the content object 8 | module.exports = function(req, res) { 9 | var fullUrl = readabilityUrl + '?url=' + encodeURIComponent(req.params.url) + '&token=' + readabilityToken; 10 | 11 | request.get({url: fullUrl, json: true}, function (err, req, body) { 12 | if (err) { 13 | res.json({error: 'error'}); 14 | } else { 15 | res.json(body); 16 | } 17 | }); 18 | 19 | }; 20 | 21 | -------------------------------------------------------------------------------- /app/styles/_variables.scss: -------------------------------------------------------------------------------- 1 | 2 | /* -- Colors -- */ 3 | 4 | $asbestos: #7f8c8d; 5 | $aliezarin: #e74c3c; 6 | $green-sea: #16a085; 7 | $nephritis: #27ae60; 8 | $orange: #f39c12; 9 | $pumpkin: #d35400; 10 | $belizeHole: #2980b9; 11 | 12 | 13 | $primary-color: $aliezarin; 14 | 15 | $header-color: $primary-color; 16 | $grabber-color: $primary-color; 17 | 18 | $resizer-width: 24px; //matches HB.RESIZER_WIDTH 19 | 20 | $story-color: #2980b9; 21 | $ask-color: $nephritis; 22 | $show-color: $pumpkin; 23 | 24 | $move-dur: 200ms; //Should be matched by JS global 25 | 26 | 27 | /* -- TYPOGRAPHY -- */ 28 | $font-family: 'Roboto', sans-serif; 29 | $col-text-primary: $primary-color; 30 | $col-text-light: #ddd; 31 | 32 | 33 | /* -- Dimensions -- */ 34 | $header-height: 50px; 35 | $break-sm: 400px; 36 | $break-md: 700px; 37 | -------------------------------------------------------------------------------- /artifacts/star.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | ]> 7 | 9 | 11 | 12 | -------------------------------------------------------------------------------- /test/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Mocha Spec Runner 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /app/scripts/init.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | console.time('initialize app'); 4 | 5 | //Everything here runs before anything else is initialized 6 | 7 | //Constants 8 | NB.DUR_FAST = 200; //should match _variables.scss duration variable 9 | NB.DUR_SLOW = 2000; 10 | NB.RESIZER_WIDTH = 24; 11 | NB.splitPos = 0; 12 | 13 | if (!!document.location.host.match(/localhost/)) { 14 | NB.IS_LOCALHOST = true; 15 | } else { 16 | NB.IS_LOCALHOST = false; 17 | } 18 | 19 | NB.hasTouch = false; 20 | NB.oldestStory = Infinity; 21 | 22 | 23 | var targetLocalStorageVersion = 1; //increment this to wipe the localstorage for older versions 24 | var lsVersion = localStorage.v ? localStorage.v : 0; 25 | if (lsVersion < targetLocalStorageVersion) { 26 | console.log('Clearing local storage.'); 27 | localStorage.clear(); 28 | localStorage.v = targetLocalStorageVersion; 29 | } -------------------------------------------------------------------------------- /app/scripts/nav.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var NB = NB || {}; 4 | 5 | NB.Nav = (function() { 6 | var Nav = {} 7 | , currentSource 8 | ; 9 | 10 | function init() { 11 | currentSource = NB.Settings.getSetting('source'); 12 | Nav.navModel.currentSource(currentSource); 13 | } 14 | 15 | Nav.navModel = { 16 | currentSource: ko.observable(currentSource) 17 | }; 18 | 19 | Nav.navigate = function(newSource) { 20 | NB.Layout.hideStoryPanel(); 21 | NB.StoryModel.clear(); 22 | // console.log('Going to navigate to', newSource); 23 | Nav.navModel.currentSource(newSource); 24 | NB.Settings.setSetting('source', newSource); 25 | 26 | NB.Chart.reset(); 27 | NB.Data.getData(); 28 | 29 | //TODO: less dumb way to do this? 30 | var body = d3.select('body'); 31 | body.classed('rdt', newSource === 'rdt'); 32 | body.classed('hxn', newSource === 'hxn'); 33 | body.classed('fav', newSource === 'fav'); 34 | }; 35 | 36 | init(); 37 | return Nav; 38 | })(); -------------------------------------------------------------------------------- /app/scripts/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var NB = NB || {}; 4 | 5 | NB.Utils = (function() { 6 | var Utils = {}; 7 | 8 | Utils.constrain = function(low, val, high) { 9 | val = Math.max(low, val); 10 | val = Math.min(val, high); 11 | return val; 12 | }; 13 | 14 | Utils.unescape = function(str) { 15 | var unEscapeMap = { 16 | '&': '&', 17 | '<': '<', 18 | '>': '>', 19 | '"': '"', 20 | ''': "'", 21 | '`': '`', 22 | '/': '/' 23 | }; 24 | 25 | var escaper = function(match) { 26 | return unEscapeMap[match]; 27 | }; 28 | 29 | var keysAsString = Object.keys(unEscapeMap).join('|'); 30 | var source = '(?:' + keysAsString + ')'; 31 | var testRegexp = RegExp(source); 32 | var replaceRegexp = RegExp(source, 'g'); 33 | 34 | str = str == null ? '' : '' + str; 35 | return testRegexp.test(str) ? str.replace(replaceRegexp, escaper) : str; 36 | 37 | }; 38 | 39 | return Utils; 40 | })(); -------------------------------------------------------------------------------- /app/styles/_settings.scss: -------------------------------------------------------------------------------- 1 | .modal-wrapper.settings { 2 | .footer .about-link { 3 | line-height: 40px; 4 | } 5 | .option { 6 | margin-bottom: 10px; 7 | 8 | p { 9 | margin: 0; 10 | padding-bottom: 5px; 11 | } 12 | label, input { 13 | cursor: pointer; 14 | } 15 | .inline-input { 16 | transition: 100ms; 17 | border: none; 18 | padding-left: 15px; 19 | border-bottom: 1px solid #ddd; 20 | text-align: center; 21 | /* background: #eee; */ 22 | width: 80px; 23 | &:focus { 24 | width: 80px; 25 | outline: none; 26 | } 27 | } 28 | .color-list { 29 | @include no-style-ul; 30 | 31 | li { 32 | padding-bottom: 4px; 33 | } 34 | .dot { 35 | display: inline-block; 36 | height: 20px; 37 | width: 20px; 38 | border-radius: 50%; 39 | } 40 | .text { 41 | vertical-align: 4px; 42 | padding-left: 9px; 43 | } 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 David Gilbertson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/models/Story.model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var mongoose = require('mongoose'); 3 | 4 | var storySchema = mongoose.Schema({ 5 | id: String, //source + id (e.g. 'hn-123456') 6 | source: String, //e.g. hn 7 | sourceId: String, //e.g. 123456 8 | modifiedDate: Date, 9 | name: String, 10 | desc: String, 11 | postDate: Date, 12 | postDateSeconds: Number, 13 | url: String, 14 | sourceUrl: String, 15 | authorUrl: String, 16 | category: String, //e.g. askHN, imgur, askReddit, nytimes.com 17 | commentCount: Number, 18 | score: Number, 19 | author: String, 20 | thumbnail: String, 21 | rdt: {}, //reddit specific stuff 22 | hxn: {}, //hacker news specific stuff 23 | twt: {}, //twitter specific stuff 24 | tbl: {}, //tumblr specific stuff 25 | history: [ 26 | { 27 | dateTime: Date, 28 | commentCount: Number, 29 | score: Number 30 | } 31 | ] 32 | }); 33 | 34 | storySchema.set('autoIndex', false); //redundant since I've removed indexes, but there as a net 35 | 36 | var Story = mongoose.model('Story', storySchema); 37 | 38 | exports.Story = Story; -------------------------------------------------------------------------------- /app/scripts/main.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.main = (function() { 5 | NB.splitPos = document.body.offsetWidth - NB.RESIZER_WIDTH; 6 | NB.Layout.render(); 7 | NB.Layout.init(); 8 | 9 | var src = NB.Settings.getSetting('source') || 'rdt'; //this should never be empty, but 'rd' is there for the fun of it. 10 | var minScore = NB.Settings.getSetting(src + 'MinScore'); 11 | 12 | 13 | d3.select('body').classed(src, true); 14 | 15 | //On page load, use the APIs directly from the client to get a fresh batch of results 16 | //The server will be emitting new/changed stories as they become available. 17 | NB.Data.getData(src, minScore); 18 | 19 | // ko.applyBindings(NB.StoryModel.tooltipStory, document.getElementById('story-tooltip')); 20 | // ko.applyBindings(NB.StoryModel.panelStory, document.getElementById('story-panel')); 21 | // ko.applyBindings(NB.Nav.navModel, document.getElementById('news-sources')); 22 | 23 | 24 | //Two approaches to touch detection 25 | // if (!('ontouchstart' in window) && !(window.DocumentTouch && document instanceof DocumentTouch)) { 26 | // d3.select('body').classed('no-touch', true); 27 | // NB.hasTouch = false; 28 | // } 29 | 30 | var onFirstTouch = function() { 31 | document.body.classList.remove('no-touch'); 32 | NB.hasTouch = true; 33 | document.body.removeEventListener('touchstart', onFirstTouch); 34 | }; 35 | document.body.addEventListener('touchstart', onFirstTouch); 36 | 37 | 38 | 39 | })(); -------------------------------------------------------------------------------- /server/new-hxncrawler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path') 4 | , request = require('request') 5 | , Firebase = require('firebase') 6 | , storyController = require(path.join(__dirname, 'controllers', 'story.controller')) 7 | , utils = require(path.join(__dirname, 'utils')) 8 | , devLog = utils.devLog 9 | , prodLog = utils.prodLog 10 | , fbBaseUrl = 'https://hacker-news.firebaseio.com/v0' 11 | ; 12 | 13 | 14 | 15 | function getByIdFromFirebase(id) { 16 | var requestOptions = { 17 | url: fbBaseUrl + '/item/' + id + '.json', 18 | json: true 19 | }; 20 | request.get(requestOptions, function(err, res, data) { 21 | if (err) { 22 | return prodLog('Error getting details from firebase:', err); 23 | } else if (data && data.type !== 'story') { 24 | return; //doing nothing with comments for now. 25 | } else { 26 | return storyController.upsertFbHxnStory(data); 27 | } 28 | }); 29 | } 30 | 31 | function start() { 32 | var fb = new Firebase(fbBaseUrl + '/updates'); 33 | var newStoryList = []; 34 | 35 | fb.on('value', function (snapshot) { 36 | try { 37 | if (!snapshot.val() || !snapshot.val().items) { return; } 38 | 39 | snapshot.val().items.forEach(function(storyId) { 40 | getByIdFromFirebase(storyId); 41 | }); 42 | 43 | } catch (err) { 44 | prodLog('Error in Firebase listener:', err); 45 | } 46 | }, function(err) { 47 | prodLog('Error in Firebase listener:', err); 48 | }); 49 | 50 | } 51 | 52 | exports.start = start; -------------------------------------------------------------------------------- /server/config.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var prodUrl = 'http://www.bubblereader.com'; 4 | var devUrl = 'http://local.bubblereader.com'; 5 | 6 | var prodConfig = { 7 | // baseUrl: 'http://www.bubblereader.com', 8 | db: { 9 | port: process.env.PORT, 10 | conn: process.env.MONGOLAB_URL 11 | }, 12 | auth: { 13 | facebook: { 14 | clientId: '833772886647232', 15 | secret: '862d4c22a83572793c7214d798afe5f3', 16 | callbackUrl: prodUrl + '/auth/facebook/callback' 17 | }, 18 | reddit: { 19 | clientId: '1_v-tNQj16e7Sg', 20 | secret: '_yzoDtfgvzMlrFK56mLFlPt6oY4', 21 | callbackUrl: prodUrl + '/auth/reddit/callback' 22 | } 23 | }, 24 | nodetime: { 25 | accountKey: process.env.NODETIME_ACCOUNT_KEY, 26 | appName: 'News Bubbles' // optional 27 | } 28 | }; 29 | 30 | 31 | var devConfig = { 32 | // baseUrl: 'http://local.bubblereader.com', 33 | db: { 34 | port: 9000, 35 | conn: 'mongodb://localhost/news_bubbles' 36 | }, 37 | auth: { 38 | facebook: { 39 | clientId: prodConfig.auth.facebook.clientId, 40 | secret: prodConfig.auth.facebook.secret, 41 | callbackUrl: devUrl + '/auth/facebook/callback' 42 | }, 43 | reddit: { 44 | clientId: '4tPkcfJiC76--w', 45 | secret: 'hWWKy8NNYeiP8ZFXadhS204t4a4', 46 | callbackUrl: devUrl + '/auth/reddit/callback' 47 | } 48 | }, 49 | nodetime: { 50 | accountKey: '05d915a7339098057141246ef49ab77a3c5bd013', 51 | appName: 'News Bubbles Dev' 52 | } 53 | }; 54 | 55 | 56 | module.exports = { 57 | prod: prodConfig, 58 | dev: devConfig 59 | }; 60 | -------------------------------------------------------------------------------- /app/styles/_globals.scss: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | overflow: hidden; 4 | min-width: 300px; 5 | } 6 | body { 7 | font-family: $font-family; 8 | font-weight: 300; 9 | letter-spacing: 0.3px; 10 | color: #333; 11 | background-image: linear-gradient(0deg, #000, #1C2732); 12 | } 13 | h1, h2, h3, h4, h5, h6 { 14 | font-weight: 300; 15 | /* color: $header-color; */ 16 | } 17 | a { 18 | /* text-decoration: none; */ 19 | /* color: darken($header-color, 10%); */ 20 | color: $header-color; 21 | } 22 | a:hover { 23 | color: darken($header-color, 50%); 24 | text-decoration: underline; 25 | } 26 | p { 27 | font-weight: 300; 28 | } 29 | figure { 30 | margin: 0; 31 | } 32 | 33 | button.nil-style { 34 | background: none; 35 | border: none; 36 | outline: none; 37 | padding: 0; 38 | &:focus { 39 | outline: none; 40 | } 41 | } 42 | 43 | #test-modal { 44 | position: fixed; 45 | width: 500px; 46 | height: 500px; 47 | left: 100px; 48 | top: 100px; 49 | background: white; 50 | z-index: 1; 51 | padding: 5px; 52 | } 53 | 54 | //Logic for setting font faces like so: 55 | //http://www.smashingmagazine.com/2013/02/14/setting-weights-and-styles-at-font-face-declaration/ 56 | @font-face { 57 | font-family: 'Roboto'; 58 | font-weight: 400; 59 | scr: local('Roboto') 60 | , url(http://fonts.gstatic.com/s/roboto/v13/CWB0XYA8bzo0kSThX0UTuA.woff2); 61 | } 62 | @font-face { 63 | font-family: 'RobotoLight'; 64 | font-weight: 300; 65 | scr: local('Roboto-Light') 66 | , local('Roboto Light') 67 | , url(http://fonts.gstatic.com/s/roboto/v13/Hgo13k-tfSpn0qi1SFdUfVtXRa8TVwTICgirnJhmVJw.woff2); 68 | } -------------------------------------------------------------------------------- /server/models/User.model.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var mongoose = require('mongoose'); 3 | 4 | var userSchema = mongoose.Schema({ 5 | providerId: String, //TODO delete 6 | provider: String, //e.g. reddit, facebook, gooogle, etc. 7 | username: String, //TODO delete 8 | password: String, //TODO delete 9 | facebook: { 10 | id: String, 11 | token: String 12 | }, 13 | reddit: { 14 | id: String, 15 | token: String, 16 | refreshToken: String 17 | }, 18 | name: { 19 | first: String, 20 | middle: String, 21 | last: String, 22 | display: String 23 | }, 24 | displayName: String, 25 | email: String, 26 | settings: { 27 | hitLimit: {type: Number, default: 80}, 28 | hxnMinScore: {type: Number, default: 5}, 29 | rdtMinScore: {type: Number, default: 500}, 30 | clickAction: {type: String, default: 'storyPanel'}, 31 | rightClickAction: {type: String, default: 'toggleRead'}, 32 | source: {type: String, default: 'rdt'} 33 | }, 34 | readList: [], //array of sourceIds for stories that have been read 35 | favs: [], //array of sourceIds for stories that are favourites 36 | stories: [ 37 | { 38 | storyId: String, //the mongo ID of the story 39 | fav: Boolean, 40 | read: Boolean, 41 | vote: String // undefined || 'up' || 'down' 42 | } 43 | ] //array of stories that the user has had some interaction with. 44 | }); 45 | 46 | var User = mongoose.model('user', userSchema); 47 | 48 | module.exports = User; -------------------------------------------------------------------------------- /app/styles/_chart.scss: -------------------------------------------------------------------------------- 1 | .chart { 2 | &-wrapper { 3 | transition: width $move-dur * 4; //turned off while dragging 4 | position: absolute; 5 | left: 0px; 6 | width: 100%; //gets reset on load 7 | height: 100%; 8 | .overlay { 9 | pointer-events: all; 10 | fill: none; 11 | cursor: ew-resize; 12 | } 13 | .stripes { 14 | opacity: 0; 15 | .stripe-odd { 16 | fill: white; 17 | } 18 | .stripe-even { 19 | fill: none; 20 | } 21 | 22 | } 23 | } 24 | &-axis { 25 | shape-rendering: crispEdges; 26 | text { 27 | fill: white; 28 | opacity: 0.4; 29 | font-size: 12px; 30 | letter-spacing: 1px; 31 | } 32 | path { 33 | fill: #777; 34 | stroke-width: 1; 35 | } 36 | } 37 | &-legend { 38 | display: none; 39 | text { 40 | font-size: 14px; 41 | stroke: none; 42 | fill: $col-text-light; 43 | } 44 | } 45 | } 46 | 47 | .story-circle { 48 | transition: 1000ms; 49 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 50 | -webkit-tap-highlight-color: transparent; 51 | /* opacity: 0.7; */ 52 | fill-opacity: 0.7; 53 | stroke-opacity: 0.7; 54 | /* transition: 200ms; */ 55 | cursor: pointer; 56 | /* .chart-plot-area:not(.moving) { */ 57 | /* opacity: 0.7; */ 58 | /* } */ 59 | body:not(.fav) &.read { //yeah that's not confusing AT ALL 60 | fill-opacity: 0.2; 61 | stroke-opacity: 0.6; 62 | } 63 | &.selected { 64 | stroke-width: 2px; 65 | stroke-opacity: 1; 66 | stroke: #eee; 67 | } 68 | } 69 | .tick { 70 | line { 71 | stroke: white; 72 | opacity: 0.05; 73 | } 74 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "bubblereader", 3 | "version": "0.4.10", 4 | "dependencies": { 5 | "body-parser": "^1.9.0", 6 | "compression": "^1.1.0", 7 | "cookie-parser": "^1.3.3", 8 | "cookie-session": "^1.0.2", 9 | "express": "^4.8.4", 10 | "express-session": "^1.8.2", 11 | "firebase": "^1.1.0", 12 | "mongoose": "4.0.1", 13 | "nodetime": "^0.8.15", 14 | "passport": "^0.2.1", 15 | "passport-facebook": "^1.0.3", 16 | "passport-local": "^1.0.0", 17 | "passport-reddit": "^0.2.4", 18 | "passport-remember-me": "0.0.1", 19 | "request": "^2.40.0", 20 | "socket.io": "^1.0.6", 21 | "webkit-devtools-agent": "^0.3.1" 22 | }, 23 | "devDependencies": { 24 | "connect": "^2.14.4", 25 | "connect-livereload": "^0.4.0", 26 | "gulp": "^3.6.0", 27 | "gulp-autoprefixer": "^0.0.7", 28 | "gulp-bower-files": "^0.2.1", 29 | "gulp-cache": "^0.1.1", 30 | "gulp-clean": "^0.2.4", 31 | "gulp-csso": "^0.2.6", 32 | "gulp-filter": "^0.4.1", 33 | "gulp-flatten": "^0.0.2", 34 | "gulp-imagemin": "^0.5.0", 35 | "gulp-jshint": "^1.5.3", 36 | "gulp-livereload": "^1.2.0", 37 | "gulp-load-plugins": "^0.5.0", 38 | "gulp-minify-css": "^0.3.7", 39 | "gulp-nodemon": "^1.0.4", 40 | "gulp-rev": "^1.1.0", 41 | "gulp-rev-replace": "^0.3.1", 42 | "gulp-ruby-sass": "^0.7.1", 43 | "gulp-sass": "^0.7.3", 44 | "gulp-size": "^0.3.0", 45 | "gulp-sourcemaps": "^1.1.1", 46 | "gulp-uglify": "^0.2.1", 47 | "gulp-useref": "^0.4.2", 48 | "jshint-stylish": "^0.2.0", 49 | "main-bower-files": "^1.0.2", 50 | "opn": "^0.1.1", 51 | "v8-profiler": "^5.1.1", 52 | "wiredep": "^1.4.3" 53 | }, 54 | "engines": { 55 | "node": "0.10.30" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /npm-debug.log: -------------------------------------------------------------------------------- 1 | 0 info it worked if it ends with ok 2 | 1 verbose cli [ 'c:\\Program Files (x86)\\nodejs\\node.exe', 3 | 1 verbose cli 'c:\\Users\\David\\AppData\\Roaming\\npm\\node_modules\\npm\\bin\\npm-cli.js', 4 | 1 verbose cli 'version', 5 | 1 verbose cli 'patch' ] 6 | 2 info using npm@2.4.1 7 | 3 info using node@v0.10.36 8 | 4 info git [ 'status', '--porcelain' ] 9 | 5 verbose stack Error: Git working directory not clean. 10 | 5 verbose stack M app/index.html 11 | 5 verbose stack M app/scripts/comments.js 12 | 5 verbose stack M app/scripts/storyPanel.js 13 | 5 verbose stack M app/styles/_storyPanel.scss 14 | 5 verbose stack M app/styles/main.scss 15 | 5 verbose stack M dist/index.html 16 | 5 verbose stack D dist/scripts/main-4c4fe940.js 17 | 5 verbose stack at c:\Users\David\AppData\Roaming\npm\node_modules\npm\lib\version.js:138:37 18 | 5 verbose stack at ChildProcess.exithandler (child_process.js:656:7) 19 | 5 verbose stack at ChildProcess.emit (events.js:98:17) 20 | 5 verbose stack at maybeClose (child_process.js:766:16) 21 | 5 verbose stack at Process.ChildProcess._handle.onexit (child_process.js:833:5) 22 | 6 verbose cwd g:\web\bubblereader 23 | 7 error Windows_NT 6.2.9200 24 | 8 error argv "c:\\Program Files (x86)\\nodejs\\node.exe" "c:\\Users\\David\\AppData\\Roaming\\npm\\node_modules\\npm\\bin\\npm-cli.js" "version" "patch" 25 | 9 error node v0.10.36 26 | 10 error npm v2.4.1 27 | 11 error Git working directory not clean. 28 | 11 error M app/index.html 29 | 11 error M app/scripts/comments.js 30 | 11 error M app/scripts/storyPanel.js 31 | 11 error M app/styles/_storyPanel.scss 32 | 11 error M app/styles/main.scss 33 | 11 error M dist/index.html 34 | 11 error D dist/scripts/main-4c4fe940.js 35 | 12 error If you need help, you may report this error at: 36 | 12 error 37 | 13 verbose exit [ 1, true ] 38 | -------------------------------------------------------------------------------- /app/scripts/favs.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var NB = NB || {}; 4 | 5 | NB.Favs = (function() { 6 | var Favs = {}; 7 | 8 | var store = []; 9 | 10 | function init() { 11 | //favourite array 12 | if (localStorage.favs) { 13 | var favs = JSON.parse(localStorage.favs); 14 | if (Array.isArray(favs)) { 15 | store = favs; 16 | } 17 | } 18 | } 19 | 20 | Favs.addToFavs = function(story) { 21 | store.push(story); 22 | localStorage.favs = JSON.stringify(store); 23 | 24 | NB.Data.emit('addToFavs', {story: story}); 25 | }; 26 | 27 | Favs.removeFromFavs = function(story) { 28 | // console.log('Removing story from favs:', story); 29 | var id = story._id; 30 | NB.Data.emit('removeFromFavs', {storyId: story._id}); 31 | 32 | store.forEach(function(fav, i) { 33 | if (fav._id === id) { 34 | store.splice(i, 1); 35 | localStorage.favs = JSON.stringify(store); 36 | return; 37 | } 38 | }); 39 | }; 40 | 41 | Favs.isFav = function(story) { 42 | if (!store.length) { return false; } 43 | var id = story._id; 44 | var hasMatch = false; 45 | 46 | store.forEach(function(fav) { 47 | if (fav._id === id) { hasMatch = true; } 48 | }); 49 | 50 | return hasMatch; 51 | }; 52 | 53 | //adds/removes from the store, returns true if it's now a fav, false otherwise 54 | Favs.toggleFav = function(koStory) { 55 | var story = koStory.raw; 56 | var isFav = Favs.isFav(story); 57 | if (isFav) { 58 | Favs.removeFromFavs(story); 59 | koStory.isFav(false); 60 | return false; 61 | } else { 62 | Favs.addToFavs(story); 63 | koStory.isFav(true); 64 | return true; 65 | } 66 | }; 67 | 68 | Favs.getAll = function() { 69 | return store; 70 | }; 71 | 72 | 73 | 74 | 75 | init(); 76 | return Favs; 77 | })(); -------------------------------------------------------------------------------- /app/images/gearIcon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | ]> 7 | 9 | 21 | 22 | -------------------------------------------------------------------------------- /server/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //devLog will print to console in DEV only. 4 | exports.devLog = function() { 5 | if (!process.env.DEV && !process.env.DEBUG && process.env.LOGGING !== 'DEV') { return; } 6 | var args = arguments; 7 | 8 | function go() { 9 | var result = ''; 10 | for (var i = 0; i < args.length; i++) { 11 | result += args[i] + ' '; 12 | } 13 | console.log(result); 14 | } 15 | 16 | process.nextTick(go); 17 | }; 18 | 19 | exports.prodLog = function() { 20 | var args = arguments; 21 | 22 | function go() { 23 | var result = ''; 24 | for (var i = 0; i < args.length; i++) { 25 | result += args[i] + ' '; 26 | } 27 | console.log(result); 28 | } 29 | 30 | process.nextTick(go); 31 | }; 32 | 33 | exports.randomString = function(len) { 34 | function getRandomInt(min, max) { 35 | return Math.floor(Math.random() * (max - min + 1)) + min; 36 | } 37 | var buf = [] 38 | , chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 39 | , charlen = chars.length; 40 | 41 | for (var i = 0; i < len; ++i) { 42 | buf.push(chars[getRandomInt(0, charlen - 1)]); 43 | } 44 | 45 | return buf.join(''); 46 | }; 47 | 48 | 49 | //Proudly butchered from the underscore source 50 | //http://underscorejs.org/docs/underscore.html#section-137 51 | 52 | exports.unescape = function(str) { 53 | var unEscapeMap = { 54 | '&': '&', 55 | '<': '<', 56 | '>': '>', 57 | '"': '"', 58 | ''': "'", 59 | '`': '`' 60 | }; 61 | 62 | var escaper = function(match) { 63 | return unEscapeMap[match]; 64 | }; 65 | 66 | var keysAsString = Object.keys(unEscapeMap).join('|'); 67 | var source = '(?:' + keysAsString + ')'; 68 | var testRegexp = RegExp(source); 69 | var replaceRegexp = RegExp(source, 'g'); 70 | 71 | str = str == null ? '' : '' + str; 72 | return testRegexp.test(str) ? str.replace(replaceRegexp, escaper) : str; 73 | 74 | }; -------------------------------------------------------------------------------- /app/styles/_modal.scss: -------------------------------------------------------------------------------- 1 | /* For fun, trying a different nesting strategy here */ 2 | /* Lower classes have short names, but nothing is ever not nested */ 3 | .modal-wrapper { 4 | display: none; 5 | opacity: 0; 6 | position: absolute; 7 | top: 0; 8 | width: 100%; 9 | height: 100%; 10 | background: rgba(0, 0, 0, 0.7); 11 | @include shadow; 12 | z-index: 2; 13 | 14 | .panel { 15 | position: relative; 16 | width: 500px; 17 | max-width: 100%; 18 | height: 1050px; 19 | max-height: 90%; 20 | margin: 0 auto; 21 | top: 5%; 22 | background: white; 23 | padding: 20px; 24 | overflow-y: auto; 25 | 26 | .close-x { 27 | $size: 40px; 28 | transition: 300ms; 29 | position: absolute; 30 | width: $size; 31 | height: $size; 32 | top: 0; 33 | right: 0; 34 | font-size: $size; 35 | line-height: $size - 4; 36 | text-align: center; 37 | background: $primary-color; 38 | color: white; 39 | cursor: pointer; 40 | &:hover { 41 | background: darken($primary-color, 10%); 42 | } 43 | } 44 | .footer { 45 | position: absolute; 46 | left: 0; 47 | bottom: 0; 48 | right: 0; 49 | /* background: beige; */ 50 | height: 80px; 51 | padding: 20px; 52 | } 53 | .save-btn { 54 | transition: 300ms; 55 | float: right; 56 | /* position: absolute; */ 57 | /* bottom: 20px; */ 58 | /* right: 20px; */ 59 | width: 200px; 60 | background: $belizeHole; 61 | padding: 10px 15px; 62 | color: white; 63 | text-align: center; 64 | cursor: pointer; 65 | &:hover { 66 | background: darken($belizeHole, 10%); 67 | } 68 | } 69 | .body { 70 | position: absolute; 71 | top: 106px; 72 | bottom: 80px; 73 | left: 0; 74 | right: 0; 75 | padding: 20px; 76 | overflow-y: auto; 77 | 78 | .row { 79 | margin: 30px 0 20px 0; 80 | 81 | h2 { 82 | font-size: 18px; 83 | margin: 15px 0 7px 0; 84 | } 85 | 86 | } 87 | } 88 | 89 | } 90 | } -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | if (process.env.NODETIME_ACCOUNT_KEY) { 4 | require('nodetime').profile({ 5 | accountKey: process.env.NODETIME_ACCOUNT_KEY, 6 | appName: 'News Bubbles' // optional 7 | }); 8 | } 9 | 10 | var path = require('path') 11 | , mongoose = require('mongoose') 12 | , bodyParser = require('body-parser') 13 | , cookieParser = require('cookie-parser') 14 | 15 | , configVars = require(path.join(__dirname, 'config')) 16 | , hxnCrawler = require(path.join(__dirname, 'hxnCrawler')) 17 | , newHxnCrawler = require(path.join(__dirname, 'new-hxncrawler')) 18 | , rdtCrawler = require(path.join(__dirname, 'rdtCrawler')) 19 | , auth = require(path.join(__dirname, 'auth')) 20 | , utils = require(path.join(__dirname, 'utils')) 21 | // , devLog = utils.devLog 22 | , prodLog = utils.prodLog 23 | , workers = require(path.join(__dirname, 'workers')) 24 | ; 25 | 26 | 27 | 28 | var config; 29 | if (process.env.DEV) { 30 | config = configVars.dev; 31 | } else { 32 | config = configVars.prod; 33 | } 34 | global.config = config; 35 | 36 | // console.log('Running with config:', config); 37 | 38 | //TODO change to createConnections 39 | mongoose.connect(config.db.conn); 40 | var db = mongoose.connection; 41 | 42 | exports.start = function(app) { 43 | prodLog('Server Starting'); 44 | 45 | var http = require('http').Server(app); 46 | global.io = require('socket.io')(http); //TODO put io in global? 47 | // var io = require('socket.io')(http); 48 | // global.io = io; //IO is used for global emitting 49 | 50 | app.use(cookieParser()); 51 | app.use(bodyParser.json()); 52 | app.use(bodyParser.urlencoded({extended: true})); 53 | 54 | auth.setUp(app); 55 | 56 | require(path.join(__dirname, 'routes.js'))(app); 57 | 58 | db.on('open', function() { 59 | // hxnCrawler.startCrawler(); 60 | 61 | 62 | newHxnCrawler.start(); 63 | 64 | 65 | rdtCrawler.startCrawler(); 66 | workers.startCleanupWorker(); 67 | // workers.startMemoryStatsReporter(); 68 | 69 | http.listen(config.db.port); 70 | }); 71 | 72 | db.on('error', function(err) { 73 | prodLog('Database connection error:', err); 74 | }); 75 | 76 | 77 | }; 78 | 79 | -------------------------------------------------------------------------------- /server/routes.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var path = require('path') 3 | , readabilityApi = require(path.join(__dirname, 'readability')) 4 | , storyController = require(path.join(__dirname, 'controllers', 'story.controller')) 5 | , hxnCrawler = require(path.join(__dirname, 'hxnCrawler')) 6 | , rdtCrawler = require(path.join(__dirname, 'rdtCrawler')) 7 | , devLog = require(path.join(__dirname, 'utils')).devLog 8 | , request = require('request') 9 | , userController = require(path.join(__dirname, 'controllers', 'user.controller')) 10 | ; 11 | 12 | module.exports = function(app) { 13 | 14 | //more routes are in auth.js during dev 15 | 16 | io.on('connection', function(socket) { 17 | socket.on('markAsRead', userController.markAsRead); 18 | socket.on('markAsUnread', userController.markAsUnread); 19 | socket.on('addToFavs', userController.addToFavs); 20 | socket.on('updateSettings', userController.updateSettings); 21 | socket.on('removeFromFavs', userController.removeFromFavs); 22 | }); 23 | 24 | app.get('/readability/:url', readabilityApi); 25 | 26 | app.get('/api/:source/:limit/:minScore', storyController.getStories); 27 | 28 | app.get('/crawlers/forceHxnFetch', hxnCrawler.forceFetch); 29 | 30 | app.get('/crawlers/forceRdtFetch/:list/:limit', rdtCrawler.forceFetch); 31 | 32 | // app.get('/api/reddit/info', function(req, res) { 33 | // if (!req.isAuthenticated()) { //TODO: make this middleware to share in all reddit routes (isAuthenticated + req.user.reddit.token) 34 | // return res.json({err: 'not logged in'}); 35 | // } 36 | // console.log('using token:', req.user.reddit.token); 37 | // console.log('got query:', req.query); 38 | // var url = 'http://www.reddit.com/by_id/' + req.query.id + '.json'; 39 | // var options = { 40 | // url: url, 41 | // json: true, 42 | // headers: { 43 | // 'User-Agent': 'news-bubbles.herokuapp.com/0.3.8 by /u/bubble_boi', 44 | // 'Authorization': 'bearer ' + req.user.reddit.token 45 | // } 46 | // }; 47 | // console.log('request to submit a request with object:', options); 48 | 49 | // request.get(options, function(err, req, data) { 50 | // console.log('got response from URL:', url); 51 | // console.log('err:', err); 52 | // console.log('data:', data); 53 | // res.json(data); 54 | // }); 55 | // }); 56 | 57 | }; -------------------------------------------------------------------------------- /server/workers.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var path = require('path') 3 | , models = require(path.join(__dirname, 'models', 'Story.model')) 4 | , Story = models.Story 5 | , utils = require(path.join(__dirname, 'utils')) 6 | , devLog = utils.devLog 7 | , prodLog = utils.prodLog; 8 | 9 | 10 | function startCleanupWorker() { 11 | function cull() { 12 | var now = new Date(); 13 | prodLog(' -- Running a cull now -- ', now); 14 | 15 | var oneDayAgo = new Date(now - (1 * 24 * 60 * 60 * 1000)); 16 | var twoDaysAgo = new Date(now - (2 * 24 * 60 * 60 * 1000)); 17 | var fourDaysAgo = new Date(now - (4 * 24 * 60 * 60 * 1000)); 18 | var eightDaysAgo = new Date(now - (8 * 24 * 60 * 60 * 1000)); 19 | 20 | Story.remove( 21 | { 22 | $or: [ 23 | { 24 | $and: [ 25 | {postDate: {$lt: oneDayAgo}}, 26 | {score: {$lt: 10}} 27 | ] 28 | }, 29 | { 30 | $and: [ 31 | {postDate: {$lt: twoDaysAgo}}, 32 | {score: {$lt: 100}} 33 | ] 34 | }, 35 | { 36 | $and: [ 37 | {postDate: {$lt: fourDaysAgo}}, 38 | {score: {$lt: 1000}} 39 | ] 40 | }, 41 | { 42 | $and: [ 43 | {postDate: {$lt: eightDaysAgo}}, 44 | {score: {$lt: 10000}} 45 | ] 46 | } 47 | ] 48 | }, function (err, data) { 49 | if (err) { 50 | prodLog('Error culling objects:', err); 51 | } else { 52 | devLog('cull removed', data, 'objects'); 53 | } 54 | }); 55 | } 56 | 57 | cull(); 58 | setInterval(function() { 59 | cull(); 60 | }, 1 * 60 * 60 * 1000); //hourly 61 | 62 | } 63 | 64 | 65 | function startMemoryStatsReporter() { 66 | 67 | function printMemStats() { 68 | var usage = process.memoryUsage(); 69 | var rss = Math.round(+usage.rss / (1024 * 1024)) + 'mb'; 70 | var heapTotal = Math.round(+usage.heapTotal / (1024 * 1024)) + 'mb'; 71 | var heapUsed = Math.round(+usage.heapUsed / (1024 * 1024)) + 'mb'; 72 | prodLog(' -- Memory usage -- | rss:', rss, ' Heap Total:', heapTotal, ' Heap Used:', heapUsed); 73 | } 74 | 75 | setInterval(function() { 76 | process.nextTick(printMemStats); 77 | }, 30000); 78 | 79 | } 80 | 81 | exports.startCleanupWorker = startCleanupWorker; 82 | exports.startMemoryStatsReporter = startMemoryStatsReporter; 83 | -------------------------------------------------------------------------------- /app/scripts/layout.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.Layout = (function() { 5 | 6 | var Layout = {} 7 | , chartWrapper = d3.select('#chart-wrapper') 8 | , storyPanel = d3.select('#story-panel') 9 | , storyPanelVisible = false 10 | ; 11 | 12 | 13 | /* ------------------- */ 14 | /* -- Story Panel -- */ 15 | /* ------------------- */ 16 | function setChartAndStoryPanelSize() { 17 | chartWrapper.style('width', NB.splitPos + 'px'); 18 | storyPanel.style('left', NB.splitPos + 'px'); 19 | } 20 | 21 | function init() { 22 | setChartAndStoryPanelSize(); 23 | 24 | chartWrapper.style('display', 'block'); 25 | storyPanel.style('display', 'block'); 26 | } 27 | 28 | function showStoryPanel() { 29 | if (storyPanelVisible) { return; } 30 | storyPanelVisible = true; 31 | 32 | d3.select('#story-panel-toggle').text('»'); 33 | 34 | NB.splitPos = document.body.offsetWidth * 0.618; 35 | setChartAndStoryPanelSize(); 36 | NB.Chart.resize('fast'); 37 | } 38 | function hideStoryPanel(force) { 39 | if (!force && !storyPanelVisible) { return; } 40 | storyPanelVisible = false; 41 | 42 | d3.select('#story-panel-toggle').text('«'); 43 | 44 | NB.splitPos = document.body.offsetWidth - NB.RESIZER_WIDTH; 45 | setChartAndStoryPanelSize(); 46 | NB.Chart.resize('fast'); 47 | } 48 | 49 | 50 | /* -- EXPORTS -- */ 51 | 52 | Layout.render = function() { 53 | setChartAndStoryPanelSize(); 54 | 55 | //If the orientation flips, don't loose the panel, just hide it: 56 | if (document.body.offsetWidth - NB.splitPos < 100) { 57 | hideStoryPanel(true); 58 | } 59 | if (!storyPanelVisible && (NB.splitPos + NB.RESIZER_WIDTH !== document.body.offsetWidth)) { 60 | hideStoryPanel(true); 61 | } 62 | }; 63 | 64 | Layout.moveSplitPos = function() { 65 | if (!storyPanelVisible) { //the divider is dragged out from the edge 66 | storyPanelVisible = true; 67 | d3.select('#story-panel-toggle').text('»'); 68 | } 69 | 70 | setChartAndStoryPanelSize(); 71 | }; 72 | 73 | Layout.showStoryPanel = function() { 74 | showStoryPanel(); 75 | }; 76 | 77 | Layout.hideStoryPanel = function() { 78 | hideStoryPanel(); 79 | }; 80 | Layout.toggleStoryPanel = function() { 81 | if (storyPanelVisible) { 82 | hideStoryPanel(); 83 | } else { 84 | showStoryPanel(); 85 | } 86 | 87 | }; 88 | 89 | 90 | 91 | 92 | Layout.init = function() { 93 | init(); 94 | }; 95 | 96 | 97 | return Layout; 98 | })(); -------------------------------------------------------------------------------- /app/styles/_layout.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | @include shadow(); 3 | height: $header-height; 4 | background: $header-color; 5 | position: relative; 6 | z-index: 2; 7 | overflow: hidden; 8 | &-title { 9 | float: left; 10 | padding: 10px; 11 | margin: 0; 12 | font-size: 22px; 13 | color: white; 14 | font-weight: normal; 15 | letter-spacing: 1px; 16 | @media (max-width: 760px) { 17 | display: none; 18 | } 19 | 20 | .by-line { 21 | font-family: monospace; 22 | font-size: 12px; 23 | color: black; 24 | @media (max-width: 830px) { 25 | display: none; 26 | } 27 | } 28 | } 29 | .header-float { 30 | float: right; 31 | height: 100%; 32 | } 33 | .user-items { 34 | height: 100%; 35 | padding: 0 0 0 20px; 36 | color: white; 37 | .user-item { 38 | //TODO not sure how to stop FOUC 39 | /* display: none; //Overridden by knockout */ 40 | color: white; 41 | /* text-decoration: none; */ 42 | &.name { 43 | display: inline-block; 44 | position: relative; 45 | top: -5px; 46 | padding-right: 10px; 47 | @media (max-width: 600px) { 48 | display: none; 49 | } 50 | } 51 | &.sign { 52 | padding: 13px 14px; 53 | } 54 | &.icon { 55 | padding: 9px; 56 | } 57 | } 58 | } 59 | .news-sources { 60 | cursor: pointer; 61 | &-source { 62 | transition: 200ms; 63 | @include no-style-button; 64 | color: white; 65 | font-size: 17px; 66 | height: 100%; 67 | padding: 0 12px; 68 | margin-left: -3px; //narrow the inline-block gap 69 | background: $primary-color; 70 | body.no-touch &:hover { 71 | background: white; 72 | color: $primary-color; 73 | } 74 | &.active { 75 | background: white; 76 | color: $primary-color; 77 | } 78 | } 79 | } 80 | &-icons-wrapper { 81 | float: right; 82 | width: 60px; 83 | height: 40px; 84 | padding: 9px; 85 | text-align: center; 86 | cursor: pointer; 87 | color: white; 88 | } 89 | &-btn { //TODO what is this? 90 | float: right; 91 | font-size: 16px; 92 | height: 100%; 93 | padding: 12px 15px; 94 | background-color: white; 95 | color: $primary-color; 96 | cursor: pointer; 97 | @media (max-width: 500px) { 98 | padding: 12px 5px; 99 | font-size: 14px; 100 | } 101 | @media (max-width: 350px) { 102 | padding: 14px 8px; 103 | font-size: 12px; 104 | } 105 | } 106 | } 107 | .nb-content-wrapper { 108 | position: absolute; 109 | top: $header-height; 110 | left: 0; 111 | bottom: 0; 112 | right: 0; 113 | overflow: hidden; 114 | } 115 | -------------------------------------------------------------------------------- /app/styles/_tooltip.scss: -------------------------------------------------------------------------------- 1 | #tooltip { 2 | @include shadow(); 3 | visibility: hidden; 4 | position: fixed; 5 | left: 100px; 6 | top: 100px; 7 | background: $primary-color; 8 | color: white; 9 | text-align: center; 10 | padding: 10px; 11 | max-width: 200px; 12 | word-wrap: break-word; 13 | z-index: 9; 14 | } 15 | 16 | .maxi-tooltip { 17 | $width: 540px; 18 | $height: 150px; 19 | $dark: #2c3e50; 20 | $light: #7f8c8d; 21 | $radius: 40px; 22 | 23 | /* display: none; */ 24 | position: fixed; 25 | left: 20px; 26 | bottom: 20px; 27 | width: $width; 28 | height: $height; 29 | background: black; 30 | color: white; 31 | /* border-radius: $radius; */ 32 | @include shadow(); 33 | z-index: 2; 34 | 35 | a { 36 | color: white; 37 | text-decoration: none; 38 | &:hover { 39 | text-decoration: underline; 40 | } 41 | } 42 | 43 | .table { 44 | display: table; 45 | .row { 46 | display: table-row; 47 | } 48 | .cell { 49 | display: table-cell; 50 | vertical-align: middle; 51 | } 52 | } 53 | .left { 54 | float: left; 55 | width: 33%; 56 | height: 100%; 57 | background: darken($primary-color, 15%); 58 | /* border-radius: $radius 0 0 $radius; */ 59 | 60 | .btn { 61 | transition: 300ms; 62 | width: 100%; 63 | text-align: center; 64 | height: $height / 3; 65 | cursor: pointer; 66 | &:hover { 67 | background: darken($primary-color, 20%); 68 | text-decoration: none; 69 | } 70 | &.left-top { 71 | /* border-radius: $radius 0 0 0; */ 72 | } 73 | &.left-middle { 74 | box-sizing: content-box; //border box is ignored in chrome and god knows where else 75 | height: $height / 3 - 2; 76 | border-top: 1px solid darken($primary-color, 10%); 77 | border-bottom: 1px solid darken($primary-color, 10%); 78 | } 79 | &.left-bottom { 80 | /* border-radius: 0 0 0 $radius; */ 81 | } 82 | } 83 | } 84 | .right { 85 | float: left; 86 | width: 67%; 87 | height: 100%; 88 | background: $primary-color; 89 | border-left: 1px solid white; 90 | /* border-radius: 0 $radius $radius 0; */ 91 | 92 | &-top { 93 | height: $height / 2; 94 | width: 100%; 95 | padding: 0 10px; 96 | border-bottom: 1px solid lighten($primary-color, 10%); 97 | overflow-y: hidden; 98 | 99 | h1 { 100 | color: white; 101 | height: 100%; 102 | width: 100%; 103 | font-size: 25px; 104 | font-weight: 300; 105 | margin: 0; 106 | } 107 | } 108 | &-bottom { 109 | height: $height * 0.5; 110 | width: 100%; 111 | padding: 12px 10px 0 10px; 112 | font-size: 13px; 113 | p { 114 | margin: 0 7px 0 0; 115 | white-space: nowrap; 116 | overflow: hidden; 117 | } 118 | &-left { 119 | float: left; 120 | width: 62%; 121 | height: 100%; 122 | } 123 | &-right { 124 | float: left; 125 | width: 38%; 126 | height: 100%; 127 | text-align: right; 128 | } 129 | } 130 | } 131 | } -------------------------------------------------------------------------------- /app/scripts/actions.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.Actions = (function() { 5 | var Actions = {}; 6 | //TODO get reference to both tooltips in here? 7 | var maxiTooltipShowing = false; 8 | 9 | function init() { 10 | 11 | 12 | } 13 | 14 | 15 | function showTooltip(options) { //TODO 'd' is stupid 16 | var setting = NB.Settings.getSetting('clickAction') 17 | , d = options.story 18 | , el = options.domEl 19 | , w = options.chartWidth 20 | ; 21 | 22 | 23 | if (setting === 'storyPanel') { 24 | NB.Data.markAsRead(d._id); 25 | el.classed('read', true); 26 | NB.Layout.showStoryPanel(); 27 | NB.StoryPanel.render(d); 28 | } 29 | 30 | if (setting === 'storyTooltip') { //TODO move this out to maxiTooltip module (pass el and d) 31 | var maxiTooltip = d3.select('#story-tooltip'); //TODO move these up to top of NB.Actions 32 | var tooltipWidth = parseInt(maxiTooltip.style('width')); 33 | var tooltipHeight = parseInt(maxiTooltip.style('height')); 34 | 35 | var thisDims = el.node().getBoundingClientRect(); 36 | 37 | // var r = z(d.commentCount); 38 | var left = thisDims.left + (thisDims.width / 2) - (tooltipWidth / 2); 39 | var maxLeft = w - tooltipWidth - 20; 40 | left = Math.min(left, maxLeft); 41 | left = Math.max(left, 0); 42 | 43 | var top = thisDims.top - tooltipHeight; 44 | if (top < 50) { 45 | top = thisDims.bottom; 46 | } 47 | 48 | NB.StoryModel.setCurrentStory(d); //TODO should this make visible? E.g. control vis in model? 49 | 50 | var readUnreadLink = d3.select('#tooltip-mark-as-read'); 51 | if (el.classed('read')) { 52 | readUnreadLink.text('Mark as unread'); 53 | } else { 54 | readUnreadLink.text('Mark as read'); 55 | } 56 | 57 | 58 | var duration = maxiTooltipShowing ? 200 : 0; 59 | maxiTooltip 60 | .style('display', 'block') 61 | .transition() 62 | .duration(duration) 63 | .style('left', left + 'px') 64 | .style('top', top + 'px'); 65 | 66 | maxiTooltipShowing = true; //will block little tooltip from showing 67 | 68 | d3.event.stopPropagation(); //TODO I do not know the diff between this and immediate. Immediate stops other events on this el? 69 | 70 | $(document).on('click.tooltip', function() { //TODO try .one, still not working? 71 | maxiTooltip.style('display', 'none'); 72 | $(document).off('click.tooltip'); 73 | 74 | window.setTimeout(function() { 75 | maxiTooltipShowing = false; //wait a bit before allowing the little tooltip to show 76 | }, 300); 77 | }); 78 | readUnreadLink.on('click', function() { //D3 will remove any existing listener 79 | toggleRead(el, d); 80 | }); 81 | d3.select('#tooltip-open-reading-pane').on('click', function() { //D3 will remove any existing listener 82 | NB.Data.markAsRead(d.id); 83 | el.classed('read', true); 84 | NB.Layout.showStoryPanel(); 85 | NB.StoryPanel.render(d); 86 | }); 87 | 88 | } 89 | 90 | } 91 | 92 | /* --------------- */ 93 | /* -- Exports -- */ 94 | /* --------------- */ 95 | 96 | Actions.showTooltip = showTooltip; 97 | 98 | init(); 99 | return Actions; 100 | })(); -------------------------------------------------------------------------------- /app/scripts/auth.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.Auth = (function() { 5 | var Auth = {} 6 | , rawUser = {} 7 | , authModal = d3.select('#auth-modal') 8 | ; 9 | 10 | // Remove the ugly Facebook appended hash 11 | // 12 | // source for this code: https://github.com/jaredhanson/passport-facebook/issues/12#issuecomment-5913711 13 | function removeFacebookAppendedHash() { 14 | if (!window.location.hash || window.location.hash !== '#_=_') { 15 | return; 16 | } else if (window.history && window.history.replaceState) { 17 | return window.history.replaceState('', document.title, window.location.pathname); 18 | } else { 19 | window.location.hash = ''; 20 | } 21 | 22 | 23 | } 24 | 25 | function close() { 26 | authModal 27 | .transition().duration(500) 28 | .style('opacity', 0) 29 | .transition() 30 | .style('display', 'none'); 31 | } 32 | 33 | function save() { 34 | close(); 35 | } 36 | 37 | function open() { 38 | authModal 39 | .style('display', 'block') 40 | .transition().duration(500) 41 | .style('opacity', 1); 42 | } 43 | 44 | var userModel = { 45 | _id: '', 46 | name: { 47 | first: ko.observable(''), 48 | last: ko.observable(''), 49 | display: ko.observable('') 50 | }, 51 | displayName: ko.observable(''), 52 | signedIn: ko.observable(false), 53 | headerText: ko.observable('Sign in'), 54 | open: open, 55 | close: close, 56 | save: save 57 | }; 58 | 59 | 60 | function init() { 61 | // ko.applyBindings(userModel, document.getElementById('user-items')); 62 | // ko.applyBindings(userModel, document.getElementById('auth-modal')); 63 | } 64 | 65 | 66 | function setUser(user) { 67 | if (user.reddit) { 68 | $('body').addClass('user-rdt'); 69 | // $('#story-panel-header').addClass('show-vote-btns'); 70 | } else { 71 | $('body').removeClass('user-rdt'); 72 | // $('#story-panel-header').removeClass('show-vote-btns'); 73 | } 74 | rawUser = user; 75 | var displayName = user.displayName || user.name.display; 76 | if (user) { 77 | userModel._id = user._id; 78 | userModel.displayName(displayName); 79 | userModel.signedIn(true); 80 | userModel.headerText(displayName); 81 | removeFacebookAppendedHash(); //TODO test for FB? 82 | } else { 83 | userModel._id = null; 84 | userModel.displayName(null); 85 | userModel.signedIn(false); 86 | userModel.headerText('Sign in'); //TODO not used when no signed in 87 | } 88 | } 89 | 90 | function getUser() { 91 | if (userModel.signedIn()) { 92 | return userModel; 93 | } else { 94 | return null; 95 | } 96 | 97 | } 98 | function getRawUser() { 99 | return rawUser; 100 | } 101 | 102 | function signOut() { 103 | console.log('OK, will sign out (ha ha, but I am not really!'); 104 | } 105 | 106 | 107 | /* --------------- */ 108 | /* -- Exports -- */ 109 | /* --------------- */ 110 | 111 | Auth.setUser = setUser; 112 | Auth.getUser = getUser; 113 | Auth.getRawUser = getRawUser; 114 | Auth.signOut = signOut; 115 | Auth.userModel = userModel; 116 | 117 | init(); 118 | return Auth; 119 | })(); -------------------------------------------------------------------------------- /app/scripts/storyModel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.StoryModel = (function() { 5 | var StoryModel = {}; 6 | 7 | /* -- Story methods -- */ 8 | function rdtVote(upOrDown) { 9 | var data = { 10 | upOrDown: upOrDown, 11 | id: StoryModel.storyModel.raw._id, 12 | sourceId: StoryModel.storyModel.raw.sourceId 13 | }; 14 | 15 | StoryModel.storyModel.userVote(upOrDown); 16 | 17 | $.post('/api/reddit/vote', data, function(res) { 18 | if (res.err) { 19 | console.log('Error saving a vote:', res); 20 | } 21 | }); 22 | 23 | } 24 | 25 | function init() { 26 | StoryModel.storyModel = { 27 | raw: {}, 28 | name: ko.observable(), 29 | shortName: ko.observable(), 30 | url: ko.observable(), 31 | sourceUrl: ko.observable(), 32 | authorUrl: ko.observable(), 33 | category: ko.observable(), 34 | color: ko.observable(), 35 | author: ko.observable(), 36 | commentCount: ko.observable(), 37 | score: ko.observable(), 38 | timeString: ko.observable(), 39 | dateString: ko.observable(), 40 | content: ko.observable(''), 41 | isFav: ko.observable(false), 42 | userVote: ko.observable(''), 43 | upVote: function() { 44 | if (StoryModel.storyModel.userVote() !== 'up') { //'this' refers to NB.App 45 | rdtVote('up'); 46 | } else { 47 | rdtVote(''); 48 | } 49 | }, 50 | downVote: function() { 51 | if (StoryModel.storyModel.userVote() !== 'down') { //'this' refers to NB.App 52 | rdtVote('down'); 53 | } else { 54 | rdtVote(''); 55 | } 56 | } 57 | }; 58 | } 59 | 60 | function setCurrentStory(story) { 61 | var dateFormatter = d3.time.format('%a, %-e %b %Y') 62 | , timeFormatter = d3.time.format('%-I:%M%p') 63 | , domain 64 | , shortName = story.name 65 | , isFav = NB.Favs.isFav(story) 66 | , color = NB.Settings.getColor(story.source, story.category) 67 | ; 68 | 69 | 70 | if (name.length > 50) { //TODO push to database? 71 | shortName = name.substr(0, 47).trim() + '...'; 72 | } 73 | 74 | StoryModel.storyModel.raw = story; 75 | 76 | StoryModel.storyModel 77 | .name(story.name) 78 | .shortName(shortName) 79 | .url(story.url) 80 | .sourceUrl(story.sourceUrl) 81 | .authorUrl(story.authorUrl) 82 | .category(story.category) 83 | .color(color) 84 | .author(story.author) 85 | .commentCount(story.commentCount) 86 | .score(Math.round(story.score)) 87 | .timeString(timeFormatter(story.postDate)) 88 | .dateString(dateFormatter(story.postDate)) 89 | .content(story.content) 90 | .isFav(isFav) 91 | .userVote(story.vote); 92 | } 93 | 94 | function clear() { 95 | StoryModel.storyModel 96 | .name('') 97 | .url('') 98 | .sourceUrl('') 99 | .authorUrl('') 100 | .category('') 101 | .color('') 102 | .author('') 103 | .commentCount('') 104 | .score('') 105 | .timeString('') 106 | .dateString('') 107 | .content('') 108 | .isFav('') 109 | .userVote(''); 110 | } 111 | 112 | 113 | /* --------------- */ 114 | /* -- Exports -- */ 115 | /* --------------- */ 116 | 117 | StoryModel.setCurrentStory = setCurrentStory; 118 | StoryModel.clear = clear; 119 | 120 | init(); 121 | return StoryModel; 122 | })(); -------------------------------------------------------------------------------- /gulpfile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var gulp = require('gulp'); 4 | var path = require('path'); 5 | 6 | // load plugins 7 | var $ = require('gulp-load-plugins')(); 8 | 9 | gulp.task('sass', function () { 10 | return gulp.src('app/styles/main.scss') 11 | .pipe($.sass()) 12 | // .pipe($.autoprefixer('last 1 version')) 13 | .pipe(gulp.dest('.tmp/styles')) 14 | .pipe($.size()); 15 | 16 | }); 17 | 18 | gulp.task('styles', ['sass'], function() { 19 | return gulp.src('.tmp/styles/main.css') 20 | .pipe($.sourcemaps.init({ 21 | loadMaps: true, 22 | includeContent: false, 23 | sourceRoot: '../cats' 24 | })) 25 | .pipe($.sourcemaps.write('.')) 26 | .pipe(gulp.dest('.tmp/styles')); 27 | }); 28 | 29 | gulp.task('scripts', function () { 30 | return gulp.src('app/scripts/**/*.js') 31 | .pipe($.jshint()) 32 | .pipe($.jshint.reporter(require('jshint-stylish'))) 33 | .pipe($.size()); 34 | }); 35 | 36 | gulp.task('html', ['styles', 'scripts'], function () { 37 | var jsFilter = $.filter('**/*.js'); 38 | var cssFilter = $.filter('**/*.css'); 39 | 40 | return gulp.src('app/index.html') 41 | .pipe($.useref.assets({searchPath: '{.tmp,app}'})) 42 | .pipe(jsFilter) 43 | .pipe($.uglify()) 44 | .pipe(jsFilter.restore()) 45 | .pipe(cssFilter) 46 | .pipe($.csso()) 47 | .pipe($.minifyCss()) 48 | .pipe(cssFilter.restore()) 49 | .pipe($.rev()) 50 | .pipe($.useref.restore()) 51 | .pipe($.useref()) 52 | .pipe($.revReplace()) 53 | .pipe(gulp.dest('dist')) 54 | .pipe($.size()); 55 | }); 56 | 57 | gulp.task('extras', function () { 58 | return gulp.src(['app/*.*', '!app/index.html'], { dot: true }) 59 | .pipe(gulp.dest('dist')); 60 | }); 61 | 62 | 63 | gulp.task('clean', function () { 64 | return gulp.src(['.tmp', 'dist'], { read: false }) 65 | .pipe($.clean()); 66 | }); 67 | 68 | 69 | gulp.task('express', ['mongod'], function () { 70 | var express = require('express') 71 | , app = express(); 72 | 73 | app.use(require('connect-livereload')({ port: 35729 })); 74 | 75 | app.use(require('compression')()); 76 | app.use(express.static('app')); 77 | app.use(express.static('.tmp')); 78 | process.env.DEV = true; 79 | process.env.DEBUG = true; 80 | 81 | var server = require(path.join(__dirname, 'server', 'server.js')); 82 | server.start(app); 83 | 84 | }); 85 | 86 | 87 | gulp.task('mongod', function() { 88 | //From here: http://stackoverflow.com/questions/18334181/spawn-on-node-js-windows-server-2012 89 | var spawn = require('child_process').spawn; 90 | spawn(process.env.comspec, ['/c', 'start mongod',]); 91 | }); 92 | 93 | 94 | // inject bower components 95 | gulp.task('wiredep', function () { 96 | var wiredep = require('wiredep').stream; 97 | 98 | gulp.src('app/styles/*.scss') 99 | .pipe(wiredep({ 100 | directory: 'app/bower_components' 101 | })) 102 | .pipe(gulp.dest('app/styles')); 103 | 104 | gulp.src('app/*.html') 105 | .pipe(wiredep({ 106 | directory: 'app/bower_components' 107 | })) 108 | .pipe(gulp.dest('app')); 109 | }); 110 | 111 | 112 | gulp.task('watch', ['express'], function () { 113 | var server = $.livereload(); 114 | 115 | // watch for changes 116 | 117 | gulp.watch([ 118 | 'app/*.html', 119 | '.tmp/styles/**/*.css', 120 | 'app/scripts/**/*.js', 121 | 'app/images/**/*' 122 | ]).on('change', function (file) { 123 | server.changed(file.path); 124 | }); 125 | 126 | gulp.watch('app/styles/**/*.scss', ['styles']); 127 | gulp.watch('app/scripts/**/*.js', ['scripts']); 128 | gulp.watch('app/images/**/*', ['images']); 129 | gulp.watch('bower.json', ['wiredep']); 130 | }); 131 | 132 | gulp.task('build', ['html', 'extras']); 133 | // gulp.task('build', ['html', 'images', 'fonts', 'extras']); 134 | 135 | gulp.task('default', ['clean'], function () { 136 | gulp.start('build'); 137 | }); 138 | -------------------------------------------------------------------------------- /app/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Not Found :( 6 | 141 | 142 | 143 |
144 |

Not found :(

145 |

Sorry, but the page you were trying to view does not exist.

146 |

It looks like this was the result of either:

147 |
    148 |
  • a mistyped address
  • 149 |
  • an out-of-date link
  • 150 |
151 | 154 | 155 |
156 | 157 | 158 | -------------------------------------------------------------------------------- /dist/404.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Page Not Found :( 6 | 141 | 142 | 143 |
144 |

Not found :(

145 |

Sorry, but the page you were trying to view does not exist.

146 |

It looks like this was the result of either:

147 |
    148 |
  • a mistyped address
  • 149 |
  • an out-of-date link
  • 150 |
151 | 154 | 155 |
156 | 157 | 158 | -------------------------------------------------------------------------------- /app/styles/_storyPanel.scss: -------------------------------------------------------------------------------- 1 | .story-panel { 2 | transition: $move-dur; //turned off while dragging 3 | position: absolute; 4 | display: none; //overridden on startup 5 | left: 100%; //overridden on startup 6 | height: 100%; 7 | @include shadow(); 8 | right: 0px; 9 | padding-left: 28px; 10 | background-color: white; 11 | z-index: 1; 12 | 13 | .story-panel-resizer { 14 | position: absolute; 15 | left: 0; 16 | top: 0; 17 | height: 100%; 18 | width: 28px; 19 | /* background-color: $primary-color; */ 20 | background-color: white; 21 | overflow-y: hidden; 22 | cursor: ew-resize; 23 | &.active .story-panel-resizer-dots { 24 | border: 2px dotted lighten($primary-color, 20%); 25 | } 26 | &-dots { 27 | float: left; 28 | margin-top: -10%; 29 | height: 120%; 30 | width: 8px; 31 | margin-left: 4px; 32 | border: 2px dotted lighten($primary-color, 30%); 33 | } 34 | .story-panel-toggle { 35 | position: absolute; 36 | top: 50%; 37 | left: 0px; 38 | margin-top: -50px; 39 | width: 100%; 40 | height: 100px; 41 | font-size: 40px; 42 | line-height: 95px; 43 | text-align: center; 44 | color: $primary-color; 45 | cursor: pointer; 46 | background-color: white; 47 | } 48 | } 49 | 50 | .story-wrapper { 51 | height: 100%; 52 | min-width: 200px; 53 | overflow-y: scroll; 54 | -webkit-overflow-scrolling: touch; 55 | padding: 10px 20px 50px 10px; 56 | background-color: white; 57 | z-index: 0; 58 | @media (max-width: 500px) { 59 | padding: 7px; 60 | } 61 | 62 | .story-title { 63 | .active .line-art { 64 | fill-opacity: 1; 65 | } 66 | 67 | .vote-button-wrapper { 68 | display: none; 69 | position: absolute; 70 | padding-left: 3px; 71 | left: 0; 72 | width: 30px; 73 | 74 | .vote-button { 75 | transition: 300ms; 76 | color: $primary-color; 77 | opacity: 0.3; 78 | 79 | &:hover { 80 | opacity: 1; 81 | } 82 | 83 | } 84 | &.up .vote-button.up { 85 | opacity: 1; 86 | } 87 | &.down .vote-button.down { 88 | opacity: 1; 89 | } 90 | body.user-rdt.rdt & { 91 | display: block; 92 | } 93 | } 94 | .meta-data { 95 | position: absolute; 96 | left: 0; 97 | top: 2px; 98 | 99 | body.user-rdt.rdt & { 100 | left: 50px; 101 | } 102 | } 103 | h1 { 104 | font-size: 24px; 105 | font-weight: 300; 106 | color: #222; 107 | line-height: 26px; 108 | a { 109 | text-decoration: none; 110 | font-size: inherit; 111 | } 112 | } 113 | .sub-title { 114 | position: relative; 115 | height: 65px; 116 | 117 | .category-dot { 118 | display: inline-block; 119 | position: relative; 120 | top: -3px; 121 | width: 13px; 122 | height: 13px; 123 | border-radius: 50%; 124 | margin-right: 7px; 125 | vertical-align: bottom; 126 | } 127 | a { 128 | text-decoration: none; 129 | } 130 | p { 131 | margin-bottom: 6px; 132 | margin-top: 0; 133 | } 134 | font-size: 12px; 135 | line-height: 2; 136 | margin-bottom: 10px; 137 | color: #777; 138 | } 139 | } 140 | p { 141 | line-height: 1.5; 142 | margin-bottom: 30px; 143 | } 144 | img { 145 | display: block; 146 | max-width: 100%; 147 | height: auto; 148 | } 149 | figcaption { 150 | margin-bottom: 20px; 151 | font-style: italic; 152 | } 153 | .comment-separator { 154 | border-bottom: 1px solid $primary-color; 155 | margin-top: 40px; 156 | padding: 9px 0; 157 | text-align: center; 158 | } 159 | 160 | .comment-list { 161 | list-style-type: none; 162 | margin: 0; 163 | padding: 0 0 0 10px; 164 | 165 | &-title { 166 | font-style: italic; 167 | margin: 15px 0; 168 | } 169 | &.level-1 { 170 | padding: 0; 171 | } 172 | &-item { 173 | border-left: 1px dotted #ccc; 174 | padding-left: 5px; 175 | margin-top: 25px; 176 | 177 | blockquote { 178 | border-left: 2px solid $primary-color; 179 | margin: 10px 0 10px 10px; 180 | padding-left: 10px; 181 | } 182 | 183 | &-text { 184 | margin: 0; 185 | padding: 0; 186 | &.meta { 187 | padding: 3px 0 0 10px; 188 | font-size: 13px; 189 | color: #999; 190 | a.reply { 191 | color: #999; 192 | &:hover { 193 | color: $primary-color; 194 | } 195 | } 196 | } 197 | &.body p { 198 | margin: 0; 199 | } 200 | } 201 | } 202 | } 203 | } 204 | } 205 | 206 | .show-story-panel { 207 | .story-panel { 208 | margin-right: 0px; 209 | &-grabber-inner { 210 | transform: scaleX(1); 211 | } 212 | &-closer:before { 213 | content: '»'; 214 | } 215 | } 216 | #more-btn { 217 | right: 425px; 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /server/controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path') 4 | , User = require(path.join(__dirname, '..', 'models', 'User.model')) 5 | , utils = require(path.join(__dirname, '..', 'utils')) 6 | , devLog = utils.devLog 7 | , request = require('request') 8 | ; 9 | 10 | 11 | //add this id to the read list for the user 12 | function markAsRead(data) { 13 | // devLog('will add to read list:', data.userId, 'and', data.storyId); 14 | var userId = data.userId 15 | , storyId = data.storyId 16 | ; 17 | User.findById(userId, function(err, user) { 18 | if (err) { return; } //TODO feed back to client 19 | if (!user) { return; } //perhaps user was deleted in another session? TODO hande better 20 | 21 | var foundMatch = false 22 | , i 23 | ; 24 | 25 | if (user.stories) { //older stories won't have this. Can go if I do a DB wipe 26 | for (i = 0; i < user.stories.length; i++) { 27 | if (user.stories[i].storyId === storyId) { 28 | user.stories[i].read = true; 29 | foundMatch = true; 30 | break; 31 | } 32 | } 33 | } 34 | 35 | if (!foundMatch) { 36 | user.stories.push({ 37 | storyId: storyId, 38 | read: true 39 | }); 40 | } 41 | user.save(); 42 | 43 | 44 | }); 45 | // User.findById(userId, function(err, user) { 46 | // if (err) { return; } //TODO feed back to client 47 | // if (!user) { return; } //perhaps user was deleted in another session? TODO hande better 48 | 49 | // if (user.readList.indexOf(storyId) === -1) { 50 | // // console.log('Adding', storyId, 'to the list of read things for user', userId); 51 | // user.readList.push(storyId); 52 | // user.save(); 53 | // } 54 | 55 | // }); 56 | } 57 | //add this id to the read list for the user 58 | function markAsUnread(data) { 59 | // devLog('will remove from read list:', data.userId, 'and', data.storyId); 60 | var userId = data.userId 61 | , storyId = data.storyId 62 | ; 63 | User.findById(userId, function(err, user) { 64 | if (err) { return; } //TODO feed back to client 65 | if (!user) { return; } //perhaps user was deleted in another session? TODO hande better 66 | var i; 67 | 68 | if (user.stories) { //older stories won't have this. Can go if I do a DB wipe 69 | for (i = 0; i < user.stories.length; i++) { 70 | if (user.stories[i].storyId === storyId) { 71 | user.stories[i].read = false; 72 | break; 73 | } 74 | } 75 | } 76 | user.save(); 77 | 78 | // var pos = user.readList.indexOf(storyId); 79 | // if (pos > -1) { 80 | // // devLog('Marking this story as not read:', user.readList[pos]); 81 | // user.readList.splice(pos, 1); 82 | // user.save(); 83 | // } else { 84 | // devLog('No story with id', storyId, 'is in the read list. That is odd.'); 85 | // } 86 | 87 | }); 88 | } 89 | 90 | function addToFavs(data) { 91 | //TODO for now I'm adding the entire story to the user object. 92 | //Eventually just store the ID, then generate the fav list when a user navigates to fav tab. 93 | // devLog('will add to favs:', data.userId, 'and', data.story.name); 94 | var userId = data.userId 95 | , story = data.story 96 | ; 97 | User.findById(userId, function(err, user) { 98 | if (err) { return; } //TODO feed back to client 99 | if (!user) { return; } //perhaps user was deleted in another session? TODO hande better 100 | var storyExists = false; 101 | user.favs.forEach(function(fav) { 102 | if (fav.id === story.id) { storyExists = true; } 103 | }); 104 | if (storyExists) { 105 | return; 106 | } else { 107 | user.favs.push(story); 108 | user.save(); 109 | } 110 | 111 | }); 112 | } 113 | 114 | function updateSettings(data) { 115 | var userId = data.userId 116 | , settings = data.settings 117 | ; 118 | User.findById(userId, function(err, user) { 119 | if (err) { return; } //TODO feed back to client 120 | if (!user) { return; } //perhaps user was deleted in another session? TODO hande better 121 | 122 | //TODO the settings sent from the client that aren't the schema will be ignored 123 | //but still, I should be less brutal about what I save here. 124 | user.settings = settings; 125 | user.save(); 126 | }); 127 | 128 | } 129 | 130 | function removeFromFavs(data) { 131 | var userId = data.userId 132 | , storyId = data.storyId 133 | ; 134 | User.findById(userId, function(err, user) { 135 | if (err) { return; } //TODO feed back to client 136 | if (!user) { return; } //perhaps user was deleted in another session? TODO hande better 137 | user.favs.forEach(function(fav, i) { 138 | if (fav.id === storyId) { 139 | user.favs.splice(i, 1); 140 | user.save(); 141 | return; 142 | } 143 | }); 144 | }); 145 | 146 | } 147 | 148 | function updateToken(userId, token, done) { 149 | console.log('updateToken(', userId, token, ')'); 150 | User.findById(userId, function(err, userDoc) { 151 | console.log('saving for user:', userDoc); 152 | userDoc.reddit.token = token; 153 | userDoc.save(function(err) { 154 | done(err); 155 | }); 156 | }); 157 | } 158 | 159 | function rdtVote(req, res) { 160 | } 161 | 162 | exports.markAsRead = markAsRead; 163 | exports.markAsUnread = markAsUnread; 164 | exports.addToFavs = addToFavs; 165 | exports.updateSettings = updateSettings; 166 | exports.removeFromFavs = removeFromFavs; 167 | exports.rdtVote = rdtVote; 168 | exports.updateToken = updateToken; 169 | 170 | -------------------------------------------------------------------------------- /app/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | About 6 | 15 | 16 | 17 | Back to the bubbles 18 |

Release Notes

19 |
20 |

Version 0.4.5

21 |

11 October 2014

22 |

New

23 | 29 |

Fixed

30 | 35 |
36 |
37 | 38 |

Version 0.4.0

39 |

7 October 2014

40 |

New

41 | 44 |

Fixed

45 | 48 |
49 |
50 | 51 |

Version 0.3.7

52 |

2 October 2014

53 |

New

54 | 58 |

Fixed

59 | 68 |
69 |
70 | 71 |

Version 0.3.6

72 |

24 September 2014

73 |

New

74 | 77 |

Fixed

78 | 85 |
86 |
87 | 88 |

Version 0.3.5

89 |

19 September 2014

90 |

New

91 | 94 |

Fixed

95 | 99 |
100 |
101 | 102 |

Version 0.3.3

103 |

16 September 2014

104 |

New

105 | 109 |

Fixed

110 | 113 |
114 |
115 | 116 |

Version 0.3.2

117 |

15 September 2014

118 |

New

119 | 123 |

Fixed

124 | 127 |
128 |
129 | 130 |

Version 0.3.1

131 |

14 September 2014, in the afternoon

132 |

New

133 | 136 |

Fixed

137 | 140 |
141 |
142 | 143 |

Version 0.3.0

144 |

14 September 2014

145 |

New

146 | 151 |

Fixed

152 | 159 |
160 |
161 |

Pre version 0.3.0

162 |

New

163 | 166 |

Fixed

167 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /dist/about.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | About 6 | 15 | 16 | 17 | Back to the bubbles 18 |

Release Notes

19 |
20 |

Version 0.4.5

21 |

11 October 2014

22 |

New

23 | 29 |

Fixed

30 | 35 |
36 |
37 | 38 |

Version 0.4.0

39 |

7 October 2014

40 |

New

41 | 44 |

Fixed

45 | 48 |
49 |
50 | 51 |

Version 0.3.7

52 |

2 October 2014

53 |

New

54 | 58 |

Fixed

59 | 68 |
69 |
70 | 71 |

Version 0.3.6

72 |

24 September 2014

73 |

New

74 | 77 |

Fixed

78 | 85 |
86 |
87 | 88 |

Version 0.3.5

89 |

19 September 2014

90 |

New

91 | 94 |

Fixed

95 | 99 |
100 |
101 | 102 |

Version 0.3.3

103 |

16 September 2014

104 |

New

105 | 109 |

Fixed

110 | 113 |
114 |
115 | 116 |

Version 0.3.2

117 |

15 September 2014

118 |

New

119 | 123 |

Fixed

124 | 127 |
128 |
129 | 130 |

Version 0.3.1

131 |

14 September 2014, in the afternoon

132 |

New

133 | 136 |

Fixed

137 | 140 |
141 |
142 | 143 |

Version 0.3.0

144 |

14 September 2014

145 |

New

146 | 151 |

Fixed

152 | 159 |
160 |
161 |

Pre version 0.3.0

162 |

New

163 | 166 |

Fixed

167 | 170 | 171 | 172 | -------------------------------------------------------------------------------- /app/scripts/events.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.Events = (function() { 5 | var Events = {} 6 | , storyPanel 7 | , chartWrapper 8 | , storyPanelResizer 9 | , offsetX 10 | , body 11 | ; 12 | 13 | function resizerMousedown() { 14 | if (d3.event.target.id === 'story-panel-toggle') { return false; } 15 | chartWrapper = d3.select('#chart-wrapper').style('transition', '0ms'); 16 | storyPanel = d3.select('#story-panel').style('transition', '0ms'); 17 | 18 | storyPanelResizer = d3.select('#story-panel-resizer').classed('active', true); 19 | 20 | body = d3.select('body'); 21 | offsetX = d3.mouse(document.body)[0] - NB.splitPos; 22 | 23 | body.on('mousemove', resizerMousemove); 24 | body.on('mouseup', resizerMouseup); 25 | body.on('touchmove', resizerMousemove); 26 | body.on('touchend', resizerMouseup); 27 | } 28 | 29 | function resizerMousemove() { 30 | d3.event.preventDefault(); 31 | NB.splitPos = Math.max(100, d3.mouse(document.body)[0] - offsetX); 32 | NB.Layout.moveSplitPos(); 33 | NB.Chart.resize(); 34 | } 35 | 36 | function resizerMouseup() { 37 | chartWrapper.style('transition', null); 38 | storyPanel.style('transition', null); 39 | storyPanelResizer.classed('active', false); 40 | 41 | //Snap the splitter to the right if it's less that xpx 42 | if (document.body.offsetWidth - NB.splitPos < 100) { 43 | NB.Layout.hideStoryPanel(); 44 | } 45 | 46 | body.on('mousemove', null); 47 | body.on('mouseup', null); 48 | body.on('touchmove', null); 49 | body.on('touchend', null); 50 | } 51 | 52 | // function chartBubbleClicked(d) { 53 | 54 | // //move to back 55 | // moveToBack(); 56 | 57 | // //get the D3 flvoured dom el 58 | // var el = d3.select(d3.event.currentTarget); 59 | // //TODO if clicked story is already showing, return. (lastID === d._id) 60 | 61 | // //Make the last selected item read and no longer selected 62 | // d3.select('.selected') 63 | // .classed('selected', false); 64 | 65 | // //Now select the item just clicked 66 | // el.classed('selected', true); 67 | 68 | 69 | 70 | // var setting = NB.Settings.getSetting('clickAction'); 71 | 72 | // if (setting === 'storyPanel') { 73 | // NB.Data.markAsRead(d._id); 74 | // el.classed('read', true); 75 | // NB.Layout.showStoryPanel(); 76 | // NB.StoryPanel.render(d); 77 | // } 78 | 79 | // if (setting === 'storyTooltip') { //TODO move this out to maxiTooltip module (pass el and d) 80 | // var maxiTooltip = d3.select('#story-tooltip'); 81 | // var tooltipWidth = parseInt(maxiTooltip.style('width')); 82 | // var tooltipHeight = parseInt(maxiTooltip.style('height')); 83 | 84 | // var thisDims = el.node().getBoundingClientRect(); 85 | 86 | // var r = z(d.commentCount); 87 | // var left = thisDims.left + r - tooltipWidth / 2; 88 | // var maxLeft = w - tooltipWidth - 20; 89 | // left = Math.min(left, maxLeft); 90 | // left = Math.max(left, 0); 91 | 92 | // var top = thisDims.top - tooltipHeight; 93 | // if (top < 50) { 94 | // top = thisDims.bottom; 95 | // } 96 | 97 | // NB.StoryModel.setCurrentStory('tooltip', d); //TODO should this make visible? E.g. control vis in model? 98 | 99 | // var readUnreadLink = d3.select('#tooltip-mark-as-read'); 100 | // if (el.classed('read')) { 101 | // readUnreadLink.text('Mark as unread'); 102 | // } else { 103 | // readUnreadLink.text('Mark as read'); 104 | // } 105 | 106 | 107 | // var duration = maxiTooltipShowing ? 200 : 0; 108 | // maxiTooltip 109 | // .style('display', 'block') 110 | // .transition() 111 | // .duration(duration) 112 | // .style('left', left + 'px') 113 | // .style('top', top + 'px'); 114 | 115 | // maxiTooltipShowing = true; //will block little tooltip from showing 116 | 117 | // d3.event.stopPropagation(); //TODO I do not know the diff between this and immediate 118 | 119 | // $(document).on('click.tooltip', function() { //TODO try .one, still not working? 120 | // maxiTooltip.style('display', 'none'); 121 | // $(document).off('click.tooltip'); 122 | 123 | // window.setTimeout(function() { 124 | // maxiTooltipShowing = false; //wait a bit before allowing the little tooltip to show 125 | // }, 300); 126 | // }); 127 | // readUnreadLink.on('click', function() { //D3 will remove any existing listener 128 | // toggleRead(el, d); 129 | // }); 130 | // d3.select('#tooltip-open-reading-pane').on('click', function() { //D3 will remove any existing listener 131 | // NB.Data.markAsRead(d.id); 132 | // el.classed('read', true); 133 | // NB.Layout.showStoryPanel(); 134 | // NB.StoryPanel.render(d); 135 | // }); 136 | 137 | // } 138 | 139 | // if (setting === 'openTab') { 140 | // //TODO I'm not sure I can do this, maybe the text should be 'open page' or 'navigate to URL' 141 | // } 142 | // tooltip.style('visibility', 'hidden'); 143 | // } 144 | 145 | 146 | function init() { 147 | window.onresize = function() { 148 | NB.Layout.render(); 149 | NB.Chart.resize(); 150 | }; 151 | 152 | d3.select('#story-panel-resizer').on('mousedown', resizerMousedown); 153 | d3.select('#story-panel-resizer').on('touchstart', resizerMousedown); 154 | 155 | 156 | d3.select('#story-panel-toggle').on('click', function() { 157 | d3.event.preventDefault(); 158 | NB.Layout.toggleStoryPanel(); 159 | return false; 160 | }); 161 | 162 | $('#more-btn').on('click', function() { 163 | NB.Data.getNextPage(function(data) { 164 | NB.Chart.addStories(data); 165 | }); 166 | }); 167 | 168 | } 169 | 170 | 171 | 172 | /* --------------- */ 173 | /* -- Exports -- */ 174 | /* --------------- */ 175 | 176 | 177 | init(); 178 | return Events; 179 | 180 | })(); -------------------------------------------------------------------------------- /server/rdtCrawler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var path = require('path') 4 | , request = require('request') 5 | , storyController = require(path.join(__dirname, 'controllers', 'story.controller')) 6 | , utils = require(path.join(__dirname, 'utils')) 7 | , devLog = utils.devLog 8 | , prodLog = utils.prodLog 9 | , getInProgress = false 10 | ; 11 | 12 | function upsert(story) { 13 | process.nextTick(function() { 14 | storyController.upsertRdtStory(story); 15 | }); 16 | } 17 | 18 | function saveStories(data) { 19 | // devLog(' -- Saving', data.length, 'RDT stories --'); 20 | data.forEach(function(story) { 21 | upsert(story); 22 | }); 23 | } 24 | 25 | 26 | function goGet(url, cb) { 27 | var options = { 28 | url: url, 29 | json: true, 30 | 'User-Agent': 'news-bubbles.herokuapp.com/0.4.0 by /u/bubble_boi' 31 | }; 32 | 33 | request.get(options, function(err, response, data) { 34 | if (!response) { return; } 35 | if (response.headers['X-Ratelimit-Used']) { 36 | prodLog('X-Ratelimit-Used' + response.headers['X-Ratelimit-Used']); 37 | } 38 | if (response.headers['X-Ratelimit-Remaining']) { 39 | prodLog('X-Ratelimit-Remaining' + response.headers['X-Ratelimit-Remaining']); 40 | } 41 | if (response.headers['X-Ratelimit-Remaining']) { 42 | prodLog('X-Ratelimit-Reset' + response.headers['X-Ratelimit-Reset']); 43 | } 44 | // devLog('got data:'); 45 | // console.log(data); 46 | cb(data); 47 | }); 48 | } 49 | 50 | function buildUrl(props) { 51 | props = props || {}; 52 | var url = 'http://www.reddit.com/' + (props.list || 'new') + '.json'; 53 | url += '?limit=' + (props.limit || 100); 54 | url += props.after ? '&after=' + props.after : ''; 55 | 56 | return url; 57 | } 58 | 59 | function startCrawler() { 60 | prodLog('Starting Reddit crawler'); 61 | 62 | /* -- looper variables -- */ 63 | //'new' loopers 64 | var loopers = [ 65 | { 66 | name: 'Looper 1', 67 | list: 'new', 68 | count: 0, 69 | interval: 11000, 70 | loops: 15, 71 | lastKnownAfter: undefined 72 | }, 73 | { 74 | name: 'Looper 2', 75 | list: 'new', 76 | count: 0, 77 | interval: 31000, 78 | loops: 30, 79 | lastKnownAfter: undefined 80 | }, 81 | { 82 | name: 'Looper 3', 83 | list: 'new', 84 | count: 0, 85 | interval: 61000, 86 | loops: 60, 87 | lastKnownAfter: undefined 88 | }, 89 | { 90 | name: 'Looper 4', 91 | list: 'new', 92 | count: 0, 93 | interval: 127000, 94 | loops: 120, 95 | lastKnownAfter: undefined 96 | }, 97 | // { 98 | // name: 'Looper 5', 99 | // list: 'new', 100 | // count: 0, 101 | // interval: 241000, 102 | // loops: 240, 103 | // lastKnownAfter: undefined 104 | // }, 105 | 106 | //'hot' loopers 107 | { 108 | name: 'Looper 6', 109 | list: 'hot', 110 | count: 0, 111 | interval: 13000, 112 | loops: 15, 113 | lastKnownAfter: undefined 114 | }, 115 | { 116 | name: 'Looper 7', 117 | list: 'hot', 118 | count: 0, 119 | interval: 29000, 120 | loops: 30, 121 | lastKnownAfter: undefined 122 | }, 123 | { 124 | name: 'Looper 8', 125 | list: 'hot', 126 | count: 0, 127 | interval: 63000, 128 | loops: 60, 129 | lastKnownAfter: undefined 130 | }, 131 | { 132 | name: 'Looper 9', 133 | list: 'hot', 134 | count: 0, 135 | interval: 123000, 136 | loops: 120, 137 | lastKnownAfter: undefined 138 | }, 139 | // { 140 | // name: 'Looper 10', 141 | // list: 'hot', 142 | // count: 0, 143 | // interval: 240000, 144 | // loops: 240, 145 | // lastKnownAfter: undefined 146 | // } 147 | ]; 148 | 149 | function fetch(looper) { 150 | // devLog(looper.name + ' - getting...'); 151 | var url = buildUrl({after: looper.lastKnownAfter, list: looper.list}); 152 | 153 | goGet(url, function(response) { 154 | getInProgress = false; 155 | try { 156 | if (response.data) { //this should save the try, but who knows. 157 | saveStories(response.data.children); 158 | looper.lastKnownAfter = response.data.after; 159 | } else { 160 | prodLog('The reddit response did not have data. It looks like this:'); 161 | console.log(response); 162 | } 163 | } catch (err) { 164 | prodLog('Error in reddit crawler:', err); 165 | looper.count = 0; 166 | looper.lastKnownAfter = undefined; 167 | } 168 | }); 169 | } 170 | 171 | function startLooper(looper) { 172 | setInterval(function() { 173 | if (getInProgress) { 174 | // prodLog('There is a get already in progress, skipping this loop'); 175 | return; 176 | } //I think overlapping might be causing problems 177 | getInProgress = true; 178 | if (looper.count > looper.loops) { 179 | looper.count = 0; 180 | looper.lastKnownAfter = undefined; 181 | } else { 182 | looper.count++; 183 | } 184 | 185 | //TODO do I need nextTick here? 186 | // process.nextTick(function() { 187 | fetch(looper); 188 | // }); 189 | 190 | }, looper.interval); 191 | } 192 | 193 | for (var i = 0; i < loopers.length; i++) { 194 | startLooper(loopers[i]); 195 | } 196 | } 197 | 198 | exports.startCrawler = startCrawler; 199 | 200 | 201 | exports.forceFetch = function(req, res) { 202 | var loops = req.params.limit / 100 203 | , count = 0 204 | , lastKnownAfter 205 | , url = ''; 206 | 207 | function go() { 208 | url = buildUrl({after: lastKnownAfter, list: req.params.list}); 209 | // devLog('Getting data with the URL:', url); 210 | 211 | devLog('tick', count); 212 | goGet(url, function(response) { 213 | res.send('Forced reddit crawl'); 214 | 215 | if (response.data && response.data.children) { 216 | saveStories(response.data.children); 217 | lastKnownAfter = response.data.after; 218 | } 219 | 220 | }); 221 | } 222 | 223 | var interval = setInterval(function() { 224 | if (count >= loops) { 225 | devLog('done'); 226 | clearInterval(interval); 227 | } else { 228 | count++; 229 | go(); 230 | } 231 | 232 | }, 2000); 233 | }; -------------------------------------------------------------------------------- /server/hxnCrawler.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | //Hacker News 4 | //https://hn.algolia.com/api 5 | 6 | var path = require('path') 7 | , request = require('request') 8 | , storyController = require(path.join(__dirname, 'controllers', 'story.controller')) 9 | , utils = require(path.join(__dirname, 'utils')) 10 | , devLog = utils.devLog 11 | , prodLog = utils.prodLog 12 | , HITS_PER_PAGE_LIMIT = 1000 13 | , MIN_POINTS = 0 14 | 15 | , oneMin = 60 16 | , oneHour = 60 * 60 17 | , oneDay = 24 * 60 * 60 18 | 19 | //Intervals (milliseconds) 20 | , every10Secs = 1000 * 10 21 | , every1Min = 1000 * 60 22 | , every5Mins = 1000 * 60 * 5 23 | , every10Mins = 1000 * 60 * 10 24 | , every20Mins = 1000 * 60 * 20 25 | , every30Mins = 1000 * 60 * 30 26 | , every1Day = 1000 * 60 * 60 * 24 27 | ; 28 | 29 | 30 | 31 | /* --------------------------- */ 32 | /* -- HACKER NEWS CRAWLER -- */ 33 | /* --------------------------- */ 34 | 35 | function goGet(url, cb) { 36 | request.get({url: url, json: true}, function(err, req, data) { 37 | cb(data); 38 | }); 39 | } 40 | 41 | 42 | 43 | //TODO this probably belongs in controllers, but don't want callback soup or passing io around everywhere right now 44 | function saveStories(data, suppressResults) { 45 | try { 46 | // devLog(' -- Saving', data.hits.length, 'HXN stories --'); 47 | if (!data) { return; } 48 | var stories = data.hits; 49 | // var newOrUpdatedStories = []; 50 | // var savedStories = 0; 51 | stories.forEach(function(story) { 52 | storyController.upsertHxnStory(story, suppressResults); 53 | }); 54 | } catch (err) { 55 | devLog('Error saving HXN stories:', err); 56 | } 57 | } 58 | 59 | function buildUrl(props) { 60 | var url = 'https://hn.algolia.com/api/v1/'; 61 | url += 'search_by_date?'; 62 | url += 'tags=(story,show_hn,ask_hn)'; 63 | url += '&hitsPerPage=' + (props.hitsPerPage || HITS_PER_PAGE_LIMIT); 64 | url += props.page ? '&page=' + props.page : ''; 65 | url += '&numericFilters=created_at_i>' + props.minDate + ',created_at_i<' + props.maxDate; 66 | url += ',points>' + (props.minPoints || MIN_POINTS); 67 | 68 | return url; 69 | } 70 | 71 | 72 | //Force get the last 1000 stories over 1 point. Handy if the server goes down or something. 73 | exports.forceFetch = function(req, res) { 74 | var now = new Date().getTime() / 1000; 75 | var url = buildUrl({minDate: 0, maxDate: now, minPoints: 1}); 76 | 77 | goGet(url, function(data) { 78 | saveStories(data); 79 | res.send('Forced hacker news crawl'); 80 | }); 81 | }; 82 | 83 | 84 | exports.startCrawler = function() { 85 | prodLog('Starting Hacker News crawler!'); 86 | // io.emit('data update', {data: 'yes, there will totally be data here'}); 87 | 88 | //Get stories from last 30 mins 89 | setInterval(function() { 90 | var now = new Date().getTime() / 1000; 91 | var url = buildUrl({minDate: now - oneMin * 30, maxDate: now}); 92 | 93 | goGet(url, function(data) { 94 | saveStories(data); 95 | }); 96 | }, every10Secs); //TODO this should not be uncommented in prod 97 | // }, every1Min); 98 | 99 | //Get stories from 30 mins to 2 hours 100 | setTimeout(function() { 101 | setInterval(function() { 102 | var now = new Date().getTime() / 1000; 103 | var url = buildUrl({minDate: now - oneHour * 2, maxDate: now - oneMin * 30}); 104 | goGet(url, function(data) { 105 | saveStories(data); 106 | }); 107 | }, every5Mins); 108 | }, 10000); //stagger 109 | 110 | // //Get stories from 2 to 6 hours 111 | setTimeout(function() { 112 | setInterval(function() { 113 | var now = new Date().getTime() / 1000; 114 | var url = buildUrl({minDate: now - oneHour * 6, maxDate: now - oneHour * 2}); 115 | 116 | goGet(url, function(data) { 117 | saveStories(data); 118 | }); 119 | }, every10Mins); 120 | }, 20000); //stagger 121 | 122 | //Get stories from 6 to 12 hours 123 | setTimeout(function() { 124 | setInterval(function() { 125 | var now = new Date().getTime() / 1000; 126 | var url = buildUrl({minDate: now - oneHour * 12, maxDate: now - oneHour * 6}); 127 | 128 | goGet(url, function(data) { 129 | saveStories(data); 130 | }); 131 | }, every20Mins); 132 | }, 30000); //stagger 133 | 134 | 135 | //Get stories from 12 to 24 hours 136 | setTimeout(function() { 137 | setInterval(function() { 138 | var now = new Date().getTime() / 1000; 139 | var url = buildUrl({minDate: now - oneHour * 24, maxDate: now - oneHour * 12}); 140 | 141 | goGet(url, function(data) { 142 | saveStories(data); 143 | }); 144 | }, every30Mins); 145 | }, 40000); //stagger 146 | 147 | 148 | 149 | 150 | 151 | /* -- The below run daily and get older stories over a certain number of points -- */ 152 | 153 | //Get stories from 1 to 30 days over 100 points 154 | setTimeout(function() { 155 | setInterval(function() { 156 | var now = new Date().getTime() / 1000; 157 | var url = buildUrl({minDate: now - oneDay * 30, maxDate: now - oneDay, minPoints: 100}); 158 | 159 | goGet(url, function(data) { 160 | saveStories(data, true); 161 | }); 162 | }, every1Day); 163 | }, 50000); //stagger 164 | 165 | //Get stories from 30 to 90 days over 150 points 166 | setTimeout(function() { 167 | setInterval(function() { 168 | var now = new Date().getTime() / 1000; 169 | var url = buildUrl({minDate: now - oneDay * 90, maxDate: now - oneDay * 30, minPoints: 150}); 170 | 171 | goGet(url, function(data) { 172 | saveStories(data, true); 173 | }); 174 | }, every1Day); 175 | }, 50000); //stagger 176 | 177 | //Get stories from 90 to 200 days over 200 points 178 | setTimeout(function() { 179 | setInterval(function() { 180 | var now = new Date().getTime() / 1000; 181 | var url = buildUrl({minDate: now - oneDay * 200, maxDate: now - oneDay * 90, minPoints: 150}); 182 | 183 | goGet(url, function(data) { 184 | saveStories(data, true); 185 | }); 186 | }, every1Day); 187 | }, 50000); //stagger 188 | 189 | //Get stories from 200 to 365 days over 250 points 190 | setTimeout(function() { 191 | setInterval(function() { 192 | var now = new Date().getTime() / 1000; 193 | var url = buildUrl({minDate: now - oneDay * 365, maxDate: now - oneDay * 200, minPoints: 250}); 194 | 195 | goGet(url, function(data) { 196 | saveStories(data, true); 197 | }); 198 | }, every1Day); 199 | }, 50000); //stagger 200 | 201 | //Get stories from 365 to 600 days over 300 points 202 | setTimeout(function() { 203 | setInterval(function() { 204 | var now = new Date().getTime() / 1000; 205 | var url = buildUrl({minDate: now - oneDay * 600, maxDate: now - oneDay * 365, minPoints: 300}); 206 | 207 | goGet(url, function(data) { 208 | saveStories(data, true); 209 | }); 210 | }, every1Day); 211 | }, 50000); //stagger 212 | 213 | //Get stories over 600 days old and over 400 points 214 | setTimeout(function() { 215 | setInterval(function() { 216 | var now = new Date().getTime() / 1000; 217 | var url = buildUrl({minDate: 0, maxDate: now - oneDay * 600, minPoints: 400}); 218 | 219 | goGet(url, function(data) { 220 | saveStories(data, true); 221 | }); 222 | }, every1Day); 223 | }, 50000); //stagger 224 | 225 | }; 226 | 227 | 228 | -------------------------------------------------------------------------------- /app/scripts/settings.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.Settings = (function() { 5 | 6 | var Settings = {} 7 | , settings = {} 8 | , settingsEl 9 | ; 10 | 11 | function retrieveLocalSettings() { 12 | if (localStorage.settings) { 13 | var localSettings = JSON.parse(localStorage.settings); 14 | 15 | if (localSettings.clickAction) { Settings.clickAction(localSettings.clickAction); } 16 | if (localSettings.rightClickAction) { Settings.rightClickAction(localSettings.rightClickAction); } 17 | if (localSettings.source) { 18 | //TODO replace this logic with versioning the localstorage 19 | if (localSettings.source === 'rd') { localSettings.source = 'rdt'; } 20 | if (localSettings.source === 'hn') { localSettings.source = 'hxn'; } 21 | Settings.source(localSettings.source); 22 | } 23 | if (localSettings.hitLimit) { Settings.hitLimit(+localSettings.hitLimit); } 24 | if (localSettings.rdtMinScore) { Settings.rdtMinScore(+localSettings.rdtMinScore); } 25 | if (localSettings.hxnMinScore) { Settings.hxnMinScore(+localSettings.hxnMinScore); } 26 | 27 | } 28 | } 29 | 30 | function init() { 31 | var defaultPage = 'rdt'; 32 | if (window.location.hash === '#hxn') { 33 | defaultPage = 'hxn'; 34 | if (window.history && window.history.replaceState) { 35 | window.history.replaceState('', document.title, window.location.pathname); 36 | } else { 37 | window.location.hash = ''; 38 | } 39 | } 40 | 41 | //TODO why are these not just bound with KO? 42 | d3.select('#open-settings-btn').on('click', Settings.openSettings); 43 | d3.select('#save-settings-btn').on('click', Settings.saveSettings); 44 | d3.select('#cancel-settings-btn').on('click', Settings.cancelSettings); 45 | 46 | //Init a settings objects with some defaults. 47 | Settings.clickAction = ko.observable('storyPanel'); //storyPanel | storyTooltip 48 | Settings.rightClickAction = ko.observable('toggleRead'); // toggleRead | nothing 49 | Settings.source = ko.observable(defaultPage); // rdt | hxn 50 | Settings.hitLimit = ko.observable(100); 51 | Settings.rdtMinScore = ko.observable(500); 52 | Settings.hxnMinScore = ko.observable(5); 53 | Settings.favMinScore = ko.observable(0); 54 | //TODO this will need to be universal so that favourites will be coloured correctly. 55 | Settings.hxnCategoryColors = ko.observableArray([ 56 | {category: 'Ask HN', color: '#e74c3c'}, 57 | {category: 'Show HN', color: '#16a085'}, 58 | {category: 'Everything else', color: '#2980b9'} 59 | ]); 60 | Settings.rdtCategoryColors = ko.observableArray([ 61 | {category: 'AskReddit', color: '#2980b9'}, 62 | {category: 'funny', color: '#2ecc71'}, 63 | {category: 'pics', color: '#e67e22'}, 64 | {category: 'aww', color: '#8e44ad'}, 65 | {category: 'videos', color: '#e74c3c'}, 66 | {category: 'Showerthoughts', color: '#f1c40f'}, 67 | {category: 'Everything else', color: '#7f8c8d'} 68 | ]); 69 | 70 | settingsEl = d3.select('#settings-modal'); 71 | 72 | // ko.applyBindings(settings, settingsEl.node(0)); 73 | 74 | retrieveLocalSettings(); //Override the defaults if they were in local storage. 75 | 76 | } 77 | 78 | function closeSettings() { 79 | settingsEl 80 | .transition().duration(500) 81 | .style('opacity', 0) 82 | .transition() 83 | .style('display', 'none'); 84 | } 85 | 86 | function saveSettings(silent) { 87 | if (!silent) { 88 | NB.Data.emit('updateSettings', {settings: ko.toJS(Settings)}); 89 | } 90 | 91 | //The settings ko object is bound so nothing needs to be updated there 92 | var tmp = NB.Utils.constrain(1, Settings.hitLimit(), 500); 93 | Settings.hitLimit(tmp); 94 | 95 | tmp = Math.max(0, Settings.rdtMinScore()); 96 | Settings.rdtMinScore(tmp); 97 | 98 | tmp = Math.max(0, Settings.hxnMinScore()); 99 | Settings.hxnMinScore(tmp); 100 | 101 | var localSettings = { 102 | clickAction: Settings.clickAction(), 103 | rightClickAction: Settings.rightClickAction(), 104 | source: Settings.source(), 105 | hitLimit: Settings.hitLimit(), 106 | rdtMinScore: Settings.rdtMinScore(), 107 | hxnMinScore: Settings.hxnMinScore() 108 | }; 109 | 110 | var previousSettings = {}; 111 | 112 | if (localStorage.settings) { 113 | previousSettings = JSON.parse(localStorage.settings); 114 | } 115 | 116 | if (Settings.hitLimit() !== previousSettings.hitLimit) { 117 | NB.Chart.reset(); 118 | NB.Data.getData(); 119 | } 120 | 121 | var src = Settings.source(); 122 | var koScore = Settings[src + 'MinScore']; 123 | if (koScore && koScore() !== previousSettings[src + 'MinScore']) { 124 | NB.Chart.reset(); 125 | NB.Data.getData(); 126 | } 127 | //TODO if hxn or rdt limits changed... 128 | 129 | localStorage.settings = JSON.stringify(localSettings); 130 | closeSettings(); 131 | } 132 | 133 | function setAll(settings) { 134 | var keys = Object.keys(settings); 135 | keys.forEach(function(setting) { 136 | //TODO just save the settings directly here, but don't emit saved changes after 137 | Settings.setSetting(setting, settings[setting], true); 138 | }); 139 | } 140 | 141 | 142 | function openSettings() { 143 | settingsEl 144 | .style('display', 'block') 145 | .transition().duration(100) 146 | .style('opacity', 1); 147 | } 148 | 149 | function cancelSettings() { 150 | //since the settings object is bound to the radio buttons, it may have changed. 151 | //so reset it to what's in localStorage 152 | retrieveLocalSettings(); 153 | closeSettings(); 154 | } 155 | 156 | function getSetting(setting) { //TODO this will be redundant soon with direct access 157 | if (!Settings[setting]) { 158 | console.log(setting + ' is not a setting.'); 159 | return; 160 | } 161 | return Settings[setting](); 162 | } 163 | 164 | function setSetting(setting, value, silent) { 165 | //TODO, if this took an object, then I could use Object.keys and merge this with setAll. 166 | if (!Settings[setting]) { //TODO test for "typeof function" 167 | console.log(setting + ' is not something that can be set.'); 168 | return; 169 | } 170 | Settings[setting](value); 171 | saveSettings(silent); 172 | } 173 | 174 | function getColor(source, category) { 175 | if (!Settings[source + 'CategoryColors']) { 176 | console.log('There are no colours for this source'); 177 | return; 178 | } 179 | var arr = Settings[source + 'CategoryColors'](); 180 | var defaultColor; 181 | for (var i = 0; i < arr.length; i++) { 182 | if (arr[i].category === category) { 183 | return arr[i].color; 184 | } 185 | if (arr[i].category === 'Everything else') { 186 | defaultColor = arr[i].color; 187 | } 188 | } 189 | return defaultColor; 190 | } 191 | 192 | 193 | 194 | 195 | /* --------------- */ 196 | /* -- Exports -- */ 197 | /* --------------- */ 198 | 199 | Settings.openSettings = openSettings; 200 | Settings.saveSettings = saveSettings; 201 | Settings.cancelSettings = cancelSettings; 202 | Settings.getSetting = getSetting; 203 | Settings.setAll = setAll; 204 | Settings.setSetting = setSetting; 205 | Settings.getColor = getColor; 206 | 207 | init(); 208 | return Settings; 209 | 210 | })(); 211 | 212 | -------------------------------------------------------------------------------- /app/scripts/storyPanel.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var NB = NB || {}; 3 | 4 | NB.StoryPanel = (function() { 5 | var StoryPanel = {}; 6 | var currentStoryId; 7 | var currentStory; 8 | 9 | function getGifvHtml(gifVUrl) { 10 | var webm = gifVUrl.replace(/.gifv$/, '.webm'); 11 | var mp4 = gifVUrl.replace(/.gifv$/, '.mp4'); 12 | 13 | var html = [ 14 | '', 15 | '', 19 | '' 20 | ].join(''); 21 | 22 | return html; 23 | } 24 | 25 | 26 | function getReadability(story, cb) { 27 | var urlBase = '/readability/' 28 | , pageUrl = story.url 29 | , fullUrl = urlBase + encodeURIComponent(pageUrl); 30 | 31 | //TODO remove jQuery 32 | $.get(fullUrl, function(data) { 33 | 34 | if (data.error) { 35 | var msg = [ 36 | '

Whoa!

', 37 | '

This is far too good for this little panel. Better go see the whole thing ', 38 | 'here.', 39 | '

' 40 | ].join(''); 41 | cb(msg); 42 | } else { 43 | cb(data.content); 44 | } 45 | 46 | }); 47 | } 48 | 49 | 50 | function renderRdt(story) { 51 | var dom = story.rdt.domain.toLowerCase(); 52 | currentStoryId = story.sourceId; //To check when comments come back 53 | story.content = ''; 54 | 55 | //get comments and append. NB done() is not needed. 56 | function appendComments() { 57 | NB.Comments.getForRdtStory(story, function(commentTree) { 58 | story.content += '

Comments

'; 59 | story.content += [ 60 | '

Head on over to ', 61 | 'Reddit to comment.', 62 | '

' 63 | ].join(''); 64 | story.content += commentTree.html(); 65 | 66 | //Because a user can click one story, then another before the first story comments are loaded 67 | //Check that the expected story is still the active one. 68 | if (story.sourceId === currentStoryId) { 69 | NB.StoryModel.setCurrentStory(story); 70 | } else { 71 | console.log('The story has already changed, dumping these comments'); 72 | } 73 | 74 | }); 75 | } 76 | 77 | function done(thenAppendComments) { 78 | NB.StoryModel.setCurrentStory(story); 79 | if (thenAppendComments) { 80 | appendComments(); 81 | } 82 | } 83 | 84 | 85 | if (story.rdt.self) { 86 | NB.Comments.getForRdtStory(story, function(commentTree) { 87 | story.content = commentTree.html(); 88 | done(); 89 | }); 90 | 91 | } else if (story.url.match(/(gif|png|jpg)$/)) { //any old image link, might be imgur 92 | story.content = ''; 93 | done(true); 94 | } else if (story.url.match(/gifv$/)) { //any old image link, might be imgur 95 | story.content = getGifvHtml(story.url); 96 | done(true); 97 | } else if (dom === 'i.imgur.com' || dom === 'imgur.com' || dom === 'm.imgur.com') { //TODO does m. exist, and obviously regex 98 | 99 | if (story.url.match(/\imgur\.com\/a\//)) { //it is an imgur album (/a/) 100 | var albumId = story.url.replace(/.*?\imgur\.com\/a\//, ''); 101 | albumId = albumId.replace(/#.*/, ''); //remove trailing hash 102 | albumId = albumId.replace(/\?.*/, ''); //remove trailing query string 103 | var url = 'https://api.imgur.com/3/album/' + albumId + '/images'; 104 | var html = ''; 105 | $.get(url, function(response) { 106 | html += '
'; 107 | 108 | response.data.forEach(function(img) { 109 | html += '
'; 110 | }); 111 | 112 | html += '
'; 113 | 114 | story.content = html; 115 | done(true); 116 | 117 | }); 118 | } else if (story.url.match(/\imgur\.com\/gallery\//)) { 119 | var id = story.url.match(/imgur\.com\/gallery\/([^?]*)/)[1]; 120 | 121 | NB.Data.getImgurGalleryAsHtml(id, function(html) { 122 | story.content = '
' + html + '
'; 123 | done(true); 124 | }); 125 | 126 | } else { 127 | var imgUrl = story.url.replace('imgur.com', 'i.imgur.com') + '.jpg'; 128 | 129 | story.content = [ 130 | '
', 131 | '', 132 | '
' 133 | ].join(''); 134 | done(true); 135 | } 136 | 137 | } else { 138 | 139 | getReadability(story, function(content) { 140 | story.content = content; 141 | done(true); 142 | }); 143 | 144 | } 145 | 146 | 147 | } //END renderRdt 148 | 149 | 150 | 151 | 152 | function renderHxn(story) { 153 | 154 | function appendComments() { 155 | NB.Comments.getForHxnStory(story, function(commentTree) { 156 | story.content += '

Comments

'; 157 | story.content += commentTree; 158 | NB.StoryModel.setCurrentStory(story); 159 | }); 160 | } 161 | 162 | function done(thenAppendComments) { 163 | NB.StoryModel.setCurrentStory(story); 164 | if (thenAppendComments) { 165 | appendComments(); 166 | } 167 | } 168 | 169 | if (story.url) { 170 | if (story.url.match(/pdf\?*.*$/)) { 171 | story.content = 'Open this PDF'; 172 | done(true); 173 | // NB.StoryModel.setCurrentStory('panel', story); 174 | 175 | } else { 176 | getReadability(story, function(content) { 177 | story.content = content; 178 | done(true); 179 | // NB.StoryModel.setCurrentStory('panel', story); 180 | 181 | }); 182 | } 183 | } else { 184 | NB.Comments.getForHxnStory(story, function(commentTree) { 185 | story.content = commentTree; 186 | done(false); 187 | // NB.StoryModel.setCurrentStory('panel', story); 188 | }); 189 | 190 | } 191 | } 192 | 193 | 194 | 195 | 196 | function render(story) { 197 | currentStory = story; 198 | NB.StoryModel.setCurrentStory(story); //to get a quick change in the panel. 199 | 200 | //The story panel element is passed into these funciton because if it goes to readability it's an async call 201 | //and I don't want to mess around with cbs everywhere 202 | if (story.source === 'rdt') { 203 | renderRdt(story); 204 | } 205 | 206 | if (story.source === 'hxn') { 207 | renderHxn(story); 208 | } 209 | 210 | } 211 | 212 | function clear() { 213 | NB.StoryModel.clear(); 214 | } 215 | 216 | 217 | /* --------------- */ 218 | /* -- Exports -- */ 219 | /* --------------- */ 220 | 221 | StoryPanel.render = render; 222 | StoryPanel.clear = clear; 223 | 224 | return StoryPanel; 225 | })(); 226 | -------------------------------------------------------------------------------- /app/scripts/comments.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var NB = NB || {}; 4 | 5 | NB.Comments = (function() { 6 | var Comments = {}; 7 | 8 | function parseHtml(str) { 9 | var result = $('