Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Initial (Rough, quick implementation)

  • Loading branch information...
commit 37091fe1351ce12bfd75d0713e857e5b99b40792 0 parents
@medikoo authored
4 .gitignore
@@ -0,0 +1,4 @@
+.DS_Store
+/config.json
+/node_modules
+/npm-debug.log
20 README.md
@@ -0,0 +1,20 @@
+# Github News Reader
+
+_Rough quick implementation for own purposes, tested with Node v0.6 and Google Chrome browser_
+
+Groups articles into relative subjects and provides Google Reader like interface.
+Individual sections can be unsubscribed.
+
+## Installation
+
+ $ git clone https://github.com/medikoo/github-news-reader.git
+ $ npm install
+ $ npm run-script setup
+
+Create `config.json` in project path with following settings:
+
+* `port` - Port on which application server should listen
+* `username` - Your github username
+* `token` - Copy it from your news feed url
+
+ $ npm start
67 lib/client/data.js
@@ -0,0 +1,67 @@
+'use strict';
+
+var isArray = Array.isArray
+ , pluck = require('es5-ext/lib/Function/pluck')
+ , not = require('es5-ext/lib/Function/prototype/not')
+ , count = require('es5-ext/lib/Object/count')
+ , forEach = require('es5-ext/lib/Object/for-each')
+ , map = require('es5-ext/lib/Object/map')
+ , ee = require('event-emitter')
+
+ , markRead;
+
+markRead = function () {
+ if (!this.read) {
+ this.read = true;
+ this.emit('update', { type: 'read' });
+ }
+};
+
+var data = JSON.parse("%RSS%");
+
+console.log("DATA", data);
+
+module.exports = exports = map(data, function self(data, name) {
+ if (isArray(data)) {
+ data = ee(data);
+ data.forEach(function (article) {
+ ee(article);
+ article.on('update', function () {
+ data.emit('update');
+ });
+ article.markRead = markRead
+ });
+ } else {
+ data = ee(map(data, self));
+ forEach(data, function (obj, name) {
+ if (isArray(obj)) {
+ obj.on('update', function () {
+ if (!this.filter(not.call(pluck('read'))).length) {
+ delete data[name];
+ data.emit('update');
+ }
+ });
+ obj.on('ignore', function () {
+ delete data[name];
+ data.emit('update');
+ });
+ } else {
+ obj.on('update', function () {
+ if (!count(this)) {
+ delete data[name];
+ data.emit('update');
+ }
+ });
+ }
+ });
+ }
+ return data;
+});
+
+forEach(exports, function (obj, name) {
+ obj.on('update', function () {
+ if (!count(this)) {
+ delete exports[name];
+ }
+ });
+});
5 lib/client/public/main.js
@@ -0,0 +1,5 @@
+'use strict';
+
+require('../view');
+
+require('../server');
30 lib/client/server.js
@@ -0,0 +1,30 @@
+'use strict';
+
+var isArray = Array.isArray
+ , copy = require('es5-ext/lib/Array/prototype/copy')
+ , forEach = require('es5-ext/lib/Object/for-each')
+ , data = require('./data')
+ , socket = io.connect(location.protocol + '//' + location.host)
+
+ , path = [];
+
+forEach(data, function self(value, name, context) {
+ path.push(name);
+ if (isArray(value)) {
+ value.$path = copy.call(path);
+ value.forEach(function (article) {
+ article.on('update', function (e) {
+ if (e && (e.type === 'read')) {
+ socket.emit('read', value.$path.concat(article.guid));
+ }
+ });
+ });
+
+ value.on('ignore', function () {
+ socket.emit('ignore', value.$path);
+ });
+ } else {
+ forEach(value, self);
+ }
+ path.pop();
+});
138 lib/client/view.js
@@ -0,0 +1,138 @@
+'use strict';
+
+var isArray = Array.isArray
+ , call = Function.prototype.call
+ , max = Math.max
+ , keys = Object.keys
+ , last = require('es5-ext/lib/Array/prototype/last')
+ , format = require('es5-ext/lib/Date/get-format')('%Y-%m-%d %H:%S:%S')
+ , memoize = require('es5-ext/lib/Function/memoize')
+ , pluck = require('es5-ext/lib/Function/pluck')
+ , not = require('es5-ext/lib/Function/prototype/not')
+ , count = require('es5-ext/lib/Object/count')
+ , map = require('es5-ext/lib/Object/map')
+ , mapToArray = require('es5-ext/lib/Object/map-to-array')
+ , lcSort = call.bind(
+ require('es5-ext/lib/String/prototype/case-insensitive-compare'))
+ , domjs = require('domjs/lib/html5')(document)
+ , data = require('./data')
+
+ , articleDOM;
+
+articleDOM = memoize(function (article) {
+ var el, body
+ el = this.li({ 'class': 'article' },
+ !article.skipTitle &&
+ this.h2(this.a({ href: article.link, target: '_blank' }, article.title)),
+ !article.skipAuthor && this.div({ 'class': 'author' }, this.b(article.author), ' at ' +
+ format.call(new Date(Date.parse(article.date)))),
+ body = this.div({ 'class': 'body' })())();
+ body.innerHTML = article.description;
+ return el;
+}.bind(domjs.map));
+
+document.body.appendChild(domjs.build(function () {
+ var nest = 0, container, content, load, selected, fixPadding
+ , offsets = [], current, scope, articles, ignore, reset;
+
+ reset = function () {
+ scope = data;
+ while (!isArray(scope)) {
+ scope = scope[keys(scope).sort(lcSort)[0]];
+ }
+ load.call(scope);
+ };
+
+ load = function () {
+ var els;
+ current = this;
+ els = this.map(articleDOM);
+ articles().innerHTML = '';
+ articles(els);
+ this.emit('select');
+ this.some(function (el, i) {
+ if (!el.read) {
+ container.scrollTop = max(els[i].offsetTop - ((i === 0) ? 50 : 20), 0);
+ return true;
+ }
+ });
+ offsets = els.map(function (el) {
+ return el.offsetTop + 60;
+ });
+ };
+
+ ignore = function () {
+ current.emit('ignore');
+ };
+
+ fixPadding = function () {
+ content.style.paddingBottom = max(document.body.scrollHeight,
+ document.body.offsetHeight,
+ document.documentElement.clientHeight,
+ document.documentElement.scrollHeight,
+ document.documentElement.offsetHeight) + 'px';
+ };
+
+ section({ 'class': 'aside' },
+ ul({ 'class': 'nest-' + nest }, mapToArray(data, function self(value, key, context) {
+ var el, len;
+ if (isArray(value)) {
+ el = li({ 'class': 'feed' }, a({ onclick: load.bind(value) },
+ (last.call(value).headTitle || key) + "\u00a0(",
+ (len = _text(value.filter(not.call(pluck('read'))).length)), ")"))();
+ value.on('select', function () {
+ if (selected) {
+ selected.classList.remove('selected');
+ }
+ el.classList.add('selected');
+ selected = el;
+ });
+ value.on('update', function () {
+ var rlen = this.filter(not.call(pluck('read'))).length;
+ len.data = rlen;
+ if (!rlen) {
+ el.parentNode.removeChild(el);
+ }
+ });
+ value.on('ignore', function () {
+ el.parentNode.removeChild(el);
+ reset();
+ });
+ } else {
+ el = li(h3(key), ul({ 'class': 'nest-' + (++nest) },
+ mapToArray(value, self, null, lcSort)))();
+ --nest;
+ value.on('update', function () {
+ if (!count(this)) {
+ el.parentNode.removeChild(el);
+ }
+ });
+ }
+ return el;
+ }, null, lcSort)));
+ container = section({ 'class': 'content' },
+ content = div(
+ p({ 'class': 'controls' },
+ input({ type: 'button', value: 'Unsubscribe', onclick: ignore })),
+ articles = ul(),
+ p({ 'class': 'controls' },
+ input({ type: 'button', value: 'Unsubscribe', onclick: ignore }))
+ )())();
+
+ fixPadding();
+ window.onresize = fixPadding;
+
+ container.onscroll = function () {
+ var index = -1, pos = container.scrollTop;
+ offsets.every(function (offset, i) {
+ if (pos > offset) {
+ current[i].markRead();
+ return true;
+ }
+ });
+ };
+
+ // Show first
+ reset();
+
+}));
39 lib/server/client.js
@@ -0,0 +1,39 @@
+'use strict';
+
+var find = require('es5-ext/lib/Array/prototype/find')
+ , forEach = require('es5-ext/lib/Object/for-each')
+ , data = require('./data')
+ , socket = require('socket.io').listen(require('./server')).sockets
+
+ , actions;
+
+actions = {
+ read: function (path) {
+ var scope = data
+ , guid = path.pop();
+
+ console.log("READ", path.join('|'), guid);
+ path.forEach(function (name) {
+ scope = scope[name];
+ });
+ find.call(scope, function (art) {
+ return art.guid === guid;
+ }).read = true;
+ },
+ ignore: function (path) {
+ var scope = data
+ , name = path.pop();
+
+ console.log("IGNORE", path.join('|'), name);
+ path.forEach(function (name) {
+ scope = scope[name];
+ });
+ scope[name] = false;
+ }
+};
+
+socket.on('connection', function (socket) {
+ forEach(actions, function (listener, name) {
+ socket.on(name, listener);
+ });
+});
215 lib/server/data.js
@@ -0,0 +1,215 @@
+'use strict';
+
+var isFunction = require('es5-ext/lib/Function/is-function')
+ , noop = require('es5-ext/lib/Function/noop')
+ , partial = require('es5-ext/lib/Function/prototype/partial')
+ , isNumber = require('es5-ext/lib/Number/is-number')
+ , isObject = require('es5-ext/lib/Object/is-object')
+ , contains = require('es5-ext/lib/String/prototype/contains')
+ , endsWith = require('es5-ext/lib/String/prototype/ends-with')
+ , startsWith = require('es5-ext/lib/String/prototype/starts-with')
+ , github = new (require('github'))({ version: "3.0.0" })
+ , decode = require('ent').decode
+ , config = require('../../config')
+ , logToMail = require('./log-to-mail')
+ , Parser = require('./parser')
+
+ , reType = /^tag:github.com,\d+:([A-Za-z0-9]+)\/\d+$/
+ , reURI = new RegExp("(?:http|https):\\/\\/(?:\\w+:{0,1}\\w*@)?(?:\\S+)(:[0-9]+)?(?:\\/|\\/(?:[\\w#!:.?+=&%@!\\-\\/]))?", 'gi')
+
+ , process, fixLinks, sort, parse, parseUser, toHTML, titleFromAttr
+ , bodyFromAPI, log;
+
+sort = function (a, b) {
+ return Date.parse(a.date) - Date.parse(b.date);
+};
+
+log = function (subject, body) {
+ console.log("LOOG");
+ if (isObject(body)) {
+ body = [body.guid, body.link, body.description].join("\n\n");
+ }
+ logToMail(subject, body);
+};
+
+fixLinks = function (article) {
+ article.description =
+ article.description.replace(/ href="\//g, ' href="https://github.com/');
+ article.skipTitle = true;
+ article.skipAuthor = true;
+};
+
+parse = function (re, map, normalize, article) {
+ var match = article.link.match(re), scope = this, name;
+ if (!match) {
+ log("Could not parse link", article);
+ return;
+ }
+ map.forEach(function (key, index) {
+ if (isNumber(key)) {
+ name = match[key];
+ } else if (isFunction(key)) {
+ name = key(article, match);
+ } else {
+ name = key;
+ }
+ if (scope[name] == null) {
+ scope[name] = (index === (map.length - 1)) ? [] : {};
+ }
+ scope = scope[name];
+ });
+ if (scope) {
+ normalize(article, match);
+ scope.push(article);
+ scope.sort(sort);
+ }
+};
+
+parseUser = function (name, normalize, article) {
+ var author = article.author, scope;
+ if (!this.User) {
+ this.User = {};
+ }
+ scope = this.User
+ if (!scope[author]) {
+ scope[author] = {};
+ }
+ scope = scope[author];
+ if (scope[name] == null) {
+ scope[name] = [];
+ }
+ if (scope) {
+ normalize(article);
+ scope[name].push(article);
+ scope[name].sort(sort);
+ }
+};
+
+toHTML = function (str) {
+ return str.split('\n').map(function (str) {
+ var cls;
+ if (startsWith.call(str, '>')) {
+ cls = 'email-quoted';
+ str = str.slice(1);
+ }
+ return '<p' + (cls ? ' class="' + cls + '"' : '')
+ + '>' + str.replace('/>/g', '&gt;').replace('/>/g', '&lt;')
+ .replace('/&/g', '&amp;').replace('/"/g', '&quot;')
+ .replace(reURI, function (match) {
+ return '<a href="' + match + '">' + match + '</a>';
+ }) + '</p>'
+
+ }).join('');
+};
+
+titleFromAttr = function (article) {
+ var match = article.description
+ .match(/ title="([\0-!#-\uffff]+)">(?:issue|pull) /);
+ if (match) {
+ article.headTitle = decode(match[1]);
+ } else {
+ log("Could not parse title", article.description);
+ }
+};
+
+bodyFromAPI = function (article) {
+ return function (err, obj) {
+ if (err) {
+ log("API responded with an error", article.link + ' ' + err);
+ return;
+ }
+ var pre = article.description
+ article.description = article.description
+ .replace(/<blockquote([\0-\uffff]+)<\/blockquote>/,
+ '<blockquote>' + toHTML(obj.body) + '</blockquote>');
+ };
+};
+
+process = {
+ createevent: partial.call(parseUser, 'Create', fixLinks),
+ followevent: partial.call(parseUser, 'Follow', fixLinks),
+ forkevent: partial.call(parseUser, 'Fork', fixLinks),
+ gistevent: partial.call(parse,
+ /\/(\d+)$/,
+ ['Gist', 1], function (article) {
+ var m = article.description
+ .match(/div class="message">([\0-;=-\uffff]+)<\/div>/);
+ if (m) {
+ article.headTitle = article.author + ': ' + m[1];
+ }
+ fixLinks(article);
+ }),
+ gollumevent: partial.call(parse,
+ /^https:\/\/github.com\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\/wiki\/([a-zA-Z0-9_.-]+)$/,
+ ['Repo', 1, 'Wiki', 2], fixLinks),
+ issuecommentevent: partial.call(parse,
+ /^https:\/\/github.com\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\/issues\/(\d+)#issuecomment-(\d+)$/,
+ ['Repo', 1, 'Issues', 2], function (article, orgmatch) {
+ var match, data;
+ fixLinks(article);
+ titleFromAttr(article);
+ if (contains.call(article.description, '…</p>')) {
+ data = orgmatch[1].split('/');
+ github.issues.getComment({
+ user: data[0],
+ repo: data[1],
+ id: orgmatch[3]
+ }, bodyFromAPI(article));
+ }
+ }),
+ issuesevent: partial.call(parse,
+ /^https:\/\/github.com\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\/issues\/(\d+)$/,
+ ['Repo', 1, 'Issues', 2], function (article, match) {
+ var data;
+ fixLinks(article);
+ titleFromAttr(article);
+ if (contains.call(article.title, 'opened issue')) {
+ data = match[1].split('/');
+ github.issues.getRepoIssue({
+ user: data[0],
+ repo: data[1],
+ number: match[2]
+ }, bodyFromAPI(article));
+ } else if (contains.call(article.title, 'closed issue')) {
+ article.description = article.description
+ .replace(/<blockquote>([\0-\uffff]+)<\/blockquote>/, '')
+ .replace(' class="details">',
+ ' class="details" style="display:none">');
+ }
+ }),
+ pullrequestevent: partial.call(parse,
+ /^https:\/\/github.com\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\/pull\/(\d+)$/,
+ ['Repo', 1, 'Pull requests'], fixLinks),
+ pushevent: partial.call(parse,
+ /^https:\/\/github.com\/([a-zA-Z0-9_.-]+\/[a-zA-Z0-9_.-]+)\/compare\/([a-z0-9.]+)$/,
+ ['Repo', 1, 'Commits'], fixLinks),
+ watchevent: partial.call(parseUser, 'Watch', fixLinks)
+};
+
+var parser = new Parser('https://github.com/' + config.user +
+ '.private.atom?token=' + config.token)
+ , data = {};
+
+module.exports = data;
+
+parser.on('article', function (article) {
+ var type = article.guid.match(reType);
+ if (!type) {
+ log("Article with no guid", article);
+ return
+ }
+ type = type[1].toLowerCase();
+
+ if (!process[type]) {
+ log("Not supported article type", article);
+ return;
+ }
+ process[type].call(data, article);
+});
+
+parser.on('update', function () {
+ console.log(data);
+});
+
+parser.get();
+setInterval(parser.get.bind(parser), 15000);
36 lib/server/log-to-mail.js
@@ -0,0 +1,36 @@
+'use strict';
+
+var config = require('../../config')
+
+ , mailer, subjectPrefix;
+
+if (config.devMail) {
+ mailer = require('nodemailer').createTransport('SMTP', config.devMail);
+ if (config.devMail.subject) {
+ subjectPrefix = config.devMail.subject + ': ';
+ } else {
+ subjectPrefix = '';
+ }
+}
+
+
+module.exports = function (subject, body) {
+ var opts;
+ if (mailer) {
+ mailer.sendMail({
+ from: config.devMail.from,
+ to: config.devMail.to,
+ subject: subjectPrefix + subject,
+ body: body
+ }, function (err, response) {
+ if (err) {
+ console.error("Could not send email: " + err);
+ console.error(subject + ": " + body);
+ return;
+ }
+ console.log("Email succesfully sent", subject, body);
+ });
+ } else {
+ console.error(subject + ": " + body);
+ }
+};
47 lib/server/parser.js
@@ -0,0 +1,47 @@
+'use strict';
+
+var FeedParser = require('feedparser')
+ , request = require('request')
+ , memoize = require('es5-ext/lib/Function/memoize')
+ , ee = require('event-emitter')
+
+ , Parser;
+
+Parser = module.exports = function (uri) {
+ this._parser = new FeedParser();
+ this._parser.on('article', function (parse, article) {
+ parse(article.guid, article);
+ }.bind(this, memoize(this.parseArticle.bind(this), 1)));
+ this._request = { uri: uri, headers: {} };
+};
+
+Parser.prototype = ee({
+ parse: function (body) {
+ this._parser.parseString(body);
+ },
+ parseArticle: function (guid, article) {
+ this.emit('article', article);
+ },
+ process: function (res) {
+ var headers = res.headers;
+ if (headers.etag) {
+ this._request.headers['If-None-Match'] = headers.etag;
+ }
+ if (headers['Last-Modified']) {
+ this._request.headers['If-Modified-Since'] = headers['Last-Modified'];
+ }
+ },
+ get: function () {
+ request(this._request, function (err, res, body) {
+ if (err) {
+ console.log(err);
+ return;
+ }
+ this.process(res);
+ if (body) {
+ this.parse(body);
+ this.emit('update');
+ }
+ }.bind(this));
+ }
+}, true);
36 lib/server/server.js
@@ -0,0 +1,36 @@
+'use strict';
+
+var resolve = require('path').resolve
+ , createServer = require('http').createServer
+ , staticServer = require('node-static').Server
+ , partial = require('es5-ext/lib/Function/prototype/partial')
+ , config = require('../../config')
+ , webmake = require('./webmake')
+
+ , root = resolve(__dirname, '../../')
+
+ , server
+
+staticServer = new staticServer(resolve(root, 'public'));
+
+server = module.exports = createServer(function (req, res) {
+ req.addListener('end', function () {
+ if (config.dev && (req.url === '/j/main.js')) {
+ res.writeHead(200, { 'Content-Type':
+ 'application/javascript; charset=utf-8',
+ 'Cache-Control': 'no-cache' });
+ webmake()(res.end.bind(res)).end();
+ } else {
+ staticServer.serve(req, res);
+ }
+ });
+})
+server.listen(config.port);
+
+require('./client');
+
+if (!config.dev) {
+ require('./parser').on('update', function () {
+ setTimeout(partial.call(webmake, true), 15000);
+ });
+}
31 lib/server/webmake.js
@@ -0,0 +1,31 @@
+'use strict';
+
+var isArray = Array.isArray
+ , stringify = JSON.stringify
+ , resolve = require('path').resolve
+ , pluck = require('es5-ext/lib/Function/pluck')
+ , compact = require('es5-ext/lib/Object/compact')
+ , count = require('es5-ext/lib/Object/count')
+ , every = require('es5-ext/lib/Object/every')
+ , map = require('es5-ext/lib/Object/map')
+ , webmake = require('webmake')
+ , data = require('./data')
+
+ , root = resolve(__dirname, '../../'), build
+
+module.exports = function (save) {
+ var copy = compact(map(data, function self(value, key) {
+ if (isArray(value)) {
+ return every(value, pluck('read')) ? null : value;
+ } else if (value) {
+ value = compact(map(value, self));
+ return count(value) ? value : null;
+ } else {
+ return false;
+ }
+ }));
+
+ return webmake(resolve(root, 'lib/client/public/main.js'))
+ .invoke('replace', '%RSS%', stringify(stringify(copy)).slice(1, -1),
+ { output: save && resolve(root, 'public/j/main.js') });
+};
29 package.json
@@ -0,0 +1,29 @@
+{
+ "author": "Mariusz Nowak <medikoo+github-rss-tree@medikoo.com> (http://www.medikoo.com/)",
+ "name": "github-news-reader",
+ "description": "Reader for GitHub private News Feed",
+ "version": "0.1.0",
+ "dependencies": {
+ "domjs": "git://github.com/medikoo/domjs.git",
+ "ent": "0.0.4",
+ "es5-ext": "git://github.com/medikoo/es5-ext.git",
+ "event-emitter": "git://github.com/medikoo/event-emitter.git",
+ "feedparser": "0.9.x",
+ "github": "0.1.x",
+ "node-static": "0.5.x",
+ "request": "2.x",
+ "socket.io": "0.9.x",
+ "webmake": "git://github.com/medikoo/modules-webmake.git"
+ },
+ "devDependencies": {},
+ "optionalDependencies": {
+ "nodemailer": "0.3.x"
+ },
+ "engines": {
+ "node": "*"
+ },
+ "scripts": {
+ "setup": "node ./node_modules/webmake/bin/webmake ./lib/clientpublic/main.js ./public/j/main.js",
+ "start": "node ./lib/server/server.js"
+ }
+}
56 public/c/style.css
@@ -0,0 +1,56 @@
+* { margin: 0; padding: 0; }
+ul { list-style: none; }
+html, button, input, select, textarea {
+ font-family: sans-serif; font-size: 13px; }
+body {
+ padding: 0; color: #333; background: white; min-width: 750px;
+ max-width: 1560px; }
+
+ul.nest-0 > li > h3, ul.nest-0 > li > a { padding-left: 20px; }
+ul.nest-1 > li > h3, ul.nest-1 > li > a { padding-left: 40px; }
+ul.nest-2 > li > h3, ul.nest-2 > li > a { padding-left: 60px; }
+ul.nest-3 > li > h3, ul.nest-3 > li > a { padding-left: 80px; }
+
+h2, h3, a { font-size: 13px; }
+section.aside h3, section.aside a { padding-right: 10px; }
+
+ul.nest-0 > li > h3 { color: #888; font-size: 15px; }
+
+h3, a { line-height: 18px; }
+
+a { color: #666; cursor: pointer; }
+a:hover { color: #888; }
+
+section.aside a { cursor: pointer; display: block; }
+section.aside a:hover { background: #eee; }
+
+html { height: 100%; }
+body { display: -webkit-box; -webkit-box-orient: horizontal; height: 100%; }
+
+section.aside { width: 250px; overflow: auto; height: 100%; }
+section.aside > ul { padding: 18px 0; border-right: 1px solid #ccc; }
+li.selected a { color: #D14836; background: #ffebe8; }
+li.feed a { padding-top: 4.5px; padding-bottom: 4.5px; }
+
+section.content {
+ -webkit-box-flex: 1; overflow: auto; height: 100%; padding: 0; background: url(../i/github_icon.png); }
+section.content > div { padding: 18px; padding-bottom: 100%: }
+
+li.article {
+ border: 1px solid #ddd; box-shadow: 0 0 4px #e3e5eb; padding: 8px 10px;
+ margin: 9px 0 18px; overflow: auto; background: white; }
+
+li.article h2 a, li.article div.title, li.article div.title a {
+ font-weight: bold; font-size: 14px; line-height: 18px; }
+li.article div.author { font-size: 13px; line-height: 18px; color: #666; }
+
+div.details { margin: 18px; }
+blockquote { margin: 9px; line-height: 18px; }
+
+p.controls { text-align: right; }
+p.controls input { font-weight: bold; }
+
+blockquote p.email-quoted { padding-left: 18px; color: #888; }
+p.email-quoted a { color: inherit; }
+blockquote p:not(.email-quoted) + p.email-quoted,
+blockquote p.email-quoted + p { margin-top: 18px; }
BIN  public/favicon.ico
Binary file not shown
BIN  public/i/github_icon.png
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
6 public/index.html
@@ -0,0 +1,6 @@
+<!DOCTYPE html>
+<title>GitHub News Feed Reader</title>
+<link rel="stylesheet" href="/c/style.css" />
+<script src="/socket.io/socket.io.js"></script>
+<body>
+<script src="/j/main.js"></script>
1  public/j/.gitignore
@@ -0,0 +1 @@
+/main.js
Please sign in to comment.
Something went wrong with that request. Please try again.