Unverified Commit 7a37f90c authored by Anthony Fu's avatar Anthony Fu Committed by GitHub

migrate to nuxt3 (#96)

parent 8b4413eb
{
"extends": "@nuxtjs/eslint-config-typescript",
"rules": {
"vue/no-v-html": "off",
"vue/no-multiple-template-root": "off"
}
}
module.exports = {
extends: [
'@nuxtjs'
]
}
...@@ -8,7 +8,8 @@ package-lock.json ...@@ -8,7 +8,8 @@ package-lock.json
*.iml *.iml
.nuxt .nuxt
.vscode .vscode
.output
static/manifest*.json public/manifest*.json
static/sw.js public/sw.js
static/workbox-sw*.js* public/workbox-sw*.js*
shamefully-hoist=true
node_modules
.nuxt
dist
# Nuxt.js Hacker News # Nuxt3 Hacker News
HackerNews clone built with [Nuxt.js](https://nuxtjs.org). Hacker News clone built with [Nuxt3](https://v3.nuxtjs.org).
<p align="center"> <p align="center">
<a href="https://hn.nuxtjs.org" target="_blank"> <a href="https://hn.nuxtjs.org" target="_blank">
<img width="1090" alt="Screenshot 2019-06-04 at 13 27 51" src="https://user-images.githubusercontent.com/904724/58875721-97382400-86cc-11e9-94c6-af21544817bb.png"> <img width="1090" src="https://user-images.githubusercontent.com/904724/58875721-97382400-86cc-11e9-94c6-af21544817bb.png">
<br> <br>
Live Demo Live Demo
</a> </a>
</p> </p>
## Modes ## Deploy
- Universal: https://hn.nuxtjs.org - Universal: https://hn.nuxtjs.org
> Hosted on [Now 2](https://zeit.co): `npm run build` + `now.json` > Hosted on [Vercel](https://vercel.com/): `npm run build`
- Single Page: https://hn-spa.nuxtjs.org - Single Page: https://hn-spa.nuxtjs.org
> Hosted on [Netlify](https://www.netlify.com): `npm run build-spa` + `dist/` directory > Hosted on [Netlify](https://www.netlify.com): `npm run build-spa`
## Performance ## Performance
- Lighthouse [100/100](https://cdn.rawgit.com/Atinux/e2f424e6794babc00d2158406b0ab37d/raw/4de834145881697ea83292b381df5f591f1ed2f5/lighthouse-result-nuxt.html) - [Webpagetest](https://www.webpagetest.org/lighthouse.php?test=170620_PG_a2a9feaf4ace07a61b2c6c2a171b1c79&run=1) - Lighthouse [100/100](https://pagespeed.web.dev/report?url=https%3A%2F%2Fhackernews-git-nuxt3-nuxt-js.vercel.app%2Fnews%2F1) (Slow 4G / Mobile Moto G4)
- Interactive (Faster 3G) [3.5s](https://www.webpagetest.org/result/170620_PG_a2a9feaf4ace07a61b2c6c2a171b1c79) - Interactive: 1.4s
- Interactive (Emerging Markets) [3.8s](https://www.webpagetest.org/result/170620_B1_0b83d61272c77c16c3f3f1f16fb72d2e) - Total Blocking Time: 30ms
## Features ## Features
- Server Side Rendering - Server Side Rendering
- Vite-based hot module replacement (HMR) dev environment
- Deploys anywhere with zero config (Vercel, Netlify, Cloudflare, etc.) powered by [Nitro](https://github.com/unjs/nitro)
- Code Splitting - Code Splitting
- Single-file Vue Components
- Prefetch/Preload JS + DNS + Data - Prefetch/Preload JS + DNS + Data
- Critical Path CSS
- PWA experience using [PWA Module](https://pwa.nuxtjs.org) with almost _zero config_
- PRPL
- Hot reloading dev environment integrated with [ESLint](https://eslint.org/) and [Prettier](https://prettier.io/)
- Hosted on [Vercel](https://vercel.com)
## Build Setup ## Build Setup
**Requires Node.js 8+** **Requires Node.js 14+**
``` bash ``` bash
# install dependencies # install dependencies
...@@ -59,7 +55,7 @@ npm start ...@@ -59,7 +55,7 @@ npm start
npm run build-spa npm run build-spa
# serve in production mode (spa) # serve in production mode (spa)
npm run start-spa # or upload dist/ directory npm run start-spa # or upload .output/public/ directory
# validate code with ESLint (with Prettier) # validate code with ESLint (with Prettier)
npm run lint npm run lint
...@@ -70,7 +66,7 @@ npm run lintfix ...@@ -70,7 +66,7 @@ npm run lintfix
## Links ## Links
For the communiy typescript fork please see [nuxt-community/hackernews-nuxt-ts](https://github.com/nuxt-community/hackernews-nuxt-ts) For the Nuxt 2 version, check out the [`nuxt2` branch](https://github.com/nuxt/hackernews/tree/nuxt2)
## License ## License
......
<script setup lang="ts">
useHead({
titleTemplate: 'Nuxt HN | %s',
meta: [
{ property: 'og:image', content: 'https://user-images.githubusercontent.com/11247099/169022756-cdb6ef6f-1299-4ce0-8f80-81fb6e86a2e1.png' },
{ name: 'description', content: 'Hacker News clone built with Nuxt 3' },
{ name: 'twitter:card', content: 'summary_large_image' },
{ name: 'twitter:site', content: '@nuxt_js' },
{ name: 'twitter:creator', content: '@nuxt_js' },
{ name: 'twitter:image', content: 'https://user-images.githubusercontent.com/11247099/169022756-cdb6ef6f-1299-4ce0-8f80-81fb6e86a2e1.png' }
],
link: [
{ rel: 'icon', type: 'image/png', href: '/icon.png' }
]
})
</script>
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>
<svg width="96" height="72" viewBox="0 0 96 72" version="1" xmlns="http://www.w3.org/2000/svg">
<g fill="none" fill-rule="evenodd">
<path d="M6 66h23l1-3 21-37L40 6 6 66zM79 66h11L62 17l-5 9 22 37v3zM54 31L35 66h38z"/>
<path d="M29 69v-1-2H6L40 6l11 20 3-6L44 3s-2-3-4-3-3 1-5 3L1 63c0 1-2 3 0 6 0 1 2 2 5 2h28c-3 0-4-1-5-2z" fill="#00C58E"/>
<path d="M95 63L67 14c0-1-2-3-5-3-1 0-3 0-4 3l-4 6 3 6 5-9 28 49H79a5 5 0 0 1 0 3c-2 2-5 2-5 2h16c1 0 4 0 5-2 1-1 2-3 0-6z" fill="#108775"/>
<path d="M79 69v-1-2-3L57 26l-3-6-3 6-21 37-1 3a5 5 0 0 0 0 3c1 1 2 2 5 2h40s3 0 5-2zM54 31l19 35H35l19-35z" fill="#FFF" fill-rule="nonzero"/>
</g>
</svg>
export const lazy = (commit, task, optimistic, enabled) => {
// By default, do lazy operations only in client
if (enabled === undefined) {
enabled = process.client
}
// Non lazy mode
if (!enabled) {
return task().then(commit).catch(console.error) // eslint-disable-line no-console
}
// Do real task in background
Promise.resolve(task(optimistic))
.then(commit)
.catch(console.error) // eslint-disable-line no-console
// Commit optimistic value and resolve
return Promise.resolve(commit(optimistic))
}
<script setup lang="ts">
import { timeAgo } from '~/composables/utils'
defineProps({
comment: {
type: Object,
required: true
}
})
const open = ref(true)
function pluralize (n: number) {
return n + (n === 1 ? ' reply' : ' replies')
}
</script>
<template> <template>
<li v-if="comment && comment.user" class="comment"> <li v-if="comment && comment.user" class="comment">
<div class="by"> <div class="by">
<router-link :to="'/user/' + comment.user"> <NuxtLink :to="'/user/' + comment.user">
{{ comment.user }} {{ comment.user }}
</router-link> </NuxtLink>
{{ comment.time | timeAgo }} ago {{ timeAgo(comment.time) }} ago
</div> </div>
<div class="text" v-html="comment.content" /> <div class="text" v-html="comment.content" />
<div v-if="comment.comments && comment.comments.length" :class="{ open }" class="toggle"> <div v-if="comment.comments && comment.comments.length" :class="{ open }" class="toggle">
...@@ -17,27 +34,7 @@ ...@@ -17,27 +34,7 @@
</li> </li>
</template> </template>
<script> <style lang="postcss">
export default {
name: 'Comment',
props: {
comment: {
type: Object,
required: true
}
},
data () {
return {
open: true
}
},
methods: {
pluralize: n => n + (n === 1 ? ' reply' : ' replies')
}
}
</script>
<style lang="stylus">
.comment-children { .comment-children {
.comment-children { .comment-children {
margin-left: 1.5em; margin-left: 1.5em;
......
<script setup lang="ts">
import { timeAgo, isAbsolute, host } from '~/composables/utils'
defineProps<{
item: any
}>()
</script>
<template> <template>
<li class="news-item"> <li class="news-item">
<span class="score">{{ item.points }}</span> <span class="score">{{ item.points }}</span>
<span class="title"> <span class="title">
<template v-if="isAbsolute(item.url)"> <template v-if="isAbsolute(item.url)">
<a :href="item.url" target="_blank" rel="noopener">{{ item.title }}</a> <a :href="item.url" target="_blank" rel="noopener">{{ item.title }}</a>
<span class="host"> ({{ item.url | host }})</span> <span class="host"> ({{ host(item.url) }})</span>
</template> </template>
<template v-else> <template v-else>
<router-link :to="'/item/' + item.id">{{ item.title }}</router-link> <NuxtLink :to="'/item/' + item.id">{{ item.title }}</NuxtLink>
</template> </template>
</span> </span>
<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 by
<router-link :to="'/user/' + item.user">{{ item.user }}</router-link> <NuxtLink :to="'/user/' + item.user">{{ item.user }}</NuxtLink>
</span> </span>
<span class="time"> <span class="time">
{{ item.time | timeAgo }} ago {{ timeAgo(item.time) }} ago
</span> </span>
<span v-if="item.type !== 'job'" class="comments-link">
| |
<router-link :to="'/item/' + item.id">{{ item.comments_count }} comments</router-link> <span v-if="item.type !== 'job'" class="comments-link">
<NuxtLink :to="'/item/' + item.id">{{ item.comments_count }} comments</NuxtLink>
</span> </span>
</span> </span>
</li> </li>
</template> </template>
<script> <style lang="postcss">
export default {
name: 'NewsItem',
props: {
item: {
type: Object,
required: true
}
},
methods: {
isAbsolute (url) {
return /^https?:\/\//.test(url)
}
}
}
</script>
<style lang="stylus">
.news-item { .news-item {
background-color: #fff; background-color: #fff;
padding: 20px 30px 20px 80px; padding: 20px 30px 20px 80px;
...@@ -68,6 +59,10 @@ export default { ...@@ -68,6 +59,10 @@ export default {
font-size: 0.85em; font-size: 0.85em;
color: #595959; color: #595959;
span {
margin: 0 0.2rem;
}
a { a {
color: #595959; color: #595959;
text-decoration: underline; text-decoration: underline;
......
<script setup lang="ts">
const props = defineProps<{
feed: string,
page: number,
maxPage: number
}>()
const hasMore = $computed(() => props.page < props.maxPage)
</script>
<template> <template>
<div class="news-list-nav"> <div class="news-list-nav">
<router-link v-if="page > 1" :to="`/${feed}/${page - 1}`"> <NuxtLink v-if="page > 1" :to="`/${feed}/${page - 1}`">
&lt; prev &lt; prev
</router-link> </NuxtLink>
<a v-else class="disabled">&lt; prev</a> <span v-else class="disabled">&lt; prev</span>
<span>{{ page }}/{{ maxPage }}</span> <span class="page">{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="`/${feed}/${page + 1}`"> <NuxtLink v-if="hasMore" :to="`/${feed}/${page + 1}`">
more &gt; more &gt;
</router-link> </NuxtLink>
<a v-else class="disabled">more &gt;</a> <span v-else class="disabled">more &gt;</span>
</div> </div>
</template> </template>
<script> <style lang="postcss">
export default {
props: {
feed: {
type: String,
required: true
},
page: {
type: Number,
required: true
},
maxPage: {
type: Number,
required: true
}
},
computed: {
hasMore () {
return this.page < this.maxPage
}
}
}
</script>
<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;
...@@ -46,13 +32,20 @@ export default { ...@@ -46,13 +32,20 @@ export default {
padding: 15px 30px; padding: 15px 30px;
text-align: center; text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
user-select: none;
a { a {
margin: 0 1em; margin: 0 1em;
} }
.disabled { .disabled {
opacity: 0.8; opacity: 0.5;
}
.page {
width: 100px;
display: inline-block;
text-align: center;
} }
} }
</style> </style>
<script>
import Spinner from './Spinner'
export default {
functional: true,
props: {
loading: {
type: Boolean,
default: false
}
},
render (h, { props, children }) {
return props.loading
? h('div', { style: { 'text-align': 'center' } }, [
h(Spinner, { props: { show: true } })
])
: children
}
}
</script>
<script setup lang="ts">
defineProps<{
loading: boolean,
}>()
</script>
<template>
<div v-if="loading" style="text-align:center">
<Spinner />
</div>
<slot v-else />
</template>
<template> <template>
<transition> <div class="spinner" />
<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"
/>
</svg>
</transition>
</template> </template>
<script> <style scoped>
export default { .spinner,
name: 'Spinner', .spinner:after {
props: { border-radius: 50%;
show: { width: 3em;
type: Boolean, height: 3em;
required: true
}
}
} }
</script>
<style lang="stylus">
$offset = 126;
$duration = 1.4s;
.spinner { .spinner {
transition: opacity 0.15s ease; margin: 30px auto;
animation: rotator $duration linear infinite; font-size: 10px;
animation-play-state: paused; position: relative;
text-indent: -9999em;
&.show { border-top: 0.2em solid rgba(0, 196, 141, 0.2);
animation-play-state: running; border-right: 0.2em solid rgba(0, 196, 141, 0.2);
} border-bottom: 0.2em solid rgba(0, 196, 141, 0.2);
border-left: 0.2em solid #00C48D;
&.v-enter, &.v-leave-active { -webkit-transform: translateZ(0);
opacity: 0; -ms-transform: translateZ(0);
} transform: translateZ(0);
-webkit-animation: loading 1.1s infinite linear;
&.v-enter-active, &.v-leave { animation: loading 1.1s infinite linear;
opacity: 1;
}
} }
@-webkit-keyframes loading {
@keyframes rotator {
0% { 0% {
transform: scale(0.5) rotate(0deg); -webkit-transform: rotate(0deg);
transform: rotate(0deg);
} }
100% { 100% {
transform: scale(0.5) rotate(270deg); -webkit-transform: rotate(360deg);
transform: rotate(360deg);
} }
} }
@keyframes loading {
.spinner .path {
stroke: #ff6600;
stroke-dasharray: $offset;
stroke-dashoffset: 0;
transform-origin: center;
animation: dash $duration ease-in-out infinite;
}
@keyframes dash {
0% { 0% {
stroke-dashoffset: $offset; -webkit-transform: rotate(0deg);
} transform: rotate(0deg);
50% {
stroke-dashoffset: ($offset / 2);
transform: rotate(135deg);
} }
100% { 100% {
stroke-dashoffset: $offset; -webkit-transform: rotate(360deg);
transform: rotate(450deg); transform: rotate(360deg);
} }
} }
</style> </style>
export const feeds = { export const feedsInfo = {
news: { title: 'News', pages: 10 }, news: { title: 'News', pages: 10 },
newest: { title: 'Newest', pages: 12 }, newest: { title: 'Newest', pages: 12 },
ask: { title: 'Ask', pages: 2 }, ask: { title: 'Ask', pages: 2 },
...@@ -6,4 +6,4 @@ export const feeds = { ...@@ -6,4 +6,4 @@ export const feeds = {
jobs: { title: 'Jobs', pages: 1 } jobs: { title: 'Jobs', pages: 1 }
} }
export const validFeeds = Object.keys(feeds) export const validFeeds = Object.keys(feedsInfo)
import { Item, User } from '~/types'
export interface StoreState {
items: Record<number, Item>
comments: Record<number, Item[]>
users: Record<number, User>
feeds: Record<string, Record<number, number[]>>
}
export const useStore = () => useState<StoreState>('store', () => ({
items: {},
users: {},
comments: {},
feeds: Object.fromEntries(validFeeds.map(i => [i, {}]))
}))
interface FeedQuery {
feed: string;
page: number
}
export function getFeed (state:StoreState, { feed, page }: FeedQuery) {
const ids = state.feeds?.[feed]?.[page]
if (ids?.length) {
return ids.map(i => state.items[i])
}
return undefined
}
export function fetchFeed (query: FeedQuery) {
const state = $(useStore())
const { feed, page } = query
return reactiveLoad<Item[]>(
() => getFeed(state, query),
(items) => {
const ids = items.map(item => item.id)
state.feeds[feed][page] = ids
items
.filter(Boolean)
.forEach((item) => {
if (state.items[item.id]) {
Object.assign(state.items[item.id], item)
} else {
state.items[item.id] = item
}
})
},
() => $fetch('/api/hn/feeds', { params: { feed, page } }),
(state.feeds[feed][page] || []).map(id => state.items[id])
)
}
export function fetchItem (id: string) {
const state = $(useStore())
return reactiveLoad<Item>(
() => state.items[id],
(item) => { state.items[id] = item },
() => $fetch('/api/hn/item', { params: { id } })
)
}
export function fetchComments (id: string) {
const state = $(useStore())
return reactiveLoad<Item[]>(
() => state.comments[id],
(comments) => { state.comments[id] = comments },
() => $fetch('/api/hn/item', { params: { id } }).then(i => i.comments)
)
}
export function fetchUser (id: string) {
const state = $(useStore())
return reactiveLoad<User>(
() => state.users[id],
(user) => { state.users[id] = user },
() => $fetch('/api/hn/user', { params: { id } })
)
}
/**
* Create reactive state for SWR
*
* On server side the data will be fetched eagerly
*/
export async function reactiveLoad<T> (
get: () => T | undefined,
set: (data: T) => void,
fetch: ()=> Promise<T>,
init?: T
) {
const data = computed({
get,
set
})
const loading = ref(false)
if (data.value == null) {
if (init != null) {
data.value = init
}
const task = async () => {
try {
loading.value = true
const fetched = await fetch()
if (data.value != null) {
data.value = Object.assign(data.value, fetched)
} else {
data.value = fetched
}
} catch (e) {
console.error(e)
data.value = undefined
} finally {
loading.value = false
}
}
if (process.client) {
task()
} else {
await task()
}
}
return reactive({
loading,
data
})
}
export function host (url: string) {
const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace('?id=', '/')
const parts = host.split('.').slice(-3)
if (parts[0] === 'www') { parts.shift() }
return parts.join('.')
}
export function timeAgo (time: number | Date) {
const between = Date.now() / 1000 - Number(time)
if (between < 3600) { return pluralize(~~(between / 60), ' minute') } else if (between < 86400) { return pluralize(~~(between / 3600), ' hour') } else { return pluralize(~~(between / 86400), ' day') }
}
export function pluralize (time: number, label:string) {
if (time === 1) { return time + label }
return `${time + label}s`
}
export function isAbsolute (url: string) {
return /^https?:\/\//.test(url)
}
<script setup lang="ts">
import { feedsInfo } from '~/composables/api'
const route = useRoute()
const host = process.server
? useNuxtApp().ssrContext.req.headers.host
: window.location.host
useHead({
link: [
// We use route.path since we don't use query parameters
{ rel: 'canonical', href: `https://${host}${route.path}` }
]
})
</script>
<template> <template>
<div id="app"> <div>
<header class="header"> <header class="header">
<nav class="inner" role="navigation"> <nav class="inner" role="navigation">
<router-link to="/" exact> <NuxtLink to="/" exact>
<img class="logo" src="~/assets/logo.svg" alt="logo"> <img class="logo" src="/logo.svg" alt="logo">
</router-link> </NuxtLink>
<router-link v-for="(list, key) in feeds" :key="key" :to="`/${key}`"> <NuxtLink v-for="(list, key) in feedsInfo" :key="key" :to="`/${key}`">
{{ list.title }} {{ list.title }}
</router-link> </NuxtLink>
<a class="github" href="https://github.com/nuxt/hackernews" target="_blank" rel="noopener banner"> <a class="github" href="https://github.com/nuxt/hackernews" target="_blank" rel="noopener banner">
Built with Nuxt.js Built with Nuxt3
</a> </a>
</nav> </nav>
</header> </header>
<nuxt nuxt-child-key="none" role="main" /> <slot role="main" />
</div> </div>
</template> </template>
<script> <style lang="postcss">
import { feeds } from '~/common/api'
export default {
head () {
const host = process.server
? this.$ssrContext.req.headers.host
: window.location.host
return {
link: [
// We use $route.path since we don't use query parameters
{ rel: 'canonical', href: `https://${host}${this.$route.path}` }
]
}
},
computed: {
feeds: () => feeds
}
}
</script>
<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;
...@@ -64,7 +58,9 @@ a { ...@@ -64,7 +58,9 @@ a {
max-width: 800px; max-width: 800px;
box-sizing: border-box; box-sizing: border-box;
margin: 0px auto; margin: 0px auto;
padding: 15px 5px; padding: 12px 5px;
display: flex;
place-items: center;
} }
a { a {
...@@ -94,13 +90,15 @@ a { ...@@ -94,13 +90,15 @@ a {
.github { .github {
color: #fff; color: #fff;
font-size: 0.9em; font-size: 0.9em;
margin: 0; margin: auto;
float: right; text-align: right;
flex-grow: 1;
} }
} }
.logo { .logo {
width: 24px; width: 30px;
height: 30px;
margin-right: 10px; margin-right: 10px;
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
...@@ -116,11 +114,7 @@ a { ...@@ -116,11 +114,7 @@ a {
transition: opacity 0.4s ease; transition: opacity 0.4s ease;
} }
.page-enter-active, .page-leave-active { .appear {
transition: all 0.2s ease;
}
.appear, .page-enter, .page-leave-active {
opacity: 0; opacity: 0;
} }
......
export default defineNuxtRouteMiddleware((from) => {
if (!from.params.feed || !validFeeds.includes(from.params.feed as string)) {
return navigateTo(`/${validFeeds[0]}/1`)
}
if (!from.params.page) {
return navigateTo(`/${from.params.feed}/1`)
}
})
export default defineNuxtRouteMiddleware((from) => {
if (from.path === '/') {
return navigateTo(`/${validFeeds[0]}/1`)
}
})
[build.environment]
# bypass npm auto install
NPM_FLAGS = "--version"
NODE_VERSION = "16"
[build]
command = "npx pnpm i --store=node_modules/.pnpm-store && npx pnpm run build"
export default {
head: {
titleTemplate: 'Nuxt HN | %s',
meta: [
{ property: 'og:image', content: 'https://user-images.githubusercontent.com/904724/58238637-f189ca00-7d47-11e9-8213-ae072d7cd3aa.png' },
{ property: 'twitter:card', content: 'summary_large_image' },
{ property: 'twitter:site', content: '@nuxt_js' }
],
link: [
{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }
]
},
loading: {
color: '#00C48D'
},
manifest: {
name: 'Nuxt Hacker News',
short_name: 'Nuxt HN',
description: 'HackerNews clone built with Nuxt.js',
theme_color: '#2F495E',
start_url: '/news'
},
buildModules: [
'@nuxtjs/pwa',
'@nuxtjs/axios'
],
axios: {
baseURL: 'http://localhost:3000/api/hn',
browserBaseURL: '/api/hn'
},
plugins: [
'~/plugins/swr',
'~/plugins/filters'
],
serverMiddleware: [
{
handler: '~/server/api/hn/index.ts',
path: '/api/hn'
},
'~/server/api/swr.ts'
],
render: {
http2: {
push: true
},
static: {
maxAge: '1y',
setHeaders (res, path) {
if (path.includes('sw.js')) {
res.setHeader('Cache-Control', `public, max-age=${15 * 60}`)
}
}
}
}
}
import { defineNuxtConfig } from 'nuxt'
export default defineNuxtConfig({
manifest: {
name: 'Nuxt Hacker News',
short_name: 'Nuxt HN',
description: 'HackerNews clone built with Nuxt3',
theme_color: '#2F495E',
start_url: '/news'
},
modules: [
'@nuxtjs/pwa'
],
postcss: {
plugins: {
'postcss-nested': {}
}
},
experimental: {
reactivityTransform: true,
viteNode: true
}
})
{ {
"private": true,
"name": "nuxt-hn", "name": "nuxt-hn",
"private": true,
"packageManager": "pnpm@7.0.1",
"description": "Nuxt Hacker News", "description": "Nuxt Hacker News",
"version": "1.0.0",
"author": "Evan You <yyx990803@gmail.com>", "author": "Evan You <yyx990803@gmail.com>",
"contributors": [ "contributors": [
{ {
...@@ -13,37 +13,29 @@ ...@@ -13,37 +13,29 @@
}, },
{ {
"name": "Pooya Parsa (@pi0)" "name": "Pooya Parsa (@pi0)"
},
{
"name": "Anthony Fu (@antfu)"
} }
], ],
"scripts": { "scripts": {
"dev": "nuxt dev", "dev": "nuxi dev",
"build": "nuxt build", "build": "nuxi build",
"start": "nuxt start", "start": "nuxi start",
"dev-spa": "nuxt dev --spa", "dev-spa": "nuxi dev --spa",
"build-spa": "nuxt build --spa", "build-spa": "nuxi build --spa",
"start-spa": "nuxt start --spa", "start-spa": "nuxi start --spa",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore .", "lint": "eslint --ext .vue,.js,.ts --ignore-path .gitignore ."
"lintfix": "eslint --fix --ext .js,.vue --ignore-path .gitignore ."
}, },
"dependencies": { "engines": {
"@nuxtjs/axios": "^5.13.6", "node": ">=14.0"
"h3": "^0.2.10",
"nuxt": "^2.15.6",
"ohmyfetch": "^0.2.0"
}, },
"devDependencies": { "devDependencies": {
"@nuxtjs/eslint-config": "^6.0.1", "@nuxtjs/eslint-config-typescript": "^10.0.0",
"@nuxtjs/pwa": "3.3.5", "@nuxtjs/pwa": "3.3.5",
"babel-eslint": "^10.1.0", "eslint": "^8.15.0",
"eslint": "^7.28.0", "nuxt": "^3.0.0-rc.3",
"eslint-config-standard": "^16.0.3", "postcss-nested": "^5.0.6",
"eslint-plugin-import": "^2.23.4", "typescript": "^4.6.4"
"eslint-plugin-jest": "^24.3.6",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-promise": "^5.1.0",
"eslint-plugin-standard": "^5.0.0",
"eslint-plugin-vue": "^7.10.0",
"stylus": "^0.54.8",
"stylus-loader": "^4.3.3"
} }
} }
<script setup lang="ts">
import { feedsInfo } from '~/composables/api'
definePageMeta({
middleware: 'feed'
})
const route = useRoute()
const router = useRouter()
const page = $computed(() => +route.params.page || 1)
const feed = $computed(() => route.params.feed as string)
const isValidFeed = $computed(() => !!feedsInfo[feed])
// const transition = $ref('slide-right')
const pageNo = $computed(() => Number(page) || 1)
const displayedPage = ref(pageNo)
useHead({
title: feedsInfo[feed]?.title
})
const state = $(useStore())
if (isValidFeed) {
await fetchFeed({ page: pageNo, feed })
}
const items = $computed(() => getFeed(state, { page: pageNo, feed }) || [])
const loading = $computed(() => items.length === 0)
const maxPage = $computed(() => {
return +(feedsInfo[feed]?.pages) || 0
})
function pageChanged (to: number, from = -1) {
if (!isValidFeed) { return }
if (to <= 0 || to > maxPage) {
router.replace(`/${feed}/1`)
return
}
// Prefetch next page
fetchFeed({
feed,
page: page + 1
}).catch(() => {})
// transition = from === -1
// ? null
// : to > from
// ? 'slide-left'
// : 'slide-right'
displayedPage.value = to
}
onMounted(() => pageChanged(page))
watch(() => page, (to, old) => pageChanged(to, old))
</script>
<template>
<div class="view">
<ItemListNav :feed="feed" :page="page" :max-page="maxPage" />
<div :key="displayedPage" class="news-list">
<Spinner v-if="loading" />
<template v-else>
<ul>
<Item v-for="item in items" :key="item.id" :item="item" />
</ul>
<ItemListNav :feed="feed" :page="page" :max-page="maxPage" />
</template>
</div>
</div>
</template>
<style lang="postcss">
.news-list {
background-color: #fff;
border-radius: 2px;
position: absolute;
top: 40px;
left: 0;
margin: 10px 0;
width: 100%;
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 {
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>
<script setup lang="ts">
definePageMeta({
middleware: 'feed'
})
</script>
<template>
<div>
<slot />
</div>
</template>
<template>
<div class="view">
<item-list-nav :feed="feed" :page="page" :max-page="maxPage" />
<lazy-wrapper :loading="loading">
<transition :name="transition" mode="out-in">
<div :key="displayedPage" class="news-list">
<ul>
<item v-for="item in displayedItems" :key="item.id" :item="item" />
</ul>
</div>
</transition>
<item-list-nav :feed="feed" :page="page" :max-page="maxPage" />
</lazy-wrapper>
</div>
</template>
<script>
import Item from '~/components/Item.vue'
import ItemListNav from '~/components/ItemListNav.vue'
import LazyWrapper from '~/components/LazyWrapper'
import { feeds, validFeeds } from '~/common/api'
export default {
components: {
Item,
ItemListNav,
LazyWrapper
},
validate ({ params: { feed } }) {
return validFeeds.includes(feed)
},
data () {
return {
transition: 'slide-right',
displayedPage: Number(this.page) || 1
}
},
fetch () {
const { feed, page = 1 } = this.$route.params
return this.$store.dispatch('FETCH_FEED', { page: Number(page) || 1, feed })
},
head () {
return {
title: feeds[this.$route.params.feed].title
}
},
computed: {
feed () {
return this.$route.params.feed
},
page () {
return Number(this.$route.params.page) || 1
},
maxPage () {
return feeds[this.feed].pages
},
pageData () {
return this.$store.state.feeds[this.feed][this.page] || []
},
displayedItems () {
return this.pageData.map(id => this.$store.state.items[id])
},
loading () {
return this.displayedItems.length === 0
}
},
watch: {
feed: '$fetch',
page: 'pageChanged'
},
mounted () {
if (!this.pageData.length) {
this.$fetch()
}
this.pageChanged(this.page)
},
methods: {
pageChanged (to, from = -1) {
if (to <= 0 || to > this.maxPage) {
this.$router.replace(`/${this.feed}/1`)
return
}
// Prefetch next page
this.$store
.dispatch('FETCH_FEED', {
feed: this.feed,
page: this.page + 1,
prefetch: true
})
.catch(() => {})
this.transition =
from === -1 ? null : to > from ? 'slide-left' : 'slide-right'
this.displayedPage = to
}
}
}
</script>
<style lang="stylus">
.news-list {
background-color: #fff;
border-radius: 2px;
}
.news-list {
margin: 10px 0;
width: 100%;
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 {
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>
<script> <script setup lang="ts">
import { validFeeds } from '~/common/api' definePageMeta({
middleware: 'index'
export default { })
fetch ({ redirect }) {
redirect('/' + validFeeds[0])
}
}
</script> </script>
<template>
<div>Index</div>
</template>
<script setup lang="ts">
import { host, timeAgo, isAbsolute } from '~/composables/utils'
const route = useRoute()
const id = $computed(() => route.params.id as string)
const resultItem = await fetchItem(id)
const resultComments = await fetchComments(id)
const { data: item } = $(resultItem)
const { data: comments, loading: commentsLoading } = $(resultComments)
useHead({
title: item?.title
})
</script>
<template> <template>
<div class="item-view view"> <div class="item-view view">
<div v-if="!item" class="item-view-header">
<h1>Page not found</h1>
</div>
<template v-else>
<div class="item-view-header"> <div class="item-view-header">
<template v-if="isAbsolute(item.url)"> <template v-if="isAbsolute(item.url)">
<a :href="item.url" target="_blank" rel="noopener"><h1 v-text="item.title" /></a> <a :href="item.url" target="_blank" rel="noopener"><h1 v-text="item.title" /></a>
<span class="host"> ({{ item.url | host }})</span> <span class="host"> ({{ host(item.url) }})</span>
</template> </template>
<template v-else> <template v-else>
<h1 v-text="item.title" /> <h1 v-text="item.title" />
</template> </template>
<p class="meta"> <p class="meta">
{{ item.points }} points | by {{ item.points }} points | by
<router-link :to="'/user/' + item.user"> <NuxtLink :to="'/user/' + item.user">
{{ item.user }} {{ item.user }}
</router-link> </NuxtLink>
{{ item.time | timeAgo }} ago {{ timeAgo(+item.time) }} ago
</p> </p>
</div> </div>
<div class="item-view-comments"> <div class="item-view-comments">
<lazy-wrapper :loading="item.loading"> <LoadingWrapper :loading="commentsLoading">
<p class="item-view-comments-header"> <p class="item-view-comments-header">
{{ item.comments ? item.comments.length + ' comments' : 'No comments yet.' }} {{ comments ? comments.length + ' comments' : 'No comments yet.' }}
</p> </p>
<ul class="comment-children"> <ul class="comment-children">
<comment v-for="comment in item.comments" :key="comment.id" :comment="comment" /> <Comment v-for="comment in comments" :key="comment.id" :comment="comment" />
</ul> </ul>
</lazy-wrapper> </LoadingWrapper>
</div> </div>
</template>
</div> </div>
</template> </template>
<script> <style lang="postcss">
import Comment from '~/components/Comment'
import LazyWrapper from '~/components/LazyWrapper'
export default {
name: 'ItemView',
components: { Comment, LazyWrapper },
fetch () {
const { id } = this.$route.params
return this.$store.dispatch('FETCH_ITEM', { id })
},
computed: {
id () {
return this.$route.params.id
},
item () {
return this.$store.state.items[this.id]
}
},
methods: {
isAbsolute (url) {
return /^https?:\/\//.test(url)
}
},
head () {
return {
title: this.item.title
}
}
}
</script>
<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;
......
<script setup lang="ts">
import { timeAgo } from '~/composables/utils'
const route = useRoute()
const id = $computed(() => route.params.id as string)
const result = await fetchUser(id)
const { data: user, loading } = $(result)
useHead({
title: loading
? 'Loading'
: user
? user.id
: 'User not found'
})
</script>
<template> <template>
<div class="user-view view"> <div class="user-view view">
<template v-if="user"> <Spinner v-if="loading" />
<h1>User : {{ user.id }}</h1> <template v-else-if="user">
<lazy-wrapper :loading="user.loading"> <h1>User: {{ user.id }}</h1>
<ul class="meta"> <ul class="meta">
<li> <li>
<span class="label">Created:</span> {{ user.created_time | timeAgo }} ago <span class="label">Created:</span> {{ timeAgo(user.created_time) }} ago
</li> </li>
<li> <li>
<span class="label">Karma:</span> {{ user.karma || '-' }} <span class="label">Karma:</span> {{ user.karma || '-' }}
</li> </li>
<li v-if="user.about" class="about" v-html="user.about" /> <li v-if="user.about" class="about" v-html="user.about" />
</ul> </ul>
</lazy-wrapper>
<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> |
<a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a> <a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a>
...@@ -24,34 +41,7 @@ ...@@ -24,34 +41,7 @@
</div> </div>
</template> </template>
<script> <style lang="postcss">
import LazyWrapper from '~/components/LazyWrapper'
export default {
name: 'UserView',
components: { LazyWrapper },
fetch () {
const { id } = this.$route.params
return this.$store.dispatch('FETCH_USER', { id })
},
computed: {
user () {
return this.$store.state.users[this.$route.params.id]
}
},
head () {
return {
title: this.user ? this.user.id : 'User not found'
}
}
}
</script>
<style lang="stylus">
.user-view { .user-view {
background-color: #fff; background-color: #fff;
box-sizing: border-box; box-sizing: border-box;
......
import Vue from 'vue'
export function host (url) {
const host = url.replace(/^https?:\/\//, '').replace(/\/.*$/, '').replace('?id=', '/')
const parts = host.split('.').slice(-3)
if (parts[0] === 'www') { parts.shift() }
return parts.join('.')
}
export function timeAgo (time) {
const between = Date.now() / 1000 - Number(time)
if (between < 3600) {
return pluralize(~~(between / 60), ' minute')
} else if (between < 86400) {
return pluralize(~~(between / 3600), ' hour')
} else {
return pluralize(~~(between / 86400), ' day')
}
}
function pluralize (time, label) {
if (time === 1) {
return time + label
}
return time + label + 's'
}
const filters = {
host,
timeAgo
}
export default filters
Object.keys(filters).forEach((key) => {
Vue.filter(key, filters[key])
})
import Vue from 'vue'
const currentTime = new Date().getTime()
if (!Vue.__SWR_MIXIN__) {
Vue.__SWR_MIXIN__ = true
Vue.mixin({
mounted () {
if (
'$fetch' in this &&
(currentTime - this.$store.state.swr.time > 30000 ||
Object.keys(this.$route.query).includes('refresh'))
) {
this.$fetch()
}
}
})
}
export default function ({ store }) {
store.registerModule('swr', {
state: () => ({
time: new Date().getTime()
})
})
}
This source diff could not be displayed because it is too large. You can view the blob instead.
<svg width="70" height="70" viewBox="0 0 70 70" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M31.4918 17.1505C29.8172 14.2712 25.6308 14.2712 23.9562 17.1505L5.70589 48.5305C4.03131 51.4099 6.12453 55.009 9.4737 55.009H23.7209C22.2898 53.7583 21.7598 51.5946 22.8428 49.7382L36.6648 26.0451L31.4918 17.1505Z" fill="#80EEC0"/>
<path d="M43.0556 24.0338C44.4415 21.678 47.9061 21.678 49.292 24.0338L64.3957 49.7083C65.7816 52.0641 64.0493 55.0089 61.2775 55.0089H31.0701C28.2984 55.0089 26.566 52.0641 27.9519 49.7083L43.0556 24.0338Z" fill="#00DC82"/>
</svg>
import { createError, PHandle } from 'h3' import { createError } from 'h3'
import { $fetch } from 'ohmyfetch/node' import { $fetch } from 'ohmyfetch'
import { getQuery, parseURL, withoutLeadingSlash } from 'ufo' import { getQuery, parseURL } from 'ufo'
import { feeds, validFeeds } from '../../../common/api' import { feedsInfo, validFeeds } from '~/composables/api'
import { baseURL } from '.' import { baseURL } from '~/server/constants'
import { configureSWRHeaders } from '~/server/swr'
const feedUrls: Record<keyof typeof feeds, string> = { const feedUrls: Record<keyof typeof feedsInfo, string> = {
ask: 'askstories', ask: 'askstories',
jobs: 'jobstories', jobs: 'jobstories',
show: 'showstories', show: 'showstories',
newest: 'newstories', newest: 'newstories',
news: 'topstories', news: 'topstories'
} }
async function fetchFeed(feed: string, page = '1') { async function fetchFeed (feed: string, page = '1') {
const { fetchItem } = await import('./item') const { fetchItem } = await import('./item')
const entries = Object.values( const entries = Object.values(
await $fetch(`${baseURL}/${feedUrls[feed]}.json`) await $fetch(`${baseURL}/${feedUrls[feed]}.json`)
...@@ -22,21 +23,17 @@ async function fetchFeed(feed: string, page = '1') { ...@@ -22,21 +23,17 @@ async function fetchFeed(feed: string, page = '1') {
return Promise.all(entries.map(id => fetchItem(id))) return Promise.all(entries.map(id => fetchItem(id)))
} }
const handler: PHandle = async req => { export default defineEventHandler(({ req, res }) => {
const { pathname, search } = parseURL(req.url) configureSWRHeaders(res)
const feed = withoutLeadingSlash(pathname) const { search } = parseURL(req.url)
const { page = '1' } = getQuery(search) const { page = '1', feed = 'news' } = getQuery(search) as { page: string, feed: string }
if (!validFeeds.includes(feed) || String(Number(page)) !== page) { if (!validFeeds.includes(feed) || String(Number(page)) !== page) {
throw createError({ throw createError({
statusCode: 422, statusCode: 422,
statusMessage: `Must provide one of ${validFeeds.join( statusMessage: `Must provide one of ${validFeeds.join(', ')} and a valid page number.`
', '
)} and a valid page number.`,
}) })
} }
return fetchFeed(feed, page) return fetchFeed(feed, page)
} })
export default handler
import { createApp } from 'h3'
export const baseURL = 'https://hacker-news.firebaseio.com/v0'
const app = createApp({
onError: (error) => {
console.log(error)
}
})
app.use((_req, res, next) => {
res.setHeader('Cache-Control', 's-maxage=100, stale-while-revalidate')
next()
})
app.use('/item', () => import('./item'), { lazy: true })
app.use('/user', () => import('./user'), { lazy: true })
app.use(() => import('./feeds'), { lazy: true })
export default app
import { createError, PHandle } from 'h3' import { createError } from 'h3'
import { $fetch } from 'ohmyfetch/node' import { $fetch } from 'ohmyfetch'
import { withoutLeadingSlash } from 'ufo' import { parseURL, getQuery } from 'ufo'
import { baseURL } from '~/server/constants'
import { Item } from '~/types'
import { configureSWRHeaders } from '~/server/swr'
import { baseURL } from '.' export async function fetchItem (
export interface Item {
id: number
url?: string
title?: string
type: 'job' | 'story' | 'comment' | 'poll'
points: number
user: string
content?: string
time: string
comments_count?: number
comments?: Item[]
}
export async function fetchItem(
id: string, id: string,
withComments = false withComments = false
): Promise<Item> { ): Promise<Item> {
...@@ -39,19 +27,27 @@ export async function fetchItem( ...@@ -39,19 +27,27 @@ export async function fetchItem(
fetchItem(id, withComments) fetchItem(id, withComments)
) )
) )
: [], : []
} }
} }
const handler: PHandle = async req => { export default defineEventHandler(({ req, res }) => {
const itemId = withoutLeadingSlash(req.url) configureSWRHeaders(res)
if (!itemId) { const { search } = parseURL(req.url)
const { id } = getQuery(search) as { id: string }
if (!id) {
throw createError({ throw createError({
statusCode: 422, statusCode: 422,
statusMessage: 'Must provide a user ID.', statusMessage: 'Must provide a item ID.'
})
}
if (Number.isNaN(+id)) {
throw createError({
statusCode: 400,
statusMessage: 'Item ID mush a number but got ' + id
}) })
} }
return fetchItem(itemId, true)
}
export default handler return fetchItem(id, true)
})
import { createError, PHandle } from 'h3' import { createError } from 'h3'
import { $fetch } from 'ohmyfetch/node' import { $fetch } from 'ohmyfetch'
import { withoutLeadingSlash } from 'ufo' import { parseURL, getQuery } from 'ufo'
import { User } from '~/types'
import { baseURL } from '~/server/constants'
import { configureSWRHeaders } from '~/server/swr'
import { baseURL } from '.' async function fetchUser (id: string): Promise<User> {
export interface User {
id: string
created_time: string
karma: number
about: string
}
async function fetchUser(id: string): Promise<User> {
const user = await $fetch(`${baseURL}/user/${id}.json`) const user = await $fetch(`${baseURL}/user/${id}.json`)
return { return {
id: user.id, id: user.id,
karma: user.karma, karma: user.karma,
created_time: user.created, created_time: user.created,
about: user.about, about: user.about
} }
} }
const handler: PHandle = async req => { export default defineEventHandler(({ req, res }) => {
const userId = withoutLeadingSlash(req.url) configureSWRHeaders(res)
if (!userId) { const { search } = parseURL(req.url)
const { id } = getQuery(search) as { id: string }
if (!id) {
throw createError({ throw createError({
statusCode: 422, statusCode: 422,
statusMessage: 'Must provide a user ID.', statusMessage: 'Must provide a user ID.'
}) })
} }
return fetchUser(userId) return fetchUser(id)
} })
export default handler
export const baseURL = 'https://hacker-news.firebaseio.com/v0'
export default (_, res, next) => { import type { H3Response } from 'h3'
export function configureSWRHeaders (res: H3Response) {
res.setHeader('Cache-Control', 's-maxage=10, stale-while-revalidate') res.setHeader('Cache-Control', 's-maxage=10, stale-while-revalidate')
next()
} }
import Vue from 'vue'
import { CancelToken } from 'axios'
import { validFeeds } from '~/common/api'
import { lazy } from '~/common/utils'
// Learn more on https://nuxtjs.org/guide/vuex-store
// =================================================
// State
// =================================================
export const state = () => {
const s = {
items: {
/* [id: number]: Item */
},
users: {
/* [id: string]: User */
},
feeds: {
/* [page: number] : [ [id: number] ] */
}
}
validFeeds.forEach((feed) => {
s.feeds[feed] = {}
})
return s
}
// =================================================
// Mutations
// =================================================
export const mutations = {
SET_FEED: (state, { feed, ids, page }) => {
Vue.set(state.feeds[feed], page, ids)
},
SET_ITEM: (state, { item }) => {
if (item) {
Vue.set(state.items, item.id, item)
}
},
SET_ITEMS: (state, { items }) => {
items.forEach((item) => {
if (item) {
Vue.set(state.items, item.id, item)
}
})
},
SET_USER: (state, { id, user }) => {
Vue.set(state.users, id, user || false) /* false means user not found */
}
}
// =================================================
// Actions
// =================================================
export const actions = {
FETCH_FEED ({ commit, state }, { feed, page, prefetch }) {
// Don't priorotize already fetched feeds
if (state.feeds[feed][page] && state.feeds[feed][page].length) {
prefetch = true
}
if (!prefetch) {
if (this.feedCancelSource) {
this.feedCancelSource.cancel(
'priorotize feed: ' + feed + ' page: ' + page
)
}
this.feedCancelSource = CancelToken.source()
}
return lazy(
(items) => {
const ids = items.map(item => item.id)
commit('SET_FEED', { feed, ids, page })
commit('SET_ITEMS', { items })
},
() =>
this.$axios.$get(`/${feed}?page=${page}`, {
cancelToken: this.feedCancelSource && this.feedCancelSource.token
}),
(state.feeds[feed][page] || []).map(id => state.items[id])
)
},
FETCH_ITEM ({ commit, state }, { id }) {
return lazy(
item => commit('SET_ITEM', { item }),
() => this.$axios.$get(`/item/${id}`),
Object.assign({ id, loading: true, comments: [] }, state.items[id])
)
},
FETCH_USER ({ state, commit }, { id }) {
return lazy(
user => commit('SET_USER', { id, user }),
() => this.$axios.$get(`/user/${id}`),
Object.assign({ id, loading: true }, state.users[id])
)
}
}
{
"extends": "./.nuxt/tsconfig.json"
}
export interface Item {
id: number
url?: string
title?: string
type: 'job' | 'story' | 'comment' | 'poll'
points: number
user: string
content?: string
time: string
comments_count?: number
comments?: Item[]
loading?: boolean
}
export interface User {
id: string
created_time: string
karma: number
about: string
loading?: boolean
}
{
"builds": [
{
"src": "nuxt.config.js",
"use": "@nuxtjs/vercel-builder",
"config": {
"serverFiles": ["./server/api/**/*", "./common/api.js"]
}
}
]
}
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