├── extension.zip
├── public
├── favicon.ico
├── manifest.json
└── index.html
├── src
├── App.test.js
├── index.js
├── App.css
├── index.css
├── utils
│ └── index.js
├── logo.svg
├── registerServiceWorker.js
└── App.js
├── .gitignore
├── extension.json
├── package.json
└── README.md
/extension.zip:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cosmicjs/hacker-news-top-stories/master/extension.zip
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cosmicjs/hacker-news-top-stories/master/public/favicon.ico
--------------------------------------------------------------------------------
/src/App.test.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import App from './App';
4 |
5 | it('renders without crashing', () => {
6 | const div = document.createElement('div');
7 | ReactDOM.render(, div);
8 | });
9 |
--------------------------------------------------------------------------------
/src/index.js:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './index.css';
4 | import App from './App';
5 | import registerServiceWorker from './registerServiceWorker';
6 |
7 | ReactDOM.render(, document.getElementById('root'));
8 | registerServiceWorker();
9 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | }
10 | ],
11 | "start_url": "./index.html",
12 | "display": "standalone",
13 | "theme_color": "#000000",
14 | "background_color": "#ffffff"
15 | }
16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/ignore-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 |
6 | # testing
7 | /coverage
8 |
9 | # production
10 | /build
11 | build.zip
12 |
13 | # misc
14 | .DS_Store
15 | .env.local
16 | .env.development.local
17 | .env.test.local
18 | .env.production.local
19 |
20 | npm-debug.log*
21 | yarn-debug.log*
22 | yarn-error.log*
23 |
--------------------------------------------------------------------------------
/extension.json:
--------------------------------------------------------------------------------
1 | {
2 | "title": "HN Top Stories",
3 | "font_awesome_class": "fa-hacker-news",
4 | "image_url": "https://cosmicjs.com/uploads/c21b2000-6694-11e7-9685-7984c518abf8-hn.svg",
5 | "repo_url": "https://github.com/cosmicjs/hacker-news-top-stories",
6 | "object_types": [
7 | {
8 | "title": "Stories",
9 | "singular": "Story",
10 | "slug": "stories"
11 | }
12 | ]
13 | }
--------------------------------------------------------------------------------
/src/App.css:
--------------------------------------------------------------------------------
1 | .App {
2 | padding-bottom: 60px;
3 | }
4 | .App-header {
5 | text-align: center;
6 | }
7 | .hn-item {
8 | padding: 5px 0 10px;
9 | content: "";
10 | display: table;
11 | clear: both;
12 | }
13 | .hn-item__save {
14 | margin-right: 20px;
15 | }
16 | .hn-item__save-button,
17 | .hn-item__info {
18 | float: left;
19 | }
20 | .hn-item__save-button {
21 | padding-top: 4px;
22 | }
23 | .hn-item__title {
24 | font-size: 16px;
25 | line-height: 24px;
26 | }
27 | .hn-item__title a {
28 | color: #333;
29 | }
30 | .hn-item__meta {
31 | font-size: 13px;
32 | color: #666;
33 | }
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "hacker-news-story-saver",
3 | "version": "0.1.0",
4 | "private": true,
5 | "dependencies": {
6 | "axios": "^0.16.2",
7 | "cosmicjs": "^2.39.91",
8 | "javascript-time-ago": "^0.4.9",
9 | "lodash": "^4.17.4",
10 | "react": "^15.6.1",
11 | "react-dom": "^15.6.1",
12 | "semantic-ui-react": "^0.71.0"
13 | },
14 | "devDependencies": {
15 | "react-scripts": "1.0.10"
16 | },
17 | "scripts": {
18 | "start": "react-scripts start",
19 | "build": "react-scripts build",
20 | "test": "react-scripts test --env=jsdom",
21 | "eject": "react-scripts eject",
22 | "export": "npm run build && cp extension.json build/extension.json && zip -r extension.zip build"
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | padding: 0;
4 | font-family: sans-serif;
5 | background: #f6f6ef;
6 | }
7 | @-webkit-keyframes rotating /* Safari and Chrome */ {
8 | from {
9 | -webkit-transform: rotate(0deg);
10 | -o-transform: rotate(0deg);
11 | transform: rotate(0deg);
12 | }
13 | to {
14 | -webkit-transform: rotate(360deg);
15 | -o-transform: rotate(360deg);
16 | transform: rotate(360deg);
17 | }
18 | }
19 | @keyframes rotating {
20 | from {
21 | -ms-transform: rotate(0deg);
22 | -moz-transform: rotate(0deg);
23 | -webkit-transform: rotate(0deg);
24 | -o-transform: rotate(0deg);
25 | transform: rotate(0deg);
26 | }
27 | to {
28 | -ms-transform: rotate(360deg);
29 | -moz-transform: rotate(360deg);
30 | -webkit-transform: rotate(360deg);
31 | -o-transform: rotate(360deg);
32 | transform: rotate(360deg);
33 | }
34 | }
35 | .rotating {
36 | -webkit-animation: rotating 1.5s linear infinite;
37 | -moz-animation: rotating 1.5s linear infinite;
38 | -ms-animation: rotating 1.5s linear infinite;
39 | -o-animation: rotating 1.5s linear infinite;
40 | animation: rotating 1.5s linear infinite;
41 | }
--------------------------------------------------------------------------------
/src/utils/index.js:
--------------------------------------------------------------------------------
1 | const getTimeAgo = time => {
2 | var units = [
3 | { name: "second", limit: 60, in_seconds: 1 },
4 | { name: "minute", limit: 3600, in_seconds: 60 },
5 | { name: "hour", limit: 86400, in_seconds: 3600 },
6 | { name: "day", limit: 604800, in_seconds: 86400 },
7 | { name: "week", limit: 2629743, in_seconds: 604800 },
8 | { name: "month", limit: 31556926, in_seconds: 2629743 },
9 | { name: "year", limit: null, in_seconds: 31556926 }
10 | ];
11 | var diff = (new Date() - new Date(time*1000)) / 1000;
12 | if (diff < 5) return "now";
13 |
14 | var i = 0, unit;
15 | while (unit = units[i++]) {
16 | if (diff < unit.limit || !unit.limit){
17 | diff = Math.floor(diff / unit.in_seconds);
18 | return diff + " " + unit.name + (diff>1 ? "s" : "");
19 | }
20 | };
21 | }
22 |
23 | const getParameterByName = (name, url) => {
24 | if (!url) url = window.location.href;
25 | name = name.replace(/[[\]]/g, "\\$&");
26 | var regex = new RegExp("[?&]" + name + "(=([^]*)|&|#|$)"),
27 | results = regex.exec(url);
28 | if (!results) return null;
29 | if (!results[2]) return '';
30 | return decodeURIComponent(results[2].replace(/\+/g, " "));
31 | }
32 |
33 | export { getTimeAgo, getParameterByName }
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Hacker News Top Stories
2 | 
3 | ### What is it?
4 | A [Cosmic JS](https://cosmicjs.com) Extension that allows you to pull in the top news from [Hacker News](https://news.ycombinator.com/) and save your favorite stories to your Cosmic JS Bucket. Easily access your favorite stories via your own personal endpoint for use in any internet connected application. Oh and all story links open in a new browser window (which is nice). Forks welcome!
5 |
6 | 
7 |
8 | ### It uses:
9 | 1. [The Algolia Hacker News API](https://hn.algolia.com/api)
10 | 2. [Create React App](https://github.com/facebookincubator/create-react-app)
11 | 3. [Semantic UI React](http://react.semantic-ui.com/)
12 | 4. [Cosmic JS Extensions](https://cosmicjs.com/extensions)
13 | 5. [Cosmic JS NPM Module](https://www.npmjs.com/package/cosmicjs) to save stories to your Bucket
14 |
15 | ### Why?
16 | It demonstrates how to connect your Cosmic JS Extension to third-party APIs to easily add content to your Cosmic JS Bucket.
17 |
18 | ## Getting Started
19 | ### Quick install
20 | 1. [Log in to Cosmic JS](https://cosmicjs.com) and choose a new or existing Bucket to save your Hacker News stories.
21 | 2. Go to Your Bucket > Extensions.
22 | 3. Find the HN Story Saver Extension and click "Install".
23 |
24 | 
25 | ### Run locally
26 | ```
27 | git clone https://github.com/cosmicjs/hacker-news-top-stories
28 | cd hacker-news-top-stories
29 | yarn
30 | npm start
31 | ```
32 | Go to http://localhost:3000?bucket_slug=your-cosmic-js-bucket-slug
33 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
11 |
12 |
13 |
22 |
23 | Hacker News Top Stories
24 |
25 |
26 |
29 |
30 |
40 |
41 |
42 |
--------------------------------------------------------------------------------
/src/logo.svg:
--------------------------------------------------------------------------------
1 |
8 |
--------------------------------------------------------------------------------
/src/registerServiceWorker.js:
--------------------------------------------------------------------------------
1 | // In production, we register a service worker to serve assets from local cache.
2 |
3 | // This lets the app load faster on subsequent visits in production, and gives
4 | // it offline capabilities. However, it also means that developers (and users)
5 | // will only see deployed updates on the "N+1" visit to a page, since previously
6 | // cached resources are updated in the background.
7 |
8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy.
9 | // This link also includes instructions on opting out of this behavior.
10 |
11 | const isLocalhost = Boolean(
12 | window.location.hostname === 'localhost' ||
13 | // [::1] is the IPv6 localhost address.
14 | window.location.hostname === '[::1]' ||
15 | // 127.0.0.1/8 is considered localhost for IPv4.
16 | window.location.hostname.match(
17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
18 | )
19 | );
20 |
21 | export default function register() {
22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
23 | // The URL constructor is available in all browsers that support SW.
24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location);
25 | if (publicUrl.origin !== window.location.origin) {
26 | // Our service worker won't work if PUBLIC_URL is on a different origin
27 | // from what our page is served on. This might happen if a CDN is used to
28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374
29 | return;
30 | }
31 |
32 | window.addEventListener('load', () => {
33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
34 |
35 | if (!isLocalhost) {
36 | // Is not local host. Just register service worker
37 | registerValidSW(swUrl);
38 | } else {
39 | // This is running on localhost. Lets check if a service worker still exists or not.
40 | checkValidServiceWorker(swUrl);
41 | }
42 | });
43 | }
44 | }
45 |
46 | function registerValidSW(swUrl) {
47 | navigator.serviceWorker
48 | .register(swUrl)
49 | .then(registration => {
50 | registration.onupdatefound = () => {
51 | const installingWorker = registration.installing;
52 | installingWorker.onstatechange = () => {
53 | if (installingWorker.state === 'installed') {
54 | if (navigator.serviceWorker.controller) {
55 | // At this point, the old content will have been purged and
56 | // the fresh content will have been added to the cache.
57 | // It's the perfect time to display a "New content is
58 | // available; please refresh." message in your web app.
59 | console.log('New content is available; please refresh.');
60 | } else {
61 | // At this point, everything has been precached.
62 | // It's the perfect time to display a
63 | // "Content is cached for offline use." message.
64 | console.log('Content is cached for offline use.');
65 | }
66 | }
67 | };
68 | };
69 | })
70 | .catch(error => {
71 | console.error('Error during service worker registration:', error);
72 | });
73 | }
74 |
75 | function checkValidServiceWorker(swUrl) {
76 | // Check if the service worker can be found. If it can't reload the page.
77 | fetch(swUrl)
78 | .then(response => {
79 | // Ensure service worker exists, and that we really are getting a JS file.
80 | if (
81 | response.status === 404 ||
82 | response.headers.get('content-type').indexOf('javascript') === -1
83 | ) {
84 | // No service worker found. Probably a different app. Reload the page.
85 | navigator.serviceWorker.ready.then(registration => {
86 | registration.unregister().then(() => {
87 | window.location.reload();
88 | });
89 | });
90 | } else {
91 | // Service worker found. Proceed as normal.
92 | registerValidSW(swUrl);
93 | }
94 | })
95 | .catch(() => {
96 | console.log(
97 | 'No internet connection found. App is running in offline mode.'
98 | );
99 | });
100 | }
101 |
102 | export function unregister() {
103 | if ('serviceWorker' in navigator) {
104 | navigator.serviceWorker.ready.then(registration => {
105 | registration.unregister();
106 | });
107 | }
108 | }
109 |
--------------------------------------------------------------------------------
/src/App.js:
--------------------------------------------------------------------------------
1 | import React, { Component } from 'react';
2 | import axios from 'axios';
3 | import './App.css';
4 | import { Container, Icon, Button, Loader, Menu, Message } from 'semantic-ui-react'
5 | import { getTimeAgo, getParameterByName } from './utils'
6 | import Cosmic from 'cosmicjs'
7 | import _ from 'lodash'
8 | const config = {
9 | bucket: {
10 | slug: getParameterByName('bucket_slug'),
11 | read_key: getParameterByName('read_key'),
12 | write_key: getParameterByName('write_key')
13 | }
14 | }
15 | class App extends Component {
16 | constructor(props) {
17 | super();
18 | this.getStories(this);
19 | this.getSavedStories(this);
20 | this.state = {
21 | saved_stories: [],
22 | stories: [],
23 | loading: true
24 | }
25 | }
26 | getStories() {
27 | axios.get('https://hn.algolia.com/api/v1/search?tags=front_page')
28 | .then(response => {
29 | delete this.state.loading
30 | this.setState({
31 | top_stories: response.data.hits,
32 | active_menu_item: 'top_stories',
33 | stories: response.data.hits
34 | })
35 | })
36 | .catch(error => {
37 | console.log(error)
38 | })
39 | }
40 | getSavedStories() {
41 | const params = {
42 | type_slug: 'stories'
43 | }
44 | Cosmic.getObjectType(config, params, (err, res) => {
45 | if (err) {
46 | this.setState({
47 | bucket_error: true
48 | })
49 | }
50 | if (!res.objects)
51 | return
52 | const saved_stories = res.objects.all
53 | this.setState({
54 | ...this.state,
55 | saved_stories: saved_stories
56 | })
57 | })
58 | }
59 | handleSaveClick(story) {
60 | this.setState({
61 | ...this.state,
62 | saving: story
63 | })
64 | const params = {
65 | title: story.title,
66 | type_slug: 'stories'
67 | }
68 | params.metafields = []
69 | // Add Metafields
70 | Object.keys(story).forEach(function(key) {
71 | if (key !== '_highlightResult') {
72 | const metafield = {
73 | title: key,
74 | key,
75 | value: story[key],
76 | type: 'text'
77 | }
78 | params.metafields.push(metafield)
79 | }
80 | })
81 | params.write_key = config.bucket.write_key;
82 | Cosmic.addObject(config, params, (err, res) => {
83 | let saved_stories = this.state.saved_stories
84 | if (!saved_stories)
85 | saved_stories = []
86 | saved_stories.push(res.object)
87 | this.setState({
88 | ...this.state,
89 | saved_stories
90 | })
91 | })
92 | }
93 | handleDeleteClick(story) {
94 | this.setState({
95 | ...this.state,
96 | saving: story
97 | })
98 | let saved_story
99 | this.state.saved_stories.forEach(story_loop => {
100 | if (story_loop.metadata.objectID === story.objectID)
101 | saved_story = story_loop
102 | })
103 | const params = {
104 | slug: saved_story.slug,
105 | write_key: config.bucket.write_key
106 | }
107 | Cosmic.deleteObject(config, params, (err, res) => {
108 | let saved_stories = this.state.saved_stories
109 | saved_stories = saved_stories.filter(loop_story => {
110 | return loop_story.metadata.objectID !== story.objectID
111 | })
112 | delete this.state.saving
113 | this.setState({
114 | ...this.state,
115 | saved_stories
116 | })
117 | })
118 | }
119 | handleMenuItemClick(key) {
120 | let saved_stories = this.state.saved_stories
121 | const top_stories = this.state.top_stories
122 | if (key === 'saved_stories') {
123 | // Rekey from saved Objects
124 | if (saved_stories) {
125 | saved_stories.map(saved_story => {
126 | return Object.keys(saved_story.metadata).forEach(function(key) {
127 | saved_story[key] = saved_story.metadata[key]
128 | })
129 | })
130 | }
131 | if (!saved_stories)
132 | saved_stories = null
133 | this.setState({
134 | ...this.state,
135 | active_menu_item: 'saved_stories',
136 | stories: saved_stories
137 | })
138 | }
139 | if (key === 'top_stories') {
140 | this.setState({
141 | ...this.state,
142 | active_menu_item: 'top_stories',
143 | stories: top_stories
144 | })
145 | }
146 | }
147 | handleSavedOver(story) {
148 | this.setState({
149 | ...this.state,
150 | hovered_story: story
151 | })
152 | }
153 | handleSavedOut(story) {
154 | delete this.state.hovered_story
155 | this.setState({
156 | ...this.state
157 | })
158 | }
159 | render() {
160 | const saved_story_ids = _.map(this.state.saved_stories, 'metadata.objectID')
161 | const activeItem = this.state.active_menu_item
162 | return (
163 |
164 |
165 |
166 | Hacker News Top Stories
167 |
168 |
169 |
174 | {
175 | this.state.bucket_error &&
176 |
177 | There was an error accessing your Cosmic JS Bucket. Make sure the query parameter bucket_slug in the URL is correct.
178 |
179 | }
180 | {
181 | this.state.loading &&
182 |
183 |
184 |
185 | }
186 | {
187 | this.state.stories &&
188 | this.state.stories.map(story => {
189 | return (
190 |
191 |
192 |
193 | {
194 | saved_story_ids.indexOf(story.objectID) === -1 &&
195 |
198 | }
199 | {
200 | saved_story_ids.indexOf(story.objectID) !== -1 &&
201 |
204 | }
205 |
206 |
207 |
217 |
218 |
219 | )
220 | })
221 | }
222 | {
223 | !this.state.stories &&
224 | You don't have any saved stories yet.
225 | }
226 | {
227 | this.state.stories && !this.state.stories.length && !this.state.loading &&
228 | You don't have any saved stories yet.
229 | }
230 |
231 |
232 | );
233 | }
234 | }
235 |
236 | export default App;
--------------------------------------------------------------------------------