├── .github
└── workflows
│ └── build-hourly-deploy-to-netlify.yml
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── architecture.jpg
├── gatsby-browser.js
├── gatsby-config.js
├── gatsby-node.js
├── gatsby-ssr.js
├── package.json
├── src
├── components
│ ├── BuildInfo.js
│ ├── GithubCorner.js
│ ├── Navigation.js
│ ├── Stories.js
│ ├── Time.js
│ ├── header.js
│ └── layout.js
├── favicon.png
├── pages
│ ├── 404.js
│ ├── best.js
│ ├── index.js
│ └── new.js
└── util
│ └── Format.js
└── yarn.lock
/.github/workflows/build-hourly-deploy-to-netlify.yml:
--------------------------------------------------------------------------------
1 | name: Build and Deploy Gatsby to Netlify every 3 hours
2 |
3 | # https://help.github.com/en/actions/automating-your-workflow-with-github-actions/configuring-a-workflow#triggering-a-workflow-with-events
4 | on:
5 | schedule:
6 | - cron: '0 */3 * * *'
7 | # on:
8 | # push:
9 | # branches:
10 | # - ci-npm-cache
11 |
12 | jobs:
13 | build:
14 | runs-on: ubuntu-latest
15 |
16 | steps:
17 | - uses: actions/checkout@v2
18 |
19 | # https://stackoverflow.com/a/62244232/4035
20 | - name: Get yarn cache directory path
21 | id: yarn-cache-dir-path
22 | run: echo "::set-output name=dir::$(yarn cache dir)"
23 |
24 | - name: Cache yarn cache
25 | uses: actions/cache@v2
26 | id: cache-yarn-cache
27 | with:
28 | path: ${{ steps.yarn-cache-dir-path.outputs.dir }}
29 | key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
30 | restore-keys: |
31 | ${{ runner.os }}-yarn-
32 |
33 | - name: Cache node_modules
34 | id: cache-node-modules
35 | uses: actions/cache@v2
36 | with:
37 | path: node_modules
38 | key: ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-${{ hashFiles('**/yarn.lock') }}
39 | restore-keys: |
40 | ${{ runner.os }}-${{ matrix.node-version }}-nodemodules-
41 |
42 | - run: yarn
43 | if: |
44 | steps.cache-yarn-cache.outputs.cache-hit != 'true' ||
45 | steps.cache-node-modules.outputs.cache-hit != 'true'
46 |
47 | - name: Build the site
48 | run: yarn build
49 |
50 | # Deploy the gatsby build to Netlify
51 | #- run: npx netlify-cli deploy --dir=public --prod
52 | # https://github.com/netlify/actions/blob/master/cli/README.md
53 | - uses: netlify/actions/cli@master
54 | env:
55 | NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
56 | NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
57 | with:
58 | # https://help.github.com/en/actions/automating-your-workflow-with-github-actions/workflow-syntax-for-github-actions#jobsjob_idstepswithargs
59 | args: deploy --dir=public --prod
60 | secrets: '["NETLIFY_AUTH_TOKEN", "NETLIFY_SITE_ID"]'
61 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Project dependencies
2 | .cache
3 | node_modules
4 | yarn-error.log
5 |
6 | # Build directory
7 | /public
8 | .DS_Store
9 |
10 | # Local Netlify folder
11 | .netlify
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "semi": false,
3 | "singleQuote": true,
4 | "trailingComma": "es5"
5 | }
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 gatsbyjs
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## 🚀 SHaNc - Static Hacker News clone
2 |
3 | ### ❓ Why?
4 |
5 | Because I want to skim through stories and be done with it.
6 | I don't need up-to-date stories.
7 |
8 | ### ⏲ How often does it refresh?
9 |
10 | Every hour on the hour.
11 |
12 | ### ⚠️Note
13 |
14 | Currently it only shows Top stories.
15 | I will add "new" and "best" stories later on.
16 |
17 | ### 🔨 Technologies (and stuff...)
18 |
19 | * Static Site Generator: [Gatsby](https://www.gatsbyjs.org/)
20 | * [Styled Components](https://www.styled-components.com/)
21 | * Custom Hacker News GraphQL [source](https://github.com/dance2die/SHANc/blob/master/gatsby-node.js)
22 | * Simply calls Official [HN API](https://github.com/HackerNews/API).
23 | * Server: [Netlify](https://www.netlify.com/)
24 | * Build Trigger: [Azure Functions](https://docs.microsoft.com/en-us/azure/azure-functions/) (PowerShell)
25 | * Netlify exposes Build WebHook. Azure Functions written in PowerShell simply calls it with `Invoke-WebRequest` every hour
26 | * [GitHub Corners](https://github.com/tholman/github-corners) by [Tim Holman](http://tholman.com/)
27 |
28 | ### 📐 Architecture
29 |
30 | The awesome hand-drawn architecture
31 | 
32 |
--------------------------------------------------------------------------------
/architecture.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dance2die/SHANc/ae91438417e95cacb85385e46a0d42d1dfcd77e5/architecture.jpg
--------------------------------------------------------------------------------
/gatsby-browser.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Implement Gatsby's Browser APIs in this file.
3 | *
4 | * See: https://www.gatsbyjs.org/docs/browser-apis/
5 | */
6 |
7 | // You can delete this file if you're not using it
--------------------------------------------------------------------------------
/gatsby-config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | siteMetadata: {
3 | title: 'SHaNc',
4 | description: 'Static Hacker News clone',
5 | },
6 | plugins: [
7 | 'gatsby-plugin-react-helmet',
8 | 'gatsby-plugin-styled-components',
9 | {
10 | resolve: 'gatsby-plugin-google-analytics',
11 | options: {
12 | trackingId: 'UA-119809136-1',
13 | // Puts tracking script in the head instead of the body
14 | head: false,
15 | anonymize: true,
16 | respectDNT: true,
17 | // Avoids sending pageview hits from custom paths
18 | exclude: ['/preview/**', '/do-not-track/me/too/'],
19 | },
20 | },
21 | {
22 | resolve: `gatsby-plugin-favicon`,
23 | options: {
24 | logo: './src/favicon.png',
25 | injectHTML: true,
26 | icons: {
27 | android: true,
28 | appleIcon: true,
29 | appleStartup: true,
30 | coast: false,
31 | favicons: true,
32 | firefox: true,
33 | twitter: false,
34 | yandex: false,
35 | windows: false,
36 | },
37 | },
38 | },
39 | ],
40 | }
41 |
--------------------------------------------------------------------------------
/gatsby-node.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Implement Gatsby's Node APIs in this file.
3 | *
4 | * See: https://www.gatsbyjs.org/docs/node-apis/
5 | */
6 | const axios = require('axios')
7 | const crypto = require('crypto')
8 |
9 | const buildContentDigest = content =>
10 | crypto
11 | .createHash(`md5`)
12 | .update(JSON.stringify(content))
13 | .digest(`hex`)
14 |
15 | const createStoriesSource = async ({ createNode }) => {
16 | const topStoriesURL = `https://hacker-news.firebaseio.com/v0/topstories.json`
17 | const newStoriesURL = `https://hacker-news.firebaseio.com/v0/newstories.json`
18 | const bestStoriesURL = `https://hacker-news.firebaseio.com/v0/beststories.json`
19 | const getItemURL = storyId =>
20 | `https://hacker-news.firebaseio.com/v0/item/${storyId}.json`
21 |
22 | const topResults = await axios.get(topStoriesURL)
23 | const newResults = await axios.get(newStoriesURL)
24 | const bestResults = await axios.get(bestStoriesURL)
25 |
26 | // Combine all story IDs to get all items in one go for "items" map
27 | // We need only distinct SET of IDs.
28 | const storyIds = [
29 | ...new Set([...topResults.data, ...newResults.data, ...bestResults.data]),
30 | ]
31 |
32 | const getStories = async storyIds => {
33 | const stories = storyIds.map(storyId => axios.get(getItemURL(storyId)))
34 | return Promise.all(stories)
35 | }
36 |
37 | // Build item details map
38 | // for an O(1) look up for fetched item details
39 | const items = (await getStories(storyIds))
40 | .map(res => res.data)
41 | .filter(item => item !== null)
42 | .reduce((acc, item) => acc.set(item.id, item), new Map())
43 |
44 | // Expose a hacker new story available for GraphQL query
45 | const createStoryNodes = (data, type) =>
46 | data.map(storyId => {
47 | const id = `${type}-${storyId}`
48 | const storyNode = {
49 | id,
50 | parent: null,
51 | internal: { type },
52 | children: [],
53 | storyId: storyId,
54 | item: items.get(storyId),
55 | }
56 |
57 | storyNode.internal.contentDigest = buildContentDigest(storyNode)
58 |
59 | createNode(storyNode)
60 | })
61 |
62 | createStoryNodes(topResults.data, 'TopStories')
63 | createStoryNodes(newResults.data, 'NewStories')
64 | createStoryNodes(bestResults.data, 'BestStories')
65 | }
66 |
67 | const createBuildMetadataSource = ({ createNode }) => {
68 | const buildMetadataNode = {
69 | // There is only one record
70 | id: `I am the build metadata source id`,
71 | parent: null,
72 | internal: { type: `BuildMetadata` },
73 | children: [],
74 | // Unix time format to be consistent with HackerNews API date format
75 | buildDate: new Date().getTime() / 1000,
76 | }
77 |
78 | buildMetadataNode.internal.contentDigest = buildContentDigest(
79 | buildMetadataNode
80 | )
81 | createNode(buildMetadataNode)
82 | }
83 |
84 | exports.sourceNodes = async ({ actions }) => {
85 | await createBuildMetadataSource(actions)
86 | await createStoriesSource(actions)
87 | }
88 |
--------------------------------------------------------------------------------
/gatsby-ssr.js:
--------------------------------------------------------------------------------
1 | /**
2 | * Implement Gatsby's SSR (Server Side Rendering) APIs in this file.
3 | *
4 | * See: https://www.gatsbyjs.org/docs/ssr-apis/
5 | */
6 |
7 | // You can delete this file if you're not using it
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "shanc",
3 | "description": "SHaNc - Static Hacker News clone",
4 | "version": "2.0.0",
5 | "author": "Sung Kim",
6 | "dependencies": {
7 | "axios": "^0.21.1",
8 | "babel-eslint": "^10.1.0",
9 | "eslint": "^7.18.0",
10 | "eslint-plugin-react": "^7.22.0",
11 | "gatsby": "^2.31.1",
12 | "gatsby-cli": "^2.18.0",
13 | "gatsby-link": "^2.10.0",
14 | "gatsby-plugin-favicon": "^3.1.6",
15 | "gatsby-plugin-google-analytics": "^2.10.0",
16 | "gatsby-plugin-react-helmet": "^3.9.0",
17 | "gatsby-plugin-styled-components": "^3.9.0",
18 | "moment": "2.29.1",
19 | "react": "^17.0.1",
20 | "react-dom": "^17.0.1",
21 | "react-helmet": "^6.1.0",
22 | "react-router-dom": "^5.2.0",
23 | "smooth-ui": "^4.3.2",
24 | "styled-components": "^5.2.1",
25 | "url": "^0.11.0"
26 | },
27 | "keywords": [
28 | "gatsby, hackernews, clone"
29 | ],
30 | "license": "MIT",
31 | "scripts": {
32 | "build": "gatsby build",
33 | "develop": "gatsby develop",
34 | "start": "npm run develop",
35 | "format": "prettier --write 'src/**/*.js'",
36 | "lint": "eslint ."
37 | },
38 | "devDependencies": {
39 | "prettier": "^2.2.1"
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/components/BuildInfo.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import styled from 'styled-components'
3 | import PropTypes from 'prop-types'
4 |
5 | const Container = styled.div`
6 | background: #ffc633;
7 | margin: 0 auto;
8 | width: 100%;
9 | padding: 1.45rem 1.0875rem;
10 | `
11 |
12 | const BuildInfo = ({ metadata }) => {
13 | const { buildDate } = metadata
14 | const builtOn = new Date(buildDate * 1000)
15 | return
20 |
30 | )
31 |
32 | export default Navigation
33 |
--------------------------------------------------------------------------------
/src/components/Stories.js:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { OutboundLink } from 'gatsby-plugin-google-analytics'
3 | import styled from 'styled-components'
4 | import parser from 'url'
5 |
6 | import Time from '../components/Time'
7 |
8 | const Story = styled.div`
9 | display: flex;
10 | align-items: center;
11 | justify-content: flex-start;
12 | padding: 4px 0;
13 | line-height: 18px;
14 | /* Give each story more room */
15 | margin-bottom: 0.5rem;
16 |
17 | &:hover {
18 | background-color: #ffc6001a;
19 | }
20 | `
21 | const Rank = styled.span`
22 | color: #ccc;
23 | font-size: 1.2rem;
24 | width: 35px;
25 | margin-right: 10px;
26 | display: flex;
27 | justify-content: flex-start;
28 | margin-right: 2rem;
29 | `
30 |
31 | const Content = styled.div``
32 | const Body = styled.div``
33 |
34 | const Meta = styled.div`
35 | font-size: 0.7rem;
36 | color: #828282;
37 | `
38 |
39 | // Use gatsby-plugin-google-analytics plugin to track outbound clicks
40 | // https://github.com/gatsbyjs/gatsby/tree/master/packages/gatsby-plugin-google-analytics#outboundlink-component
41 | const BaseLink = styled(OutboundLink).attrs({
42 | target: '_blank',
43 | })`
44 | &:link {
45 | text-decoration: none;
46 | }
47 | &:hover {
48 | text-decoration: underline;
49 | }
50 | &:visited {
51 | color: #ddd;
52 | }
53 | `
54 | const TitleLink = styled(BaseLink)`
55 | color: #464134;
56 | cursor: pointer;
57 | `
58 |
59 | const HostLink = styled(BaseLink)`
60 | font-size: 0.7rem;
61 | color: #828282;
62 | margin-left: 5px;
63 | `
64 |
65 | class Stories extends React.Component {
66 | static SHOW_ALL_DATES = 0
67 | static SECONDS_IN_MILLISECONDS = 1000
68 | static NOW = new Date()
69 |
70 | state = {
71 | stories: this.props.stories,
72 | showDaysUpto: Stories.SHOW_ALL_DATES,
73 | }
74 |
75 | // For filtering null node items while building stories
76 | nullNodeItems = ({ node }) => node.item !== null
77 |
78 | // For filtering by dates while building stories
79 | byDates = ({ node }, index) => {
80 | const { showDaysUpto } = this.state
81 |
82 | if (showDaysUpto === 0) return true
83 | else {
84 | const postDate = new Date(
85 | node.item.time * Stories.SECONDS_IN_MILLISECONDS
86 | )
87 | const hoursDifference = Math.abs(Stories.NOW - postDate) / 36e5
88 |
89 | return hoursDifference <= showDaysUpto * 24
90 | }
91 | }
92 |
93 | buildStoriesComponents = () => {
94 | const { stories } = this.state
95 |
96 | return stories
97 | .filter(this.nullNodeItems)
98 | .filter(this.byDates)
99 | .map(({ node }, index) => {
100 | const { title, score, by, time, url } = node.item
101 |
102 | const commentLink = `//news.ycombinator.com/item?id=${node.storyId}`
103 | const host = parser.parse(url || '').host
104 | // Some stories (Jobs, ASK, etc) don't have URLs then use comment URL
105 | const titleUrl = url || commentLink
106 |
107 | const date = new Date(time * Stories.SECONDS_IN_MILLISECONDS)
108 | const rank = (index + 1).toString().padStart(3, '0')
109 |
110 | return (
111 |
You just hit a route that doesn't exist... the sadness.
7 |