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
*.iml
.nuxt
.vscode
.output
static/manifest*.json
static/sw.js
static/workbox-sw*.js*
public/manifest*.json
public/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">
<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>
Live Demo
</a>
</p>
## Modes
## Deploy
- 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
> 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
- 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)
- Interactive (Faster 3G) [3.5s](https://www.webpagetest.org/result/170620_PG_a2a9feaf4ace07a61b2c6c2a171b1c79)
- Interactive (Emerging Markets) [3.8s](https://www.webpagetest.org/result/170620_B1_0b83d61272c77c16c3f3f1f16fb72d2e)
- 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: 1.4s
- Total Blocking Time: 30ms
## Features
- 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
- Single-file Vue Components
- 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
**Requires Node.js 8+**
**Requires Node.js 14+**
``` bash
# install dependencies
......@@ -59,7 +55,7 @@ npm start
npm run build-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)
npm run lint
......@@ -70,7 +66,7 @@ npm run lintfix
## 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
......
<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>
<li v-if="comment && comment.user" class="comment">
<div class="by">
<router-link :to="'/user/' + comment.user">
<NuxtLink :to="'/user/' + comment.user">
{{ comment.user }}
</router-link>
{{ comment.time | timeAgo }} ago
</NuxtLink>
{{ timeAgo(comment.time) }} ago
</div>
<div class="text" v-html="comment.content" />
<div v-if="comment.comments && comment.comments.length" :class="{ open }" class="toggle">
......@@ -17,27 +34,7 @@
</li>
</template>
<script>
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">
<style lang="postcss">
.comment-children {
.comment-children {
margin-left: 1.5em;
......
<script setup lang="ts">
import { timeAgo, isAbsolute, host } from '~/composables/utils'
defineProps<{
item: any
}>()
</script>
<template>
<li class="news-item">
<span class="score">{{ item.points }}</span>
<span class="title">
<template v-if="isAbsolute(item.url)">
<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 v-else>
<router-link :to="'/item/' + item.id">{{ item.title }}</router-link>
<NuxtLink :to="'/item/' + item.id">{{ item.title }}</NuxtLink>
</template>
</span>
<br>
<span class="meta">
<span v-if="item.type !== 'job'" class="by">
by
<router-link :to="'/user/' + item.user">{{ item.user }}</router-link>
<NuxtLink :to="'/user/' + item.user">{{ item.user }}</NuxtLink>
</span>
<span class="time">
{{ item.time | timeAgo }} ago
{{ timeAgo(item.time) }} ago
</span>
|
<span v-if="item.type !== 'job'" class="comments-link">
|
<router-link :to="'/item/' + item.id">{{ item.comments_count }} comments</router-link>
<NuxtLink :to="'/item/' + item.id">{{ item.comments_count }} comments</NuxtLink>
</span>
</span>
</li>
</template>
<script>
export default {
name: 'NewsItem',
props: {
item: {
type: Object,
required: true
}
},
methods: {
isAbsolute (url) {
return /^https?:\/\//.test(url)
}
}
}
</script>
<style lang="stylus">
<style lang="postcss">
.news-item {
background-color: #fff;
padding: 20px 30px 20px 80px;
......@@ -68,6 +59,10 @@ export default {
font-size: 0.85em;
color: #595959;
span {
margin: 0 0.2rem;
}
a {
color: #595959;
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>
<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
</router-link>
<a v-else class="disabled">&lt; prev</a>
<span>{{ page }}/{{ maxPage }}</span>
<router-link v-if="hasMore" :to="`/${feed}/${page + 1}`">
</NuxtLink>
<span v-else class="disabled">&lt; prev</span>
<span class="page">{{ page }}/{{ maxPage }}</span>
<NuxtLink v-if="hasMore" :to="`/${feed}/${page + 1}`">
more &gt;
</router-link>
<a v-else class="disabled">more &gt;</a>
</NuxtLink>
<span v-else class="disabled">more &gt;</span>
</div>
</template>
<script>
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">
<style lang="postcss">
.news-list-nav, .news-list {
background-color: #fff;
border-radius: 2px;
......@@ -46,13 +32,20 @@ export default {
padding: 15px 30px;
text-align: center;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
user-select: none;
a {
margin: 0 1em;
}
.disabled {
opacity: 0.8;
opacity: 0.5;
}
.page {
width: 100px;
display: inline-block;
text-align: center;
}
}
</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>
<transition>
<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>
<div class="spinner" />
</template>
<script>
export default {
name: 'Spinner',
props: {
show: {
type: Boolean,
required: true
}
}
<style scoped>
.spinner,
.spinner:after {
border-radius: 50%;
width: 3em;
height: 3em;
}
</script>
<style lang="stylus">
$offset = 126;
$duration = 1.4s;
.spinner {
transition: opacity 0.15s ease;
animation: rotator $duration linear infinite;
animation-play-state: paused;
&.show {
animation-play-state: running;
}
&.v-enter, &.v-leave-active {
opacity: 0;
}
&.v-enter-active, &.v-leave {
opacity: 1;
}
margin: 30px auto;
font-size: 10px;
position: relative;
text-indent: -9999em;
border-top: 0.2em solid rgba(0, 196, 141, 0.2);
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;
-webkit-transform: translateZ(0);
-ms-transform: translateZ(0);
transform: translateZ(0);
-webkit-animation: loading 1.1s infinite linear;
animation: loading 1.1s infinite linear;
}
@keyframes rotator {
@-webkit-keyframes loading {
0% {
transform: scale(0.5) rotate(0deg);
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
transform: scale(0.5) rotate(270deg);
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
.spinner .path {
stroke: #ff6600;
stroke-dasharray: $offset;
stroke-dashoffset: 0;
transform-origin: center;
animation: dash $duration ease-in-out infinite;
}
@keyframes dash {
@keyframes loading {
0% {
stroke-dashoffset: $offset;
}
50% {
stroke-dashoffset: ($offset / 2);
transform: rotate(135deg);
-webkit-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
stroke-dashoffset: $offset;
transform: rotate(450deg);
-webkit-transform: rotate(360deg);
transform: rotate(360deg);
}
}
</style>
export const feeds = {
export const feedsInfo = {
news: { title: 'News', pages: 10 },
newest: { title: 'Newest', pages: 12 },
ask: { title: 'Ask', pages: 2 },
......@@ -6,4 +6,4 @@ export const feeds = {
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>
<div id="app">
<div>
<header class="header">
<nav class="inner" role="navigation">
<router-link to="/" exact>
<img class="logo" src="~/assets/logo.svg" alt="logo">
</router-link>
<router-link v-for="(list, key) in feeds" :key="key" :to="`/${key}`">
<NuxtLink to="/" exact>
<img class="logo" src="/logo.svg" alt="logo">
</NuxtLink>
<NuxtLink v-for="(list, key) in feedsInfo" :key="key" :to="`/${key}`">
{{ list.title }}
</router-link>
</NuxtLink>
<a class="github" href="https://github.com/nuxt/hackernews" target="_blank" rel="noopener banner">
Built with Nuxt.js
Built with Nuxt3
</a>
</nav>
</header>
<nuxt nuxt-child-key="none" role="main" />
<slot role="main" />
</div>
</template>
<script>
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">
<style lang="postcss">
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
font-size: 15px;
......@@ -64,7 +58,9 @@ a {
max-width: 800px;
box-sizing: border-box;
margin: 0px auto;
padding: 15px 5px;
padding: 12px 5px;
display: flex;
place-items: center;
}
a {
......@@ -94,13 +90,15 @@ a {
.github {
color: #fff;
font-size: 0.9em;
margin: 0;
float: right;
margin: auto;
text-align: right;
flex-grow: 1;
}
}
.logo {
width: 24px;
width: 30px;
height: 30px;
margin-right: 10px;
display: inline-block;
vertical-align: middle;
......@@ -116,11 +114,7 @@ a {
transition: opacity 0.4s ease;
}
.page-enter-active, .page-leave-active {
transition: all 0.2s ease;
}
.appear, .page-enter, .page-leave-active {
.appear {
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",
"private": true,
"packageManager": "pnpm@7.0.1",
"description": "Nuxt Hacker News",
"version": "1.0.0",
"author": "Evan You <yyx990803@gmail.com>",
"contributors": [
{
......@@ -13,37 +13,29 @@
},
{
"name": "Pooya Parsa (@pi0)"
},
{
"name": "Anthony Fu (@antfu)"
}
],
"scripts": {
"dev": "nuxt dev",
"build": "nuxt build",
"start": "nuxt start",
"dev-spa": "nuxt dev --spa",
"build-spa": "nuxt build --spa",
"start-spa": "nuxt start --spa",
"lint": "eslint --ext .js,.vue --ignore-path .gitignore .",
"lintfix": "eslint --fix --ext .js,.vue --ignore-path .gitignore ."
"dev": "nuxi dev",
"build": "nuxi build",
"start": "nuxi start",
"dev-spa": "nuxi dev --spa",
"build-spa": "nuxi build --spa",
"start-spa": "nuxi start --spa",
"lint": "eslint --ext .vue,.js,.ts --ignore-path .gitignore ."
},
"dependencies": {
"@nuxtjs/axios": "^5.13.6",
"h3": "^0.2.10",
"nuxt": "^2.15.6",
"ohmyfetch": "^0.2.0"
"engines": {
"node": ">=14.0"
},
"devDependencies": {
"@nuxtjs/eslint-config": "^6.0.1",
"@nuxtjs/eslint-config-typescript": "^10.0.0",
"@nuxtjs/pwa": "3.3.5",
"babel-eslint": "^10.1.0",
"eslint": "^7.28.0",
"eslint-config-standard": "^16.0.3",
"eslint-plugin-import": "^2.23.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"
"eslint": "^8.15.0",
"nuxt": "^3.0.0-rc.3",
"postcss-nested": "^5.0.6",
"typescript": "^4.6.4"
}
}
<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>
import { validFeeds } from '~/common/api'
export default {
fetch ({ redirect }) {
redirect('/' + validFeeds[0])
}
}
<script setup lang="ts">
definePageMeta({
middleware: 'index'
})
</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>
<div class="item-view view">
<div class="item-view-header">
<template v-if="isAbsolute(item.url)">
<a :href="item.url" target="_blank" rel="noopener"><h1 v-text="item.title" /></a>
<span class="host"> ({{ item.url | host }})</span>
</template>
<template v-else>
<h1 v-text="item.title" />
</template>
<p class="meta">
{{ item.points }} points | by
<router-link :to="'/user/' + item.user">
{{ item.user }}
</router-link>
{{ item.time | timeAgo }} ago
</p>
<div v-if="!item" class="item-view-header">
<h1>Page not found</h1>
</div>
<div class="item-view-comments">
<lazy-wrapper :loading="item.loading">
<p class="item-view-comments-header">
{{ item.comments ? item.comments.length + ' comments' : 'No comments yet.' }}
<template v-else>
<div class="item-view-header">
<template v-if="isAbsolute(item.url)">
<a :href="item.url" target="_blank" rel="noopener"><h1 v-text="item.title" /></a>
<span class="host"> ({{ host(item.url) }})</span>
</template>
<template v-else>
<h1 v-text="item.title" />
</template>
<p class="meta">
{{ item.points }} points | by
<NuxtLink :to="'/user/' + item.user">
{{ item.user }}
</NuxtLink>
{{ timeAgo(+item.time) }} ago
</p>
<ul class="comment-children">
<comment v-for="comment in item.comments" :key="comment.id" :comment="comment" />
</ul>
</lazy-wrapper>
</div>
</div>
<div class="item-view-comments">
<LoadingWrapper :loading="commentsLoading">
<p class="item-view-comments-header">
{{ comments ? comments.length + ' comments' : 'No comments yet.' }}
</p>
<ul class="comment-children">
<Comment v-for="comment in comments" :key="comment.id" :comment="comment" />
</ul>
</LoadingWrapper>
</div>
</template>
</div>
</template>
<script>
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">
<style lang="postcss">
.item-view-header {
background-color: #fff;
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>
<div class="user-view view">
<template v-if="user">
<h1>User : {{ user.id }}</h1>
<lazy-wrapper :loading="user.loading">
<ul class="meta">
<li>
<span class="label">Created:</span> {{ user.created_time | timeAgo }} ago
</li>
<li>
<span class="label">Karma:</span> {{ user.karma || '-' }}
</li>
<li v-if="user.about" class="about" v-html="user.about" />
</ul>
</lazy-wrapper>
<Spinner v-if="loading" />
<template v-else-if="user">
<h1>User: {{ user.id }}</h1>
<ul class="meta">
<li>
<span class="label">Created:</span> {{ timeAgo(user.created_time) }} ago
</li>
<li>
<span class="label">Karma:</span> {{ user.karma || '-' }}
</li>
<li v-if="user.about" class="about" v-html="user.about" />
</ul>
<p class="links">
<a :href="'https://news.ycombinator.com/submitted?id=' + user.id">submissions</a> |
<a :href="'https://news.ycombinator.com/threads?id=' + user.id">comments</a>
......@@ -24,34 +41,7 @@
</div>
</template>
<script>
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">
<style lang="postcss">
.user-view {
background-color: #fff;
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 { $fetch } from 'ohmyfetch/node'
import { getQuery, parseURL, withoutLeadingSlash } from 'ufo'
import { createError } from 'h3'
import { $fetch } from 'ohmyfetch'
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',
jobs: 'jobstories',
show: 'showstories',
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 entries = Object.values(
await $fetch(`${baseURL}/${feedUrls[feed]}.json`)
......@@ -22,21 +23,17 @@ async function fetchFeed(feed: string, page = '1') {
return Promise.all(entries.map(id => fetchItem(id)))
}
const handler: PHandle = async req => {
const { pathname, search } = parseURL(req.url)
const feed = withoutLeadingSlash(pathname)
const { page = '1' } = getQuery(search)
export default defineEventHandler(({ req, res }) => {
configureSWRHeaders(res)
const { search } = parseURL(req.url)
const { page = '1', feed = 'news' } = getQuery(search) as { page: string, feed: string }
if (!validFeeds.includes(feed) || String(Number(page)) !== page) {
throw createError({
statusCode: 422,
statusMessage: `Must provide one of ${validFeeds.join(
', '
)} and a valid page number.`,
statusMessage: `Must provide one of ${validFeeds.join(', ')} and a valid page number.`
})
}
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 { $fetch } from 'ohmyfetch/node'
import { withoutLeadingSlash } from 'ufo'
import { createError } from 'h3'
import { $fetch } from 'ohmyfetch'
import { parseURL, getQuery } from 'ufo'
import { baseURL } from '~/server/constants'
import { Item } from '~/types'
import { configureSWRHeaders } from '~/server/swr'
import { baseURL } from '.'
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(
export async function fetchItem (
id: string,
withComments = false
): Promise<Item> {
......@@ -35,23 +23,31 @@ export async function fetchItem(
comments_count: Object.values(item.kids).length,
comments: withComments
? await Promise.all(
Object.values(item.kids as string[]).map(id =>
fetchItem(id, withComments)
)
Object.values(item.kids as string[]).map(id =>
fetchItem(id, withComments)
)
: [],
)
: []
}
}
const handler: PHandle = async req => {
const itemId = withoutLeadingSlash(req.url)
if (!itemId) {
export default defineEventHandler(({ req, res }) => {
configureSWRHeaders(res)
const { search } = parseURL(req.url)
const { id } = getQuery(search) as { id: string }
if (!id) {
throw createError({
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 { $fetch } from 'ohmyfetch/node'
import { withoutLeadingSlash } from 'ufo'
import { createError } from 'h3'
import { $fetch } from 'ohmyfetch'
import { parseURL, getQuery } from 'ufo'
import { User } from '~/types'
import { baseURL } from '~/server/constants'
import { configureSWRHeaders } from '~/server/swr'
import { baseURL } from '.'
export interface User {
id: string
created_time: string
karma: number
about: string
}
async function fetchUser(id: string): Promise<User> {
async function fetchUser (id: string): Promise<User> {
const user = await $fetch(`${baseURL}/user/${id}.json`)
return {
id: user.id,
karma: user.karma,
created_time: user.created,
about: user.about,
about: user.about
}
}
const handler: PHandle = async req => {
const userId = withoutLeadingSlash(req.url)
if (!userId) {
export default defineEventHandler(({ req, res }) => {
configureSWRHeaders(res)
const { search } = parseURL(req.url)
const { id } = getQuery(search) as { id: string }
if (!id) {
throw createError({
statusCode: 422,
statusMessage: 'Must provide a user ID.',
statusMessage: 'Must provide a user ID.'
})
}
return fetchUser(userId)
}
export default handler
return fetchUser(id)
})
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')
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