├── .gitignore ├── LICENSE ├── README.md ├── bundle.js ├── index.html ├── package.json ├── server.js ├── src ├── README.md ├── components │ ├── README.md │ ├── appNav │ │ ├── index.js │ │ ├── style.css │ │ └── view.html │ ├── appRouter │ │ └── index.js │ ├── commentItem │ │ ├── index.js │ │ ├── style.css │ │ └── view.html │ ├── dynamicHTML │ │ └── index.js │ ├── hackerNews │ │ ├── index.js │ │ ├── style.css │ │ └── view.html │ ├── index.js │ ├── storyItem │ │ ├── index.js │ │ ├── style.css │ │ └── view.html │ ├── storyList │ │ ├── index.js │ │ ├── style.css │ │ └── view.html │ ├── storyPage │ │ ├── index.js │ │ ├── style.css │ │ └── view.html │ └── userPage │ │ ├── index.js │ │ ├── style.css │ │ └── view.html ├── index.js └── store │ ├── README.md │ └── index.js └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | 6 | # Runtime data 7 | pids 8 | *.pid 9 | *.seed 10 | 11 | # Directory for instrumented libs generated by jscoverage/JSCover 12 | lib-cov 13 | 14 | # Coverage directory used by tools like istanbul 15 | coverage 16 | 17 | # nyc test coverage 18 | .nyc_output 19 | 20 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 21 | .grunt 22 | 23 | # node-waf configuration 24 | .lock-wscript 25 | 26 | # Compiled binary addons (http://nodejs.org/api/addons.html) 27 | build/Release 28 | 29 | # Dependency directories 30 | node_modules 31 | jspm_packages 32 | 33 | # Optional npm cache directory 34 | .npm 35 | 36 | # Optional REPL history 37 | .node_repl_history 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2016 Miklos Bertalan 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hacker News example 2 | 3 | [Live demo](https://nx-js.github.io/hackernews-example/) 4 | 5 | A Hacker News clone built with [NX](http://nx-framework.com), which 6 | features client-side routing, real-time updates and animations. 7 | 8 | ## Usage 9 | 10 | Clone the repo and run `npm i` and `npm start`. The `npm start` command bundles 11 | the source and starts a local server. The demo is exposed on `localhost:3000`. 12 | 13 | ## Project structure 14 | 15 | The project is structured in the following way. 16 | 17 | - The [src](/src) folder includes the API and the components of the app. 18 | - The source is bundled with [Webpack](https://webpack.github.io/). You can find the 19 | webpack config in [src](/webpack.config.js). 20 | - [bundle.js](/bundle.js) is the app's source and NX - bundled together by webpack. 21 | - [index.html](/index.html) imports the bundled source script and has a single 22 | `` component in its body, which is the root component. 23 | - [server.js](/server.js) is only used for local testing, as the page is hosted on 24 | Github Pages. It serves as a simple server example for single page applications. 25 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Hacker News implemented in NX 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "hackernews-example", 3 | "version": "1.0.0", 4 | "description": "NX implementation of Hacker News", 5 | "main": "index.js", 6 | "scripts": { 7 | "build": "webpack", 8 | "prestart": "npm run build", 9 | "start": "node server.js" 10 | }, 11 | "author": "Miklos Bertalan", 12 | "license": "MIT", 13 | "dependencies": { 14 | "compression": "^1.6.2", 15 | "express": "^4.14.0" 16 | }, 17 | "devDependencies": { 18 | "@nx-js/framework": "^1.0.0-beta.2.0.0", 19 | "events": "^1.1.1", 20 | "firebase": "^2.4.2", 21 | "webpack": "^1.13.1", 22 | "raw-loader": "^0.5.1" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const express = require('express') 4 | const compression = require('compression') 5 | const app = express() 6 | 7 | // compress everything 8 | app.use(compression()) 9 | 10 | // serve the static assets of the page first 11 | app.use(express.static(__dirname)) 12 | 13 | // finally serve the index.html file, the routing will take place on the client side 14 | app.use((req, res) => res.sendFile(__dirname + '/index.html')) 15 | 16 | app.listen(3000) 17 | -------------------------------------------------------------------------------- /src/README.md: -------------------------------------------------------------------------------- 1 | # The source folder 2 | 3 | This folder includes the main code of the app. 4 | You can find the NX components in the [components](/src/components) folder and the 5 | framework independent API in the [store](/src/store) folder.The store is used to 6 | fetch data from Hacker News. 7 | -------------------------------------------------------------------------------- /src/components/README.md: -------------------------------------------------------------------------------- 1 | # The components 2 | 3 | Each component has its own folder, named after the component for readability. Component folders may include an `index.js`, a `view.html` and `style.css` file, that 4 | are linked together by the `nx.middlewares.render` middleware. Styles are scoped to the components by default. 5 | 6 | None of the above structuring and naming pattern is mandatory for NX. I simply chose to follow these conventions for this project. 7 | 8 | This is what each of the components is responsible for. 9 | 10 | - [hacker-news](/src/components/hackerNews): The top level component. 11 | Adds some global filters and renders the main view. 12 | - [app-nav](/src/components/appNav): Renders the navbar view. 13 | - [app-router](/src/components/appRouter): A very basic router component. 14 | Not very interesting, for the 'router config' see the `view.html` of the hacker-news component instead. 15 | - [dynamic-html](/src/components/dynamicHTML): A component that allows 16 | the interpolation of any HTML into the view. 17 | - [story-item](/src/components/storyItem): Renders a single story item view. 18 | - [story-list](/src/components/storyList): Fetches and renders a list of stories. 19 | - [user-page](/src/components/userPage): Fetches and renders a user. 20 | - [comment-item](/src/components/commentItem): Renders a single comment. 21 | - [story-page](/src/components/storyPage): Fetches and renders a single story and 22 | all of its comments. 23 | -------------------------------------------------------------------------------- /src/components/appNav/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.components.rendered({ 4 | element: 'nav', 5 | template: require('./view.html'), 6 | style: require('./style.css') 7 | }).register('app-nav') 8 | -------------------------------------------------------------------------------- /src/components/appNav/style.css: -------------------------------------------------------------------------------- 1 | :host { 2 | background-color: rgb(255, 102, 0); 3 | padding: 4px; 4 | overflow: hidden; 5 | } 6 | 7 | a.active { 8 | color: white; 9 | } 10 | 11 | b { 12 | padding: 0 4px; 13 | } 14 | 15 | span { 16 | color: white; 17 | display: inline-block; 18 | float: right; 19 | font-size: 9pt; 20 | } 21 | 22 | span a:hover { 23 | text-decoration: underline; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/appNav/view.html: -------------------------------------------------------------------------------- 1 | 2 | Hacker News 3 | new 4 | | show 5 | | ask 6 | | jobs 7 | 8 | Built with NX | 9 | Source 10 | 11 | -------------------------------------------------------------------------------- /src/components/appRouter/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.components.router() 4 | .register('app-router') 5 | -------------------------------------------------------------------------------- /src/components/commentItem/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.components.rendered({ 4 | element: 'li', 5 | template: require('./view.html'), 6 | style: require('./style.css') 7 | }).use(setup).register('comment-item') 8 | 9 | // this is a custom middleware 10 | // it registers a comment-id attribute for the component, that fetches a comment by id 11 | function setup (elem, state) { 12 | elem.$attribute('comment-id', id => { 13 | store.fetchItem(id) 14 | .then(comment => state.comment = comment) 15 | }) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/commentItem/style.css: -------------------------------------------------------------------------------- 1 | :host { 2 | margin: 20px 0; 3 | font-size: 9pt; 4 | list-style-type: none; 5 | } 6 | 7 | .header { 8 | margin-bottom: 5px; 9 | } 10 | 11 | .body a { 12 | text-decoration: underline; 13 | } 14 | -------------------------------------------------------------------------------- /src/components/commentItem/view.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 8 |
9 | 10 |
    11 |
  • 12 |
13 |
14 |
15 |
16 | -------------------------------------------------------------------------------- /src/components/dynamicHTML/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.component() 4 | .use(setup) 5 | .register('dynamic-html') 6 | 7 | // this is a custom middleware 8 | // it registers an attribute named 'content' on the component, which sets its innerHTML 9 | function setup (elem, state) { 10 | elem.$attribute('content', content => elem.innerHTML = content || '') 11 | } 12 | -------------------------------------------------------------------------------- /src/components/hackerNews/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const urlParser = document.createElement('a') 4 | 5 | // this is the root component 6 | nx.components.app({ 7 | template: require('./view.html'), 8 | style: require('./style.css') 9 | }).register('hacker-news') 10 | 11 | // register two custom filters, that can be used inside expressions 12 | nx.utils.compiler.filter('host', hostFilter) 13 | nx.utils.compiler.filter('timeAgo', timeAgoFilter) 14 | 15 | // this is a custom filter, that can be used in the view as 'value | host' 16 | function hostFilter (url) { 17 | urlParser.href = url 18 | return urlParser.host 19 | } 20 | 21 | // this is a custom filter, that can be used in the view as 'value | timeAgo' 22 | function timeAgoFilter (timestamp) { 23 | const diffInSeconds = Math.round(Date.now() / 1000) - timestamp 24 | if (diffInSeconds < 3600) { 25 | return Math.round(diffInSeconds / 60) + 'minutes' 26 | } 27 | if (diffInSeconds < 86400) { 28 | return Math.round(diffInSeconds / 3600) + 'hours' 29 | } 30 | return Math.round(diffInSeconds / 86400) + 'days' 31 | } 32 | -------------------------------------------------------------------------------- /src/components/hackerNews/style.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | width: 85%; 4 | margin: auto; 5 | color: black; 6 | background-color: rgb(246, 246, 239); 7 | font: 10pt Verdana, Geneva, sans-serif; 8 | } 9 | 10 | a { 11 | color: inherit; 12 | text-decoration: none; 13 | cursor: pointer; 14 | } 15 | 16 | .light { 17 | color: #828282; 18 | } 19 | 20 | .light a:hover { 21 | text-decoration: underline; 22 | } 23 | 24 | .subtext { 25 | font-size: 7pt; 26 | } 27 | 28 | @media all and (max-width: 750px) { 29 | :host { 30 | width: 100% 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/hackerNews/view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /src/components/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | require('./hackerNews') 4 | require('./appRouter') 5 | require('./appNav') 6 | require('./storyList') 7 | require('./storyItem') 8 | require('./storyPage') 9 | require('./userPage') 10 | require('./commentItem') 11 | require('./dynamicHTML') 12 | -------------------------------------------------------------------------------- /src/components/storyItem/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.components.display({ 4 | template: require('./view.html'), 5 | style: require('./style.css'), 6 | props: ['story'] 7 | }).register('story-item') 8 | -------------------------------------------------------------------------------- /src/components/storyItem/style.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | min-height: 27px; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/storyItem/view.html: -------------------------------------------------------------------------------- 1 |
2 | ${story.title} 3 | (${story.url | host}) 4 |
5 |
6 | ${story.title} 7 |
8 | 9 |
10 | ${story.time | timeAgo} ago 11 |
12 |
13 | ${story.score | unit 'point'} by 14 | ${story.by} 15 | ${story.time | timeAgo} ago | 16 | ${story.descendants | unit 'comment'} 17 |
18 | -------------------------------------------------------------------------------- /src/components/storyList/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.components.page({ 4 | template: require('./view.html'), 5 | style: require('./style.css'), 6 | title: 'Stories | Hacker News', 7 | params: { 8 | type: {url: true, history: true, default: 'top'}, 9 | page: {url: true, history: false, type: 'number', default: 0} 10 | } 11 | }).use(setup).register('story-list') 12 | 13 | // this is a custom middleware 14 | // it loads stories when the store broadcasts an update event or when the 'type' or 'page' parameters change 15 | function setup (elem, state) { 16 | store.on('stories-updated', loadStories) 17 | elem.$cleanup(() => store.removeListener('stories-updated', loadStories)) 18 | elem.$observe(loadStories) 19 | 20 | function loadStories () { 21 | store.fetchItemsByType(state.type, state.page) 22 | .then(items => { 23 | state.stories = items.filter(item => item && !item.deleted && !item.dead) 24 | }) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/components/storyList/style.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | position: relative; 4 | padding: 10px; 5 | padding-bottom: 30px; 6 | min-height: 1000px; 7 | } 8 | 9 | story-item { 10 | margin-bottom: 8px; 11 | } 12 | 13 | .paginator { 14 | position: absolute; 15 | padding: 10px 0; 16 | bottom: 0; 17 | } 18 | -------------------------------------------------------------------------------- /src/components/storyList/view.html: -------------------------------------------------------------------------------- 1 |
2 | 6 | 7 |
8 | More 9 | -------------------------------------------------------------------------------- /src/components/storyPage/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.components.page({ 4 | template: require('./view.html'), 5 | style: require('./style.css'), 6 | title: 'Story page | Hacker News', 7 | params: { 8 | id: {url: true, readOnly: true, required: true} 9 | } 10 | }).use(setup).register('story-page') 11 | 12 | // this is a custom middleware, that fetches a story by its id 13 | function setup (elem, state) { 14 | store.fetchItem(state.id) 15 | .then(story => state.story = story) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/storyPage/style.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | padding: 10px; 4 | } 5 | 6 | .body { 7 | display: block; 8 | margin: 20px 0; 9 | } 10 | -------------------------------------------------------------------------------- /src/components/storyPage/view.html: -------------------------------------------------------------------------------- 1 |
2 |
3 | 4 | 5 | 6 |
  • 7 |
    8 |
    9 |
    10 | -------------------------------------------------------------------------------- /src/components/userPage/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | nx.components.page({ 4 | template: require('./view.html'), 5 | style: require('./style.css'), 6 | title: 'User page | Hacker News', 7 | params: { 8 | id: {url: true, readOnly: true, required: true} 9 | } 10 | }).use(setup).register('user-page') 11 | 12 | // this is a custom middleware, that fetches a user by its id 13 | function setup (elem, state) { 14 | store.fetchUser(state.id) 15 | .then(user => state.user = user) 16 | } 17 | -------------------------------------------------------------------------------- /src/components/userPage/style.css: -------------------------------------------------------------------------------- 1 | :host { 2 | display: block; 3 | padding: 10px; 4 | } 5 | -------------------------------------------------------------------------------- /src/components/userPage/view.html: -------------------------------------------------------------------------------- 1 | 2 | 3 |
    4 |
    5 |

    user: ${user.id}

    6 |

    created: ${user.created}

    7 |

    karma: ${user.karma}

    8 |

    about:

    9 |
    10 |
    11 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // this exposes the global nx object, which is the entry point of the NX framework 4 | require('@nx-js/framework') 5 | 6 | // this exposes a global store object, that can be used to access Hacker News data 7 | require('./store') 8 | 9 | // this registers the NX components to be used in the HTML view by their name 10 | require('./components') 11 | -------------------------------------------------------------------------------- /src/store/README.md: -------------------------------------------------------------------------------- 1 | # The store 2 | 3 | The store is a small abstraction over the [Hacker News API](https://github.com/HackerNews/API). 4 | It uses [Firebase](https://firebase.google.com/) and an [EventEmitter](https://github.com/Gozala/events) 5 | to support real-time updates. The real-time updates are chocked when the window is not visible. 6 | Visibility is detected with the native [page visibility API](https://developer.mozilla.org/en-US/docs/Web/API/Page_Visibility_API). 7 | A simple Map is used for caching. The store is exposed globally, which in my opinion is 8 | acceptable for a project of this scale. 9 | -------------------------------------------------------------------------------- /src/store/index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // use Firebase and EventListener for simple real-time updates 4 | const Firebase = require('firebase') 5 | const EventListener = require('events') 6 | 7 | const api = new Firebase('https://hacker-news.firebaseio.com/v0') 8 | const store = new EventListener() 9 | const cache = new Map() 10 | const idsByType = new Map() 11 | const ITEMS_PER_PAGE = 30 12 | 13 | // fire an update event when the page becomes visible 14 | document.addEventListener('visibilitychange', () => { 15 | if (!document.hidden) { 16 | setTimeout(() => store.emit('stories-updated'), 100) 17 | } 18 | }) 19 | 20 | // keep the story ids real-time updated, broadcast an event when they change 21 | for (let type of ['top', 'new', 'ask', 'show', 'job']) { 22 | api.child(`${type}stories`).on('value', snapshot => { 23 | idsByType.set(type, snapshot.val()) 24 | // do not fire events if the page is hidden 25 | if (!document.hidden) { 26 | store.emit('stories-updated') 27 | } 28 | }) 29 | } 30 | 31 | store.fetchIdsByType = fetchIdsByType 32 | store.fetchItemsByType = fetchItemsByType 33 | store.fetchItems = fetchItems 34 | store.fetchItem = fetchItem 35 | store.fetchUser = fetchUser 36 | window.store = store 37 | 38 | function fetch (child) { 39 | if (cache.has(child)) { 40 | return Promise.resolve(cache.get(child)) 41 | } else { 42 | return new Promise((resolve, reject) => { 43 | api.child(child).once('value', snapshot => { 44 | const val = snapshot.val() 45 | cache.set(child, val) 46 | resolve(val) 47 | }, reject) 48 | }) 49 | } 50 | } 51 | 52 | function fetchIdsByType (type, page) { 53 | if (idsByType.has(type)) { 54 | const ids = idsByType.get(type) 55 | return Promise.resolve(ids.slice(page * ITEMS_PER_PAGE, (page + 1) * ITEMS_PER_PAGE)) 56 | } 57 | return fetch(`${type}stories`) 58 | .then(ids => ids.slice(page * ITEMS_PER_PAGE, (page + 1) * ITEMS_PER_PAGE)) 59 | } 60 | 61 | function fetchItemsByType (type, page) { 62 | return fetchIdsByType(type, page) 63 | .then(fetchItems) 64 | } 65 | 66 | function fetchItems (ids) { 67 | ids = ids || [] 68 | return Promise.all(ids.map(fetchItem)) 69 | } 70 | 71 | function fetchItem (id) { 72 | return fetch(`item/${id}`) 73 | } 74 | 75 | function fetchUser (id) { 76 | return fetch(`user/${id}`) 77 | } 78 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | module.exports = { 4 | entry: './src/index.js', 5 | output: { 6 | path: __dirname, 7 | filename: '/bundle.js' 8 | }, 9 | module: { 10 | loaders: [ 11 | {test: /\.(html)|(css)$/, loader: 'raw'} 12 | ] 13 | } 14 | } 15 | --------------------------------------------------------------------------------