├── CHANGELOG.md
├── .gitattributes
├── .bowerrc
├── bower.json
├── src
├── common
│ ├── icons
│ │ ├── icon-16.png
│ │ ├── icon-19.png
│ │ ├── icon-38.png
│ │ └── icon-128.png
│ ├── loader
│ │ ├── loader.png
│ │ ├── loader@2x.png
│ │ └── _main.scss
│ ├── constants
│ │ └── index.js
│ ├── dispatcher
│ │ └── index.js
│ ├── screens
│ │ └── _main.scss
│ ├── analytics
│ │ ├── NullAnalytics.js
│ │ ├── index.js
│ │ ├── __tests__
│ │ │ └── Tracker-test.js
│ │ └── tracker.js
│ ├── img
│ │ └── _main.scss
│ ├── render
│ │ ├── index.js
│ │ └── __tests__
│ │ │ └── index-test.js
│ ├── google-analytics
│ │ ├── __tests__
│ │ │ └── index-test.js
│ │ └── index.js
│ ├── settings
│ │ ├── __tests__
│ │ │ └── index-test.js
│ │ └── index.js
│ ├── actions
│ │ ├── __tests__
│ │ │ └── PostActions-test.js
│ │ └── PostActions.js
│ ├── stores
│ │ ├── __tests__
│ │ │ └── PostStore-test.js
│ │ └── PostStore.js
│ ├── api
│ │ └── index.js
│ └── product-hunt
│ │ └── index.js
├── apps
│ ├── tabs
│ │ ├── assets
│ │ │ ├── arrow_up.svg
│ │ │ ├── comment.svg
│ │ │ └── logo.svg
│ │ ├── components
│ │ │ ├── __tests__
│ │ │ │ ├── Logo.react-test.js
│ │ │ │ ├── PostGroup.react-test.js
│ │ │ │ ├── DefaultTab.react-test.js
│ │ │ │ └── Post.react-test.js
│ │ │ ├── Logo.react.js
│ │ │ ├── PostGroup.react.js
│ │ │ ├── Post.react.js
│ │ │ ├── InfiniteScroll.react.js
│ │ │ └── DefaultTab.react.js
│ │ ├── util
│ │ │ ├── groupByDay.js
│ │ │ ├── __tests__
│ │ │ │ ├── groupByDay-test.js
│ │ │ │ ├── sliceWithRest-test.js
│ │ │ │ └── getDay-test.js
│ │ │ ├── getDay.js
│ │ │ ├── buildPost.js
│ │ │ └── sliceWithRest.js
│ │ ├── main.html
│ │ ├── main.js
│ │ └── main.scss
│ ├── popup
│ │ ├── main.html
│ │ ├── components
│ │ │ ├── __tests__
│ │ │ │ ├── Popup.react-test.js
│ │ │ │ └── Tab.react-test.js
│ │ │ ├── popup.react.js
│ │ │ └── tab.react.js
│ │ ├── main.js
│ │ └── main.scss
│ └── background
│ │ ├── buildUrl.js
│ │ ├── xframe.js
│ │ └── main.js
├── _locales
│ └── en
│ │ └── messages.json
└── manifest.json
├── .gitignore
├── .env.assert
├── test
├── setup.js
└── popup.js
├── .env.test
├── LICENSE
├── jest
├── setup.js
└── env.js
├── .env.example
├── package.json
├── README.md
├── CONTRIBUTING.md
└── Gulpfile.js
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 |
2 |
--------------------------------------------------------------------------------
/.gitattributes:
--------------------------------------------------------------------------------
1 | * text=auto
2 |
--------------------------------------------------------------------------------
/.bowerrc:
--------------------------------------------------------------------------------
1 | {
2 | "directory": "build/vendor/"
3 | }
4 |
--------------------------------------------------------------------------------
/bower.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "producthunt-chrome",
3 | "version": "0.0.0",
4 | "private": true,
5 | "dependencies": {}
6 | }
7 |
--------------------------------------------------------------------------------
/src/common/icons/icon-16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/producthunt/producthunt-chrome-extension/HEAD/src/common/icons/icon-16.png
--------------------------------------------------------------------------------
/src/common/icons/icon-19.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/producthunt/producthunt-chrome-extension/HEAD/src/common/icons/icon-19.png
--------------------------------------------------------------------------------
/src/common/icons/icon-38.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/producthunt/producthunt-chrome-extension/HEAD/src/common/icons/icon-38.png
--------------------------------------------------------------------------------
/src/common/loader/loader.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/producthunt/producthunt-chrome-extension/HEAD/src/common/loader/loader.png
--------------------------------------------------------------------------------
/src/common/icons/icon-128.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/producthunt/producthunt-chrome-extension/HEAD/src/common/icons/icon-128.png
--------------------------------------------------------------------------------
/src/common/loader/loader@2x.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/producthunt/producthunt-chrome-extension/HEAD/src/common/loader/loader@2x.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /node_modules/
2 | /tmp/
3 | /build/
4 | /dist/
5 | /.env
6 | /.env.staging
7 | /.env.production
8 | /dev/
9 | *.pem
10 |
--------------------------------------------------------------------------------
/.env.assert:
--------------------------------------------------------------------------------
1 | POST_SEARCH_URL
2 | SEARCH_URL
3 | API_BASE_URL
4 | OAUTH_KEY
5 | OAUTH_SECRET
6 | ANALYTICS_KEY
7 | GA_ID
8 | PRODUCTS_CACHE_KEY
9 | EXTENSION_ID
10 |
--------------------------------------------------------------------------------
/src/apps/tabs/assets/arrow_up.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/common/constants/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Constants.
3 | */
4 |
5 | const consts = {
6 | RECEIVE_POST: 'RECEIVE_POST',
7 | RECEIVE_POSTS: 'RECEIVE_POSTS'
8 | };
9 |
10 | /**
11 | * Export `consts`.
12 | */
13 |
14 | module.exports = consts;
15 |
--------------------------------------------------------------------------------
/src/apps/popup/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Product Hunt
6 |
7 |
8 |
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/src/apps/popup/components/__tests__/Popup.react-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('Popup', function() {
4 | let Popup = require('../Popup.react');
5 | let React = require('react');
6 |
7 | it('renders a tab', function() {
8 | expect().toRender('iframe');
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/__tests__/Logo.react-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | import React from 'react';
4 | import Logo from '../Logo.react';
5 |
6 | describe('Logo', function() {
7 | it('renders the Product Hunt header', function() {
8 | expect().toRender('Product Hunt');
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/common/dispatcher/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let Dispatcher = require('flux').Dispatcher;
6 |
7 | /**
8 | * Application Dispatcher.
9 | */
10 |
11 | let AppDispatcher = new Dispatcher;
12 |
13 | /**
14 | * Export `AppDispatcher`.
15 | */
16 |
17 | module.exports = AppDispatcher;
18 |
--------------------------------------------------------------------------------
/src/apps/tabs/assets/comment.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/apps/background/buildUrl.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Build search URL.
3 | *
4 | * @param {String} base url
5 | * @param {String} query
6 | * @returns {String}
7 | * @private
8 | */
9 |
10 | function buildUrl(baseUrl, query) {
11 | return baseUrl.replace('{query}', encodeURI(query));
12 | }
13 |
14 | /**
15 | * Export `buildUrl`.
16 | */
17 |
18 | module.exports = buildUrl;
19 |
--------------------------------------------------------------------------------
/src/apps/tabs/util/groupByDay.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Group items by `day`.
3 | *
4 | * @param {Array} items
5 | * @returns {Object}
6 | * @public
7 | */
8 |
9 | export default function groupByDay(items) {
10 | return items.reduce(function(groups, item) {
11 | groups[item.day] = groups[item.day] || [];
12 | groups[item.day].push(item);
13 | return groups;
14 | }, {});
15 | }
16 |
--------------------------------------------------------------------------------
/src/apps/tabs/main.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Product Hunt: The best new products, every day
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/common/screens/_main.scss:
--------------------------------------------------------------------------------
1 | // Variables
2 |
3 | $small-screen-breakpoint: 768px;
4 | $large-screen-breakpoint: 1200px;
5 |
6 | // Mixins
7 |
8 | @mixin medium-screen {
9 | @media (min-width: $small-screen-breakpoint + 1px) and (max-width: $large-screen-breakpoint - 1px) { @content; }
10 | }
11 |
12 | @mixin small-screen {
13 | @media (max-width: $small-screen-breakpoint) { @content; }
14 | }
15 |
--------------------------------------------------------------------------------
/src/apps/tabs/assets/logo.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/apps/popup/main.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let React = require('react');
6 | let renderComponent = require('../../common/render');
7 | let Popup = require('./components/Popup.react');
8 |
9 | // Do not render immediately, because Chrome
10 | // will wait for the entire page to load (incl. the iframe) in order
11 | // to show the popup.
12 | setTimeout(function() {
13 | renderComponent();
14 | }, 0);
15 |
--------------------------------------------------------------------------------
/src/_locales/en/messages.json:
--------------------------------------------------------------------------------
1 | {
2 | "appName": {
3 | "message": "Product Hunt",
4 | "description": "The name of the application"
5 | },
6 | "title": {
7 | "message": "Product Hunt: The best new products, every day",
8 | "description": "Default title"
9 | },
10 | "appDescription": {
11 | "message": "Product Hunt brings you the best new products, every day.",
12 | "description": "The description of the application"
13 | }
14 | }
15 |
--------------------------------------------------------------------------------
/src/apps/tabs/main.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | import React from 'react';
6 | import DefaultTab from './components/DefaultTab.react';
7 | import renderComponent from '../../common/render';
8 | import loadGoogleAnalytics from '../../common/google-analytics';
9 |
10 | /**
11 | * Constants.
12 | */
13 |
14 | const GA_ID = process.env.GA_ID;
15 |
16 | loadGoogleAnalytics(GA_ID);
17 | renderComponent(, document.getElementById('main'));
18 |
--------------------------------------------------------------------------------
/src/common/analytics/NullAnalytics.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let debug = require('debug')('ph:analytics:null-analytics');
6 |
7 | /**
8 | * Null Analytics.
9 | */
10 |
11 | class NullAnalytics {
12 |
13 | /**
14 | * Noop track.
15 | *
16 | * @public
17 | */
18 |
19 | track() {
20 | debug('track %j', arguments);
21 | }
22 | }
23 |
24 | /**
25 | * Export `NullAnalytics`.
26 | */
27 |
28 | module.exports = NullAnalytics;
29 |
--------------------------------------------------------------------------------
/src/apps/tabs/util/__tests__/groupByDay-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('groupByDay', function() {
4 | let groupByDay = require('../groupByDay');
5 |
6 | it('returns the items grouped by day', function() {
7 | let item1 = { foo: 'bar', day: '1' };
8 | let item2 = { foo: 'bar', day: '2' };
9 | let items = [item1, item2];
10 | let expected = { '1': [item1], '2': [item2] };
11 |
12 | expect(groupByDay(items)).toEqual(expected);
13 | });
14 | });
15 |
--------------------------------------------------------------------------------
/src/apps/popup/components/__tests__/Tab.react-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('Tab', function() {
4 | let Tab = require('../Tab.react');
5 | let React = require('react');
6 | let url = 'http://example.com';
7 |
8 | it('renders the given page', function() {
9 | expect().toRender(`iframe src="${url}"`);
10 | });
11 |
12 | it('renders a loader', function() {
13 | expect().toRender('
16 | * ```
17 | */
18 |
19 | export default function Logo() {
20 | return (
21 |
22 |
23 |
24 | );
25 | }
26 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/__tests__/PostGroup.react-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | import PostGroup from '../PostGroup.react';
4 | import React from 'react';
5 | import buildPost from '../../util/buildPost';
6 |
7 | describe('PostGroup', function() {
8 | let post = buildPost();
9 |
10 | it('renders the group name', function() {
11 | expect().toRender('Thursday');
12 | });
13 |
14 | it('renders the post name', function() {
15 | expect().toRender(post.name);
16 | });
17 | });
18 |
--------------------------------------------------------------------------------
/src/common/img/_main.scss:
--------------------------------------------------------------------------------
1 | @mixin img($width: 300px, $height: 300px, $name: "assets", $extension: ".png") {
2 | $img: $name + $extension;
3 | $x2img: $name + "@2x" + $extension;
4 |
5 | background-image: image-url($img);
6 | background-repeat: no-repeat;
7 | @media (min--moz-device-pixel-ratio: 1.3),
8 | (-o-min-device-pixel-ratio: 2.6/2),
9 | (-webkit-min-device-pixel-ratio: 1.3),
10 | (min-device-pixel-ratio: 1.3),
11 | (min-resolution: 1.3dppx) {
12 | background-image: image-url($x2img);
13 | background-size: $width;
14 | }
15 | }
16 |
--------------------------------------------------------------------------------
/test/setup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | var chai = require('chai');
6 | var chaiAsPromised = require('chai-as-promised');
7 | var envc = require('envc')({ nodeenv: 'test' });
8 | var path = require('path');
9 |
10 | // setup chai and wd
11 | global.wd = require('wd');
12 |
13 | chaiAsPromised.transferPromiseness = wd.transferPromiseness;
14 | chai.use(chaiAsPromised);
15 | chai.should();
16 |
17 | // global configs
18 | global.TEST = {
19 | path: path.join(__dirname, '..', 'build'),
20 | url: 'chrome-extension://' + process.env.EXTENSION_ID
21 | };
22 |
--------------------------------------------------------------------------------
/src/common/render/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let ReactDom = require('react-dom');
6 |
7 | /**
8 | * Render React `component`.
9 | *
10 | * @param {Object} component
11 | * @param {DOMElement} container (optional)
12 | * @public
13 | */
14 |
15 | function render(component, el) {
16 | if (!el) {
17 | el = document.createElement('div');
18 | document.body.insertBefore(el, document.body.firstChild)
19 | }
20 |
21 | ReactDom.render(component, el);
22 | }
23 |
24 | /**
25 | * Export `render`.
26 | */
27 |
28 | module.exports = render;
29 |
--------------------------------------------------------------------------------
/src/apps/tabs/util/sliceWithRest.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Smart slicing:
3 | *
4 | * @example
5 | *
6 | * sliceWithRest([1, 2], 1) -> [[1, 2], []]
7 | * sliceWithRest([1, 2, 3], 1) -> [[1], [2, 3]]
8 | * sliceWithRest([1, 2, 3, 4], 1) -> [[1], [2, 3, 4]]
9 | *
10 | * @param {Array} items
11 | * @param {number} limit
12 | * @returns {[Array, Array]}
13 | * @public
14 | */
15 |
16 |
17 | export default function sliceWithRest(array, limit) {
18 | if (array.length <= limit + 1) {
19 | return [array, []];
20 | }
21 |
22 | return [
23 | array.slice(0, limit),
24 | array.slice(limit),
25 | ];
26 | }
27 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/__tests__/DefaultTab.react-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | import DefaultTab from '../DefaultTab.react';
4 | import React from 'react';
5 | import TestUtils from 'react-addons-test-utils';
6 | import buildPost from '../../util/buildPost';
7 |
8 | describe('DefaultTab', function() {
9 | const PostStore = load('/common/stores/PostStore');
10 |
11 | it('listens for post change events', function() {
12 | let post = buildPost();
13 | let component = TestUtils.renderIntoDocument();
14 | PostStore.setData([post]);
15 | PostStore.emitChange();
16 | expect(component).toRender(post.name);
17 | });
18 | });
19 |
--------------------------------------------------------------------------------
/src/apps/tabs/util/__tests__/sliceWithRest-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | import call from '../sliceWithRest';
4 |
5 | describe('groupByDay', function() {
6 | it('returns all items when their count is less than limit', function() {
7 | let result = call([1], 10);
8 |
9 | expect(result).toEqual([[1], []]);
10 | });
11 |
12 | it('returns all items when their count + 1 is the limit', function() {
13 | let result = call([1, 2], 1);
14 |
15 | expect(result).toEqual([[1, 2], []]);
16 | });
17 |
18 | it('splits items after a limit', function() {
19 | let result = call([1, 2, 3], 1);
20 |
21 | expect(result).toEqual([[1], [2, 3]]);
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/common/render/__tests__/index-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('render', function() {
4 | let React = require('react');
5 | let render = require('../');
6 | let Dummy = React.createClass({
7 | render() {
8 | return Foo
;
9 | }
10 | });
11 |
12 | it('renders a react component into the body', function() {
13 | render();
14 | expect(document.querySelector('h1')).toBeTruthy();
15 | });
16 |
17 |
18 | it('renders a react component into supplied element', function() {
19 | let container = document.createElement('div');
20 | render(, container);
21 | expect(container.querySelector('h1')).toBeTruthy();
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/.env.test:
--------------------------------------------------------------------------------
1 | EXTENSION_ID="pinaciaaafilededpcmmpfeakplinncd"
2 | POPUP_URL="http://example.com/"
3 | SEARCH_URL="http://example.com/"
4 |
5 | API_BASE_URL="https://api.producthunt.com"
6 |
7 | OAUTH_KEY="7e5dab6037a254329d6be6b2ea6372d3b6328866f81f78e406f1a620075c54fe"
8 | OAUTH_SECRET="7dedcd8b9f137f5a62fe247f049dc3b4f14d832a1895b2dd2433bb30c2974210"
9 |
10 | PRODUCT_HUNT_HOST="www.producthunt.com"
11 |
12 | PRODUCT_BAR_ID="__phc-bar"
13 | PRODUCT_BAR_HEIGHT="50px"
14 |
15 | BODY_CLASS="__phc-body"
16 | OVERLAY_BODY_CLASS='__phc-no-scroll';
17 |
18 | FB_APP_ID="1467820943460899"
19 | TWITTER_VIA="producthunt"
20 |
21 | PRODUCTS_CACHE_KEY="ph.chrome.products"
22 |
23 | BAR_DISABLED_KEY="barDisabled"
24 | TAB_DISABLED_KEY="tabDisabled"
25 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Copyright (c) 2015, PRODUCT HUNT, INC.
2 |
3 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above copyright notice and this permission notice appear in all copies.
4 |
5 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
6 |
--------------------------------------------------------------------------------
/src/apps/background/xframe.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Remove ProductHunt's X-frame header.
3 | *
4 | * Temporary workaround, because the API responses do not include a link to the widgets
5 | * page. Likely we will just modify the API URL on the fly, instead of removing the
6 | * x-frame option, but that will do too.
7 | */
8 |
9 | chrome.webRequest.onHeadersReceived.addListener(function(details) {
10 | for (var i = 0; i < details.responseHeaders.length; ++i) {
11 | if (details.responseHeaders[i].name.toLowerCase() == 'x-frame-options') {
12 | details.responseHeaders.splice(i, 1);
13 | return { responseHeaders: details.responseHeaders };
14 | }
15 | }
16 | }, { urls: ['*://www.producthunt.com/*'] }, ['blocking', 'responseHeaders']);
17 |
--------------------------------------------------------------------------------
/src/common/google-analytics/__tests__/index-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('Google Analytics', function() {
4 | let loadGa = require('../');
5 |
6 | describe('without key', function() {
7 | it('does not load Google Analytics', function() {
8 | loadGa();
9 | expect(document.querySelector('script')).toBeFalsy();
10 | });
11 | });
12 |
13 | describe('with key', function() {
14 | beforeEach(function() {
15 | document.body.appendChild(document.createElement('script'));
16 | });
17 |
18 | it('loads Google Analytics', function() {
19 | loadGa('key');
20 | expect(document.querySelector('script').src).toEqual('https://ssl.google-analytics.com/ga.js');
21 | });
22 | });
23 | });
24 |
--------------------------------------------------------------------------------
/src/apps/tabs/util/__tests__/getDay-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('getDay', function() {
4 | let moment = require('moment');
5 | let getDay = require('../getDay');
6 |
7 | it('returns today if the supplied date is today', function() {
8 | let date = moment();
9 | expect(getDay(date)).toEqual('Today');
10 | });
11 |
12 | it('returns yesterday if the supplied date is yesetday', function() {
13 | let date = moment().subtract(1, 'day');
14 | expect(getDay(date)).toEqual('Yesterday');
15 | });
16 |
17 | it('returns the actual day of the week if the date is not yesterday or tomorrow', function() {
18 | let date = moment(new Date('2015-03-01'));
19 | expect(getDay(date)).toEqual('Sunday');
20 | });
21 | });
22 |
--------------------------------------------------------------------------------
/src/common/settings/__tests__/index-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('settings', function() {
4 | let settings = require('../');
5 |
6 | afterEach(function() {
7 | chrome.storage.sync.get = function(){};
8 | });
9 |
10 | it('gets a key from the chrome storage', function() {
11 | chrome.storage.sync.get = function(items, cb) {
12 | expect(items).toEqual({ foo: false });
13 | };
14 |
15 | settings.get('foo');
16 | });
17 |
18 | it('yields the value returned by the chrome storage', function() {
19 | chrome.storage.sync.get = function(items, cb) {
20 | cb({ foo: 'bar' });
21 | };
22 |
23 | settings.get('foo', function(value) {
24 | expect(value).toEqual('bar');
25 | });
26 | });
27 | });
28 |
--------------------------------------------------------------------------------
/src/common/actions/__tests__/PostActions-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 | jest.mock('../../dispatcher');
3 |
4 | var AppDispatcher = require('../../dispatcher');
5 |
6 | describe('PostActions', function() {
7 | let PostActions = require('../PostActions');
8 |
9 | describe('#receivePost', function() {
10 | it('dispatches a new action', function() {
11 | var data = { foo: 'bar' };
12 | PostActions.receivePost(data);
13 | expect(AppDispatcher.dispatch.mock.calls[0][0].action.data).toEqual(data);
14 | });
15 | });
16 |
17 | describe('#receivePosts', function() {
18 | it('dispatches a new action', function() {
19 | var data = { foo: 'bar' };
20 | PostActions.receivePosts(data);
21 | expect(AppDispatcher.dispatch.mock.calls[0][0].action.data).toEqual(data);
22 | });
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/jest/setup.js:
--------------------------------------------------------------------------------
1 | const ReactDOM = require('react-dom');
2 | const ReactDOMServer = require('react-dom/server');
3 | const TestUtils = require('react-addons-test-utils');
4 |
5 | jasmine.addMatchers({
6 | toRender: function() {
7 | function renderComponent(actual) {
8 | if (TestUtils.isElement(actual)) {
9 | return ReactDOMServer.renderToString(actual);
10 | } else {
11 | return ReactDOM.findDOMNode(actual).innerHTML;
12 | }
13 | }
14 |
15 | return {
16 | compare: function(actual, expected) {
17 | const html = renderComponent(actual);
18 | const pass = !!html.match(expected);
19 |
20 | return {
21 | pass: pass,
22 | message: pass ? '' : `Expected ${ html } to match "${ expected }", but it didn't`,
23 | };
24 | }
25 | };
26 | }
27 | });
28 |
--------------------------------------------------------------------------------
/src/common/analytics/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let NullAnalytics = require('./NullAnalytics');
6 | let Tracker = require('./Tracker');
7 |
8 | /**
9 | * Constants.
10 | */
11 |
12 | const ANALYTICS_KEY = process.env.ANALYTICS_KEY;
13 |
14 | /**
15 | * Locals.
16 | *
17 | * Note(andreasklinger): window.ProductHuntAnalytics gets set by a custom built of the analytics.js
18 | * To recreate this use their make script - it offers a options to set the variable name.
19 | */
20 |
21 | let enableAnalytics = window.ProductHuntAnalytics && ANALYTICS_KEY;
22 | let ProductHuntAnalytics = enableAnalytics ? window.ProductHuntAnalytics : NullAnalytics;
23 | let analytics = new ProductHuntAnalytics(process.env.ANALYTICS_KEY);
24 |
25 | /**
26 | * Export a new `Tracker`.
27 | */
28 |
29 | module.exports = new Tracker(analytics);
30 |
--------------------------------------------------------------------------------
/src/apps/background/main.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Modifiers.
3 | */
4 |
5 | require('./xframe');
6 |
7 | /**
8 | * Dependencies.
9 | */
10 |
11 | let analytics = require('../../common/analytics');
12 | let buildUrl = require('./buildUrl');
13 |
14 | /**
15 | * Constants.
16 | */
17 |
18 | const SEARCH_URL = process.env.POST_SEARCH_URL;
19 |
20 | /**
21 | * Register omnibox "enter" event listner
22 | */
23 |
24 | chrome.omnibox.onInputEntered.addListener(function(query) {
25 | if (!query) return;
26 |
27 | chrome.tabs.getSelected(null, function(tab) {
28 | let url = buildUrl(SEARCH_URL, query);
29 | chrome.tabs.update(tab.id, { url: url });
30 | });
31 | });
32 |
33 | /**
34 | * Track product bar clicks.
35 | */
36 |
37 | chrome.runtime.onMessage.addListener(function(request, sender, sendResponse) {
38 | analytics.clickBar(request);
39 | });
40 |
--------------------------------------------------------------------------------
/src/common/google-analytics/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let debug = require('debug')('ph:google-analytics');
6 |
7 | /**
8 | * Load Google Analytics.
9 | *
10 | * @param {String} key
11 | * @public
12 | */
13 |
14 | function loadGa(key) {
15 | if (!key) {
16 | debug('no key - bail out');
17 | return;
18 | }
19 |
20 | let _gaq = window._gaq = _gaq || [];
21 | _gaq.push(['_setAccount', key]);
22 | _gaq.push(['_trackPageview']);
23 |
24 | let ga = document.createElement('script');
25 | ga.type = 'text/javascript';
26 | ga.async = true;
27 | ga.src = 'https://ssl.google-analytics.com/ga.js';
28 |
29 | let s = document.getElementsByTagName('script')[0];
30 | s.parentNode.insertBefore(ga, s);
31 |
32 | debug('inserted');
33 | }
34 |
35 | /**
36 | * Export `loadGa`.
37 | */
38 |
39 | module.exports = loadGa;
40 |
--------------------------------------------------------------------------------
/src/common/stores/__tests__/PostStore-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('PostStore', function() {
4 | let PostStore = require('../PostStore');
5 |
6 | afterEach(function() {
7 | PostStore.reset();
8 | });
9 |
10 | it('sets/returns a post', function() {
11 | let post = { foo: 'bar' };
12 | PostStore.setData(post);
13 | expect(PostStore.getPost()).toEqual(post);
14 | });
15 |
16 | it('sets/returns an array of posts', function() {
17 | let post = { foo: 'bar' };
18 | PostStore.setData([post]);
19 | expect(PostStore.getPosts()).toEqual([post]);
20 | });
21 |
22 | it('emits change events', function() {
23 | var cb = jest.genMockFn();
24 |
25 | PostStore.addChangeListener(cb);
26 | PostStore.emitChange();
27 | PostStore.removeChangeListener(cb);
28 |
29 | expect(cb).toBeCalled();
30 | });
31 | });
32 |
--------------------------------------------------------------------------------
/test/popup.js:
--------------------------------------------------------------------------------
1 | describe('Popup App', function() {
2 | var browser = null;
3 |
4 | before(function() {
5 | browser = wd.promiseChainRemote();
6 |
7 | return browser.init({
8 | browserName: 'chrome',
9 | chromeOptions: {
10 | args: ['--load-extension=' + TEST.path]
11 | }
12 | });
13 | });
14 |
15 | beforeEach(function() {
16 | return browser.get(TEST.url + '/apps/popup/main.html');
17 | });
18 |
19 | after(function() {
20 | return browser.quit();
21 | });
22 |
23 | it('has a proper title', function() {
24 | return browser
25 | .title().should.become('Product Hunt');
26 | });
27 |
28 | it('loads the specified URL', function() {
29 | return browser
30 | .elementByTagName('iframe')
31 | .getAttribute('src').should.eventually.include('example.com');
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/apps/popup/main.scss:
--------------------------------------------------------------------------------
1 | @import "bourbon";
2 | @import "common/loader/main";
3 |
4 | // Variables
5 |
6 | $width: 550px;
7 | $height: 550px;
8 |
9 | // Definitions
10 |
11 | * {
12 | margin: 0;
13 | padding: 0;
14 | }
15 |
16 | body {
17 | font-family: 'proxima-nova', 'Proxima Nova', sans-serif;
18 | }
19 |
20 | body,
21 | iframe {
22 | width: $width;
23 | height: $height;
24 | }
25 |
26 | iframe {
27 | border: none;
28 | }
29 |
30 | .loader {
31 | @include loader;
32 | }
33 |
34 | header {
35 | background: #534540;
36 | color: #C4BFBD;
37 | height: 54px;
38 | line-height: 54px;
39 |
40 | ul {
41 | list-style: none;
42 |
43 | li {
44 | cursor: pointer;
45 | font-size: 16px;
46 | float: left;
47 | text-align: center;
48 | width: 50%;
49 |
50 | &:hover, &.current {
51 | background: #fff;
52 | }
53 | }
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/src/common/loader/_main.scss:
--------------------------------------------------------------------------------
1 | @import "common/img/main";
2 |
3 | @mixin loader {
4 | min-height: 40px;
5 | min-width: 40px;
6 |
7 | opacity: 1;
8 |
9 | &.m-hide {
10 | opacity: 0;
11 | pointer-events: none;
12 | }
13 |
14 | &:before {
15 | content: '';
16 | display: block;
17 | height: 40px;
18 | width: 40px;
19 | @include img(40px, 40px, "common/loader/loader");
20 | @include animation-name(rotate);
21 | @include animation-duration(1.2s);
22 | @include animation-iteration-count(infinite);
23 | @include animation-timing-function(linear);
24 | @include transition(opacity .1s ease);
25 |
26 | // centering
27 | position: absolute;
28 | left: 50%;
29 | top: 50%;
30 | margin-left: -20px;
31 | margin-top: -20px;
32 | }
33 | }
34 |
35 | @include keyframes(rotate) {
36 | from {
37 | @include transform(rotate(0deg));
38 | }
39 | to {
40 | @include transform(rotate(360deg));
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/apps/popup/components/popup.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let React = require('react');
6 | let debug = require('debug')('ph:popup:popup');
7 | let Tab = require('./Tab.react');
8 |
9 | /**
10 | * Constants.
11 | */
12 |
13 | const SEARCH_URL = process.env.SEARCH_URL;
14 |
15 | /**
16 | * Popup Component.
17 | *
18 | * Renders the popup.
19 | *
20 | * Usage:
21 | *
22 | * ```js
23 | *
24 | * ```
25 | *
26 | * State:
27 | *
28 | * - url: Popup URL address
29 | *
30 | * @class
31 | */
32 |
33 | let Popup = React.createClass({
34 |
35 | /**
36 | * Return initial state.
37 | *
38 | * @returns {Object}
39 | */
40 |
41 | getInitialState() {
42 | return { url: SEARCH_URL };
43 | },
44 |
45 | /**
46 | * Render the view.
47 | */
48 |
49 | render() {
50 | return (
51 |
52 |
53 |
54 | );
55 | }
56 | });
57 |
58 | /**
59 | * Export `Popup`.
60 | */
61 |
62 | module.exports = Popup;
63 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/__tests__/Post.react-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | import Post from '../Post.react';
4 | import React from 'react';
5 | import buildPost from '../../util/buildPost';
6 |
7 | describe('Post', function() {
8 | let post = buildPost();
9 |
10 | it('renders the votes count', function() {
11 | expect().toRender(post.votes_count);
12 | });
13 |
14 | it('renders the comments count', function() {
15 | expect().toRender(post.comments_count);
16 | });
17 |
18 | it('renders the post name', function() {
19 | expect().toRender(post.name);
20 | });
21 |
22 | it('renders the post tagline', function() {
23 | expect().toRender(post.tagline);
24 | });
25 |
26 | it('renders the screenshot_url', function() {
27 | expect().toRender(post.screenshot_url['300px']);
28 | });
29 |
30 | it('renders the discussion_url', function() {
31 | expect().toRender(post.discussion_url);
32 | });
33 | });
34 |
--------------------------------------------------------------------------------
/src/common/actions/PostActions.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let debug = require('debug')('ph:actions:post');
6 | let AppDispatcher = require('../dispatcher');
7 | let PostConstants = require('../constants');
8 |
9 | /**
10 | * Post Actions.
11 | */
12 |
13 | let PostActions = {
14 |
15 | /**
16 | * Handle the receive post action.
17 | *
18 | * @param {Object} data
19 | * @public
20 | */
21 |
22 | receivePost(data) {
23 | this._dispatch(PostConstants.RECEIVE_POST, data);
24 | },
25 |
26 | /**
27 | * Handle the receive posts action.
28 | *
29 | * @param {Object} data
30 | * @public
31 | */
32 |
33 | receivePosts(data) {
34 | this._dispatch(PostConstants.RECEIVE_POSTS, data);
35 | },
36 |
37 | /**
38 | * Dispatch action with `type` and `data`.
39 | *
40 | * @param {String} type
41 | * @param {Mixed} data
42 | * @private
43 | */
44 |
45 | _dispatch(type, data) {
46 | debug('dispatching %s', type);
47 | AppDispatcher.dispatch({
48 | action: { actionType: type, data: data }
49 | });
50 | }
51 | };
52 |
53 | /**
54 | * Export `PostActions`.
55 | */
56 |
57 | module.exports = PostActions;
58 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/PostGroup.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | import React from 'react';
6 | import moment from 'moment';
7 | import Post from './Post.react';
8 | import getDay from '../util/getDay';
9 | import groupByDay from '../util/groupByDay';
10 |
11 | /**
12 | * Post Group component.
13 | *
14 | * Renders the posts grouped by date.
15 | *
16 | * Usage:
17 | *
18 | * ```js
19 | *
20 | * ```
21 | */
22 |
23 | export default function PostGroup({ posts }) {
24 | let groups = groupByDay(posts);
25 | let out = Object.keys(groups).map(function(day) {
26 | let date = moment(new Date(day));
27 | let humanDay = getDay(date);
28 | let monthDay = date.format('MMMM Do');
29 |
30 | return (
31 |
32 |
33 | {monthDay}
34 | {humanDay}
35 |
36 |
37 | {groups[day].map((post) =>
)}
38 |
39 |
40 | );
41 | });
42 |
43 | return (
44 | {out}
45 | );
46 | }
47 |
--------------------------------------------------------------------------------
/jest/env.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | var path = require('path');
6 | var envc = require('envc')({ nodeenv: 'test' });
7 |
8 | /**
9 | * Project src directory.
10 | */
11 |
12 | var srcDir = path.join(__dirname, '..', 'src');
13 |
14 | /**
15 | * Require wrapper.
16 | *
17 | * @param {String} filename
18 | * @returns {Mixed}
19 | * @public
20 | * @global
21 | */
22 |
23 | function load(filename) {
24 | return require(filePath(filename));
25 | }
26 |
27 | /**
28 | * Return path to `file`.
29 | *
30 | * @param {String} path from the src of the project
31 | * @returns {String}
32 | * @public
33 | * @global
34 | */
35 |
36 | function filePath(file) {
37 | return path.join(srcDir, file);
38 | }
39 |
40 | /**
41 | * Expose `load` globally.
42 | */
43 |
44 | global.load = load;
45 |
46 | /**
47 | * Expose `filePath` globally.
48 | */
49 |
50 | global.filePath = filePath;
51 |
52 | /**
53 | * Expose fake `chrome` object.
54 | */
55 |
56 | global.chrome = window.chrome = {
57 | extension: {
58 | getURL: function() {}
59 | },
60 | runtime: {
61 | sendMessage: function() {}
62 | },
63 | storage: {
64 | sync: {
65 | get: function() {},
66 | set: function() {}
67 | }
68 | }
69 | };
70 |
--------------------------------------------------------------------------------
/.env.example:
--------------------------------------------------------------------------------
1 | #------------------------------------
2 | # 1. URLs
3 | #------------------------------------
4 |
5 | # Google Chrome Omnibox: Search URL
6 | POST_SEARCH_URL="https://www.producthunt.com/#!/s/posts/{query}"
7 |
8 | # Popup: Post search URL
9 | SEARCH_URL="https://www.producthunt.com/widgets/search#search"
10 |
11 | #------------------------------------
12 | # 2. API
13 | #------------------------------------
14 |
15 | # API base URL address
16 | API_BASE_URL="https://api.producthunt.com"
17 |
18 | # API OAuth key
19 | OAUTH_KEY="7e5dab6037a254329d6be6b2ea6372d3b6328866f81f78e406f1a620075c54fe"
20 |
21 | # API OAuth secret
22 | OAUTH_SECRET="7dedcd8b9f137f5a62fe247f049dc3b4f14d832a1895b2dd2433bb30c2974210"
23 |
24 | #------------------------------------
25 | # 3. Services
26 | #------------------------------------
27 |
28 | # Segment.com key
29 | ANALYTICS_KEY=""
30 |
31 | # Google Analytics ID
32 | GA_ID=""
33 |
34 | #------------------------------------
35 | # 4. Cache & settings
36 | #------------------------------------
37 |
38 | # Default Tab: products cache key
39 | PRODUCTS_CACHE_KEY="ph.chrome.products"
40 |
41 | #------------------------------------
42 | # 5. Core
43 | #------------------------------------
44 |
45 | # Google Chrome Extension ID
46 | EXTENSION_ID="likjafohlgffamccflcidmedfongmkee"
47 |
--------------------------------------------------------------------------------
/src/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "__MSG_appName__",
3 | "version": "0.1.2",
4 | "manifest_version": 2,
5 | "description": "__MSG_appDescription__",
6 | "chrome_url_overrides": {
7 | "newtab": "apps/tabs/main.html"
8 | },
9 | "background": {
10 | "scripts": [
11 | "vendor/analytics.js",
12 | "apps/background/main.js"
13 | ]
14 | },
15 | "icons": {
16 | "16": "common/icons/icon-16.png",
17 | "128": "common/icons/icon-128.png"
18 | },
19 | "default_locale": "en",
20 | "browser_action": {
21 | "default_icon": {
22 | "19": "common/icons/icon-19.png",
23 | "38": "common/icons/icon-38.png"
24 | },
25 | "default_title": "__MSG_title__",
26 | "default_popup": "apps/popup/main.html"
27 | },
28 | "omnibox": {
29 | "keyword": "ph"
30 | },
31 | "permissions": [
32 | "*://www.producthunt.com/*",
33 | "webRequestBlocking",
34 | "storage",
35 | "webRequest",
36 | "tabs"
37 | ],
38 | "web_accessible_resources": [
39 | "apps/content/product-bar.css",
40 | "apps/content/assets/comment.svg",
41 | "apps/content/assets/arrow_up.svg",
42 | "common/loader/loader.png",
43 | "common/loader/loader@2x.png"
44 | ],
45 | "content_security_policy": "script-src https://ssl.google-analytics.com 'self' 'unsafe-eval'; object-src 'self'"
46 | }
47 |
--------------------------------------------------------------------------------
/src/common/settings/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let debug = require('debug')('ph:settings');
6 | let nullStorage = {
7 | get: function(keys, cb) {
8 | cb(keys);
9 | },
10 | set: function(items, cb) {
11 | cb();
12 | }
13 | };
14 |
15 | /**
16 | * Chrome Extension Settings.
17 | */
18 |
19 | let settings = {
20 |
21 | /**
22 | * Get `key`.
23 | *
24 | * @param {String} key
25 | * @param {Function} callback
26 | * @public
27 | */
28 |
29 | get(key, cb) {
30 | this.storage().get({ [key]: false }, function(items) {
31 | debug('%j', items);
32 | cb(items[key]);
33 | });
34 | },
35 |
36 | /**
37 | * Get all `keys`.
38 | *
39 | * @param {Object} keys
40 | * @param {Function} callback
41 | * @public
42 | */
43 |
44 | getAll(keys, cb) {
45 | this.storage().get(keys, cb);
46 | },
47 |
48 | /**
49 | * Set `items`.
50 | *
51 | * @param {Object} items
52 | * @param {Function} callback
53 | * @public
54 | */
55 |
56 | setAll(items, cb) {
57 | this.storage().set(items, cb);
58 | },
59 |
60 | /**
61 | * Return the current storage.
62 | *
63 | * @returns {Object}
64 | * @public
65 | */
66 |
67 | storage() {
68 | return chrome.storage ? chrome.storage.sync : nullStorage;
69 | }
70 | };
71 |
72 | /**
73 | * Export `settings`.
74 | */
75 |
76 | module.exports = settings;
77 |
--------------------------------------------------------------------------------
/src/common/analytics/__tests__/Tracker-test.js:
--------------------------------------------------------------------------------
1 | jest.autoMockOff();
2 |
3 | describe('Tracker', function() {
4 | let Tracker = require('../Tracker');
5 | let post = { id: '2', name: 'name' };
6 | let stubStorage = {
7 | get: function(keys, cb) {
8 | cb({ userId: '1' });
9 | }
10 | };
11 |
12 | describe('#clickPost', function() {
13 | it('tracks a post click event', function() {
14 | let analytics = { track: jest.genMockFn() };
15 | let tracker = new Tracker(analytics, stubStorage);
16 |
17 | tracker.clickPost(post);
18 |
19 | expect(analytics.track).toBeCalledWith({
20 | anonymousId: '1',
21 | event: 'click',
22 | properties: {
23 | type: 'post',
24 | link_location: 'index',
25 | platform: 'chrome extension',
26 | post_id: post.id,
27 | post_name: post.name
28 | }
29 | });
30 | });
31 | });
32 |
33 | describe('#clickBar', function() {
34 | it('tracks a post click event', function() {
35 | let analytics = { track: jest.genMockFn() };
36 | let tracker = new Tracker(analytics, stubStorage);
37 |
38 | tracker.clickBar(post);
39 |
40 | expect(analytics.track).toBeCalledWith({
41 | anonymousId: '1',
42 | event: 'click',
43 | properties: {
44 | type: 'post',
45 | link_location: 'top_bar',
46 | platform: 'chrome extension',
47 | post_id: post.id,
48 | post_name: post.name
49 | }
50 | });
51 | });
52 | });
53 | });
54 |
--------------------------------------------------------------------------------
/src/common/api/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let debug = require('debug')('ph:api');
6 | let PostActions = require('../actions/PostActions');
7 | let ProductHunt = require('../product-hunt');
8 | let cache = require('lscache');
9 |
10 | /**
11 | * Constants.
12 | */
13 |
14 | const BASE_URL = process.env.API_BASE_URL;
15 | const OAUTH_KEY = process.env.OAUTH_KEY;
16 | const OAUTH_SECRET = process.env.OAUTH_SECRET;
17 | const CACHE_KEY = process.env.PRODUCTS_CACHE_KEY;
18 | const CACHE_DURARTION = 60 * 24;
19 |
20 | /**
21 | * Locals.
22 | */
23 |
24 | let ph = new ProductHunt(OAUTH_KEY, OAUTH_SECRET, BASE_URL);
25 |
26 | /**
27 | * API actions.
28 | */
29 |
30 | let api = {
31 |
32 | /**
33 | * Fetch post by `url`.
34 | *
35 | * @param {String} url
36 | * @public
37 | */
38 |
39 | getPost(url) {
40 | debug('getting post with url %s', url);
41 |
42 | ph.searchPosts({ 'search[url]': url }, function(err, posts) {
43 | if (err) throw err;
44 | debug('post received');
45 | PostActions.receivePost(posts[0]);
46 | });
47 | },
48 |
49 | /**
50 | * Fetch all posts for given date.
51 | *
52 | * @param {Date} date
53 | * @param {Function} callback [optional]
54 | * @public
55 | */
56 |
57 | getPosts(daysAgo, cb) {
58 | debug('getting posts from %d days ago', daysAgo);
59 |
60 | ph.getPosts(daysAgo, function(err, posts) {
61 | if (err) throw err;
62 | debug('posts received');
63 |
64 | if (daysAgo === 0) {
65 | debug('caching the posts...');
66 | cache.set(CACHE_KEY, posts, CACHE_DURARTION);
67 | }
68 |
69 | PostActions.receivePosts(posts);
70 | cb();
71 | });
72 | },
73 | };
74 |
75 | /**
76 | * Export `api`.
77 | */
78 |
79 | module.exports = api;
80 |
--------------------------------------------------------------------------------
/src/apps/popup/components/tab.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let React = require('react');
6 | let ReactDOM = require('react-dom');
7 | let debug = require('debug')('ph:popup:tab');
8 |
9 | /**
10 | * iframe Tab View.
11 | *
12 | * Usage:
13 | *
14 | * ```js
15 | *
16 | * ```
17 | *
18 | * Properties:
19 | *
20 | * - url: Post URL
21 | *
22 | * @class
23 | */
24 |
25 | let Tab = React.createClass({
26 |
27 | /**
28 | * Hide the iframe and show the loader.
29 | */
30 |
31 | componentDidUpdate() {
32 | ReactDOM.findDOMNode(this).querySelector('iframe').style.setProperty('display', 'none');
33 | ReactDOM.findDOMNode(this).querySelector('#loader').style.setProperty('display', 'block');
34 | },
35 |
36 | /**
37 | * Bind to iframe's load event so we can hide the
38 | * laoder and show the iframe.
39 | */
40 |
41 | componentDidMount() {
42 | let iframe = ReactDOM.findDOMNode(this).querySelector('iframe');
43 | let loader = ReactDOM.findDOMNode(this).querySelector('#loader');
44 |
45 | iframe.style.setProperty('display', 'none');
46 | loader.style.setProperty('display', 'block');
47 |
48 | iframe.onload = () => {
49 | debug('tab loaded');
50 | loader.style.setProperty('display', 'none');
51 | iframe.style.setProperty('display', 'block');
52 | };
53 | },
54 |
55 | /**
56 | * Render the view.
57 | */
58 |
59 | render() {
60 | debug('loading tab with URL %s', this.props.url);
61 |
62 | return (
63 |
67 | );
68 | }
69 | });
70 |
71 | /**
72 | * Export `Tab`.
73 | */
74 |
75 | module.exports = Tab;
76 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/Post.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | import React from 'react';
6 | import analytics from '../../../common/analytics';
7 | import sliceWithRest from '../util/sliceWithRest';
8 |
9 | /**
10 | * Post Component.
11 | *
12 | * Renders a post inside the default tab.
13 | *
14 | * Usage:
15 | *
16 | * ```js
17 | *
18 | * ```
19 | *
20 | * Properties:
21 | *
22 | * - `post`: Post from the ProductHunt API
23 | */
24 |
25 | export default class Post extends React.Component {
26 | constructor(props) {
27 | super(props);
28 |
29 | this.openPost = this.openPost.bind(this);
30 | }
31 |
32 | render() {
33 | let post = this.props.post;
34 | let [topics, overflow] = sliceWithRest(post.topics, 1);
35 |
36 | return (
37 |
38 |
39 |

40 |
41 |
42 |
{post.name}
43 |
{post.tagline}
44 |
45 |
46 | {topics.map(({ id, name }) => (
47 | {name}
48 | ))}
49 | {overflow.length > 0 &&
50 | name).join(', ')}> +{overflow.length}}
51 |
52 |
53 | {post.votes_count}
54 |
55 |
56 | {post.comments_count}
57 |
58 |
59 |
60 |
61 | );
62 | }
63 |
64 | openPost(e) {
65 | e.stopPropagation();
66 | analytics.clickPost(this.props.post);
67 | open(this.props.post.discussion_url);
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "producthunt",
3 | "private": true,
4 | "version": "0.0.0",
5 | "scripts": {
6 | "test": "gulp test",
7 | "postinstall": "bower install"
8 | },
9 | "devDependencies": {
10 | "babel-jest": "^4.0.0",
11 | "babelify": "^7.3.0",
12 | "browserify": "^13.1.1",
13 | "chai": "^3.5.0",
14 | "chai-as-promised": "^6.0.0",
15 | "envc": "^2.1.0",
16 | "envify": "^4.0.0",
17 | "gulp": "^3.8.11",
18 | "gulp-if": "^2.0.2",
19 | "gulp-imagemin": "^3.1.1",
20 | "gulp-jsonminify": "^1.0.0",
21 | "gulp-minify-css": "^1.0.0",
22 | "gulp-minify-html": "^1.0.0",
23 | "gulp-rimraf": "^0.2.1",
24 | "gulp-sass": "^3.0.0",
25 | "gulp-sourcemaps": "^1.5.1",
26 | "gulp-spawn-mocha": "^3.1.0",
27 | "gulp-uglify": "^2.0.0",
28 | "gulp-util": "^3.0.8",
29 | "gulp-watch": "^4.1.1",
30 | "gulp-zip": "^3.0.2",
31 | "jest-cli": "^18.0.0",
32 | "node-neat": "^1.7.1-beta1",
33 | "react-tools": "^0.13.3",
34 | "rimraf": "^2.3.0",
35 | "sv-selenium": "^0.2.5",
36 | "vinyl-buffer": "^1.0.0",
37 | "vinyl-source-stream": "^1.0.0",
38 | "watchify": "^3.8.0",
39 | "wd": "^0.3.11"
40 | },
41 | "dependencies": {
42 | "assert-env": "^0.6.0",
43 | "async": "^0.9.0",
44 | "babel-preset-es2015": "^6.18.0",
45 | "babel-preset-react": "^6.16.0",
46 | "bower": "^1.3.12",
47 | "debug": "^2.5.2",
48 | "flux": "^3.1.2",
49 | "lscache": "^1.0.5",
50 | "mocha": "3.2.0",
51 | "moment": "^2.9.0",
52 | "object-assign": "^4.1.0",
53 | "query-string": "^4.2.3",
54 | "react": "^15.4.1",
55 | "react-addons-test-utils": "^15.4.1",
56 | "react-dom": "^15.4.1",
57 | "superagent": "^0.21.0"
58 | },
59 | "jest": {
60 | "scriptPreprocessor": "/node_modules/babel-jest",
61 | "testPathDirs": [
62 | "/src"
63 | ],
64 | "setupEnvScriptFile": "/jest/env.js",
65 | "setupTestFrameworkScriptFile": "/jest/setup.js"
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/InfiniteScroll.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | import React from 'react';
6 | import ReactDOM from 'react-dom';
7 |
8 | /**
9 | * Utils.
10 | */
11 |
12 | function topPosition(domElt) {
13 | if (!domElt) {
14 | return 0;
15 | }
16 | return domElt.offsetTop + topPosition(domElt.offsetParent);
17 | }
18 |
19 | /**
20 | * InfiniteScroll component.
21 | */
22 |
23 | export default class InfiniteScroll extends React.Component {
24 | constructor(props) {
25 | super(props);
26 |
27 | this.scrollListener = this.scrollListener.bind(this);
28 | }
29 |
30 | componentDidMount() {
31 | this.pageLoaded = this.props.pageStart;
32 | this.attachScrollListener();
33 | }
34 |
35 | componentDidUpdate() {
36 | this.attachScrollListener();
37 | }
38 |
39 | componentWillUnmount() {
40 | this.detachScrollListener();
41 | }
42 |
43 | render() {
44 | return (
45 |
46 | {this.props.children}
47 | {this.props.hasMore && this.props.loader}
48 |
49 | );
50 | }
51 |
52 | scrollListener() {
53 | var el = ReactDOM.findDOMNode(this);
54 | var scrollTop = window.pageYOffset !== undefined ? window.pageYOffset : (document.documentElement || document.body.parentNode || document.body).scrollTop;
55 | if (topPosition(el) + el.offsetHeight - scrollTop - window.innerHeight < Number(this.props.threshold)) {
56 | this.detachScrollListener();
57 | this.props.loadMore(this.pageLoaded += 1);
58 | }
59 | }
60 |
61 | attachScrollListener() {
62 | if (!this.props.hasMore) {
63 | return;
64 | }
65 | window.addEventListener('scroll', this.scrollListener);
66 | window.addEventListener('resize', this.scrollListener);
67 | this.scrollListener();
68 | }
69 |
70 | detachScrollListener() {
71 | window.removeEventListener('scroll', this.scrollListener);
72 | window.removeEventListener('resize', this.scrollListener);
73 | }
74 | }
75 |
76 | InfiniteScroll.defaultProps = {
77 | pageStart: 0 ,
78 | hasMore: false,
79 | loadMore: function() {},
80 | threshold: 250,
81 | loader: ,
82 | };
83 |
--------------------------------------------------------------------------------
/src/common/stores/PostStore.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let assign = require('object-assign');
6 | let debug = require('debug')('ph:stores:post');
7 | let AppDispatcher = require('../dispatcher');
8 | let PostConstants = require('../constants');
9 | let EventEmitter = require('events').EventEmitter;
10 |
11 | /**
12 | * Constants.
13 | */
14 |
15 | const CHANGE_EVENT = 'change';
16 |
17 | /**
18 | * Data.
19 | */
20 |
21 | let data = [];
22 |
23 | /**
24 | * Post Store.
25 | */
26 |
27 | let PostStore = assign({}, EventEmitter.prototype, {
28 |
29 | /**
30 | * Register event listener
31 | *
32 | * @param {Function} cb
33 | * @public
34 | */
35 |
36 | addChangeListener(cb) {
37 | this.on(CHANGE_EVENT, cb);
38 | },
39 |
40 | /**
41 | * Remove event listener.
42 | *
43 | * @param {Function} cb
44 | * @public
45 | */
46 |
47 | removeChangeListener(cb) {
48 | this.removeListener(CHANGE_EVENT, cb);
49 | },
50 |
51 | /**
52 | * Return the last post.
53 | *
54 | * @returns {Object}
55 | * @public
56 | */
57 |
58 | getPost() {
59 | return data[data.length -1];
60 | },
61 |
62 | /**
63 | * Return all posts.
64 | */
65 |
66 | getPosts() {
67 | return data;
68 | },
69 |
70 | /**
71 | * Set data.
72 | *
73 | * @param {Object|Array} post(s)
74 | * @public
75 | */
76 |
77 | setData(post) {
78 | if (Array.isArray(post)) {
79 | data = data.concat(post);
80 | } else {
81 | data.push(post);
82 | }
83 | },
84 |
85 | /**
86 | * Emit change event.
87 | *
88 | * @public
89 | */
90 |
91 | emitChange() {
92 | this.emit(CHANGE_EVENT);
93 | },
94 |
95 | /**
96 | * Reset the store.
97 | *
98 | * @public
99 | */
100 |
101 | reset() {
102 | data = [];
103 | }
104 | });
105 |
106 | // Handle actions
107 |
108 | AppDispatcher.register(function(payload) {
109 | let action = payload.action;
110 | let type = action.actionType;
111 |
112 | if (type === PostConstants.RECEIVE_POST || type === PostConstants.RECEIVE_POSTS) {
113 | debug('post receive action received');
114 | PostStore.setData(action.data);
115 | PostStore.emitChange();
116 | }
117 | });
118 |
119 | /**
120 | * Export `PostStore`.
121 | */
122 |
123 | module.exports = PostStore;
124 |
--------------------------------------------------------------------------------
/src/common/analytics/tracker.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let debug = require('debug')('ph:analytics:tracker');
6 | let settings = require('../settings');
7 |
8 | /**
9 | * Tracker.
10 | *
11 | * Analytics wrapper.
12 | */
13 |
14 | class Tracker {
15 |
16 | /**
17 | * Constructor.
18 | *
19 | * @param {Object} analytics
20 | * @param {Object} storage (optional)
21 | */
22 |
23 | constructor(analytics, storage=settings.storage()) {
24 | this.analytics = analytics;
25 | this.storage = storage;
26 | this.platform = 'chrome extension';
27 | }
28 |
29 | /**
30 | * Track post click.
31 | *
32 | * @param {Object} post
33 | */
34 |
35 | clickPost(post) {
36 | this._trackPostClick(post, 'index');
37 | }
38 |
39 | /**
40 | * Track bar click.
41 | *
42 | * @param {Object} post
43 | * @public
44 | */
45 |
46 | clickBar(post) {
47 | this._trackPostClick(post, 'top_bar');
48 | }
49 |
50 | /**
51 | * Track post click on `location`.
52 | *
53 | * @param {Object} post
54 | * @param {String} location
55 | * @private
56 | */
57 |
58 | _trackPostClick(post, location) {
59 | this._getAnonymousId((userId) => {
60 | debug('track post click on "%s" for "%s"', location, userId);
61 |
62 | this.analytics.track({
63 | anonymousId: userId,
64 | event: 'click',
65 | properties: {
66 | type: 'post',
67 | link_location: location,
68 | platform: this.platform,
69 | post_id: post.id,
70 | post_name: post.name
71 | }
72 | });
73 | });
74 | }
75 |
76 | /**
77 | * Generate random user id.
78 | *
79 | * @returns {String}
80 | * @private
81 | */
82 |
83 | _anonymousId() {
84 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
85 | var r = Math.random()*16|0, v = c == 'x' ? r : (r&0x3|0x8);
86 | return v.toString(16);
87 | });
88 | }
89 |
90 | /**
91 | * Get anonymous ID for the current user, either from cache or
92 | * generate and store a new one.
93 | *
94 | * @param {Function} callback
95 | * @private
96 | */
97 |
98 | _getAnonymousId(cb) {
99 | this.storage.get({ userId: null }, (items) => {
100 | if (items.userId) {
101 | debug('User ID found in cache');
102 | return cb(items.userId);
103 | }
104 |
105 | debug('User ID not found in cache, generating a new one');
106 |
107 | let userId = this._anonymousId();
108 |
109 | this.storage.set({ userId: userId }, function() {
110 | cb(userId);
111 | });
112 | });
113 | }
114 | }
115 |
116 | /**
117 | * Export `Tracker`.
118 | */
119 |
120 | module.exports = Tracker;
121 |
--------------------------------------------------------------------------------
/src/apps/tabs/components/DefaultTab.react.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | import React from 'react';
6 | import InfiniteScroll from './InfiniteScroll.react';
7 | import cache from 'lscache';
8 | import async from 'async';
9 | import PostStore from '../../../common/stores/PostStore';
10 | import api from '../../../common/api';
11 | import PostGroup from './PostGroup.react';
12 | import Logo from './Logo.react';
13 |
14 | /**
15 | * Constants.
16 | */
17 |
18 | const CACHE_KEY = process.env.PRODUCTS_CACHE_KEY;
19 |
20 | /**
21 | * Logger.
22 | */
23 |
24 | const debug = require('debug')('ph:tabs:default-tab');
25 |
26 | /**
27 | * Queue for fetching the next page with posts.
28 | */
29 |
30 | const fetch = async.queue(function(daysAgo, cb) {
31 | debug('fetching next day');
32 | api.getPosts(daysAgo, cb);
33 | });
34 |
35 | /**
36 | * Default Tab component.
37 | *
38 | * Renders the default tab app.
39 | *
40 | * Usage:
41 | *
42 | * ```js
43 | *
44 | * ```
45 | *
46 | * State:
47 | *
48 | * - `posts`: Posts to be shown on the page
49 | * - `url`: Product pane url
50 | * - `startPage`: Start fetching posts from `startPage` days ago
51 | *
52 | */
53 |
54 | export default class DefaultTab extends React.Component {
55 |
56 | /**
57 | * Return initial state.
58 | */
59 |
60 | constructor(props) {
61 | super(props);
62 |
63 | this.loadNext = this.loadNext.bind(this);
64 | this.handleChange = this.handleChange.bind(this);
65 |
66 | this.cache = cache.get(CACHE_KEY);
67 |
68 | let firstPageCached = !!this.cache;
69 |
70 | // if we have cache, this means the first page has been already
71 | // fetched, therefore start from the next one
72 | let startPage = firstPageCached ? 0 : -1;
73 |
74 | debug('start page: %d', startPage);
75 |
76 | this.state = {
77 | posts: this.cache || [],
78 | startPage: startPage,
79 | };
80 | }
81 |
82 | /**
83 | * Before mounting the component, cache the current
84 | * date.
85 | */
86 |
87 | componentWillMount() {
88 | if (this.cache) {
89 | debug('using cache, refreshing it');
90 | this.loadNext(0);
91 | }
92 | }
93 |
94 | /**
95 | * On component mount, subscribe to post changes.
96 | */
97 |
98 | componentDidMount() {
99 | PostStore.addChangeListener(this.handleChange);
100 | }
101 |
102 | /**
103 | * On component unmount, unsubscribe from post changes.
104 | */
105 |
106 | componentWillUnmount() {
107 | PostStore.removeChangeListener(this.handleChange);
108 | }
109 |
110 | /**
111 | * Render the view.
112 | */
113 |
114 | render() {
115 | return (
116 |
117 |
118 | Hunting down posts...
}
120 | pageStart={this.state.startPage}
121 | loadMore={this.loadNext}
122 | hasMore={true}>
123 |
124 |
125 |
126 | );
127 | }
128 |
129 | /**
130 | * Load next page (day) with posts.
131 | *
132 | * @param {Number} page
133 | */
134 |
135 | loadNext(daysAgo) {
136 | fetch.push(daysAgo);
137 | }
138 |
139 | /**
140 | * Handle post change event.
141 | */
142 |
143 | handleChange() {
144 | this.setState({ posts: PostStore.getPosts() });
145 | }
146 | }
147 |
--------------------------------------------------------------------------------
/src/common/product-hunt/index.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Dependencies.
3 | */
4 |
5 | let request = require('superagent');
6 | let cache = require('lscache');
7 | let debug = require('debug')('ph:product-hunt');
8 |
9 | /**
10 | * Constants.
11 | */
12 |
13 | const CACHE_KEY = 'ph.chrome.auth';
14 |
15 | /**
16 | * ProductHunt API client.
17 | */
18 |
19 | class ProductHunt {
20 |
21 | /**
22 | * Constructor.
23 | *
24 | * @param {String} oauth key
25 | * @param {String} oauth secret
26 | * @param {String} baseUrl
27 | */
28 |
29 | constructor(key, secret, baseUrl) {
30 | this.baseUrl = baseUrl;
31 | this.key = key;
32 | this.secret = secret;
33 | this.cacheKey = CACHE_KEY;
34 | }
35 |
36 | /**
37 | * Search posts by `query`.
38 | *
39 | * @param {Object} query
40 | * @param {Function} cb
41 | * @public
42 | */
43 |
44 | searchPosts(query, cb) {
45 | debug('searching posts...');
46 |
47 | this._getAuth((err, token) => {
48 | if (err) return cb(err);
49 |
50 | request
51 | .get(`${this.baseUrl}/v1/posts/all`)
52 | .query(query)
53 | .set('Authorization', `Bearer ${token}`)
54 | .end((res) => {
55 | let retry = () => this.searchPosts(query, cb)
56 | this._handleResponse(res, retry, cb);
57 | });
58 | });
59 | }
60 |
61 | /**
62 | * Get post
63 | *
64 | * @param {Number} days ago
65 | * @param {Function} cb
66 | * @public
67 | */
68 |
69 | getPosts(daysAgo, cb) {
70 | debug('searching posts...');
71 |
72 | this._getAuth((err, token) => {
73 | if (err) return cb(err);
74 |
75 | request
76 | .get(`${this.baseUrl}/v1/posts`)
77 | .query({ days_ago: daysAgo })
78 | .set('Authorization', `Bearer ${token}`)
79 | .end((res) => {
80 | let retry = () => this.getPosts(daysAgo, cb)
81 | this._handleResponse(res, retry, cb);
82 | });
83 | });
84 | }
85 |
86 | /**
87 | * Return OAuth token either from the cache or the site.
88 | *
89 | * @param {Function} cb
90 | * @private
91 | */
92 |
93 | _getAuth(cb) {
94 | let token = cache.get(this.cacheKey);
95 |
96 | // we've got a token in the cache
97 | if (token) {
98 | debug('oauth token cache hit');
99 | return cb(null, token);
100 | }
101 |
102 | debug('oauth token cache miss');
103 |
104 | let params = {
105 | client_id: this.key,
106 | client_secret: this.secret,
107 | grant_type: 'client_credentials'
108 | }
109 |
110 | // no cached token, fire up a new request
111 | request
112 | .post(`${this.baseUrl}/v1/oauth/token`)
113 | .send(params)
114 | .end((res) => {
115 | debug('oauth token response status: %d', res.status);
116 | if (res.error) return cb(res.error);
117 | let expiry = res.body.expires_in / 60;
118 | cache.set(this.cacheKey, res.body.access_token, expiry);
119 | cb(null, res.body.access_token);
120 | });
121 | }
122 |
123 | /**
124 | * Remove the auth token from the cache.
125 | *
126 | * @private
127 | */
128 |
129 | _clearAuth() {
130 | cache.remove(this.cacheKey);
131 | }
132 |
133 | /**
134 | * Handle API response.
135 | *
136 | * @param {Object} response
137 | * @param {Function} retry function
138 | * @param {Fucntion} cb
139 | * @private
140 | */
141 |
142 | _handleResponse(res, retryFn, cb) {
143 | if (res.status === 401) {
144 | debug('invalid access token, retrying...');
145 | this._clearAuth();
146 | retryFn();
147 | } else if (res.error) {
148 | debug('response error: %s', res.error);
149 | cb(res.error);
150 | } else {
151 | cb(null, res.body.posts);
152 | }
153 | }
154 | }
155 |
156 | /**
157 | * Export `ProductHunt`.
158 | */
159 |
160 | module.exports = ProductHunt;
161 |
--------------------------------------------------------------------------------
/src/apps/tabs/main.scss:
--------------------------------------------------------------------------------
1 | @import "bourbon";
2 | @import "neat";
3 | @import "common/screens/main";
4 | @import "common/fonts/proxima-nova";
5 |
6 | // Variables
7 |
8 | $black: #000000;
9 | $white: #ffffff;
10 | $orange: #da552f;
11 | $grey: #999999;
12 | $silver: #e8e8e8;
13 |
14 | $radius: 3px;
15 |
16 | // Definitions
17 |
18 | html,
19 | body {
20 | font-family: 'proxima-nova', 'Proxima Nova', sans-serif;
21 | background: #f9f9f9;
22 | font-size: 14px;
23 | line-height: 18px;
24 | font-weight: 400;
25 | padding: 0px;
26 | margin: 0px;
27 | }
28 |
29 | a { text-decoration: none; }
30 |
31 | .clickable { cursor: pointer; }
32 |
33 | .clear { @include clearfix; }
34 |
35 | .featured {
36 | font-size: 20px;
37 | font-weight: 200;
38 | letter-spacing: .2px;
39 | line-height: 24px;
40 | color: $black;
41 | }
42 |
43 | .title {
44 | font-size: 14px;
45 | line-height: 18px;
46 | font-weight: 600;
47 | text-transform: uppercase;
48 | color: $grey;
49 | }
50 |
51 | .main {
52 | @include clearfix;
53 |
54 | @include media(500px) {
55 | max-width: 320px;
56 | }
57 |
58 | @include media(670px) {
59 | max-width: 640px;
60 | }
61 |
62 | @include media(1000px) {
63 | max-width: 960px;
64 | }
65 |
66 | position: relative;
67 | margin: {
68 | left: auto;
69 | right: auto;
70 | }
71 | }
72 |
73 | .logo {
74 | @include transition(opacity, 0.2s, ease);
75 | position: absolute;
76 | top: 0px;
77 | left: -40px;
78 | height: 30px;
79 | width: 30px;
80 | opacity: 1.0;
81 |
82 | &:hover {
83 | opacity: 0.8;
84 | }
85 |
86 | img {
87 | width: 100%;
88 | }
89 | }
90 |
91 | .day {
92 | margin: 10px 0;
93 | padding: 5px 0;
94 | border-bottom: 1px solid $silver;
95 |
96 | .date {
97 | padding-top: 5px;
98 | float: right;
99 | }
100 | }
101 |
102 | .products {
103 | margin-left: -10px;
104 | }
105 |
106 | .product {
107 | @include transition(top, 0.1s, ease);
108 | @include transition(box-shadow, 0.2s, ease);
109 | background-color: $white;
110 | border-radius: $radius;
111 | border: 1px solid $silver;
112 | float: left;
113 | height: 305px;
114 | margin: {
115 | left: 10px;
116 | bottom: 10px;
117 | }
118 | width: 312px;
119 | position: relative;
120 | top: 0px;
121 | box-shadow: 0 1px 4px 0 #f9f9f9;
122 |
123 | &:hover {
124 | box-shadow: 0 1px 4px 0 rgba(0,0,0,.04);
125 | top: -2px;
126 | }
127 |
128 | .gallery {
129 | height: 200px;
130 |
131 | img {
132 | height: 100%;
133 | width: 100%;
134 | }
135 | }
136 |
137 | .name {
138 | text-overflow: ellipsis;
139 | white-space: nowrap;
140 | overflow: hidden;
141 | }
142 |
143 | .tagline {
144 | color: $grey;
145 | height: 36px;
146 | margin-bottom: 5px;
147 | }
148 |
149 | .details {
150 | position: relative;
151 | border-top: 1px solid $silver;
152 | padding: 10px;
153 | }
154 |
155 | .info {
156 | text-align: right;
157 | }
158 |
159 | .topics {
160 | float: left;
161 | text-align: left;
162 | font-size: 10px;
163 | font-weight: 400;
164 | letter-spacing: .6px;
165 | line-height: 12px;
166 | text-transform: uppercase;
167 | color: $grey;
168 | }
169 |
170 | .topic {
171 | background: $silver;
172 | border-radius: $radius;
173 | color: $grey;
174 | padding: 3px 4px 1px;
175 | display: inline-block;
176 |
177 | & + .topic {
178 | margin-left: 5px;
179 | }
180 | }
181 |
182 | .votes {
183 | display: inline-block;
184 | background-image: url('assets/arrow_up.svg');
185 | background-position: 0px 3px;
186 | background-repeat: no-repeat;
187 | background-size: 11px;
188 | color: $grey;
189 | padding-left: 15px;
190 | }
191 |
192 | .comments {
193 | margin-left: 10px;
194 | display: inline-block;
195 | background-image: url('assets/comment.svg');
196 | background-position: 0px 1px;
197 | background-repeat: no-repeat;
198 | background-size: 13px 15px;
199 | color: $grey;
200 | padding-left: 17px;
201 | }
202 | }
203 |
204 |
205 | .loading {
206 | clear: both;
207 | color: $silver;
208 | padding: 20px;
209 | text-align: center;
210 | }
211 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Product Hunt Chrome Extension
2 |
3 | [](https://chrome.google.com/webstore/detail/product-hunt/likjafohlgffamccflcidmedfongmkee)
4 |
5 | > This is the official [Product Hunt](http://www.producthunt.com) extension.
6 |
7 | Product Hunt is a place for product-loving enthusiasts to share and geek out
8 | about the latest mobile apps, websites, hardware projects, and technology. With
9 | our shiny new Chrome extension, you can:
10 |
11 | - View a gallery of the top products of the day in every new tab
12 | - Search for products, collections, and people directly from the Chrome menu bar
13 | - Open the discussion and product details from the product page itself by clicking the PH Bar
14 | - Hunt products and curate collections in a few keystrokes
15 |
16 | Explore more ways to use Product Hunt at [http://producthunt.com/apps](http://producthunt.com/apps).
17 |
18 | ## Chrome App Store
19 |
20 | You can get this extension frome the [Google Chrome Webstore](https://chrome.google.com/webstore/detail/product-hunt/likjafohlgffamccflcidmedfongmkee)
21 |
22 | ## Development
23 |
24 | ### Development Dependencies
25 |
26 | | Name | Version | Installation |
27 | | --------------------|---------|------------------------------------------------------------------------------------|
28 | | Node.js | 0.10.x | [Instructions](http://nodejs.org/download/) |
29 | | Gulp | 3.8.x | [Instructions](https://github.com/gulpjs/gulp/blob/master/docs/getting-started.md) |
30 |
31 | ### Setup
32 |
33 | * Clone the repository:
34 |
35 | ```
36 | $ git clone git@github.com:producthunt/producthunt-chrome-extension.git
37 | $ cd producthunt-chrome-extension
38 | ```
39 |
40 | * Edit the config file:
41 |
42 | ```
43 | $ cp .env.example .env
44 | $ $EDITOR .env
45 | ```
46 |
47 | * Install yarn dependencies:
48 |
49 | ```
50 | $ yarn install
51 | ```
52 |
53 | * Create the initial build
54 |
55 | ```
56 | $ gulp build
57 | ```
58 |
59 | * Load the extension:
60 |
61 | 1. Open Google Chrome and type `chrome://extensions` inside the address bar
62 | 1. Click on `developer mode`
63 | 1. Click on `Load unpacked extension`
64 | 1. Select the `/build` folder
65 | 1. You are good to go
66 |
67 | ### Configurations (.env)
68 |
69 | See the [example .env file](.env.example).
70 |
71 | ### Gulp Tasks
72 |
73 | | Task | Description |
74 | | --------------------|-----------------------------------------------|
75 | | build | Compile, minify and copy the extension files |
76 | | build --watch | Rebuild on file change |
77 | | clean | Clean the build directory |
78 | | test | Run all tests |
79 | | test-acceptance | Run the acceptance tests |
80 | | test-unit | Run the unit tests |
81 | | pack | Create an archive for publishing |
82 |
83 | Example usage:
84 |
85 | ```
86 | $ gulp clean
87 | $ gulp build
88 | ```
89 |
90 | ### Tests
91 |
92 | * Install the selenium server and Chromedriver:
93 |
94 | ```
95 | $ node_modules/.bin/install_selenium
96 | $ node_modules/.bin/install_chromedriver
97 | ```
98 |
99 | * Start selenium with chromedriver:
100 |
101 | ```
102 | $ node_modules/.bin/start_selenium_with_chromedriver
103 | ```
104 |
105 | * Run the tests:
106 |
107 | ```
108 | $ NODE_ENV=test gulp test
109 | ```
110 |
111 | ### Debug
112 |
113 | To enable the debug output in the console:
114 |
115 | ```
116 | localStorage.debug = '*';
117 | ```
118 |
119 | ### Publishing the extension
120 |
121 | ```
122 | $ NODE_ENV=production EXT_ENV={production,staging} gulp build
123 | $ gulp pack
124 | $ open dist/
125 | ```
126 |
127 | Then upload the archive to the Chrome Web Store.
128 |
129 | ## Contributing
130 |
131 | See [CONTRIBUTING](CONTRIBUTING.md)
132 |
133 | ## Contributors
134 |
135 | See all [contributors](https://github.com/producthunt/producthunt-chrome-extension/graphs/contributors)
136 |
137 | ## Changes
138 |
139 | See [CHANGELOG](CHANGELOG.md)
140 |
141 | ## License
142 |
143 | See [LICENSE](LICENSE)
144 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Product Hunt Chrome Extension Contribution Guidelines
2 |
3 | We like to encourage you to contribute to the project. This should be as easy as possible for you but there are a few things to consider when contributing.
4 | The following guidelines for contribution should be followed if you want to submit a pull request or open an issue.
5 |
6 | Following these guidelines helps to communicate that you respect the time of the developers managing and developing this open source project.
7 | In return, they should reciprocate that respect in addressing your issue or assessing patches and features.
8 |
9 | #### Table of Contents
10 |
11 | - [TLDR;](#tldr)
12 | - [Contributing](#contributing)
13 | - [Bug Reports](#bugs)
14 | - [Feature Requests](#features)
15 | - [Pull Requests](#pull-requests)
16 |
17 |
18 | ## TLDR;
19 |
20 | - Creating an Issue or Pull Request requires a [GitHub](http://github.com) account.
21 | - Issue reports should be **clear**, **concise** and **reproducible**. Check to see if your issue has already been resolved in the [master]() branch or already reported in the [GitHub Issue Tracker](https://github.com/producthunt/producthunt-chrome-extension/issues).
22 | - Pull Requests must adhere to the existing coding style
23 | - **IMPORTANT**: By submitting a patch, you agree to allow the project owner to license your work under the same license as that used by the project.
24 |
25 |
26 | ## Contributing
27 |
28 | The issue tracker is the preferred channel for [bug reports](#bugs),
29 | [feature requests](#features) and [submitting pull
30 | requests](#pull-requests).
31 |
32 |
33 | ### Bug Reports
34 |
35 | A bug is a **demonstrable problem** that is caused by the code in the repository.
36 |
37 | Guidelines for bug reports:
38 |
39 | 1. **Use the GitHub issue search** — check if the issue has already been reported.
40 | 2. **Check if the issue has been fixed** — try to reproduce it using the latest `master` or development branch in the repository.
41 | 3. **Isolate the problem** — find a way to demonstrate your issue. Provide either screenshots or code samples to show you problem.
42 |
43 | A good bug report shouldn't leave others needing to chase you up for more information. Please try to be as detailed as possible in your report.
44 |
45 | - What is your environment?
46 | - What steps will reproduce the issue?
47 | - What browser(s) and/or Node.js versions experience the problem?
48 | - What would you expect to be the outcome?
49 |
50 | All these details will help people to fix any potential bugs.
51 |
52 | Example:
53 |
54 | > Short and descriptive example bug report title
55 | >
56 | > A summary of the issue and the browser/OS environment in which it occurs. If suitable, include the steps required to reproduce the bug.
57 | >
58 | > 1. This is the first step
59 | > 2. This is the second step
60 | > 3. Further steps, etc.
61 | > 4. Attach screenshots, etc.
62 | >
63 | > Any other information you want to share that is relevant to the issue being reported.
64 |
65 |
66 | ### Feature Requests
67 |
68 | Feature requests are welcome. But take a moment to find out whether your idea fits with the scope and aims of the project.
69 | It's up to *you* to make a strong case to convince the project's developers of the merits of this feature.
70 | Please provide as much detail and context as possible.
71 |
72 |
73 | ### Pull Requests
74 |
75 | - PRs for bug fixes are always welcome.
76 | - PRs for enhancing the interfaces are always welcome.
77 |
78 | Good pull requests - patches, improvements, new features - are a fantastic help.
79 | They should remain focused in scope and avoid containing unrelated commits.
80 |
81 | **Please ask first** before embarking on any significant pull request (e.g. implementing features, refactoring code),
82 | otherwise you risk spending a lot of time working on something that the project's developers might not want to merge into the project.
83 |
84 | Please adhere to the coding conventions used throughout a project (indentation, accurate comments, etc.) and any other requirements (such as test coverage).
85 |
86 | Follow this process if you'd like your work considered for inclusion in the project:
87 |
88 | * [Fork](http://help.github.com/fork-a-repo/) the project, clone your fork, and configure the remotes:
89 |
90 | ```bash
91 | # Clone your fork of the repo into the current directory
92 | git clone https://github.com//
93 | # Navigate to the newly cloned directory
94 | cd
95 | # Assign the original repo to a remote called "upstream"
96 | git remote add upstream https://github.com//
97 | ```
98 |
99 | * If you cloned a while ago, get the latest changes from upstream:
100 |
101 | ```bash
102 | git checkout
103 | git pull upstream
104 | ```
105 |
106 | * Create a new topic branch (off the main project development branch) to contain your feature, change, or fix:
107 |
108 | ```bash
109 | git checkout -b
110 | ```
111 |
112 | * Commit your changes in logical chunks. Use Git's [interactive rebase](https://help.github.com/articles/interactive-rebase) feature to tidy up your commits before making them public.
113 |
114 | * Locally merge (or rebase) the upstream development branch into your topic branch:
115 |
116 | ```bash
117 | git pull [--rebase] upstream
118 | ```
119 |
120 | * Push your topic branch up to your fork:
121 |
122 | ```bash
123 | git push origin
124 | ```
125 |
126 | * [Open a Pull Request](https://help.github.com/articles/using-pull-requests/) with a clear title and description.
127 |
--------------------------------------------------------------------------------
/Gulpfile.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | /**
4 | * Set the current environment.
5 | */
6 |
7 | process.env.NODE_ENV = process.env.NODE_ENV || 'development';
8 | process.env.EXT_ENV = process.env.EXT_ENV || 'development';
9 |
10 | /**
11 | * Dependencies.
12 | */
13 |
14 | var babelify = require('babelify');
15 | var browserify = require('browserify');
16 | var buffer = require('vinyl-buffer');
17 | var envc = require('envc')({ nodeenv: process.env.EXT_ENV || process.env.NODE_ENV });
18 | var envify = require('envify');
19 | var fs = require('fs');
20 | var gulp = require('gulp');
21 | var gulpif = require('gulp-if');
22 | var gutil = require('gulp-util')
23 | var gwatch = require('gulp-watch');
24 | var html = require('gulp-minify-html');
25 | var imagemin = require('gulp-imagemin');
26 | var jest = require('jest-cli');
27 | var json = require('gulp-jsonminify');
28 | var minifyCss = require('gulp-minify-css');
29 | var mocha = require('gulp-spawn-mocha');
30 | var neat = require('node-neat');
31 | var rimraf = require('rimraf');
32 | var sass = require('gulp-sass');
33 | var source = require('vinyl-source-stream');
34 | var sourcemaps = require('gulp-sourcemaps');
35 | var uglify = require('gulp-uglify');
36 | var watchify = require('watchify');
37 | var zip = require('gulp-zip');
38 |
39 | /**
40 | * Locals.
41 | */
42 |
43 | var requiredVars = fs.readFileSync('.env.assert', 'utf8').split('\n');
44 | var env = process.env;
45 | var EXT_ENV = env.EXT_ENV;
46 | var DEV = env.NODE_ENV === 'development' || env.NODE_ENV === 'test';
47 | var assertEnv = require('assert-env')(requiredVars.filter(function(key) {
48 | return !!key;
49 | }));
50 |
51 | /**
52 | * Arguments.
53 | */
54 |
55 | var argv = gutil.env;
56 |
57 | /**
58 | * File patterns.
59 | */
60 |
61 | var patterns = {
62 | html: 'src/**/*.html',
63 | img: 'src/**/*.{png,svg,ico}',
64 | css: 'src/**/*.{scss,css}',
65 | locales: 'src/_locales/**/*.json',
66 | vendor: 'vendor/**/**'
67 | };
68 |
69 | /**
70 | * Root dest.
71 | */
72 |
73 | var dest = 'build' || argv.build;
74 |
75 | /**
76 | * JavaScript bundles.
77 | */
78 |
79 | var bundles = [
80 | { entry: './src/apps/popup/main.js', out: 'apps/popup/main.js' },
81 | { entry: './src/apps/background/main.js', out: 'apps/background/main.js' },
82 | { entry: './src/apps/tabs/main.js', out: 'apps/tabs/main.js' },
83 | ];
84 |
85 | /**
86 | * Return a gulp-watch or noop, based on
87 | * the `--watch` flag.
88 | *
89 | * @return {Stream}
90 | * @private
91 | */
92 |
93 | function watch(pattern) {
94 | return argv.watch ? gwatch(pattern, { verbose: true }) : gutil.noop();
95 | }
96 |
97 | /**
98 | * Build the JavaScript bundles.
99 | */
100 |
101 | gulp.task('js', function() {
102 | return bundles.map(function(bundle) {
103 | var bundler = browserify({
104 | entries: [bundle.entry],
105 | debug: DEV,
106 | cache: {},
107 | packageCache: {},
108 | fullPaths: true
109 | }).transform(babelify, {presets: ['es2015', 'react']}).transform(envify);
110 |
111 | bundler.on('log', gutil.log)
112 |
113 | var update = function() {
114 | return bundler.bundle()
115 | .on('error', function(err) {
116 | gutil.log(err.message);
117 | })
118 | .pipe(source(bundle.out))
119 | .pipe(buffer())
120 | .pipe(gulpif(!DEV, uglify()))
121 | .pipe(gulp.dest(dest));
122 | };
123 |
124 | if (argv.watch) {
125 | bundler = watchify(bundler);
126 | bundler.on('update', update);
127 | }
128 |
129 | return update();
130 | });
131 | });
132 |
133 | /**
134 | * Minify the HTML files.
135 | */
136 |
137 | gulp.task('html', function() {
138 | return gulp.src(patterns.html)
139 | .pipe(watch(patterns.html))
140 | .pipe(html())
141 | .pipe(gulp.dest(dest));
142 | });
143 |
144 | /**
145 | * Copy vendor files.
146 | */
147 |
148 | gulp.task('vendor', function() {
149 | return gulp.src(patterns.vendor)
150 | .pipe(watch(patterns.vendor))
151 | .pipe(gulp.dest(dest + '/vendor'));
152 | });
153 |
154 | /**
155 | * Minify the locale files.
156 | */
157 |
158 | gulp.task('locales', function() {
159 | return gulp.src(patterns.locales)
160 | .pipe(watch(patterns.locales))
161 | .pipe(json())
162 | .pipe(gulp.dest(dest + '/_locales/'));
163 | });
164 |
165 | /**
166 | * Modify the manifest file.
167 | */
168 |
169 | gulp.task('manifest', function(done) {
170 | var manifest = require('./src/manifest.json');
171 |
172 | if (EXT_ENV !== 'production') {
173 | manifest.name = '[' + EXT_ENV + '] ProductHunt';
174 | }
175 |
176 | if (env.DISABLE_DEFAULT_TAB) {
177 | manifest.chrome_url_overrides = {};
178 | }
179 |
180 | fs.writeFile(dest + '/manifest.json', JSON.stringify(manifest), done);
181 | });
182 |
183 | /**
184 | * Compile the scss files.
185 | */
186 |
187 | gulp.task('scss', function() {
188 | var paths = neat.includePaths.concat(['./src']);
189 |
190 | return gulp.src(patterns.css)
191 | .pipe(watch(patterns.css))
192 | .pipe(gulpif(DEV, sourcemaps.init()))
193 | .pipe(sass({
194 | imagePath: 'chrome-extension://' + env.EXTENSION_ID,
195 | includePaths: paths,
196 | errLogToConsole: true
197 | }))
198 | .pipe(gulpif(DEV, sourcemaps.write()))
199 | .pipe(gulpif(!DEV, minifyCss()))
200 | .pipe(gulp.dest(dest));
201 | });
202 |
203 | /**
204 | * Optimize the images.
205 | */
206 |
207 | gulp.task('img', function() {
208 | return gulp.src(patterns.img)
209 | .pipe(watch(patterns.img))
210 | .pipe(imagemin())
211 | .pipe(gulp.dest(dest));
212 | });
213 |
214 | /**
215 | * Run the end to end tests.
216 | */
217 |
218 | gulp.task('test-acceptance', function() {
219 | return gulp.src(['test/*.js'], { read: false })
220 | .pipe(mocha({ r: 'test/setup.js', timeout: 10000 }));
221 | });
222 |
223 | /**
224 | * Run all unit tests.
225 | */
226 |
227 | gulp.task('test-unit', function(done) {
228 | var options = {
229 | config: {
230 | rootDir: __dirname,
231 | testPathDirs: [__dirname + '/src'],
232 | transform: {
233 | '.*': "/node_modules/babel-jest",
234 | },
235 | setupFiles: [__dirname + '/jest/env.js'],
236 | setupTestFrameworkScriptFile: __dirname + '/jest/setup.js'
237 | }
238 | };
239 |
240 | jest.runCLI(options, __dirname, function(success) {
241 | done();
242 | });
243 | });
244 |
245 | /**
246 | * Create an archive from `build`.
247 | */
248 |
249 | gulp.task('pack', function() {
250 | var version = require('./src/manifest.json').version;
251 |
252 | return gulp.src(dest + '/**/*')
253 | .pipe(zip(version + '-product-hunt.zip'))
254 | .pipe(gulp.dest('dist'));
255 | });
256 |
257 | /**
258 | * Clean the build folder.
259 | */
260 |
261 | gulp.task('clean', function() {
262 | [
263 | dest + '/_locales',
264 | dest + '/apps',
265 | dest + '/common',
266 | dest + '/vendor',
267 | dest + '/manifest.json'
268 | ].forEach(function(dir) {
269 | rimraf.sync(dir)
270 | });
271 | });
272 |
273 | /**
274 | * Tests.
275 | */
276 |
277 | gulp.task('test', ['test-acceptance', 'test-unit']);
278 |
279 | /**
280 | * Build all.
281 | */
282 |
283 | gulp.task('build', [
284 | 'clean',
285 | 'js',
286 | 'html',
287 | 'locales',
288 | 'manifest',
289 | 'scss',
290 | 'img',
291 | 'vendor'
292 | ]);
293 |
--------------------------------------------------------------------------------