Commit 1ec5a8ad authored by Pooya Parsa's avatar Pooya Parsa

nuxt hacker news

parent b9bfdd85
{
"presets": [
["env", { "modules": false }]
],
"plugins": [
"syntax-dynamic-import"
]
}
# editorconfig.org
root = true
[*]
indent_size = 2
indent_style = space
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
...@@ -5,3 +5,8 @@ npm-debug.log ...@@ -5,3 +5,8 @@ npm-debug.log
yarn-error.log yarn-error.log
.idea .idea
*.iml *.iml
.nuxt
static/manifest.*.json
static/sw.js
static/workbox-sw.*.js
# vue-hackernews-2.0 # Nuxt Hacker News!
Port of [vue-hackernews-2.0](https://github.com/vuejs/vue-hackernews-2.0) for [nuxt.js](https://github.com/nuxt/nuxt.js).
HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side rendering.
<p align="center"> <p align="center">
<a href="https://vue-hn.now.sh" target="_blank"> <a href="https://vue-hn.now.sh" target="_blank">
...@@ -12,25 +11,7 @@ HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side render ...@@ -12,25 +11,7 @@ HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side render
## Features ## Features
> Note: in practice, it is unnecessary to code-split for an app of this size (where each async chunk is only a few kilobytes), nor is it optimal to extract an extra CSS file (which is only 1kb) -- they are used simply because this is a demo app showcasing all the supported features. In real apps, you should always measure and optimize based on your actual app constraints. [TODO]
- Server Side Rendering
- Vue + vue-router + vuex working together
- Server-side data pre-fetching
- Client-side state & DOM hydration
- Automatically inlines CSS used by rendered components only
- Preload / prefetch resource hints
- Route-level code splitting
- Progressive Web App
- App manifest
- Service worker
- 100/100 Lighthouse score
- Single-file Vue Components
- Hot-reload in development
- CSS extraction for production
- Animation
- Effects when switching route views
- Real-time list updates with FLIP Animation
## Architecture Overview ## Architecture Overview
...@@ -46,7 +27,7 @@ HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side render ...@@ -46,7 +27,7 @@ HackerNews clone built with Vue 2.0 + vue-router + vuex, with server-side render
# install dependencies # install dependencies
npm install # or yarn npm install # or yarn
# serve in dev mode, with hot reload at localhost:8080 # serve in dev mode, with hot reload at localhost:300
npm run dev npm run dev
# build for production # build for production
......
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html {{ HTML_ATTRS }}>
<head> <head>
<title>{{ title }}</title> {{ HEAD }}
<meta charset="utf-8"> <style>
<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 { position:absolute; left:-10000px; top:auto; width:1px; height:1px; overflow:hidden; }
#skip a:focus { position:static; width:auto; height:auto; } #skip a:focus { position:static; width:auto; height:auto; }
</style> </style>
</head> </head>
<body> <body {{ BODY_ATTRS }}>
<div id="skip"><a href="#app">skip to content</a></div> <div id="skip"><a href="#app">skip to content</a></div>
<!--vue-ssr-outlet--> {{ APP }}
</body> </body>
</html> </html>
const path = require('path')
const webpack = require('webpack')
const MFS = require('memory-fs')
const clientConfig = require('./webpack.client.config')
const serverConfig = require('./webpack.server.config')
const readFile = (fs, file) => {
try {
return fs.readFileSync(path.join(clientConfig.output.path, file), 'utf-8')
} catch (e) {}
}
module.exports = function setupDevServer (app, cb) {
let bundle, clientManifest
let resolve
const readyPromise = new Promise(r => { resolve = r })
const ready = (...args) => {
resolve()
cb(...args)
}
// modify client config to work with hot middleware
clientConfig.entry.app = ['webpack-hot-middleware/client', clientConfig.entry.app]
clientConfig.output.filename = '[name].js'
clientConfig.plugins.push(
new webpack.HotModuleReplacementPlugin(),
new webpack.NoEmitOnErrorsPlugin()
)
// dev middleware
const clientCompiler = webpack(clientConfig)
const devMiddleware = require('webpack-dev-middleware')(clientCompiler, {
publicPath: clientConfig.output.publicPath,
noInfo: true
})
app.use(devMiddleware)
clientCompiler.plugin('done', stats => {
stats = stats.toJson()
stats.errors.forEach(err => console.error(err))
stats.warnings.forEach(err => console.warn(err))
if (stats.errors.length) return
clientManifest = JSON.parse(readFile(
devMiddleware.fileSystem,
'vue-ssr-client-manifest.json'
))
if (bundle) {
ready(bundle, {
clientManifest
})
}
})
// hot middleware
app.use(require('webpack-hot-middleware')(clientCompiler))
// watch and update server renderer
const serverCompiler = webpack(serverConfig)
const mfs = new MFS()
serverCompiler.outputFileSystem = mfs
serverCompiler.watch({}, (err, stats) => {
if (err) throw err
stats = stats.toJson()
if (stats.errors.length) return
// read bundle generated by vue-ssr-webpack-plugin
bundle = JSON.parse(readFile(mfs, 'vue-ssr-server-bundle.json'))
if (clientManifest) {
ready(bundle, {
clientManifest
})
}
})
return readyPromise
}
module.exports = {
extractCSS: process.env.NODE_ENV === 'production',
preserveWhitespace: false,
postcss: [
require('autoprefixer')({
browsers: ['last 3 versions']
})
]
}
const path = require('path')
const webpack = require('webpack')
const vueConfig = require('./vue-loader.config')
const ExtractTextPlugin = require('extract-text-webpack-plugin')
const FriendlyErrorsPlugin = require('friendly-errors-webpack-plugin')
const isProd = process.env.NODE_ENV === 'production'
module.exports = {
devtool: isProd
? false
: '#cheap-module-source-map',
output: {
path: path.resolve(__dirname, '../dist'),
publicPath: '/dist/',
filename: '[name].[chunkhash].js'
},
resolve: {
alias: {
'public': path.resolve(__dirname, '../public')
}
},
module: {
noParse: /es6-promise\.js$/, // avoid webpack shimming process
rules: [
{
test: /\.vue$/,
loader: 'vue-loader',
options: vueConfig
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'url-loader',
options: {
limit: 10000,
name: '[name].[ext]?[hash]'
}
},
{
test: /\.css$/,
use: isProd
? ExtractTextPlugin.extract({
use: 'css-loader?minimize',
fallback: 'vue-style-loader'
})
: ['vue-style-loader', 'css-loader']
}
]
},
performance: {
maxEntrypointSize: 300000,
hints: isProd ? 'warning' : false
},
plugins: isProd
? [
new webpack.optimize.UglifyJsPlugin({
compress: { warnings: false }
}),
new ExtractTextPlugin({
filename: 'common.[chunkhash].css'
})
]
: [
new FriendlyErrorsPlugin()
]
}
const glob = require('glob')
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const SWPrecachePlugin = require('sw-precache-webpack-plugin')
const VueSSRClientPlugin = require('vue-server-renderer/client-plugin')
const config = merge(base, {
entry: {
app: './src/entry-client.js'
},
resolve: {
alias: {
'create-api': './create-api-client.js'
}
},
plugins: [
// strip dev-only code in Vue source
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"client"'
}),
// extract vendor chunks for better caching
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module) {
// a module is extracted into the vendor chunk if...
return (
// it's inside node_modules
/node_modules/.test(module.context) &&
// and not a CSS file (due to extract-text-webpack-plugin limitation)
!/\.css$/.test(module.request)
)
}
}),
// extract webpack runtime & manifest to avoid vendor chunk hash changing
// on every build.
new webpack.optimize.CommonsChunkPlugin({
name: 'manifest'
}),
new VueSSRClientPlugin()
]
})
if (process.env.NODE_ENV === 'production') {
config.plugins.push(
// auto generate service worker
new SWPrecachePlugin({
cacheId: 'vue-hn',
filename: 'service-worker.js',
dontCacheBustUrlsMatching: /./,
staticFileGlobsIgnorePatterns: [/\.map$/, /\.json$/],
runtimeCaching: [
{
urlPattern: '/',
handler: 'networkFirst'
},
{
urlPattern: /\/(top|new|show|ask|jobs)/,
handler: 'networkFirst'
},
{
urlPattern: '/item/:id',
handler: 'networkFirst'
},
{
urlPattern: '/user/:id',
handler: 'networkFirst'
}
]
})
)
}
module.exports = config
const webpack = require('webpack')
const merge = require('webpack-merge')
const base = require('./webpack.base.config')
const nodeExternals = require('webpack-node-externals')
const VueSSRServerPlugin = require('vue-server-renderer/server-plugin')
module.exports = merge(base, {
target: 'node',
devtool: '#source-map',
entry: './src/entry-server.js',
output: {
filename: 'server-bundle.js',
libraryTarget: 'commonjs2'
},
resolve: {
alias: {
'create-api': './create-api-server.js'
}
},
// https://webpack.js.org/configuration/externals/#externals
// https://github.com/liady/webpack-node-externals
externals: nodeExternals({
// do not externalize CSS files in case we need to import it from a dep
whitelist: /\.css$/
}),
plugins: [
new webpack.DefinePlugin({
'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV || 'development'),
'process.env.VUE_ENV': '"server"'
}),
new VueSSRServerPlugin()
]
})
...@@ -27,7 +27,7 @@ ...@@ -27,7 +27,7 @@
</template> </template>
<script> <script>
import { timeAgo } from '../util/filters' import { timeAgo } from '../plugins/filters'
export default { export default {
name: 'news-item', name: 'news-item',
......
...@@ -3,26 +3,26 @@ ...@@ -3,26 +3,26 @@
<header class="header"> <header class="header">
<nav class="inner"> <nav class="inner">
<router-link to="/" exact> <router-link to="/" exact>
<img class="logo" src="~public/logo-48.png" alt="logo"> <img class="logo" src="~assets/logo.png" alt="logo">
</router-link> </router-link>
<router-link to="/top">Top</router-link> <router-link to="/top">Top</router-link>
<router-link to="/new">New</router-link> <router-link to="/new">New</router-link>
<router-link to="/show">Show</router-link> <router-link to="/show">Show</router-link>
<router-link to="/ask">Ask</router-link> <router-link to="/ask">Ask</router-link>
<router-link to="/job">Jobs</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"> <a class="github" href="https://github.com/nuxt/nuxt.js" target="_blank" rel="noopener">
Built with Vue.js Built with Nuxt.js
</a> </a>
</nav> </nav>
</header> </header>
<transition name="fade" mode="out-in"> <transition name="fade" mode="out-in">
<router-view class="view"></router-view> <nuxt></nuxt>
</transition> </transition>
</div> </div>
</template> </template>
<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%)
...@@ -31,12 +31,12 @@ body ...@@ -31,12 +31,12 @@ body
color #34495e color #34495e
overflow-y scroll overflow-y scroll
a a
color #34495e color #34495e
text-decoration none text-decoration none
.header .header
background-color #ff6600 background-color #3B8070
position fixed position fixed
z-index 999 z-index 999
height 55px height 55px
...@@ -70,28 +70,28 @@ a ...@@ -70,28 +70,28 @@ a
margin 0 margin 0
float right float right
.logo .logo
width 24px width 24px
margin-right 10px margin-right 10px
display inline-block display inline-block
vertical-align middle vertical-align middle
.view .view
max-width 800px max-width 800px
margin 0 auto margin 0 auto
position relative position relative
.fade-enter-active, .fade-leave-active .fade-enter-active, .fade-leave-active
transition all .2s ease transition all .2s ease
.fade-enter, .fade-leave-active .fade-enter, .fade-leave-active
opacity 0 opacity 0
@media (max-width 860px) @media (max-width 860px)
.header .inner .header .inner
padding 15px 30px padding 15px 30px
@media (max-width 600px) @media (max-width 600px)
.header .header
.inner .inner
padding 15px padding 15px
......
{
"name": "Vue Hackernews 2.0",
"short_name": "Vue 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-512.png",
"sizes": "512x512",
"type": "image/png"
}],
"start_url": "/",
"background_color": "#f2f3f5",
"display": "standalone",
"theme_color": "#f60"
}
module.exports = {
loading: {color: '#ff6600'},
modules: [
require('@nuxtjs/manifest'),
require('@nuxtjs/meta'),
require('@nuxtjs/workbox'),
require('@nuxtjs/component-cache')
],
plugins: [
'~plugins/filters.js'
],
build: {
extractCSS: true,
ssr: {
// TODO: make component-cache working in production
cache: require('lru-cache')({
max: 10000,
maxAge: 1000 * 60 * 15
})
},
extend(config, {isClient}) {
config.resolve.alias['create-api'] =
`./create-api-${isClient ? 'client' : 'server'}.js`
}
},
}
{ {
"name": "vue-hackernews-2.0", "name": "nuxt-hn",
"description": "A Vue.js project", "description": "Nuxt Hacker News",
"author": "Evan You <yyx990803@gmail.com>", "author": "Evan You <yyx990803@gmail.com>",
"contributors": [
{
"name": "Sebastien Chopin (@Atinux)"
},
{
"name": "Alexandre Chopin (@alexchopin)"
},
{
"name": "Pooya Parsa (@pi0)"
}
],
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "node server", "dev": "nuxt dev",
"start": "cross-env NODE_ENV=production node server", "start": "nuxt start",
"build": "rimraf dist && npm run build:client && npm run build:server", "build": "nuxt build"
"build:client": "cross-env NODE_ENV=production webpack --config build/webpack.client.config.js --progress --hide-modules",
"build:server": "cross-env NODE_ENV=production webpack --config build/webpack.server.config.js --progress --hide-modules"
}, },
"engines": { "engines": {
"node": ">=7.0", "node": ">=7.0",
"npm": ">=4.0" "npm": ">=4.0"
}, },
"dependencies": { "dependencies": {
"compression": "^1.6.2", "@nuxtjs/manifest": "latest",
"cross-env": "^4.0.0", "@nuxtjs/meta": "latest",
"es6-promise": "^4.1.0", "@nuxtjs/workbox": "latest",
"express": "^4.15.2", "@nuxtjs/component-cache": "latest",
"extract-text-webpack-plugin": "^2.1.0", "firebase": "^4.1.1",
"firebase": "^3.7.2", "nuxt": "^1.0.0-alpha2",
"lru-cache": "^4.0.2",
"serve-favicon": "^2.4.1",
"vue": "^2.3.2",
"vue-router": "^2.5.0",
"vue-server-renderer": "^2.3.2",
"vuex": "^2.3.1",
"vuex-router-sync": "^4.1.2"
},
"devDependencies": {
"autoprefixer": "^6.7.7",
"babel-core": "^6.24.1",
"babel-loader": "^6.4.1",
"babel-plugin-syntax-dynamic-import": "^6.18.0",
"babel-preset-env": "^1.4.0",
"css-loader": "^0.28.0",
"file-loader": "^0.11.1",
"friendly-errors-webpack-plugin": "^1.6.1",
"glob": "^7.1.1",
"rimraf": "^2.6.1",
"stylus": "^0.54.5", "stylus": "^0.54.5",
"stylus-loader": "^3.0.1", "stylus-loader": "^3.0.1"
"sw-precache-webpack-plugin": "^0.10.1",
"url-loader": "^0.5.8",
"vue-loader": "^12.0.2",
"vue-style-loader": "^3.0.0",
"vue-template-compiler": "^2.3.2",
"webpack": "^2.4.1",
"webpack-dev-middleware": "^1.10.1",
"webpack-hot-middleware": "^2.17.1",
"webpack-merge": "^4.0.0",
"webpack-node-externals": "^1.5.4"
} }
} }
...@@ -19,66 +19,69 @@ ...@@ -19,66 +19,69 @@
</template> </template>
<script> <script>
import { watchList } from '../api' import {watchList} from '../../api'
import Item from '../components/Item.vue' import Item from '../../components/Item.vue'
export default { const camelize = str => str.charAt(0).toUpperCase() + str.slice(1)
export default {
name: 'item-list', name: 'item-list',
components: { components: {
Item Item
}, },
data() {
props: {
type: String
},
data () {
return { return {
transition: 'slide-right', transition: 'slide-right',
displayedPage: Number(this.$store.state.route.params.page) || 1, displayedPage: Number(this.$route.params.page) || 1,
displayedItems: this.$store.getters.activeItems displayedItems: this.$store.getters.activeItems,
type: this.$route.params.type
} }
}, },
fetch({store, params}) {
return store.dispatch('FETCH_LIST_DATA', {type: params.type})
},
meta: {
},
computed: { computed: {
page () { page() {
return Number(this.$store.state.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)
}, },
hasMore () { hasMore() {
return this.page < this.maxPage return this.page < this.maxPage
} }
}, },
beforeMount () { 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 = watchList(this.type, ids => { this.unwatchList = 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
}) })
}) })
}, },
beforeDestroy () { beforeDestroy() {
this.unwatchList() this.unwatchList()
}, },
watch: { watch: {
page (to, from) { page(to, from) {
this.loadItems(to, from) this.loadItems(to, from)
} }
}, },
methods: { methods: {
loadItems (to = this.page, from = -1) { loadItems(to = this.page, from = -1) {
this.$bar.start() this.$bar.start()
this.$store.dispatch('FETCH_LIST_DATA', { this.$store.dispatch('FETCH_LIST_DATA', {
type: this.type type: this.type
...@@ -96,18 +99,18 @@ export default { ...@@ -96,18 +99,18 @@ export default {
}) })
} }
} }
} }
</script> </script>
<style lang="stylus"> <style lang="stylus">
.news-view .news-view
padding-top 45px padding-top 45px
.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
position fixed position fixed
text-align center text-align center
...@@ -115,43 +118,43 @@ export default { ...@@ -115,43 +118,43 @@ export default {
left 0 left 0
right 0 right 0
z-index 998 z-index 998
box-shadow 0 1px 2px rgba(0,0,0,.1) box-shadow 0 1px 2px rgba(0, 0, 0, .1)
a a
margin 0 1em margin 0 1em
.disabled .disabled
color #ccc color #ccc
.news-list .news-list
position absolute position absolute
margin 30px 0 margin 30px 0
width 100% width 100%
transition all .5s cubic-bezier(.55,0,.1,1) transition all .5s cubic-bezier(.55, 0, .1, 1)
ul ul
list-style-type none list-style-type none
padding 0 padding 0
margin 0 margin 0
.slide-left-enter, .slide-right-leave-to .slide-left-enter, .slide-right-leave-to
opacity 0 opacity 0
transform translate(30px, 0) transform translate(30px, 0)
.slide-left-leave-to, .slide-right-enter .slide-left-leave-to, .slide-right-enter
opacity 0 opacity 0
transform translate(-30px, 0) transform translate(-30px, 0)
.item-move, .item-enter-active, .item-leave-active .item-move, .item-enter-active, .item-leave-active
transition all .5s cubic-bezier(.55,0,.1,1) transition all .5s cubic-bezier(.55, 0, .1, 1)
.item-enter .item-enter
opacity 0 opacity 0
transform translate(30px, 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>
...@@ -28,10 +28,10 @@ ...@@ -28,10 +28,10 @@
</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: 'item-view',
components: { Spinner, Comment }, components: { Spinner, Comment },
...@@ -74,10 +74,10 @@ export default { ...@@ -74,10 +74,10 @@ export default {
}) })
} }
} }
} }
// 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
...@@ -85,11 +85,11 @@ function fetchComments (store, item) { ...@@ -85,11 +85,11 @@ function fetchComments (store, item) {
return fetchComments(store, store.state.items[id]) return fetchComments(store, store.state.items[id])
}))) })))
} }
} }
</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,.1)
...@@ -103,12 +103,12 @@ function fetchComments (store, item) { ...@@ -103,12 +103,12 @@ function fetchComments (store, item) {
.meta a .meta a
text-decoration underline text-decoration underline
.item-view-comments .item-view-comments
background-color #fff background-color #fff
margin-top 10px margin-top 10px
padding 0 2em .5em padding 0 2em .5em
.item-view-comments-header .item-view-comments-header
margin 0 margin 0
font-size 1.1em font-size 1.1em
padding 1em 0 padding 1em 0
...@@ -117,12 +117,12 @@ function fetchComments (store, item) { ...@@ -117,12 +117,12 @@ function fetchComments (store, item) {
display inline-block display inline-block
margin -15px 0 margin -15px 0
.comment-children .comment-children
list-style-type none list-style-type none
padding 0 padding 0
margin 0 margin 0
@media (max-width 600px) @media (max-width 600px)
.item-view-header .item-view-header
h1 h1
font-size 1.25em font-size 1.25em
......
export function host (url) { import Vue from 'vue'
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')
...@@ -16,9 +18,19 @@ export function timeAgo (time) { ...@@ -16,9 +18,19 @@ export function timeAgo (time) {
} }
} }
function pluralize (time, label) { 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 = {
host,
timeAgo
}
export default filters
Object.keys(filters).forEach(key => {
Vue.filter(key, filters[key])
})
const fs = require('fs')
const path = require('path')
const LRU = require('lru-cache')
const express = require('express')
const favicon = require('serve-favicon')
const compression = require('compression')
const resolve = file => path.resolve(__dirname, file)
const { createBundleRenderer } = require('vue-server-renderer')
const isProd = process.env.NODE_ENV === 'production'
const useMicroCache = process.env.MICRO_CACHE !== 'false'
const serverInfo =
`express/${require('express/package.json').version} ` +
`vue-server-renderer/${require('vue-server-renderer/package.json').version}`
const app = express()
const template = fs.readFileSync(resolve('./src/index.template.html'), 'utf-8')
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, {
template,
// for component caching
cache: LRU({
max: 1000,
maxAge: 1000 * 60 * 15
}),
// this is only needed when vue-server-renderer is npm-linked
basedir: resolve('./dist'),
// recommended for performance
runInNewContext: false
}))
}
let renderer
let readyPromise
if (isProd) {
// In production: create server renderer using built server bundle.
// The server bundle is generated by vue-ssr-webpack-plugin.
const bundle = require('./dist/vue-ssr-server-bundle.json')
// The client manifests are optional, but it allows the renderer
// to automatically infer preload/prefetch links and directly add <script>
// tags for any async chunks used during render, avoiding waterfall requests.
const clientManifest = require('./dist/vue-ssr-client-manifest.json')
renderer = createRenderer(bundle, {
clientManifest
})
} else {
// In development: setup the dev server with watch and hot-reload,
// and create a new renderer on bundle / index template update.
readyPromise = require('./build/setup-dev-server')(app, (bundle, options) => {
renderer = createRenderer(bundle, options)
})
}
const serve = (path, cache) => express.static(resolve(path), {
maxAge: cache && isProd ? 1000 * 60 * 60 * 24 * 30 : 0
})
app.use(compression({ threshold: 0 }))
app.use(favicon('./public/logo-48.png'))
app.use('/dist', serve('./dist', true))
app.use('/public', serve('./public', true))
app.use('/manifest.json', serve('./manifest.json', true))
app.use('/service-worker.js', serve('./dist/service-worker.js'))
// 1-second microcache.
// https://www.nginx.com/blog/benefits-of-microcaching-nginx/
const microCache = LRU({
max: 100,
maxAge: 1000
})
// since this app has no user-specific content, every page is micro-cacheable.
// if your app involves user-specific content, you need to implement custom
// logic to determine whether a request is cacheable based on its url and
// headers.
const isCacheable = req => useMicroCache
function render (req, res) {
const s = Date.now()
res.setHeader("Content-Type", "text/html")
res.setHeader("Server", serverInfo)
const handleError = err => {
if (err && err.code === 404) {
res.status(404).end('404 | Page Not Found')
} else {
// Render Error Page or Redirect
res.status(500).end('500 | Internal Server Error')
console.error(`error during render : ${req.url}`)
console.error(err.stack)
}
}
const cacheable = isCacheable(req)
if (cacheable) {
const hit = microCache.get(req.url)
if (hit) {
if (!isProd) {
console.log(`cache hit!`)
}
return res.end(hit)
}
}
const context = {
title: 'Vue HN 2.0', // default title
url: req.url
}
renderer.renderToString(context, (err, html) => {
if (err) {
return handleError(err)
}
res.end(html)
if (cacheable) {
microCache.set(req.url, html)
}
if (!isProd) {
console.log(`whole request: ${Date.now() - s}ms`)
}
})
}
app.get('*', isProd ? render : (req, res) => {
readyPromise.then(() => render(req, res))
})
const port = process.env.PORT || 8080
app.listen(port, () => {
console.log(`server started at localhost:${port}`)
})
import Vue from 'vue'
import App from './App.vue'
import { createStore } from './store'
import { createRouter } from './router'
import { sync } from 'vuex-router-sync'
import titleMixin from './util/title'
import * as filters from './util/filters'
// mixin for handling title
Vue.mixin(titleMixin)
// register global utility filters.
Object.keys(filters).forEach(key => {
Vue.filter(key, filters[key])
})
// Expose a factory function that creates a fresh set of store, router,
// app instances on each call (which is called for each SSR request)
export function createApp () {
// create store and router instances
const store = createStore()
const router = createRouter()
// sync the router with the vuex store.
// this registers `store.state.route`
sync(store, router)
// create the app instance.
// here we inject the router, store and ssr context to all child components,
// making them available everywhere as `this.$router` and `this.$store`.
const app = new Vue({
router,
store,
render: h => h(App)
})
// expose the app, the router and the store.
// note we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return { app, router, store }
}
<!-- borrowed from Nuxt! -->
<template>
<div class="progress" :style="{
'width': percent+'%',
'height': height,
'background-color': canSuccess? color : failedColor,
'opacity': show ? 1 : 0
}"></div>
</template>
<script>
export default {
data () {
return {
percent: 0,
show: false,
canSuccess: true,
duration: 3000,
height: '2px',
color: '#ffca2b',
failedColor: '#ff0000',
}
},
methods: {
start () {
this.show = true
this.canSuccess = true
if (this._timer) {
clearInterval(this._timer)
this.percent = 0
}
this._cut = 10000 / Math.floor(this.duration)
this._timer = setInterval(() => {
this.increase(this._cut * Math.random())
if (this.percent > 95) {
this.finish()
}
}, 100)
return this
},
set (num) {
this.show = true
this.canSuccess = true
this.percent = Math.floor(num)
return this
},
get () {
return Math.floor(this.percent)
},
increase (num) {
this.percent = this.percent + Math.floor(num)
return this
},
decrease (num) {
this.percent = this.percent - Math.floor(num)
return this
},
finish () {
this.percent = 100
this.hide()
return this
},
pause () {
clearInterval(this._timer)
return this
},
hide () {
clearInterval(this._timer)
this._timer = null
setTimeout(() => {
this.show = false
this.$nextTick(() => {
setTimeout(() => {
this.percent = 0
}, 200)
})
}, 500)
return this
},
fail () {
this.canSuccess = false
return this
}
}
}
</script>
<style scoped>
.progress {
position: fixed;
top: 0px;
left: 0px;
right: 0px;
height: 2px;
width: 0%;
transition: width 0.2s, opacity 0.4s;
opacity: 1;
background-color: #efc14e;
z-index: 999999;
}
</style>
import Vue from 'vue'
import 'es6-promise/auto'
import { createApp } from './app'
import ProgressBar from './components/ProgressBar.vue'
// global progress bar
const bar = Vue.prototype.$bar = new Vue(ProgressBar).$mount()
document.body.appendChild(bar.$el)
// a global mixin that calls `asyncData` when a route component's params change
Vue.mixin({
beforeRouteUpdate (to, from, next) {
const { asyncData } = this.$options
if (asyncData) {
asyncData({
store: this.$store,
route: to
}).then(next).catch(next)
} else {
next()
}
}
})
const { app, router, store } = createApp()
// prime the store with server-initialized state.
// the state is determined during SSR and inlined in the page markup.
if (window.__INITIAL_STATE__) {
store.replaceState(window.__INITIAL_STATE__)
}
// wait until router has resolved all async before hooks
// and async components...
router.onReady(() => {
// Add router hook for handling asyncData.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const matched = router.getMatchedComponents(to)
const prevMatched = router.getMatchedComponents(from)
let diffed = false
const activated = matched.filter((c, i) => {
return diffed || (diffed = (prevMatched[i] !== c))
})
if (!activated.length) {
return next()
}
bar.start()
Promise.all(activated.map(c => {
if (c.asyncData) {
return c.asyncData({ store, route: to })
}
})).then(() => {
bar.finish()
next()
}).catch(next)
})
// actually mount to DOM
app.$mount('#app')
})
// service worker
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
navigator.serviceWorker.register('/service-worker.js')
}
import { createApp } from './app'
const isDev = process.env.NODE_ENV !== 'production'
// This exported function will be called by `bundleRenderer`.
// This is where we perform data-prefetching to determine the
// state of our application before actually rendering it.
// Since data fetching is async, this function is expected to
// return a Promise that resolves to the app instance.
export default context => {
return new Promise((resolve, reject) => {
const s = isDev && Date.now()
const { app, router, store } = createApp()
// set router's location
router.push(context.url)
// wait until router has resolved possible async hooks
router.onReady(() => {
const matchedComponents = router.getMatchedComponents()
// no matched routes
if (!matchedComponents.length) {
reject({ code: 404 })
}
// Call fetchData hooks on components matched by the route.
// A preFetch hook dispatches a store action and returns a Promise,
// which is resolved when the action is complete and store state has been
// updated.
Promise.all(matchedComponents.map(component => {
return component.asyncData && component.asyncData({
store,
route: router.currentRoute
})
})).then(() => {
isDev && console.log(`data pre-fetch: ${Date.now() - s}ms`)
// After all preFetch hooks are resolved, our store is now
// filled with the state needed to render the app.
// Expose the state on the render context, and let the request handler
// inline the state in the HTML response. This allows the client-side
// store to pick-up the server-side state without having to duplicate
// the initial data fetching on the client.
context.state = store.state
resolve(app)
}).catch(reject)
}, reject)
})
}
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
// route-level code splitting
const createListView = id => () => import('../views/CreateListView').then(m => m.default(id))
const ItemView = () => import('../views/ItemView.vue')
const UserView = () => import('../views/UserView.vue')
export function createRouter () {
return new Router({
mode: 'history',
scrollBehavior: () => ({ y: 0 }),
routes: [
{ path: '/top/:page(\\d+)?', component: createListView('top') },
{ path: '/new/:page(\\d+)?', component: createListView('new') },
{ path: '/show/:page(\\d+)?', component: createListView('show') },
{ path: '/ask/:page(\\d+)?', component: createListView('ask') },
{ path: '/job/:page(\\d+)?', component: createListView('job') },
{ path: '/item/:id(\\d+)', component: ItemView },
{ path: '/user/:id', component: UserView },
{ path: '/', redirect: '/top' }
]
})
}
function getTitle (vm) {
const { title } = vm.$options
if (title) {
return typeof title === 'function'
? title.call(vm)
: title
}
}
const serverTitleMixin = {
created () {
const title = getTitle(this)
if (title) {
this.$ssrContext.title = `Vue HN 2.0 | ${title}`
}
}
}
const clientTitleMixin = {
mounted () {
const title = getTitle(this)
if (title) {
document.title = `Vue HN 2.0 | ${title}`
}
}
}
export default process.env.VUE_ENV === 'server'
? serverTitleMixin
: clientTitleMixin
import ItemList from './ItemList.vue'
const camelize = str => str.charAt(0).toUpperCase() + str.slice(1)
// 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.
// They are essentially higher order components wrapping ItemList.vue.
export default function createListView (type) {
return {
name: `${type}-stories-view`,
asyncData ({ store }) {
return store.dispatch('FETCH_LIST_DATA', { type })
},
title: camelize(type),
render (h) {
return h(ItemList, { props: { type }})
}
}
}
...@@ -8,7 +8,7 @@ export default { ...@@ -8,7 +8,7 @@ export default {
return [] return []
} }
const page = Number(state.route.params.page) || 1 const page = 1 // Number(state.route.params.page) || 1
const start = (page - 1) * itemsPerPage const start = (page - 1) * itemsPerPage
const end = page * itemsPerPage const end = page * itemsPerPage
......
import Vue from 'vue'
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'
Vue.use(Vuex) export default {
state() {
export function createStore () { return {
return new Vuex.Store({
state: {
activeType: null, activeType: null,
itemsPerPage: 20, itemsPerPage: 20,
items: {/* [id: number]: Item */}, items: {/* [id: number]: Item */},
...@@ -20,9 +16,9 @@ export function createStore () { ...@@ -20,9 +16,9 @@ export function createStore () {
ask: [], ask: [],
job: [] job: []
} }
}
}, },
actions, actions,
mutations, mutations,
getters getters
})
} }
This diff is collapsed.
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