Skip to content

Change look #385

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 27 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
dist/
.vscode/
node_modules/
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FROM node:13.10.1-buster

COPY . /app/src
WORKDIR /app/src
RUN npm i && \
npm run build
CMD ["npm", "run", "start"]
56 changes: 28 additions & 28 deletions manifest.json
Original file line number Diff line number Diff line change
@@ -1,37 +1,37 @@
{
"name": "Vue Hackernews 2.0",
"short_name": "Vue HN",
"name": "HN Time-Machine",
"short_name": "HN ⏳",
"icons": [{
"src": "/public/logo-120.png",
"sizes": "120x120",
"type": "image/png"
}, {
"src": "/public/logo-144.png",
"sizes": "144x144",
"type": "image/png"
}, {
"src": "/public/logo-152.png",
"sizes": "152x152",
"type": "image/png"
}, {
"src": "/public/logo-192.png",
"sizes": "192x192",
"type": "image/png"
}, {
"src": "/public/logo-256.png",
"sizes": "256x256",
"type": "image/png"
}, {
"src": "/public/logo-384.png",
"sizes": "384x384",
"type": "image/png"
}, {
"src": "/public/logo-120.png",
"sizes": "120x120",
"type": "image/png"
}, {
"src": "/public/logo-144.png",
"sizes": "144x144",
"type": "image/png"
}, {
"src": "/public/logo-152.png",
"sizes": "152x152",
"type": "image/png"
}, {
"src": "/public/logo-192.png",
"sizes": "192x192",
"type": "image/png"
}, {
"src": "/public/logo-256.png",
"sizes": "256x256",
"type": "image/png"
}, {
"src": "/public/logo-384.png",
"sizes": "384x384",
"type": "image/png"
}, {
"src": "/public/logo-512.png",
"sizes": "512x512",
"type": "image/png"
}],
}],
"start_url": "/",
"background_color": "#f2f3f5",
"display": "standalone",
"theme_color": "#f60"
}
}
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "vue-hackernews-2.0",
"name": "nostalgia-hn",
"description": "A Vue.js project",
"author": "Evan You <[email protected]>",
"private": true,
@@ -23,11 +23,15 @@
"extract-text-webpack-plugin": "^3.0.2",
"firebase": "4.6.2",
"lru-cache": "^4.1.1",
"node-fetch": "^2.6.0",
"route-cache": "0.4.3",
"serve-favicon": "^2.4.5",
"vue": "^2.5.22",
"vue-client-only": "^2.0.0",
"vue-collapsible": "^2.0.0",
"vue-router": "^3.0.1",
"vue-server-renderer": "^2.5.22",
"vue-star-rating": "^1.6.1",
"vuex": "^3.0.1",
"vuex-router-sync": "^5.0.0"
},
Binary file modified public/logo-120.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo-144.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo-152.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo-192.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo-384.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified public/logo-512.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
8 changes: 4 additions & 4 deletions server.js
Original file line number Diff line number Diff line change
@@ -16,7 +16,7 @@ const serverInfo =

const app = express()

function createRenderer (bundle, options) {
function createRenderer(bundle, options) {
// https://github.com/vuejs/vue/blob/dev/packages/vue-server-renderer/README.md#why-use-bundlerenderer
return createBundleRenderer(bundle, Object.assign(options, {
// for component caching
@@ -78,7 +78,7 @@ app.use('/service-worker.js', serve('./dist/service-worker.js'))
// https://www.nginx.com/blog/benefits-of-microcaching-nginx/
app.use(microcache.cacheSeconds(1, req => useMicroCache && req.originalUrl))

function render (req, res) {
function render(req, res) {
const s = Date.now()

res.setHeader("Content-Type", "text/html")
@@ -87,7 +87,7 @@ function render (req, res) {
const handleError = err => {
if (err.url) {
res.redirect(err.url)
} else if(err.code === 404) {
} else if (err.code === 404) {
res.status(404).send('404 | Page Not Found')
} else {
// Render Error Page or Redirect
@@ -98,7 +98,7 @@ function render (req, res) {
}

const context = {
title: 'Vue HN 2.0', // default title
title: 'HN Time-Machine', // default title
url: req.url
}
renderer.renderToString(context, (err, html) => {
196 changes: 113 additions & 83 deletions src/App.vue
Original file line number Diff line number Diff line change
@@ -2,17 +2,14 @@
<div id="app">
<header class="header">
<nav class="inner">
<router-link to="/" exact>
<img class="logo" src="~public/logo-48.png" alt="logo">
</router-link>
<a href="https://peltarion.com">
<img class="logo" src="~public/logo-48.png" alt="logo" />
</a>
<router-link to="/top">Top</router-link>
<router-link to="/new">New</router-link>
<router-link to="/show">Show</router-link>
<router-link to="/ask">Ask</router-link>
<router-link to="/job">Jobs</router-link>
<a class="github" href="https://github.com/vuejs/vue-hackernews-2.0" target="_blank" rel="noopener">
Built with Vue.js
</a>
</nav>
</header>
<transition name="fade" mode="out-in">
@@ -22,81 +19,114 @@
</template>

<style lang="stylus">
body
font-family -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
font-size 15px
background-color lighten(#eceef1, 30%)
margin 0
padding-top 55px
color #34495e
overflow-y scroll
a
color #34495e
text-decoration none
.header
background-color #ff6600
position fixed
z-index 999
height 55px
top 0
left 0
right 0
.inner
max-width 800px
box-sizing border-box
margin 0px auto
padding 15px 5px
a
color rgba(255, 255, 255, .8)
line-height 24px
transition color .15s ease
display inline-block
vertical-align middle
font-weight 300
letter-spacing .075em
margin-right 1.8em
&:hover
color #fff
&.router-link-active
color #fff
font-weight 400
&:nth-child(6)
margin-right 0
.github
color #fff
font-size .9em
margin 0
float right
.logo
width 24px
margin-right 10px
display inline-block
vertical-align middle
.view
max-width 800px
margin 0 auto
position relative
.fade-enter-active, .fade-leave-active
transition all .2s ease
.fade-enter, .fade-leave-active
opacity 0
@media (max-width 860px)
.header .inner
padding 15px 30px
@media (max-width 600px)
.header
.inner
padding 15px
a
margin-right 1em
.github
display none
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-style: normal;
font-weight: normal;
line-height: 22px;
font-size: 15px;
background-color: #fff;
margin: 0;
padding-top: 55px;
color: #34495e;
overflow-y: scroll;
}
footer {
position: fixed;
bottom: 0;
width: 100%;
background-color: #fff;
height: 40x;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.1);
text-align: center;
}
.external-link {
text-decoration: underline;
}
a {
color: #34495e;
text-decoration: none;
}
.header {
background-color: #F7955B;
position: fixed;
z-index: 999;
height: 55px;
top: 0;
left: 0;
right: 0;
.inner {
max-width: 800px;
box-sizing: border-box;
margin: 0px auto;
padding: 15px 5px;
}
a {
color: #111F27;
line-height: 24px;
transition: color 0.15s ease;
display: inline-block;
vertical-align: middle;
font-weight: 300;
letter-spacing: 0.075em;
margin-right: 1.8em;
&:hover {
color: #fff;
}
&.router-link-active {
color: #fff;
}
&:nth-child(6) {
margin-right: 0;
}
}
}
.logo {
width: 24px;
margin-right: 10px;
display: inline-block;
vertical-align: middle;
}
.view {
max-width: 800px;
margin: 0 auto;
position: relative;
}
.fade-enter-active, .fade-leave-active {
transition: all 0.2s ease;
}
.fade-enter, .fade-leave-active {
opacity: 0;
}
@media (max-width: 860px) {
.header .inner {
padding: 15px 30px;
}
}
@media (max-width: 600px) {
.header {
.inner {
padding: 15px;
}
a {
margin-right: 1em;
}
}
}
</style>
4 changes: 4 additions & 0 deletions src/api/create-api-client.js
Original file line number Diff line number Diff line change
@@ -5,3 +5,7 @@ export function createAPI ({ config, version }) {
Firebase.initializeApp(config)
return Firebase.database().ref(version)
}

export function fetchData(...params) {
return window.fetch(...params);
}
5 changes: 5 additions & 0 deletions src/api/create-api-server.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Firebase from 'firebase'
import LRU from 'lru-cache'
import fetch from 'node-fetch';

export function createAPI ({ config, version }) {
let api
@@ -29,3 +30,7 @@ export function createAPI ({ config, version }) {
}
return api
}

export function fetchData(...params) {
return fetch(...params);
}
52 changes: 44 additions & 8 deletions src/api/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// this is aliased in webpack config based on server/client build
import { createAPI } from 'create-api'
import { createAPI, fetchData } from 'create-api'

const logRequests = !!process.env.DEBUG_API

@@ -16,12 +16,12 @@ if (api.onServer) {
warmCache()
}

function warmCache () {
function warmCache() {
fetchItems((api.cachedIds.top || []).slice(0, 30))
setTimeout(warmCache, 1000 * 60 * 15)
}

function fetch (child) {
function fetch(child) {
logRequests && console.log(`fetching ${child}...`)
const cache = api.cachedItems
if (cache && cache.has(child)) {
@@ -41,25 +41,25 @@ function fetch (child) {
}
}

export function fetchIdsByType (type) {
export function fetchIdsByType(type) {
return api.cachedIds && api.cachedIds[type]
? Promise.resolve(api.cachedIds[type])
: fetch(`${type}stories`)
}

export function fetchItem (id) {
export function fetchItem(id) {
return fetch(`item/${id}`)
}

export function fetchItems (ids) {
export function fetchItems(ids) {
return Promise.all(ids.map(id => fetchItem(id)))
}

export function fetchUser (id) {
export function fetchUser(id) {
return fetch(`user/${id}`)
}

export function watchList (type, cb) {
export function watchList(type, cb) {
let first = true
const ref = api.child(`${type}stories`)
const handler = snapshot => {
@@ -74,3 +74,39 @@ export function watchList (type, cb) {
ref.off('value', handler)
}
}

export function fetchSimilar(queries) {
return fetchData('https://textsimilarity.research.peltarion.com/query/batch', {
method: 'POST',
body: JSON.stringify({
queries,
dataset: 'hn-sbert',
top_n: 3
})
})
.then(resp => resp.json())
.then(json => json.rankings.map(i => i.entries));
}

function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
var r = Math.random() * 16 | 0, v = c == 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}

export default function sendFeedBack(query, result, rating) {
if (document.sessionId === undefined) {
document.sessionId = uuidv4()
}
return fetchData('https://textsimilarity.research.peltarion.com/feedback', {
method: 'POST',
body: JSON.stringify({
query: query,
result: result,
rating: rating,
dataset: 'hn-sbert',
sessionId: document.sessionId
})
})
}
42 changes: 42 additions & 0 deletions src/components/Accordion.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
<template>
<div class>
<div class="tab__header">
<a href="#" @click.prevent="active = !active">
<span class="accordion-title">{{title}}</span>
<span class="expand-cta cta" v-show="!active">(click to learn more ▼)</span>
<span class="collapse-cta cta" v-show="active">(click for more space ▲)</span>
</a>
</div>
<div class="tab__content p-2" v-show="active">
<slot />
</div>
</div>
</template>>


<style lang="stylus">
.accordion-title {
font-style: normal;
font-weight: bold;
font-size: 32px;
line-height: 20px;
}
.cta {
display: table;
}
</style>


<script>
export default {
name: "accordion",
props: ["title"],
data() {
return {
active: false
};
}
};
</script>

109 changes: 66 additions & 43 deletions src/components/Comment.vue
Original file line number Diff line number Diff line change
@@ -6,11 +6,13 @@
</div>
<div class="text" v-html="comment.text"></div>
<div class="toggle" :class="{ open }" v-if="comment.kids && comment.kids.length">
<a @click="open = !open">{{
<a @click="open = !open">
{{
open
? '[-]'
: '[+] ' + pluralize(comment.kids.length) + ' collapsed'
}}</a>
? '[-]'
: '[+] ' + pluralize(comment.kids.length) + ' collapsed'
}}
</a>
</div>
<ul class="comment-children" v-show="open">
<comment v-for="id in comment.kids" :key="id" :id="id"></comment>
@@ -20,55 +22,76 @@

<script>
export default {
name: 'comment',
props: ['id'],
data () {
name: "comment",
props: ["id"],
data() {
return {
open: true
}
};
},
computed: {
comment () {
return this.$store.state.items[this.id]
comment() {
return this.$store.state.items[this.id];
}
},
methods: {
pluralize: n => n + (n === 1 ? ' reply' : ' replies')
pluralize: n => n + (n === 1 ? " reply" : " replies")
}
}
};
</script>

<style lang="stylus">
.comment-children
.comment-children
margin-left 1.5em
.comment-children {
.comment-children {
margin-left: 1.5em;
}
}
.comment {
border-top: 1px solid #eee;
position: relative;
.by, .text, .toggle {
font-size: 0.9em;
margin: 1em 0;
}
.by {
color: #828282;
a {
color: #828282;
text-decoration: underline;
}
}
.text {
overflow-wrap: break-word;
.comment
border-top 1px solid #eee
position relative
.by, .text, .toggle
font-size .9em
margin 1em 0
.by
color #828282
a
color #828282
text-decoration underline
.text
overflow-wrap break-word
a:hover
color #ff6600
pre
white-space pre-wrap
.toggle
background-color #fffbf2
padding .3em .5em
border-radius 4px
a
color #828282
cursor pointer
&.open
padding 0
background-color transparent
margin-bottom -0.5em
a:hover {
color: #F7955B;
}
pre {
white-space: pre-wrap;
}
}
.toggle {
background-color: #fffbf2;
padding: 0.3em 0.5em;
border-radius: 4px;
a {
color: #828282;
cursor: pointer;
}
&.open {
padding: 0;
background-color: transparent;
margin-bottom: -0.5em;
}
}
}
</style>
83 changes: 43 additions & 40 deletions src/components/Item.vue
Original file line number Diff line number Diff line change
@@ -1,67 +1,70 @@
<template>
<li class="news-item">
<span class="score">{{ item.score }}</span>
<span class="title">
<div class="score"></div>
<div class="title">
<template v-if="item.url">
<a :href="item.url" target="_blank" rel="noopener">{{ item.title }}</a>
<span class="host"> ({{ item.url | host }})</span>
<span class="host">&nbsp;({{ item.url | host }})</span>
</template>
<template v-else>
<router-link :to="'/item/' + item.id">{{ item.title }}</router-link>
</template>
</span>
<br>
</div>
<span class="meta">
<span v-if="item.type !== 'job'" class="by">
by <router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
</span>
<span class="time">
{{ item.time | timeAgo }} ago
by
<router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
</span>
<span class="time">&nbsp;{{ item.time | timeAgo }} ago</span>
<span v-if="item.type !== 'job'" class="comments-link">
| <router-link :to="'/item/' + item.id">{{ item.descendants }} comments</router-link>
|
<router-link :to="'/item/' + item.id">{{ item.descendants }} comments</router-link>
</span>

<span>&nbsp;| {{item.score}} points</span>
</span>
<br />
<br />
<similar v-if="item.type === 'story'" :story="item"></similar>
<span class="label" v-if="item.type !== 'story'">{{ item.type }}</span>
</li>
</template>

<script>
import { timeAgo } from '../util/filters'
import { timeAgo } from "../util/filters";
import Similar from "./Similar.vue";
export default {
name: 'news-item',
props: ['item'],
name: "news-item",
components: { Similar },
props: ["item"],
// http://ssr.vuejs.org/en/caching.html#component-level-caching
serverCacheKey: ({ item: { id, __lastUpdated, time }}) => {
return `${id}::${__lastUpdated}::${timeAgo(time)}`
serverCacheKey: ({ item: { id, __lastUpdated, time } }) => {
return `${id}::${__lastUpdated}::${timeAgo(time)}`;
}
}
};
</script>

<style lang="stylus">
.news-item
background-color #fff
padding 20px 30px 20px 80px
border-bottom 1px solid #eee
position relative
line-height 20px
.score
color #ff6600
font-size 1.1em
font-weight 700
position absolute
top 50%
left 0
width 80px
text-align center
margin-top -10px
.meta, .host
font-size .85em
color #828282
a
color #828282
text-decoration underline
&:hover
color #ff6600
.news-item {
background-color: #FFFEF2;
padding: 20px 30px 20px 30px;
border-bottom: 1px solid #eee;
position: relative;
line-height: 20px;
.meta, .host {
font-size: 0.85em;
color: #828282;
a {
color: #828282;
text-decoration: underline;
&:hover {
color: #F7955B;
}
}
}
}
</style>
119 changes: 119 additions & 0 deletions src/components/Similar.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@

<template>
<div class="similar-posts">
<ul class="list" v-if="story.similar && story.similar.length !== 0">
<li v-for="sim in story.similar" :key="sim.id">
<span class="year">{{ new Date(sim.time * 1000).getFullYear() }}</span>

<template v-if="sim.url">
<span>
<a
:href="sim.url"
target="_blank"
rel="noopener"
>{{ sim.title }} ({{ sim.url | host }})&nbsp;</a>

<router-link class="comments" :to="'/item/' + sim.id">{{ sim.descendants }} comments</router-link>
</span>

<!-- <span class="host">({{ sim.url | host }})</span> -->
</template>
<template v-else>
<span>
<router-link :to="'/item/' + sim.id">{{ sim.title }}&nbsp;</router-link>
<router-link class="comments" :to="'/item/' + sim.id">{{ sim.descendants }} comments</router-link>
</span>
</template>
<client-only>
<div class="stars">
<star-rating
v-bind:star-size="15"
active-color="#000000"
v-bind:show-rating="false"
@rating-selected="setRating($event, sim.title, story.title)"
v-bind:inline="true"
></star-rating>
</div>
</client-only>
</li>
</ul>
<div v-else class="no-posts">
<span class="similar-posts">Something went wrong retrieving similar stories 😕.</span>
</div>
</div>
</template>

<script>
import StarRating from "vue-star-rating";
import ClientOnly from "vue-client-only";
import sendFeedBack from "../api";
export default {
name: "similar-posts",
props: ["story"],
components: {
StarRating,
ClientOnly
},
methods: {
setRating: function(rating, similar, story) {
sendFeedBack(story, similar, rating);
}
}
};
</script>

<style lang="stylus">
.stars {
position: absolute;
right: 40px;
}
.comments {
text-decoration: underline;
}
.similar-posts {
line-height: 1.2;
.year {
padding-right: 8px;
}
.box {
width: 12px;
height: 0.85em;
display: inline-block;
margin-right: 8px;
}
.similar-posts {
font-style: normal;
font-weight: normal;
font-size: 14px;
line-height: 22px;
}
ul.list, .no-posts {
background-color: #fff;
font-size: 0.85em;
margin: 4px 0;
padding: 8px 12px;
border-radius: 4px;
width = 716px;
font-family: 'Courier New', Courier, monospace;
li {
padding: 4px;
padding-right: 80px;
display: flex;
align-items: center;
}
}
a {
color: #828282;
}
}
</style>
115 changes: 74 additions & 41 deletions src/components/Spinner.vue
Original file line number Diff line number Diff line change
@@ -1,54 +1,87 @@
<template>
<transition>
<svg class="spinner" :class="{ show: show }" v-show="show" width="44px" height="44px" viewBox="0 0 44 44">
<circle class="path" fill="none" stroke-width="4" stroke-linecap="round" cx="22" cy="22" r="20"></circle>
<svg
class="spinner"
:class="{ show: show }"
v-show="show"
width="44px"
height="44px"
viewBox="0 0 44 44"
>
<circle
class="path"
fill="none"
stroke-width="4"
stroke-linecap="round"
cx="22"
cy="22"
r="20"
/>
</svg>
</transition>
</template>

<script>
export default {
name: 'spinner',
props: ['show'],
name: "spinner",
props: ["show"],
serverCacheKey: props => props.show
}
};
</script>

<style lang="stylus">
$offset = 126
$duration = 1.4s
.spinner
transition opacity .15s ease
animation rotator $duration linear infinite
animation-play-state paused
&.show
animation-play-state running
&.v-enter, &.v-leave-active
opacity 0
&.v-enter-active, &.v-leave
opacity 1
@keyframes rotator
0%
transform scale(0.5) rotate(0deg)
100%
transform scale(0.5) rotate(270deg)
.spinner .path
stroke #ff6600
stroke-dasharray $offset
stroke-dashoffset 0
transform-origin center
animation dash $duration ease-in-out infinite
@keyframes dash
0%
stroke-dashoffset $offset
50%
stroke-dashoffset ($offset/2)
transform rotate(135deg)
100%
stroke-dashoffset $offset
transform rotate(450deg)
$offset = 126;
$duration = 1.4s;
.spinner {
transition: opacity 0.15s ease;
animation: rotator $duration linear infinite;
animation-play-state: paused;
&.show {
animation-play-state: running;
}
&.v-enter, &.v-leave-active {
opacity: 0;
}
&.v-enter-active, &.v-leave {
opacity: 1;
}
}
@keyframes rotator {
0% {
transform: scale(0.5) rotate(0deg);
}
100% {
transform: scale(0.5) rotate(270deg);
}
}
.spinner .path {
stroke: #F7955B;
stroke-dasharray: $offset;
stroke-dashoffset: 0;
transform-origin: center;
animation: dash $duration ease-in-out infinite;
}
@keyframes dash {
0% {
stroke-dashoffset: $offset;
}
50% {
stroke-dashoffset: ($offset / 2);
transform: rotate(135deg);
}
100% {
stroke-dashoffset: $offset;
transform: rotate(450deg);
}
}
</style>
53 changes: 34 additions & 19 deletions src/index.template.html
Original file line number Diff line number Diff line change
@@ -1,23 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ title }}</title>
<meta charset="utf-8">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" sizes="120x120" href="/public/logo-120.png">
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
<link rel="shortcut icon" sizes="48x48" href="/public/logo-48.png">
<meta name="theme-color" content="#f60">
<link rel="manifest" href="/manifest.json">
<style>
#skip a { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
#skip a:focus { position:static; width:auto; height:auto; }
</style>
</head>
<body>

<head>
<title>{{ title }}</title>
<meta charset="utf-8">
<meta name="mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="default">
<link rel="apple-touch-icon" sizes="120x120" href="/public/logo-120.png">
<meta name="viewport" content="width=device-width, initial-scale=1, minimal-ui">
<link rel="shortcut icon" sizes="48x48" href="/public/logo-48.png">
<meta name="theme-color" content="#f60">
<link rel="manifest" href="/manifest.json">
<style>
#skip a {
position: absolute;
left: -10000px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
}

#skip a:focus {
position: static;
width: auto;
height: auto;
}
</style>
</head>

<body>
<div id="skip"><a href="#app">skip to content</a></div>
<!--vue-ssr-outlet-->
</body>
</html>
</body>

</html>
31 changes: 29 additions & 2 deletions src/store/actions.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import {
fetchUser,
fetchItems,
fetchIdsByType
fetchIdsByType,
fetchSimilar
} from '../api'

export default {
@@ -35,7 +36,33 @@ export default {
return false
})
if (ids.length) {
return fetchItems(ids).then(items => commit('SET_ITEMS', { items }))
return fetchItems(ids)
.then(items => {
if (items.every(item => item.type === 'story')) {

return fetchSimilar(items.map(item => item.title))
.then(similar => items.map((item, idx) => {
item.similar = similar[idx];
return item;
}))
// Start fetching similar posts (potential performance issue...)
.then((items) => {
return fetchItems(items.map(i => i.similar).flat().map(sim => sim.id))
.then(similarItems => {
items.forEach(item => {
item.similar = item.similar.map(sim => {
const simItem = similarItems.find(si => si.id === sim.id);
return Object.assign({ similarity_score: sim.score }, simItem);
});
});
return items;
});
});
// Stop fetching similar posts (potential performance issue...)
}
return items;
})
.then(items => commit('SET_ITEMS', { items }))
} else {
return Promise.resolve()
}
10 changes: 5 additions & 5 deletions src/util/title.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
function getTitle (vm) {
function getTitle(vm) {
const { title } = vm.$options
if (title) {
return typeof title === 'function'
@@ -8,19 +8,19 @@ function getTitle (vm) {
}

const serverTitleMixin = {
created () {
created() {
const title = getTitle(this)
if (title) {
this.$ssrContext.title = `Vue HN 2.0 | ${title}`
this.$ssrContext.title = `HN Time-Machine | ${title}`
}
}
}

const clientTitleMixin = {
mounted () {
mounted() {
const title = getTitle(this)
if (title) {
document.title = `Vue HN 2.0 | ${title}`
document.title = `HN Time-Machine | ${title}`
}
}
}
332 changes: 228 additions & 104 deletions src/views/ItemList.vue
Original file line number Diff line number Diff line change
@@ -1,157 +1,281 @@
<template>
<div class="news-view">
<div class="news-list-nav">
<router-link v-if="page > 1" :to="'/' + type + '/' + (page - 1)">&lt; prev</router-link>
<a v-else class="disabled">&lt; prev</a>
<span>{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="'/' + type + '/' + (page + 1)">more &gt;</router-link>
<a v-else class="disabled">more &gt;</a>
<div>
<accordion class="page-accordion" title="HN Time-Machine">
<p>HN Time-Machine is yet another HackerNews clone, but with a slight twist.</p>
<p>
Each story on the current HackerNews frontpage is presented with the
top 3 most similar stories based on their titles between 2006 and
2015. More specifically the titles are encoded into semantically
meaningful vectors and then ranked using cosine similarity.
If you want to learn more this, check out our
<a
class="external-link"
href="https://docs.google.com/document/d/1UV4GBtMiJEgb19ucLhUGpv1frdDNZI6-qTW-1w0zNdM"
>blog post</a>.
</p>

<p>
You will probably notice that your milage may vary.
Part of that could be attributed to there just not being a similar
story in the past. But, likely, also part of the reason is that the
model that encodes the sentences was trained on a different domain.
So, you can help to (maybe) improve the results in the
making use of the star ranking.
</p>

<p>
For feedback and suggestions please reach out to
<a
class="external-link"
href="https://twitter.com/phileisn"
>@phileisn</a>.
</p>
</accordion>
<p class="subtitle">Today's stories along with the most similar ones between 2006 and 2015.</p>
<p class="credits">
Built by
<a
class="external-link"
href="https://twitter.com/phileisn"
target="_blank"
rel="noopener"
>@phileisn</a> and
<a
class="external-link"
href="https://twitter.com/nilpath"
target="_blank"
rel="noopener"
>@nilpath</a>
at
<a
class="external-link"
href="https://peltarion.com/"
target="_blank"
rel="noopener"
>Peltarion</a>
using
<a
class="external-link"
href="https://github.com/UKPLab/sentence-transformers"
target="_blank"
rel="noopener"
>sentence-transformers</a>,
<a
class="external-link"
href="https://github.com/vuejs/vue-hackernews-2.0"
target="_blank"
rel="noopener"
>Vue.js</a> and
<a
class="external-link"
href="https://github.com/nmslib/hnswlib"
target="_blank"
rel="noopener"
>hnswlib</a>.
</p>
</div>
<div class="footer">
<div class="news-list-nav">
<router-link v-if="page > 1" :to="'/' + type + '/' + (page - 1)">&lt; prev</router-link>
<a v-else class="disabled">&lt; prev</a>
<span>{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="'/' + type + '/' + (page + 1)">more &gt;</router-link>
<a v-else class="disabled">more &gt;</a>
</div>
</div>

<transition :name="transition">
<div class="news-list" :key="displayedPage" v-if="displayedPage > 0">
<transition-group tag="ul" name="item">
<item v-for="item in displayedItems" :key="item.id" :item="item">
</item>
<item v-for="item in displayedItems" :key="item.id" :item="item"></item>
</transition-group>
</div>
</transition>
</div>
</template>

<script>
import { watchList } from '../api'
import Item from '../components/Item.vue'
import { watchList } from "../api";
import Item from "../components/Item.vue";
import Accordion from "../components/Accordion.vue";
export default {
name: 'item-list',
name: "item-list",
components: {
Item
Item,
Accordion
},
props: {
type: String
},
data () {
data() {
return {
transition: 'slide-right',
transition: "slide-right",
displayedPage: Number(this.$route.params.page) || 1,
displayedItems: this.$store.getters.activeItems
}
};
},
computed: {
page () {
return Number(this.$route.params.page) || 1
page() {
return Number(this.$route.params.page) || 1;
},
maxPage () {
const { itemsPerPage, lists } = this.$store.state
return Math.ceil(lists[this.type].length / itemsPerPage)
maxPage() {
const { itemsPerPage, lists } = this.$store.state;
return Math.ceil(lists[this.type].length / itemsPerPage);
},
hasMore () {
return this.page < this.maxPage
hasMore() {
return this.page < this.maxPage;
}
},
beforeMount () {
beforeMount() {
if (this.$root._isMounted) {
this.loadItems(this.page)
this.loadItems(this.page);
}
// watch the current list for realtime updates
this.unwatchList = watchList(this.type, ids => {
this.$store.commit('SET_LIST', { type: this.type, ids })
this.$store.dispatch('ENSURE_ACTIVE_ITEMS').then(() => {
this.displayedItems = this.$store.getters.activeItems
})
})
this.$store.commit("SET_LIST", { type: this.type, ids });
this.$store.dispatch("ENSURE_ACTIVE_ITEMS").then(() => {
this.displayedItems = this.$store.getters.activeItems;
});
});
},
beforeDestroy () {
this.unwatchList()
beforeDestroy() {
this.unwatchList();
},
watch: {
page (to, from) {
this.loadItems(to, from)
page(to, from) {
this.loadItems(to, from);
}
},
methods: {
loadItems (to = this.page, from = -1) {
this.$bar.start()
this.$store.dispatch('FETCH_LIST_DATA', {
type: this.type
}).then(() => {
if (this.page < 0 || this.page > this.maxPage) {
this.$router.replace(`/${this.type}/1`)
return
}
this.transition = from === -1
? null
: to > from ? 'slide-left' : 'slide-right'
this.displayedPage = to
this.displayedItems = this.$store.getters.activeItems
this.$bar.finish()
})
loadItems(to = this.page, from = -1) {
this.$bar.start();
this.$store
.dispatch("FETCH_LIST_DATA", {
type: this.type
})
.then(() => {
if (this.page < 0 || this.page > this.maxPage) {
this.$router.replace(`/${this.type}/1`);
return;
}
this.transition =
from === -1 ? null : to > from ? "slide-left" : "slide-right";
this.displayedPage = to;
this.displayedItems = this.$store.getters.activeItems;
this.$bar.finish();
});
}
}
}
};
</script>

<style lang="stylus">
.news-view
padding-top 45px
.news-list-nav, .news-list
background-color #fff
border-radius 2px
.news-list-nav
padding 15px 30px
position fixed
text-align center
top 55px
left 0
right 0
z-index 998
box-shadow 0 1px 2px rgba(0,0,0,.1)
a
margin 0 1em
.disabled
color #ccc
.news-list
position absolute
margin 30px 0
width 100%
transition all .5s cubic-bezier(.55,0,.1,1)
ul
list-style-type none
padding 0
margin 0
.slide-left-enter, .slide-right-leave-to
opacity 0
transform translate(30px, 0)
.slide-left-leave-to, .slide-right-enter
opacity 0
transform translate(-30px, 0)
.item-move, .item-enter-active, .item-leave-active
transition all .5s cubic-bezier(.55,0,.1,1)
.item-enter
opacity 0
transform translate(30px, 0)
.item-leave-active
position absolute
opacity 0
transform translate(30px, 0)
@media (max-width 600px)
.news-list
margin 10px 0
.news-view {
padding-top: 23px;
}
.page-accordion {
padding-left: 10px;
padding-right: 10px;
}
.news-list {
background: #fff;
border-radius: 2px;
}
.footer {
background: #fff;
padding: 10px 30px;
position: fixed;
text-align: center;
bottom: 0;
left: 0;
right: 0;
z-index: 998;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.1);
}
.news-list-nav {
border-radius: 2px;
a {
margin: 0 1em;
}
.disabled {
color: #ccc;
}
}
.subtitle {
font-size: 18px;
line-height: 18px;
padding-left: 10px;
padding-right: 10px;
}
.credits {
font-family: 'Courier New', Courier, monospace;
font-style: normal;
font-weight: normal;
font-size: 13px;
line-height: 13px;
padding-left: 10px;
padding-right: 10px;
}
.news-list {
position: absolute;
margin: 30px 0;
width: 100%;
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
ul {
list-style-type: none;
padding: 0;
margin: 0;
}
}
.slide-left-enter, .slide-right-leave-to {
opacity: 0;
transform: translate(30px, 0);
}
.slide-left-leave-to, .slide-right-enter {
opacity: 0;
transform: translate(-30px, 0);
}
.item-move, .item-enter-active, .item-leave-active {
transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
}
.item-enter {
opacity: 0;
transform: translate(30px, 0);
}
.item-leave-active {
position: absolute;
opacity: 0;
transform: translate(30px, 0);
}
@media (max-width: 600px) {
.news-list {
margin: 10px 0;
}
}
</style>
161 changes: 94 additions & 67 deletions src/views/ItemView.vue
Original file line number Diff line number Diff line change
@@ -5,19 +5,19 @@
<a :href="item.url" target="_blank">
<h1>{{ item.title }}</h1>
</a>
<span v-if="item.url" class="host">
({{ item.url | host }})
</span>
<span v-if="item.url" class="host">({{ item.url | host }})</span>
<p class="meta">
{{ item.score }} points
| by <router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
| by
<router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
{{ item.time | timeAgo }} ago
</p>
<similar v-if="item.type === 'story'" :story="item"></similar>
</div>
<div class="item-view-comments">
<p class="item-view-comments-header">
{{ item.kids ? item.descendants + ' comments' : 'No comments yet.' }}
<spinner :show="loading"></spinner>
<spinner v-if="loading" :show="loading"></spinner>
</p>
<ul v-if="!loading" class="comment-children">
<comment v-for="id in item.kids" :key="id" :id="id"></comment>
@@ -28,106 +28,133 @@
</template>

<script>
import Spinner from '../components/Spinner.vue'
import Comment from '../components/Comment.vue'
import Spinner from "../components/Spinner.vue";
import Comment from "../components/Comment.vue";
import Similar from "../components/Similar.vue";
export default {
name: 'item-view',
components: { Spinner, Comment },
name: "item-view",
components: { Spinner, Similar, Comment },
data: () => ({
loading: true
}),
computed: {
item () {
return this.$store.state.items[this.$route.params.id]
item() {
return this.$store.state.items[this.$route.params.id];
}
},
// We only fetch the item itself before entering the view, because
// it might take a long time to load threads with hundreds of comments
// due to how the HN Firebase API works.
asyncData ({ store, route: { params: { id }}}) {
return store.dispatch('FETCH_ITEMS', { ids: [id] })
asyncData({
store,
route: {
params: { id }
}
}) {
return store.dispatch("FETCH_ITEMS", { ids: [id] });
},
title () {
return this.item.title
title() {
return this.item.title;
},
// Fetch comments when mounted on the client
beforeMount () {
this.fetchComments()
beforeMount() {
this.fetchComments();
},
// refetch comments if item changed
watch: {
item: 'fetchComments'
item: "fetchComments"
},
methods: {
fetchComments () {
fetchComments() {
if (!this.item || !this.item.kids) {
return
return;
}
this.loading = true
this.loading = true;
fetchComments(this.$store, this.item).then(() => {
this.loading = false
})
this.loading = false;
});
}
}
}
};
// recursively fetch all descendent comments
function fetchComments (store, item) {
function fetchComments(store, item) {
if (item && item.kids) {
return store.dispatch('FETCH_ITEMS', {
ids: item.kids
}).then(() => Promise.all(item.kids.map(id => {
return fetchComments(store, store.state.items[id])
})))
return store
.dispatch("FETCH_ITEMS", {
ids: item.kids
})
.then(() =>
Promise.all(
item.kids.map(id => {
return fetchComments(store, store.state.items[id]);
})
)
);
}
}
</script>

<style lang="stylus">
.item-view-header
background-color #fff
padding 1.8em 2em 1em
box-shadow 0 1px 2px rgba(0,0,0,.1)
h1
display inline
font-size 1.5em
margin 0
margin-right .5em
.host, .meta, .meta a
color #828282
.meta a
text-decoration underline
.item-view-comments
background-color #fff
margin-top 10px
padding 0 2em .5em
.item-view-comments-header
margin 0
font-size 1.1em
padding 1em 0
position relative
.spinner
display inline-block
margin -15px 0
.comment-children
list-style-type none
padding 0
margin 0
@media (max-width 600px)
.item-view-header
h1
font-size 1.25em
.item-view-header {
background-color: #fff;
padding: 1.8em 2em 1em;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
h1 {
display: inline;
font-size: 1.5em;
margin: 0;
margin-right: 0.5em;
}
.host, .meta, .meta a {
color: #828282;
}
.meta a {
text-decoration: underline;
}
}
.item-view-comments {
background-color: #fff;
margin-top: 10px;
padding: 0 2em 0.5em;
}
.item-view-comments-header {
margin: 0;
font-size: 1.1em;
padding: 1em 0;
position: relative;
.spinner {
display: inline-block;
margin: -15px 0;
}
}
.comment-children {
list-style-type: none;
padding: 0;
margin: 0;
}
@media (max-width: 600px) {
.item-view-header {
h1 {
font-size: 1.25em;
}
}
}
</style>