Unverified Commit 3429d397 authored by Sébastien Chopin's avatar Sébastien Chopin Committed by GitHub

chore: refactor to nuxt 4 compatibility (#148)

* chore: refactor to nuxt 4 compatibility * chore: remove hub at the moment (no needed) * Update pnpm-lock.yaml * chore: add caching with hub * chore: update links
parent 9f592c04
{
"extends": "@nuxt",
"rules": {
"vue/no-v-html": "off",
"vue/no-multiple-template-root": "off"
}
}
......@@ -13,3 +13,5 @@ package-lock.json
public/manifest*.json
public/sw.js
public/workbox-sw*.js*
.data
\ No newline at end of file
The MIT License (MIT)
Copyright (c) 2013-present, Yuxi (Evan) You
Copyright (c) 2013-present, Yuxi (Evan) You & Nuxt core team
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
......
......@@ -3,8 +3,8 @@
Hacker News clone built with [Nuxt](https://nuxt.com).
<p align="center">
<a href="https://hn.nuxt.space" target="_blank">
<img width="1090" src="https://hn.nuxt.space/cover.jpg">
<a href="https://hn.nuxt.dev" target="_blank">
<img width="1090" src="https://hn.nuxt.dev/cover.jpg">
<br>
Live Demo
</a>
......@@ -12,15 +12,13 @@ Hacker News clone built with [Nuxt](https://nuxt.com).
## Demo
https://hn.nuxt.space
https://hn.nuxt.dev
> Hosted on [Vercel](https://vercel.com/): `npm run build`
To disable server-side render for a page, simply append `?csr` to the URL, example: https://hn.nuxt.space/news/1?csr
> Hosted on Cloudflare Pages with [NuxtHub](https://hub.nuxt.com): `npm run build`
## Performance
- 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)
- Lighthouse [100/100](https://pagespeed.web.dev/report?url=https%3A%2F%2Fhn.nuxt.dev%2Fnews%2F1) (Slow 4G / Mobile Moto G4)
- Interactive: 1.4s
- Total Blocking Time: 30ms
......@@ -28,7 +26,7 @@ To disable server-side render for a page, simply append `?csr` to the URL, examp
- 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)
- Deploys anywhere with zero config (Vercel, Netlify, Cloudflare, etc.) powered by [Nitro](https://nitro.unjs.io)
- Code Splitting
- Prefetch/Preload JS + DNS + Data
......
......@@ -11,8 +11,8 @@ useSeoMeta({
})
useHead({
link: [
{ rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' }
]
{ rel: 'icon', type: 'image/svg+xml', href: '/logo.svg' },
],
})
</script>
......
<script setup lang="ts">
const props = defineProps<{
feed: string,
page: number,
feed: string
page: number
maxPage: number
}>()
......
<script setup lang="ts">
defineProps<{
loading: boolean,
loading: boolean
}>()
</script>
......
<script setup lang="ts">
import { timeAgo } from '~/composables/utils'
defineProps({
comment: {
type: Object,
required: true
}
required: true,
},
})
const open = ref(true)
function pluralize (n: number) {
function pluralize(n: number) {
return n + (n === 1 ? ' reply' : ' replies')
}
</script>
......
<script setup lang="ts">
import { timeAgo, isAbsolute, host } from '~/composables/utils'
import type { Item } from '~~/types'
defineProps<{
item: any
item: Item
}>()
</script>
......
import { WritableComputedOptions } from 'vue'
import { Item, User } from '~/types'
import type { WritableComputedOptions } from 'vue'
import { validFeeds } from '~~/utils/api'
import type { Item, User } from '~~/types'
export interface StoreState {
items: Record<number, Item>
......@@ -12,15 +13,15 @@ export const useStore = () => useState<StoreState>('store', () => ({
items: {},
users: {},
comments: {},
feeds: Object.fromEntries(validFeeds.map(i => [i, {}]))
feeds: Object.fromEntries(validFeeds.map(i => [i, {}])),
}))
interface FeedQuery {
feed: string;
feed: string
page: number
}
}
export function getFeed (state:StoreState, { feed, page }: FeedQuery) {
export function getFeed(state: StoreState, { feed, page }: FeedQuery) {
const ids = state.feeds?.[feed]?.[page]
if (ids?.length) {
return ids.map(i => state.items[i])
......@@ -28,7 +29,7 @@ export function getFeed (state:StoreState, { feed, page }: FeedQuery) {
return undefined
}
export function fetchFeed (query: FeedQuery) {
export function fetchFeed(query: FeedQuery) {
const state = useStore()
const { feed, page } = query
......@@ -43,43 +44,44 @@ export function fetchFeed (query: FeedQuery) {
.forEach((item) => {
if (state.value.items[item.id]) {
Object.assign(state.value.items[item.id], item)
} else {
}
else {
state.value.items[item.id] = item
}
})
},
() => $fetch('/api/hn/feeds', { params: { feed, page } }),
(state.value.feeds[feed][page] || []).map(id => state.value.items[id])
(state.value.feeds[feed][page] || []).map(id => state.value.items[id]),
)
}
export function fetchItem (id: number) {
export function fetchItem(id: number) {
const state = useStore()
return reactiveLoad<Item>(
() => state.value.items[id],
(item) => { state.value.items[id] = item },
() => $fetch('/api/hn/item', { params: { id } })
() => $fetch('/api/hn/item', { params: { id } }),
)
}
export function fetchComments (id: number) {
export function fetchComments(id: number) {
const state = useStore()
return reactiveLoad<Item[]>(
() => state.value.comments[id],
(comments) => { state.value.comments[id] = comments },
() => $fetch('/api/hn/item', { params: { id } }).then(i => i.comments!)
() => $fetch('/api/hn/item', { params: { id } }).then(i => i.comments!),
)
}
export function fetchUser (id: string) {
export function fetchUser(id: string) {
const state = useStore()
return reactiveLoad<User>(
() => state.value.users[id],
(user) => { state.value.users[id] = user },
() => $fetch('/api/hn/user', { params: { id } })
() => $fetch('/api/hn/user', { params: { id } }),
)
}
......@@ -88,15 +90,15 @@ export function fetchUser (id: string) {
*
* On server side the data will be fetched eagerly
*/
export async function reactiveLoad<T> (
export async function reactiveLoad<T>(
get: () => T | undefined,
set: (data: T) => void,
fetch: ()=> Promise<T>,
init?: T
fetch: () => Promise<T>,
init?: T,
) {
const data = computed({
get,
set
set,
} as WritableComputedOptions<T | undefined>)
const loading = ref(false)
......@@ -111,27 +113,30 @@ export async function reactiveLoad<T> (
const fetched = await fetch()
if (data.value != null) {
data.value = Object.assign(data.value, fetched)
} else {
}
else {
data.value = fetched
}
} catch (e) {
// eslint-disable-next-line no-console
}
catch (e) {
console.error(e)
data.value = undefined
} finally {
}
finally {
loading.value = false
}
}
if (process.client) {
if (import.meta.client) {
task()
} else {
}
else {
await task()
}
}
return reactive({
loading,
data
data,
})
}
<script setup lang="ts">
import { feedsInfo } from '~~/utils/api'
const route = useRoute()
const host = process.server
const host = import.meta.server
? useRequestHeaders().host
: window.location.host
useHead({
link: [
// We use route.path since we don't use query parameters
{ rel: 'canonical', href: `https://${host}${route.path}` }
]
{ rel: 'canonical', href: `https://${host}${route.path}` },
],
})
</script>
......@@ -32,7 +34,7 @@ useHead({
v-for="(list, key) in feedsInfo"
:key="key"
:to="`/${key}`"
:class="{ active: $route.path.startsWith(`/${key}`)}"
:class="{ active: $route.path.startsWith(`/${key}`) }"
>
{{ list.title }}
</NuxtLink>
......@@ -58,16 +60,16 @@ body {
background-color: #F4F4F5;
margin: 0;
padding: 0;
color: #18181B;
color: #020420;
overflow-y: scroll;
}
a {
color: #18181B;
color: #020420;
text-decoration: none;
}
.header {
background-color: #18181B;
background-color: #020420;
z-index: 999;
height: 55px;
......
import { validFeeds } from '~~/utils/api'
export default defineNuxtRouteMiddleware((from) => {
if (!from.params.feed || !validFeeds.includes(from.params.feed as string)) {
return navigateTo(`/${validFeeds[0]}/1`)
......
<script setup lang="ts">
import { feedsInfo } from '~~/utils/api'
definePageMeta({
middleware: 'feed'
middleware: 'feed',
})
const route = useRoute()
......@@ -14,7 +16,7 @@ const pageNo = computed(() => Number(page.value) || 1)
const displayedPage = ref(pageNo.value)
useHead({
title: feedsInfo[feed.value]?.title
title: feedsInfo[feed.value]?.title,
})
const state = useStore()
......@@ -29,8 +31,10 @@ const maxPage = computed(() => {
return +(feedsInfo[feed.value]?.pages) || 0
})
function pageChanged (to: number) {
if (!isValidFeed.value) { return }
function pageChanged(to: number) {
if (!isValidFeed.value) {
return
}
if (to <= 0 || to > maxPage.value) {
router.replace(`/${feed.value}/1`)
......@@ -40,7 +44,7 @@ function pageChanged (to: number) {
// Prefetch next page
fetchFeed({
feed: feed.value,
page: page.value + 1
page: page.value + 1,
}).catch(() => {})
// transition.value = from === -1
......
<script setup lang="ts">
definePageMeta({
middleware: 'feed'
middleware: 'feed',
})
</script>
......
<script setup lang="ts">
import { validFeeds } from '~~/utils/api'
definePageMeta({
middleware: (from) => {
if (from.path === '/') {
return navigateTo(`/${validFeeds[0]}/1`)
}
}
},
})
</script>
......
......@@ -7,7 +7,7 @@ const { data: item } = toRefs(resultItem)
const { data: comments, loading: commentsLoading } = toRefs(resultComments)
useHead({
title: item.value?.title
title: item.value?.title,
})
</script>
......
......@@ -10,7 +10,7 @@ useHead({
? 'Loading'
: user.value
? user.value.id
: 'User not found'
: 'User not found',
})
</script>
......
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)
}
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)
}
// @ts-check
import withNuxt from './.nuxt/eslint.config.mjs'
export default withNuxt({
rules: {
'vue/no-v-html': 'off',
},
})
[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 defineNuxtConfig({
future: { compatibilityVersion: 4 },
// https://nuxt.com/modules
modules: [
'@nuxthub/core',
'@nuxt/eslint',
],
hub: {
cache: true,
},
postcss: {
plugins: {
'postcss-nesting': {}
}
'postcss-nesting': {},
},
},
// https://devtools.nuxt.com
devtools: {
enabled: true
}
enabled: true,
},
// https://eslint.nuxt.com
eslint: {
config: {
stylistic: {
quotes: 'single',
},
},
},
})
......@@ -9,9 +9,6 @@
"name": "Sebastien Chopin (@Atinux)"
},
{
"name": "Alexandre Chopin (@alexchopin)"
},
{
"name": "Pooya Parsa (@pi0)"
},
{
......@@ -22,15 +19,17 @@
"dev": "nuxi dev",
"build": "nuxi build",
"start": "nuxi start",
"lint": "eslint --ext .vue,.js,.ts --ignore-path .gitignore ."
"lint": "eslint ."
},
"devDependencies": {
"@nuxt/devtools": "^1.0.5",
"@nuxt/eslint-config": "^0.1.1",
"@types/node": "^18.16.0",
"eslint": "^8.39.0",
"nuxt": "^3.4.2",
"postcss-nesting": "^11.2.2",
"typescript": "^5.0.4"
"@nuxt/devtools": "^1.3.3",
"@nuxt/eslint": "^0.3.13",
"@nuxt/eslint-config": "^0.3.13",
"@nuxthub/core": "^0.6.17",
"@types/node": "^20.14.2",
"eslint": "^9.5.0",
"nuxt": "^3.12.2",
"postcss-nesting": "^12.1.5",
"typescript": "^5.4.5"
}
}
This source diff could not be displayed because it is too large. You can view the blob instead.
import { $fetch } from 'ofetch'
import { feedsInfo, validFeeds } from '~/composables/api'
import { baseURL } from '~/server/constants'
import type { feedsInfo } from '~~/utils/api'
import { validFeeds } from '~~/utils/api'
const feedUrls: Record<keyof typeof feedsInfo, string> = {
ask: 'askstories',
jobs: 'jobstories',
show: 'showstories',
newest: 'newstories',
news: 'topstories'
news: 'topstories',
}
async function fetchFeed (feed: keyof typeof feedsInfo, page = '1') {
async function fetchFeed(feed: keyof typeof feedsInfo, page = '1') {
const { fetchItem } = await import('./item.get')
const entries = Object.values(
await $fetch(`${baseURL}/${feedUrls[feed]}.json`)
await $fetch(`${BASE_URL}/${feedUrls[feed]}.json`),
).slice((Number(page) - 1) * 10, Number(page) * 10) as string[]
return Promise.all(entries.map(id => fetchItem(id)))
}
export default defineEventHandler((event) => {
configureSWRHeaders(event)
export default defineCachedEventHandler((event) => {
const { page = '1', feed = 'news' } = getQuery(event) as { page: string, feed: keyof typeof feedsInfo }
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)
}, {
name: 'api/hn',
getKey(event) {
const { page = '1', feed = 'news' } = getQuery(event)
return ['feeds', feed, page].join('/')
},
swr: true,
maxAge: 10,
})
import { $fetch } from 'ofetch'
import { baseURL } from '~/server/constants'
import { Item } from '~/types'
import type { Item } from '~~/types'
export async function fetchItem (
export async function fetchItem(
id: string,
withComments = false
withComments = false,
): Promise<Item> {
const item = await $fetch(`${baseURL}/item/${id}.json`)
const item = await $fetch(`/item/${id}.json`, { baseURL: BASE_URL })
item.kids = item.kids || {}
return {
id: item.id,
......@@ -21,29 +20,36 @@ export async function fetchItem (
comments: withComments
? await Promise.all(
Object.values(item.kids as string[]).map(id =>
fetchItem(id, withComments)
fetchItem(id, withComments),
),
)
)
: []
: [],
}
}
export default defineEventHandler((event) => {
configureSWRHeaders(event)
export default defineCachedEventHandler((event) => {
const { id } = getQuery(event) as { id?: string }
if (!id) {
throw createError({
statusCode: 422,
statusMessage: 'Must provide a item ID.'
statusMessage: 'Must provide a item ID.',
})
}
if (Number.isNaN(+id)) {
throw createError({
statusCode: 400,
statusMessage: 'Item ID mush a number but got ' + id
statusMessage: 'Item ID mush a number but got ' + id,
})
}
return fetchItem(id, true)
}, {
name: 'api/hn',
getKey(event) {
const { id } = getQuery(event)
return ['item', id].join('/')
},
swr: true,
maxAge: 10,
})
import { $fetch } from 'ofetch'
import { User } from '~/types'
import { baseURL } from '~/server/constants'
import type { User } from '~~/types'
async function fetchUser (id: string): Promise<User> {
const user = await $fetch(`${baseURL}/user/${id}.json`)
async function fetchUser(id: string): Promise<User> {
const user = await $fetch(`/user/${id}.json`, { baseURL: BASE_URL })
return {
id: user.id,
karma: user.karma,
created_time: user.created,
about: user.about
about: user.about,
}
}
export default defineEventHandler((event) => {
configureSWRHeaders(event)
export default defineCachedEventHandler((event) => {
const { id } = getQuery(event) as { id?: string }
if (!id) {
throw createError({
statusCode: 422,
statusMessage: 'Must provide a user ID.'
statusMessage: 'Must provide a user ID.',
})
}
return fetchUser(id)
}, {
name: 'api/hn',
getKey(event) {
const { id } = getQuery(event)
return ['user', id].join('/')
},
swr: true,
maxAge: 10,
})
export const baseURL = 'https://hacker-news.firebaseio.com/v0'
export default defineEventHandler((event) => {
const query = getQuery(event)
if (typeof query.csr !== 'undefined') {
event.node.req.headers['x-nuxt-no-ssr'] = 'true'
}
})
export const BASE_URL = 'https://hacker-news.firebaseio.com/v0'
import { H3Event } from 'h3'
export function configureSWRHeaders (event: H3Event) {
setHeader(event, 'Cache-Control', 's-maxage=10, stale-while-revalidate')
}
// Shared between app & server
export const feedsInfo = {
news: { title: 'News', pages: 10 },
newest: { title: 'Newest', pages: 12 },
ask: { title: 'Ask', pages: 2 },
show: { title: 'Show', pages: 2 },
jobs: { title: 'Jobs', pages: 1 }
jobs: { title: 'Jobs', pages: 1 },
}
export const validFeeds = Object.keys(feedsInfo)
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