Last active
September 10, 2019 19:54
-
-
Save park-brian/019963d28a3985217b610b23c162ce15 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html lang="en"> | |
<head> | |
<meta charset="UTF-8" /> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> | |
<meta http-equiv="X-UA-Compatible" content="ie=edge" /> | |
<title>Hacker News</title> | |
<style> | |
body { | |
background-color: white; | |
max-width: 50rem; | |
margin: 0 auto 10px; | |
font-size: 16px; | |
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, | |
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; | |
} | |
nav { | |
background-color: inherit; | |
border-bottom: 1px solid #ddd; | |
position: sticky; | |
top: 0; | |
z-index: 1; | |
} | |
li { | |
margin-bottom: 1.5rem; | |
} | |
button { | |
background-color: white; | |
border: 1px solid #888; | |
cursor: pointer; | |
} | |
.pad-1 { | |
padding: 1rem; | |
} | |
.strong { | |
font-weight: bold; | |
} | |
.right { | |
float: right; | |
} | |
.clear::after { | |
content: ""; | |
display: table-cell; | |
clear: both; | |
} | |
.comment { | |
display: block; | |
margin: 2rem 0 0 1rem; | |
} | |
.comment .content { | |
margin-left: 0.25rem; | |
padding-left: 1rem; | |
border-left: 1px solid #ddd; | |
line-height: 1.5; | |
} | |
</style> | |
</head> | |
<body> | |
<nav class="pad-1"> | |
<a href="#news" class="strong">Hacker News</a> | | |
<a href="#newest">newest</a> | <a href="#ask">ask</a> | | |
<a href="#show">show</a> | | |
<a href="#jobs">jobs</a> | |
<div class="clear right"> | |
<button onclick="previous()"><</button> | |
<button onclick="next()">></button> | |
</div> | |
</nav> | |
<main id="root"></main> | |
<script> | |
var root = document.querySelector("#root"); | |
// shortcuts to create commonly used html elements | |
["a", "div", "span", "ol", "li", "details", "summary"].forEach(function( | |
tag | |
) { | |
window[tag] = h.bind(h, tag); | |
}); | |
/** Re-render when the url hash changes */ | |
onhashchange = render; | |
render(); | |
function render() { | |
getHN(getState(), function(response) { | |
while (root.firstChild) { | |
root.removeChild(root.firstChild); | |
} | |
root.appendChild( | |
Array.isArray(response) | |
? renderFeed(response) | |
: renderItem(response) | |
); | |
scrollTo(0, 0); | |
}); | |
} | |
/** | |
* Renders a feed, which is an ordered list | |
* of newsfeed item summaries | |
* | |
* @param items {object[]} - An array of HN items | |
* @return {HTMLElement} - The item list | |
*/ | |
function renderFeed(items) { | |
// HN api returns 30 items per page | |
var offset = 1 + (getState()[1] - 1) * 30; | |
return ol( | |
{ start: offset }, | |
items.map(function(item) { | |
return li(null, renderItemSummary(item)); | |
}) | |
); | |
} | |
/** | |
* Renders an item summary, which contains | |
* links to the article and comments page, | |
* as well as other information about the item | |
* | |
* @param item {object} - A HN new item | |
* @return {HTMLElement} - The item summary | |
*/ | |
function renderItemSummary(item) { | |
var isInternal = !item.domain; | |
var commentsUrl = "#item/" + item.id; | |
var itemUrl = isInternal ? commentsUrl : item.url; | |
return div(null, [ | |
a({ href: itemUrl, className: "strong" }, item.title), | |
h("br"), | |
item.points && pluralCount(item.points, "point") + " by ", | |
item.user, | |
" " + item.time_ago + " | ", | |
a({ href: commentsUrl }, pluralCount(item.comments_count, "comment")) | |
]); | |
} | |
/** | |
* Renders a single HN item, which consists of the | |
* item summary and comments | |
* | |
* @param item {object} A HN news item which contains | |
* a tree of comments | |
* @return {HTMLElement} - The item summary + comments | |
*/ | |
function renderItem(item) { | |
var comments = item.comments.map(renderComment); | |
return div( | |
{ className: "pad-1" }, | |
[renderItemSummary(item)].concat(comments) | |
); | |
} | |
/** | |
* Renders a comment, including its children | |
* | |
* @param comment {object} A HN comment | |
* @return {HTMLElement} - A recursively generated comment tree | |
*/ | |
function renderComment(comment) { | |
return details( | |
{ className: "comment", open: "open" }, | |
[ | |
summary(null, [h("b", null, comment.user), " " + comment.time_ago]), | |
div({ className: "content" }, [div({ innerHTML: comment.content })]) | |
].concat(comment.comments.map(renderComment)) | |
); | |
} | |
/** | |
* Gets a json resource given a url | |
* | |
* @param url {string} - The resource to retrieve | |
* @param callback {function} - A callback for the json response | |
*/ | |
function getJSON(url, callback) { | |
var request = new XMLHttpRequest(); | |
request.onload = function() { | |
callback(JSON.parse(request.responseText)); | |
}; | |
request.open("GET", url); | |
request.send(); | |
} | |
/** | |
* Gets a resource from the Hacker News PWA API | |
* @param resource {['news'|'newest'|'ask'|'show'|'jobs'|'item'|'user', string]} - | |
* The resource to retrieve, given as [type, id] | |
* @param callback {function} - A callback for the retrieved resource | |
* @example getHN(['news', 1], handleResponse) | |
*/ | |
function getHN(resource, callback) { | |
var url = "https://api.hnpwa.com/v0/" + resource.join("/") + ".json"; | |
getJSON(url, callback); | |
} | |
/** | |
* Gets the current application state from the location hash | |
* This is always an array containing two elements: [resource type, id] | |
* @return {[string, string]} | |
*/ | |
function getState() { | |
var state = location.hash | |
.substr(1) | |
.split("/") | |
.filter(Boolean); | |
state[0] = state[0] || "news"; // default resource type | |
state[1] = state[1] || "1"; // default id | |
return state; | |
} | |
/** | |
* Moves to the next page (resource id) | |
*/ | |
function next() { | |
var state = getState(); | |
++state[1]; | |
if (isValidState(state)) location.hash = state.join("/"); | |
} | |
/** | |
* Moves to the previous page (resource id) | |
*/ | |
function previous() { | |
var state = getState(); | |
--state[1]; | |
if (isValidState(state)) location.hash = state.join("/"); | |
} | |
/** | |
* Returns true if the provided state (resource/id) is valid. | |
* @param state {[string, string]} | |
*/ | |
function isValidState(state) { | |
var resource = state[0]; | |
var id = state[1]; | |
// maxId will be undefined for invalid resources | |
var maxId = { | |
news: 10, | |
newest: 7, | |
show: 2, | |
ask: 2, | |
jobs: 1 | |
}[resource]; | |
return maxId !== undefined && id <= maxId && id > 0; | |
} | |
/** | |
* Pluralizes a noun, including the quantity | |
* in the pluralized string | |
* @param quantity {number} - The quantity of the noun | |
* @param singular {string} - The singular form of the noun | |
* @param plural {string} The plural form of the noun | |
*/ | |
function pluralCount(quantity, singular, plural) { | |
return quantity + " " + | |
(quantity == 1 | |
? singular | |
: plural || singular + "s"); | |
} | |
/** | |
* Creates an html element | |
* @param tag {string} - The element's tag name | |
* @param props {object} - The element's properties | |
* @param children {(Node|string|number)[]} - The element's children | |
*/ | |
function h(tag, props, children) { | |
var e = document.createElement(tag); | |
if (!Array.isArray(children)) children = [children]; | |
for (var key in props || {}) { | |
e[key] = props[key]; | |
} | |
(children || []).filter(Boolean).forEach(function(child) { | |
if (["string", "number"].indexOf(typeof child) > -1) | |
child = document.createTextNode(child); | |
e.appendChild(child); | |
}); | |
return e; | |
} | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment