Commit dcfc9507 authored by Huseyn Guliyev's avatar Huseyn Guliyev

Fixed Hackernews demo with latest nuxt

Added Eslimt&Prettier integration
parent 2e57d062
module.exports = {
root: true,
env: {
browser: true,
node: true
},
parserOptions: {
parser: 'babel-eslint'
},
extends: [
"eslint:recommended",
// https://github.com/vuejs/eslint-plugin-vue#priority-a-essential-error-prevention
// consider switching to `plugin:vue/strongly-recommended` or `plugin:vue/recommended` for stricter rules.
"plugin:vue/recommended",
"plugin:prettier/recommended"
],
// required to lint *.vue files
plugins: [
'vue'
],
// add your custom rules here
rules: {
"semi": [2, "never"],
"no-console": "off",
"vue/max-attributes-per-line": "off",
"prettier/prettier": ["error"]
}
}
\ No newline at end of file
module.exports = {
"semi": false
}
\ No newline at end of file
import { initializeApp, database } from 'firebase/app' import { initializeApp, database } from "firebase/app"
export async function createAPI({ config, version }) { export async function createAPI({ config, version }) {
await import(/* webpackChunkName: "firebase" */ 'firebase/database') await import(/* webpackChunkName: "firebase" */ "firebase/database")
initializeApp(config) initializeApp(config)
return database().ref(version) return database().ref(version)
} }
import Firebase from 'firebase' import Firebase from "firebase"
import LRU from 'lru-cache' import LRU from "lru-cache"
export async function createAPI ({ config, version }) { export async function createAPI({ config, version }) {
let api let api
// this piece of code may run multiple times in development mode, // this piece of code may run multiple times in development mode,
// so we attach the instantiated API to `process` to avoid duplications // so we attach the instantiated API to `process` to avoid duplications
...@@ -21,8 +21,8 @@ export async function createAPI ({ config, version }) { ...@@ -21,8 +21,8 @@ export async function createAPI ({ config, version }) {
// cache the latest story ids // cache the latest story ids
api.cachedIds = {} api.cachedIds = {}
;['top', 'new', 'show', 'ask', 'job'].forEach(type => { ;["top", "new", "show", "ask", "job"].forEach(type => {
api.child(`${type}stories`).on('value', snapshot => { api.child(`${type}stories`).on("value", snapshot => {
api.cachedIds[type] = snapshot.val() api.cachedIds[type] = snapshot.val()
}) })
}) })
......
// This is aliased in webpack config based on server/client build // This is aliased in webpack config based on server/client build
import { createAPI } from 'create-api' import { createAPI } from "create-api"
const logRequests = !!process.env.DEBUG_API const logRequests = !!process.env.DEBUG_API
let api = {} let api = {}
let _api = createAPI({ let _api = createAPI({
version: '/v0', version: "/v0",
config: { config: {
databaseURL: 'https://hacker-news.firebaseio.com' databaseURL: "https://hacker-news.firebaseio.com"
} }
}).then(_api => { }).then(_api => {
api = _api api = _api
...@@ -18,8 +18,6 @@ let _api = createAPI({ ...@@ -18,8 +18,6 @@ let _api = createAPI({
} }
}) })
function warmCache() { function warmCache() {
if (!api.cachedIds) return if (!api.cachedIds) return
fetchItems((api.cachedIds.top || []).slice(0, 30)) fetchItems((api.cachedIds.top || []).slice(0, 30))
...@@ -35,14 +33,18 @@ async function fetch(child) { ...@@ -35,14 +33,18 @@ async function fetch(child) {
return cache.get(child) return cache.get(child)
} else { } else {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
api.child(child).once('value', snapshot => { api.child(child).once(
const val = snapshot.val() "value",
// mark the timestamp when this item is cached snapshot => {
if (val) val.__lastUpdated = Date.now() const val = snapshot.val()
cache && cache.set(child, val) // mark the timestamp when this item is cached
logRequests && console.log(`fetched ${child}.`) if (val) val.__lastUpdated = Date.now()
resolve(val) cache && cache.set(child, val)
}, reject) logRequests && console.log(`fetched ${child}.`)
resolve(val)
},
reject
)
}) })
} }
} }
...@@ -76,8 +78,8 @@ export async function watchList(type, cb) { ...@@ -76,8 +78,8 @@ export async function watchList(type, cb) {
cb(snapshot.val()) cb(snapshot.val())
} }
} }
ref.on('value', handler) ref.on("value", handler)
return () => { return () => {
ref.off('value', handler) ref.off("value", handler)
} }
} }
...@@ -4,71 +4,94 @@ ...@@ -4,71 +4,94 @@
<router-link :to="'/user/' + comment.by">{{ comment.by }}</router-link> <router-link :to="'/user/' + comment.by">{{ comment.by }}</router-link>
{{ comment.time | timeAgo }} ago {{ comment.time | timeAgo }} ago
</div> </div>
<div class="text" v-html="comment.text"></div> <div class="text" v-html="comment.text" />
<div class="toggle" :class="{ open }" v-if="comment.kids && comment.kids.length"> <div v-if="comment.kids && comment.kids.length" :class="{ open }" class="toggle">
<a @click="open = !open">{{ <a @click="open = !open">{{ open ? '[-]' : '[+] ' + pluralize(comment.kids.length) + ' collapsed' }}
open </a>
? '[-]'
: '[+] ' + pluralize(comment.kids.length) + ' collapsed'
}}</a>
</div> </div>
<ul class="comment-children" v-show="open"> <ul v-show="open" class="comment-children">
<comment v-for="id in comment.kids" :key="id" :id="id"></comment> <comment v-for="id in comment.kids" :key="id" :id="id" />
</ul> </ul>
</li> </li>
</template> </template>
<script> <script>
export default { export default {
name: 'comment', name: "Comment",
props: ['id'], props: {
data () { id: {
type: String,
required: true
}
},
data() {
return { return {
open: true open: true
} }
}, },
computed: { computed: {
comment () { comment() {
return this.$store.state.items[this.id] return this.$store.state.items[this.id]
} }
}, },
methods: { methods: {
pluralize: n => n + (n === 1 ? ' reply' : ' replies') pluralize: n => n + (n === 1 ? " reply" : " replies")
} }
} }
</script> </script>
<style lang="stylus"> <style lang="stylus">
.comment-children .comment-children {
.comment-children .comment-children {
margin-left 1.5em 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 a:hover {
border-top 1px solid #eee color: #ff6600;
position relative }
.by, .text, .toggle
font-size .9em pre {
margin 1em 0 white-space: pre-wrap;
.by }
color #828282 }
a
color #828282 .toggle {
text-decoration underline background-color: #fffbf2;
.text padding: 0.3em 0.5em;
overflow-wrap break-word border-radius: 4px;
a:hover
color #ff6600 a {
pre color: #828282;
white-space pre-wrap cursor: pointer;
.toggle }
background-color #fffbf2
padding .3em .5em &.open {
border-radius 4px padding: 0;
a background-color: transparent;
color #828282 margin-bottom: -0.5em;
cursor pointer }
&.open }
padding 0 }
background-color transparent
margin-bottom -0.5em
</style> </style>
...@@ -13,55 +13,71 @@ ...@@ -13,55 +13,71 @@
<br> <br>
<span class="meta"> <span class="meta">
<span v-if="item.type !== 'job'" class="by"> <span v-if="item.type !== 'job'" class="by">
by <router-link :to="'/user/' + item.by">{{ item.by }}</router-link> by
<router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
</span> </span>
<span class="time"> <span class="time">
{{ item.time | timeAgo }} ago {{ item.time | timeAgo }} ago
</span> </span>
<span v-if="item.type !== 'job'" class="comments-link"> <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>
</span> </span>
<span class="label" v-if="item.type !== 'story'">{{ item.type }}</span> <span v-if="item.type !== 'story'" class="label" >{{ item.type }}</span>
</li> </li>
</template> </template>
<script> <script>
import { timeAgo } from '../plugins/filters' import { timeAgo } from "~/plugins/filters"
export default { export default {
name: 'news-item', name: "NewsItem",
props: ['item'], props: {
item: {
type: Object,
required: true
}
},
// http://ssr.vuejs.org/en/caching.html#component-level-caching // http://ssr.vuejs.org/en/caching.html#component-level-caching
serverCacheKey: ({ item: { id, __lastUpdated, time }}) => { serverCacheKey: ({ item: { id, __lastUpdated, time } }) => {
return `${id}::${__lastUpdated}::${timeAgo(time)}` return `${id}::${__lastUpdated}::${timeAgo(time)}`
} }
} }
</script> </script>
<style lang="stylus"> <style lang="stylus">
.news-item .news-item {
background-color #fff background-color: #fff;
padding 20px 30px 20px 80px padding: 20px 30px 20px 80px;
border-bottom 1px solid #eee border-bottom: 1px solid #eee;
position relative position: relative;
line-height 20px line-height: 20px;
.score
color #C75000 .score {
font-size 1.1em color: #C75000;
font-weight 700 font-size: 1.1em;
position absolute font-weight: 700;
top 50% position: absolute;
left 0 top: 50%;
width 80px left: 0;
text-align center width: 80px;
margin-top -10px text-align: center;
.meta, .host margin-top: -10px;
font-size .85em }
color #595959
a .meta, .host {
color #595959 font-size: 0.85em;
text-decoration underline color: #595959;
&:hover
color #C75000 a {
color: #595959;
text-decoration: underline;
&:hover {
color: #C75000;
}
}
}
}
</style> </style>
<template> <template>
<div class="news-view view"> <div class="news-view view">
<item-list-nav :type="type" :page="page" :maxPage="maxPage"></item-list-nav> <item-list-nav :type="type" :page="page" :max-page="maxPage" />
<transition :name="transition" mode="out-in"> <transition :name="transition" mode="out-in">
<div class="news-list" :key="displayedPage" v-if="displayedPage > 0"> <div v-if="displayedPage > 0" :key="displayedPage" class="news-list">
<transition-group tag="ul" name="item"> <transition-group tag="ul" name="item">
<item v-for="item in displayedItems" :key="item.id" :item="item"> <item v-for="item in displayedItems" :key="item.id" :item="item" />
</item>
</transition-group> </transition-group>
</div> </div>
</transition> </transition>
<item-list-nav :type="type" :page="page" :maxPage="maxPage"></item-list-nav> <item-list-nav :type="type" :page="page" :max-page="maxPage" />
</div> </div>
</template> </template>
<script> <script>
import { watchList } from '../api' import { watchList } from "../api"
import Item from './Item.vue' import Item from "./Item.vue"
import ItemListNav from './ItemListNav.vue' import ItemListNav from "./ItemListNav.vue"
export default { export default {
name: 'item-list', name: "ItemList",
components: { components: {
Item, Item,
ItemListNav ItemListNav
}, },
props: { props: {
type: String type: {
type: String,
required: true
}
}, },
data() { data() {
return { return {
transition: 'slide-right', transition: "slide-right",
displayedPage: Number(this.$route.params.page) || 1, displayedPage: Number(this.$route.params.page) || 1,
displayedItems: this.$store.getters.activeItems displayedItems: this.$store.getters.activeItems
} }
...@@ -39,19 +41,26 @@ export default { ...@@ -39,19 +41,26 @@ export default {
return Number(this.$route.params.page) || 1 return Number(this.$route.params.page) || 1
}, },
maxPage() { maxPage() {
const {itemsPerPage, lists} = this.$store.state const { itemsPerPage, lists } = this.$store.state
return Math.ceil(lists[this.type].length / itemsPerPage) return Math.ceil(lists[this.type].length / itemsPerPage)
} }
}, },
watch: {
page(to, from) {
console.log("Page changed", to, from)
this.loadItems(to, from)
}
},
async beforeMount() { async beforeMount() {
if (this.$root._isMounted) { if (this.$root._isMounted) {
this.loadItems(this.page) this.loadItems(this.page)
} }
// watch the current list for realtime updates // watch the current list for realtime updates
this.unwatchList = await watchList(this.type, ids => { this.unwatchList = await watchList(this.type, ids => {
this.$store.commit('SET_LIST', {type: this.type, ids}) this.$store.commit("SET_LIST", { type: this.type, ids })
this.$store.dispatch('ENSURE_ACTIVE_ITEMS').then(() => { this.$store.dispatch("ENSURE_ACTIVE_ITEMS").then(() => {
this.displayedItems = this.$store.getters.activeItems this.displayedItems = this.$store.getters.activeItems
}) })
}) })
...@@ -61,70 +70,75 @@ export default { ...@@ -61,70 +70,75 @@ export default {
this.unwatchList() this.unwatchList()
}, },
watch: {
page(to, from) {
console.log('Page changed', to, from)
this.loadItems(to, from)
}
},
methods: { methods: {
loadItems(to = this.page, from = -1) { loadItems(to = this.page, from = -1) {
this.$nuxt.$loading.start() this.$nuxt.$loading.start()
this.$store.dispatch('FETCH_LIST_DATA', { this.$store
type: this.type .dispatch("FETCH_LIST_DATA", {
}).then(() => { type: this.type
if (this.page < 0 || this.page > this.maxPage) { })
this.$router.replace(`/${this.type}/1`) .then(() => {
return if (this.page < 0 || this.page > this.maxPage) {
} this.$router.replace(`/${this.type}/1`)
this.transition = from === -1 return
? null }
: to > from ? 'slide-left' : 'slide-right' this.transition =
this.displayedPage = to from === -1 ? null : to > from ? "slide-left" : "slide-right"
this.displayedItems = this.$store.getters.activeItems this.displayedPage = to
this.$nuxt.$loading.finish() this.displayedItems = this.$store.getters.activeItems
}) this.$nuxt.$loading.finish()
})
} }
} }
} }
</script> </script>
<style lang="stylus"> <style lang="stylus">
.news-list .news-list {
background-color #fff background-color: #fff;
border-radius 2px border-radius: 2px;
}
.news-list .news-list {
margin 10px 0 margin: 10px 0;
width 100% width: 100%;
transition all .3s cubic-bezier(.55, 0, .1, 1) transition: all 0.3s cubic-bezier(0.55, 0, 0.1, 1);
ul
list-style-type none
padding 0
margin 0
.slide-left-enter, .slide-right-leave-to ul {
opacity 0 list-style-type: none;
transform translate(30px, 0) padding: 0;
margin: 0;
}
}
.slide-left-leave-to, .slide-right-enter .slide-left-enter, .slide-right-leave-to {
opacity 0 opacity: 0;
transform translate(-30px, 0) transform: translate(30px, 0);
}
.item-move, .item-enter-active, .item-leave-active .slide-left-leave-to, .slide-right-enter {
transition all .5s cubic-bezier(.55, 0, .1, 1) opacity: 0;
transform: translate(-30px, 0);
}
.item-enter .item-move, .item-enter-active, .item-leave-active {
opacity 0 transition: all 0.5s cubic-bezier(0.55, 0, 0.1, 1);
transform translate(30px, 0) }
.item-enter {
opacity: 0;
transform: translate(30px, 0);
}
.item-leave-active .item-leave-active {
position absolute position: absolute;
opacity 0 opacity: 0;
transform translate(30px, 0) transform: translate(30px, 0);
}
@media (max-width 600px) @media (max-width: 600px) {
.news-list .news-list {
margin 10px 0 margin: 10px 0;
}
}
</style> </style>
...@@ -11,9 +11,18 @@ ...@@ -11,9 +11,18 @@
<script> <script>
export default { export default {
props: { props: {
type: String, type: {
page: Number, type: String,
maxPage: Number required: true
},
page: {
type: Number,
required: true
},
maxPage: {
type: Number,
required: true
}
}, },
computed: { computed: {
hasMore() { hasMore() {
...@@ -24,16 +33,22 @@ export default { ...@@ -24,16 +33,22 @@ export default {
</script> </script>
<style lang="stylus"> <style lang="stylus">
.news-list-nav, .news-list .news-list-nav, .news-list {
background-color #fff background-color: #fff;
border-radius 2px border-radius: 2px;
}
.news-list-nav .news-list-nav {
padding 15px 30px padding: 15px 30px;
text-align center text-align: center;
box-shadow 0 1px 2px rgba(0, 0, 0, .1) box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
a
margin 0 1em a {
.disabled margin: 0 1em;
opacity 0.8 }
.disabled {
opacity: 0.8;
}
}
</style> </style>
<template> <template>
<transition> <transition>
<svg class="spinner" :class="{ show: show }" v-show="show" width="44px" height="44px" viewBox="0 0 44 44"> <svg v-show="show" :class="{ show: show }" class="spinner" 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> <circle class="path" fill="none" stroke-width="4" stroke-linecap="round" cx="22" cy="22" r="20"/>
</svg> </svg>
</transition> </transition>
</template> </template>
<script> <script>
export default { export default {
name: 'spinner', name: "Spinner",
props: ['show'], props: {
show: {
type: Boolean,
required: true
}
},
serverCacheKey: props => props.show serverCacheKey: props => props.show
} }
</script> </script>
<style lang="stylus"> <style lang="stylus">
$offset = 126 $offset = 126;
$duration = 1.4s $duration = 1.4s;
.spinner .spinner {
transition opacity .15s ease transition: opacity 0.15s ease;
animation rotator $duration linear infinite animation: rotator $duration linear infinite;
animation-play-state paused animation-play-state: paused;
&.show
animation-play-state running &.show {
&.v-enter, &.v-leave-active animation-play-state: running;
opacity 0 }
&.v-enter-active, &.v-leave
opacity 1 &.v-enter, &.v-leave-active {
opacity: 0;
@keyframes rotator }
0%
transform scale(0.5) rotate(0deg) &.v-enter-active, &.v-leave {
100% opacity: 1;
transform scale(0.5) rotate(270deg) }
}
.spinner .path
stroke #ff6600 @keyframes rotator {
stroke-dasharray $offset 0% {
stroke-dashoffset 0 transform: scale(0.5) rotate(0deg);
transform-origin center }
animation dash $duration ease-in-out infinite
100% {
@keyframes dash transform: scale(0.5) rotate(270deg);
0% }
stroke-dashoffset $offset }
50%
stroke-dashoffset ($offset/2) .spinner .path {
transform rotate(135deg) stroke: #ff6600;
100% stroke-dasharray: $offset;
stroke-dashoffset $offset stroke-dashoffset: 0;
transform rotate(450deg) 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> </style>
import ItemList from './ItemList.vue' import ItemList from "./ItemList.vue"
const camelize = str => str.charAt(0).toUpperCase() + str.slice(1) const camelize = str => str.charAt(0).toUpperCase() + str.slice(1)
// This is a factory function for dynamically creating root-level list views, // This is a factory function for dynamically creating root-level list views,
// since they share most of the logic except for the type of items to display. // since they share most of the logic except for the type of items to display.
// They are essentially higher order components wrapping ItemList.vue. // They are essentially higher order components wrapping ItemList.vue.
export default function createListView (type) { export default function createListView(type) {
return { return {
name: `${type}-stories-view`, name: `${type}-stories-view`,
fetch ({ store }) { fetch({ store }) {
return store.dispatch('FETCH_LIST_DATA', { type }) return store.dispatch("FETCH_LIST_DATA", { type })
}, },
head: { head: {
title: camelize(type), title: camelize(type)
}, },
render (h) { render(h) {
return h(ItemList, { props: { type }}) return h(ItemList, { props: { type } })
} }
} }
} }
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
</a> </a>
</nav> </nav>
</header> </header>
<nuxt nuxt-child-key="none"></nuxt> <nuxt nuxt-child-key="none" />
</div> </div>
</template> </template>
...@@ -25,7 +25,7 @@ export default { ...@@ -25,7 +25,7 @@ export default {
return { return {
link: [ link: [
// We use $route.path since we don't use query parameters // We use $route.path since we don't use query parameters
{ rel: 'canonical', href: `https://hn.nuxtjs.org${this.$route.path}` } { rel: "canonical", href: `https://hn.nuxtjs.org${this.$route.path}` }
] ]
} }
} }
...@@ -33,78 +33,109 @@ export default { ...@@ -33,78 +33,109 @@ export default {
</script> </script>
<style lang="stylus"> <style lang="stylus">
body body {
font-family -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, Cantarell, "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size 15px font-size: 15px;
background-color lighten(#eceef1, 30%) background-color: lighten(#eceef1, 30%);
margin 0 margin: 0;
padding 0 padding: 0;
color #34495e color: #34495e;
overflow-y scroll overflow-y: scroll;
}
a
color #34495e a {
text-decoration none color: #34495e;
text-decoration: none;
.header }
background-color #188269
z-index 999 .header {
height 55px background-color: #188269;
.inner z-index: 999;
max-width 800px height: 55px;
box-sizing border-box
margin 0px auto .inner {
padding 15px 5px max-width: 800px;
a box-sizing: border-box;
color #fff margin: 0px auto;
line-height 24px padding: 15px 5px;
transition color .15s ease }
display inline-block
vertical-align middle a {
font-weight 300 color: #fff;
letter-spacing .075em line-height: 24px;
margin-right 1.8em transition: color 0.15s ease;
&:hover display: inline-block;
color #fff vertical-align: middle;
&.router-link-active, &.nuxt-link-active font-weight: 300;
color #fff letter-spacing: 0.075em;
font-weight 400 margin-right: 1.8em;
&:nth-child(6)
margin-right 0 &:hover {
.github color: #fff;
color #fff }
font-size .9em
margin 0 &.router-link-active, &.nuxt-link-active {
float right color: #fff;
font-weight: 400;
.logo }
width 24px
margin-right 10px &:nth-child(6) {
display inline-block margin-right: 0;
vertical-align middle }
}
.view
max-width 800px .github {
margin 0 auto color: #fff;
position relative font-size: 0.9em;
margin: 0;
.appear-active float: right;
transition opacity .4s ease }
.page-enter-active, .page-leave-active }
transition all .2s ease
.appear, .page-enter, .page-leave-active .logo {
opacity 0 width: 24px;
margin-right: 10px;
@media (max-width 860px) display: inline-block;
.header .inner vertical-align: middle;
padding 15px 30px }
@media (max-width 600px) .view {
.header max-width: 800px;
.inner margin: 0 auto;
padding 15px position: relative;
a }
margin-right 1em
.github .appear-active {
display none transition: opacity 0.4s ease;
}
.page-enter-active, .page-leave-active {
transition: all 0.2s ease;
}
.appear, .page-enter, .page-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;
}
}
}
</style> </style>
export default ({ isDev, req, redirect }) => { export default ({ isDev, req, redirect }) => {
// Redirect to https // Redirect to https
if (!isDev && req) { if (!isDev && req) {
const protocol = req.headers['x-forwarded-proto'] || (req.connection.encrypted ? 'https' : 'http') const protocol =
if (protocol === 'http') { req.headers["x-forwarded-proto"] ||
(req.connection.encrypted ? "https" : "http")
if (protocol === "http") {
return redirect(301, `https://${req.headers.host}${req.url}`) return redirect(301, `https://${req.headers.host}${req.url}`)
} }
} }
......
module.exports = { module.exports = {
mode: 'universal', mode: "universal",
build: { build: {
extend (config, { isClient }) { extend(config, { isClient, isDev }) {
config.resolve.alias['create-api'] = `./create-api-${isClient ? 'client' : 'server'}.js` // Run ESLint on save
if (isDev && isClient) {
config.module.rules.push({
enforce: "pre",
test: /\.(js|vue)$/,
loader: "eslint-loader",
exclude: /(node_modules)/
})
}
config.resolve.alias["create-api"] = `./create-api-${
isClient ? "client" : "server"
}.js`
}, },
vendor: ['firebase'] vendor: ["firebase"]
}, },
head: { head: {
titleTemplate: 'Nuxt HN | %s', titleTemplate: "Nuxt HN | %s",
meta: [ meta: [
{ {
property: 'og:image', property: "og:image",
content: 'https://user-images.githubusercontent.com/904724/26879447-689b56a8-4b91-11e7-968f-5eea1d6c71b4.png' content:
"https://user-images.githubusercontent.com/904724/26879447-689b56a8-4b91-11e7-968f-5eea1d6c71b4.png"
}, },
{ property: 'twitter:card', content: 'summary_large_image' }, { property: "twitter:card", content: "summary_large_image" },
{ property: 'twitter:site', content: '@nuxt_js' }, { property: "twitter:site", content: "@nuxt_js" }
], ],
link: [ link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, { rel: "icon", type: "image/x-icon", href: "/favicon.ico" },
{ rel: 'dns-prefetch', href: 'https://hacker-news.firebaseio.com' } { rel: "dns-prefetch", href: "https://hacker-news.firebaseio.com" }
] ]
}, },
loading: { loading: {
color: '#59cc93' color: "#59cc93"
}, },
loadingIndicator: { loadingIndicator: {
name: 'rectangle-bounce', name: "rectangle-bounce",
color: 'white', color: "white",
background: '#188269' background: "#188269"
}, },
manifest: { manifest: {
name: 'Nuxt Hacker News', name: "Nuxt Hacker News",
description: 'HackerNews clone built with Nuxt.js', description: "HackerNews clone built with Nuxt.js",
theme_color: '#188269' theme_color: "#188269"
}, },
modules: [ modules: ["@nuxtjs/pwa", "@nuxtjs/component-cache"],
'@nuxtjs/pwa',
'@nuxtjs/component-cache'
],
plugins: [ plugins: [
'~/plugins/vuex-router-sync', "~/plugins/vuex-router-sync",
'~/plugins/filters', "~/plugins/filters",
'~/plugins/components' "~/plugins/components"
], ],
router: { router: {
middleware: ['https'] middleware: ["https"]
}, },
render: { render: {
static: { static: {
maxAge: '1y', maxAge: "1y",
setHeaders (res, path) { setHeaders(res, path) {
if (path.includes('sw.js')) { if (path.includes("sw.js")) {
res.setHeader('Cache-Control', 'public, max-age=0') res.setHeader("Cache-Control", "public, max-age=0")
} }
} }
} }
......
...@@ -21,7 +21,9 @@ ...@@ -21,7 +21,9 @@
"start": "nuxt start", "start": "nuxt start",
"dev-spa": "nuxt dev --spa", "dev-spa": "nuxt dev --spa",
"build-spa": "nuxt build --spa", "build-spa": "nuxt build --spa",
"start-spa": "nuxt start --spa" "start-spa": "nuxt start --spa",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
"lintfix": "eslint --fix --ext .js,.vue --ignore-path .gitignore ."
}, },
"now": { "now": {
"alias": "hn.nuxtjs.org" "alias": "hn.nuxtjs.org"
...@@ -30,14 +32,23 @@ ...@@ -30,14 +32,23 @@
"node": ">=8.0" "node": ">=8.0"
}, },
"dependencies": { "dependencies": {
"@nuxtjs/component-cache": "^1.1.0", "@nuxtjs/component-cache": "^1.1.1",
"@nuxtjs/pwa": "latest", "@nuxtjs/pwa": "2.0.6",
"nuxt": "^1.0.0-rc11" "nuxt": "^1.4.0",
"vue-server-renderer": "^2.5.13"
}, },
"devDependencies": { "devDependencies": {
"firebase": "^4.3.1", "@babel/helper-module-imports": "^7.0.0-beta.40",
"babel-eslint": "^8.2.2",
"eslint": "^4.18.2",
"eslint-config-prettier": "^2.9.0",
"eslint-loader": "^2.0.0",
"eslint-plugin-prettier": "^2.6.0",
"eslint-plugin-vue": "^4.3.0",
"firebase": "^4.10.1",
"prettier": "^1.11.1",
"stylus": "^0.54.5", "stylus": "^0.54.5",
"stylus-loader": "^3.0.1", "stylus-loader": "^3.0.2",
"vuex-router-sync": "^4.3.2" "vuex-router-sync": "^5.0.0"
} }
} }
<script> <script>
import createListView from '~/components/createListView' import createListView from "~/components/createListView"
export default createListView('ask') export default createListView("ask")
</script> </script>
<template></template>
<script> <script>
export default { export default {
fetch({redirect}) { fetch({ redirect }) {
redirect('/top') redirect("/top")
}
} }
}
</script> </script>
<template> <template>
<div class="item-view view" v-if="item"> <div v-if="item" class="item-view view" >
<template v-if="item"> <template v-if="item">
<div class="item-view-header"> <div class="item-view-header">
<a :href="item.url" target="_blank"> <a :href="item.url" target="_blank">
...@@ -9,19 +9,18 @@ ...@@ -9,19 +9,18 @@
({{ item.url | host }}) ({{ item.url | host }})
</span> </span>
<p class="meta"> <p class="meta">
{{ item.score }} points {{ item.score }} points | by
| by
<router-link :to="'/user/' + item.by">{{ item.by }}</router-link> <router-link :to="'/user/' + item.by">{{ item.by }}</router-link>
{{ item.time | timeAgo }} ago {{ item.time | timeAgo }} ago
</p> </p>
</div> </div>
<div class="item-view-comments"> <div class="item-view-comments">
<p class="item-view-comments-header"> <p class="item-view-comments-header">
{{ item.kids ? item.descendants + ' comments' : 'No comments yet.'}} {{ item.kids ? item.descendants + ' comments' : 'No comments yet.' }}
<spinner :show="loading"></spinner> <spinner :show="loading"/>
</p> </p>
<ul v-if="!loading" class="comment-children"> <ul v-if="!loading" class="comment-children">
<comment v-for="id in item.kids" :key="id" :id="id"></comment> <comment v-for="id in item.kids" :key="id" :id="id"/>
</ul> </ul>
</div> </div>
</template> </template>
...@@ -29,105 +28,126 @@ ...@@ -29,105 +28,126 @@
</template> </template>
<script> <script>
import Spinner from '../../components/Spinner.vue' import Spinner from "~/components/Spinner.vue"
import Comment from '../../components/Comment.vue' import Comment from "~/components/Comment.vue"
export default { export default {
name: 'item-view', name: "ItemView",
components: {Spinner, Comment}, components: { Spinner, Comment },
data: () => ({ data: () => ({
loading: true loading: true
}), }),
head() { head() {
return { return {
title: this.item.title title: this.item.title
} }
}, },
computed: { computed: {
item() { item() {
return this.$store.state.items[this.$route.params.id] return this.$store.state.items[this.$route.params.id]
} }
}, },
// We only fetch the item itself before entering the view, because // We only fetch the item itself before entering the view, because
// it might take a long time to load threads with hundreds of comments // it might take a long time to load threads with hundreds of comments
// due to how the HN Firebase API works. // due to how the HN Firebase API works.
asyncData({store, route: {params: {id}}}) { asyncData({ store, route: { params: { id } } }) {
return store.dispatch('FETCH_ITEMS', {ids: [id]}) return store.dispatch("FETCH_ITEMS", { ids: [id] })
}, },
// Fetch comments when mounted on the client // refetch comments if item changed
beforeMount() { watch: {
this.fetchComments() item: "fetchComments"
}, },
// refetch comments if item changed // Fetch comments when mounted on the client
watch: { beforeMount() {
item: 'fetchComments' this.fetchComments()
}, },
methods: { methods: {
fetchComments() { fetchComments() {
this.loading = true this.loading = true
fetchComments(this.$store, this.item).then(() => { fetchComments(this.$store, this.item).then(() => {
this.loading = false this.loading = false
}) })
}
} }
} }
}
// recursively fetch all descendent comments // recursively fetch all descendent comments
function fetchComments(store, item) { function fetchComments(store, item) {
if (item && item.kids) { if (item && item.kids) {
return store.dispatch('FETCH_ITEMS', { return store
.dispatch("FETCH_ITEMS", {
ids: item.kids ids: item.kids
}).then(() => Promise.all(item.kids.map(id => { })
return fetchComments(store, store.state.items[id]) .then(() =>
}))) Promise.all(
} item.kids.map(id => {
return Promise.resolve() return fetchComments(store, store.state.items[id])
})
)
)
} }
return Promise.resolve()
}
</script> </script>
<style lang="stylus"> <style lang="stylus">
.item-view-header .item-view-header {
background-color #fff background-color: #fff;
padding 1.8em 2em 1em padding: 1.8em 2em 1em;
box-shadow 0 1px 2px rgba(0, 0, 0, .1) box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
h1
display inline h1 {
font-size 1.5em display: inline;
margin 0 font-size: 1.5em;
margin-right .5em margin: 0;
.host, .meta, .meta a margin-right: 0.5em;
color #595959 }
.meta a
text-decoration underline .host, .meta, .meta a {
color: #595959;
.item-view-comments }
background-color #fff
margin-top 10px .meta a {
padding 0 2em .5em text-decoration: underline;
}
.item-view-comments-header }
margin 0
font-size 1.1em .item-view-comments {
padding 1em 0 background-color: #fff;
position relative margin-top: 10px;
.spinner padding: 0 2em 0.5em;
display inline-block }
margin -15px 0
.item-view-comments-header {
.comment-children margin: 0;
list-style-type none font-size: 1.1em;
padding 0 padding: 1em 0;
margin 0 position: relative;
@media (max-width 600px) .spinner {
.item-view-header display: inline-block;
h1 margin: -15px 0;
font-size 1.25em }
}
.comment-children {
list-style-type: none;
padding: 0;
margin: 0;
}
@media (max-width: 600px) {
.item-view-header {
h1 {
font-size: 1.25em;
}
}
}
</style> </style>
<script> <script>
import createListView from '~/components/createListView' import createListView from "~/components/createListView"
export default createListView('job') export default createListView("job")
</script> </script>
<script> <script>
import createListView from '~/components/createListView' import createListView from "~/components/createListView"
export default createListView('new') export default createListView("new")
</script> </script>
<script> <script>
import createListView from '~/components/createListView' import createListView from "~/components/createListView"
export default createListView('show') export default createListView("show")
</script> </script>
<script> <script>
import createListView from '~/components/createListView' import createListView from "~/components/createListView"
export default createListView('top') export default createListView("top")
</script> </script>
...@@ -3,9 +3,11 @@ ...@@ -3,9 +3,11 @@
<template v-if="user"> <template v-if="user">
<h1>User : {{ user.id }}</h1> <h1>User : {{ user.id }}</h1>
<ul class="meta"> <ul class="meta">
<li><span class="label">Created:</span> {{ user.created | timeAgo }} ago</li> <li>
<li><span class="label">Karma:</span> {{user.karma}}</li> <span class="label">Created:</span> {{ user.created | timeAgo }} ago</li>
<li v-if="user.about" v-html="user.about" class="about"></li> <li>
<span class="label">Karma:</span> {{ user.karma }}</li>
<li v-if="user.about" class="about" v-html="user.about" />
</ul> </ul>
<p class="links"> <p class="links">
<a :href="'https://news.ycombinator.com/submitted?id=' + user.id">submissions</a> | <a :href="'https://news.ycombinator.com/submitted?id=' + user.id">submissions</a> |
...@@ -19,44 +21,54 @@ ...@@ -19,44 +21,54 @@
</template> </template>
<script> <script>
export default {
name: "UserView",
export default { computed: {
name: 'user-view', user() {
return this.$store.state.users[this.$route.params.id]
}
},
computed: { head() {
user() { return {
return this.$store.state.users[this.$route.params.id] title: this.user.id || "User not found"
} }
}, },
head() { fetch({ store, route: { params: { id } } }) {
return { return store.dispatch("FETCH_USER", { id })
title: this.user.id || 'User not found'
}
},
fetch({store, route: {params: {id}}}) {
return store.dispatch('FETCH_USER', {id})
},
} }
}
</script> </script>
<style lang="stylus"> <style lang="stylus">
.user-view .user-view {
background-color #fff background-color: #fff;
box-sizing border-box box-sizing: border-box;
padding 2em 3em padding: 2em 3em;
h1
margin 0 h1 {
font-size 1.5em margin: 0;
.meta font-size: 1.5em;
list-style-type none }
padding 0
.label .meta {
display inline-block list-style-type: none;
min-width 4em padding: 0;
.about }
margin 1em 0
.links a .label {
text-decoration underline display: inline-block;
min-width: 4em;
}
.about {
margin: 1em 0;
}
.links a {
text-decoration: underline;
}
}
</style> </style>
// Import common components to optimize chunks size // Import common components to optimize chunks size
import '~/components/createListView' import "~/components/createListView"
import Vue from 'vue' import Vue from "vue"
export function host(url) { export function host(url) {
const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '') const host = url.replace(/^https?:\/\//, "").replace(/\/.*$/, "")
const parts = host.split('.').slice(-3) const parts = host.split(".").slice(-3)
if (parts[0] === 'www') parts.shift() if (parts[0] === "www") parts.shift()
return parts.join('.') return parts.join(".")
} }
export function timeAgo(time) { export function timeAgo(time) {
const between = Date.now() / 1000 - Number(time) const between = Date.now() / 1000 - Number(time)
if (between < 3600) { if (between < 3600) {
return pluralize(~~(between / 60), ' minute') return pluralize(~~(between / 60), " minute")
} else if (between < 86400) { } else if (between < 86400) {
return pluralize(~~(between / 3600), ' hour') return pluralize(~~(between / 3600), " hour")
} else { } else {
return pluralize(~~(between / 86400), ' day') return pluralize(~~(between / 86400), " day")
} }
} }
...@@ -22,7 +22,7 @@ function pluralize(time, label) { ...@@ -22,7 +22,7 @@ function pluralize(time, label) {
if (time === 1) { if (time === 1) {
return time + label return time + label
} }
return time + label + 's' return time + label + "s"
} }
const filters = { const filters = {
......
import Vue from 'vue' import { sync } from "vuex-router-sync"
import { sync } from 'vuex-router-sync'
export default function ({ app, store }) { export default function({ app, store }) {
sync(store, app.router) sync(store, app.router)
} }
import { import { fetchUser, fetchItems, fetchIdsByType } from "../api"
fetchUser,
fetchItems,
fetchIdsByType
} from '../api'
export default { export default {
// ensure data for rendering given list type // ensure data for rendering given list type
FETCH_LIST_DATA: ({ commit, dispatch, state }, { type }) => { FETCH_LIST_DATA: ({ commit, dispatch }, { type }) => {
commit('SET_ACTIVE_TYPE', { type }) commit("SET_ACTIVE_TYPE", { type })
return fetchIdsByType(type) return fetchIdsByType(type)
.then(ids => commit('SET_LIST', { type, ids })) .then(ids => commit("SET_LIST", { type, ids }))
.then(() => dispatch('ENSURE_ACTIVE_ITEMS')) .then(() => dispatch("ENSURE_ACTIVE_ITEMS"))
}, },
// ensure all active items are fetched // ensure all active items are fetched
ENSURE_ACTIVE_ITEMS: ({ dispatch, getters }) => { ENSURE_ACTIVE_ITEMS: ({ dispatch, getters }) => {
return dispatch('FETCH_ITEMS', { return dispatch("FETCH_ITEMS", {
ids: getters.activeIds ids: getters.activeIds
}) })
}, },
...@@ -35,7 +31,7 @@ export default { ...@@ -35,7 +31,7 @@ export default {
return false return false
}) })
if (ids.length) { if (ids.length) {
return fetchItems(ids).then(items => commit('SET_ITEMS', { items })) return fetchItems(ids).then(items => commit("SET_ITEMS", { items }))
} else { } else {
return Promise.resolve() return Promise.resolve()
} }
...@@ -44,6 +40,6 @@ export default { ...@@ -44,6 +40,6 @@ export default {
FETCH_USER: ({ commit, state }, { id }) => { FETCH_USER: ({ commit, state }, { id }) => {
return state.users[id] return state.users[id]
? Promise.resolve(state.users[id]) ? Promise.resolve(state.users[id])
: fetchUser(id).then(user => commit('SET_USER', { id, user })) : fetchUser(id).then(user => commit("SET_USER", { id, user }))
} }
} }
export default { export default {
// ids of the items that should be currently displayed based on // ids of the items that should be currently displayed based on
// current list type and current pagination // current list type and current pagination
activeIds (state) { activeIds(state) {
const { activeType, itemsPerPage, lists } = state const { activeType, itemsPerPage, lists } = state
if (!activeType) { if (!activeType) {
...@@ -17,7 +17,7 @@ export default { ...@@ -17,7 +17,7 @@ export default {
// items that should be currently displayed. // items that should be currently displayed.
// this Array may not be fully fetched. // this Array may not be fully fetched.
activeItems (state, getters) { activeItems(state, getters) {
return getters.activeIds.map(id => state.items[id]).filter(_ => _) return getters.activeIds.map(id => state.items[id]).filter(_ => _)
} }
} }
import Vuex from 'vuex' import Vuex from "vuex"
import actions from './actions' import actions from "./actions"
import mutations from './mutations' import mutations from "./mutations"
import getters from './getters' import getters from "./getters"
export default () => { export default () => {
return new Vuex.Store({ return new Vuex.Store({
state: { state: {
activeType: null, activeType: null,
itemsPerPage: 20, itemsPerPage: 20,
items: {/* [id: number]: Item */}, items: {
users: {/* [id: string]: User */}, /* [id: number]: Item */
},
users: {
/* [id: string]: User */
},
lists: { lists: {
top: [/* number */], top: [
/* number */
],
new: [], new: [],
show: [], show: [],
ask: [], ask: [],
......
import Vue from 'vue' import Vue from "vue"
export default { export default {
SET_ACTIVE_TYPE: (state, { type }) => { SET_ACTIVE_TYPE: (state, { type }) => {
......
This source diff could not be displayed because it is too large. You can view the blob instead.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment