Skip to content

Commit

Permalink
feat: display core web vitals results (#3)
Browse files Browse the repository at this point in the history
  • Loading branch information
danielroe authored Feb 7, 2024
1 parent 57ffbce commit 4f9bd16
Show file tree
Hide file tree
Showing 13 changed files with 483 additions and 166 deletions.
169 changes: 42 additions & 127 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,29 @@
<div class="flex flex-col justify-start mt-4 sm:mt-8 md:my-12 p-4 gap-8 md:gap-12 flex-grow max-w-full">
<h1 class="flex flex-row gap-4 text-white text-3xl md:text-5xl">
<span class="text-green-400">&raquo;</span>
<button v-if="domain && !editing" class="bg-transparent" @click="enableEditing">{{ domain }}</button>
<form v-else class="flex flex-col gap-4 overflow-hidden" @submit.prevent="navigateToNewDomain">
<input ref="input" v-model="newDomain" name="domain" type="text"
class="md:-mt-1 rounded-none py-0 bg-transparent outline-none border-b-2 border-b-solid border-transparent focus:border-green-500 underline-dashed"
autofocus inputmode="url" autocapitalize="none" placeholder="Enter a domain" required />
<button type="submit"
class="bg-green-400 text-black hover: hover:bg-white focus:bg-white active:bg-white text-xl md:text-2xl py-2 px-6 md:self-start">See
results</button>
</form>
<TheDomainForm v-model:domain="domain" v-model:editing="editing" />
</h1>
<template v-if="!editing && domain">
<div v-if="status === 'pending' || results"
class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.performance : undefined"
caption="performance" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.accessibility : undefined"
caption="accessibility" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.bestPractices : undefined"
caption="best practices" />
<ProgressRing size="normal" :value="results && status !== 'pending' ? results.seo : undefined" caption="SEO" />
</div>
<template v-if="status === 'pending' || results">
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<CoreWebVitals v-if="status === 'pending' || results?.crux" :pass="results?.crux?.cwv"
:lcp="results?.crux?.lcp" :cls="results?.crux?.cls" :inp="results?.crux?.inp"
:loading="status === 'pending'" size="normal" show-p75 />
<template v-else-if="results">
<ProgressRing size="normal" :value="results.lighthouse.performance" caption="performance" />
<ProgressRing size="normal" :value="results.lighthouse.accessibility" caption="accessibility" />
<ProgressRing size="normal" :value="results.lighthouse.bestPractices" caption="best practices" />
<ProgressRing size="normal" :value="results.lighthouse.seo" caption="SEO" />
</template>
</div>
<LighthouseTable v-if="status === 'pending' || (results && results.crux)" :loading="status === 'pending'"
v-bind="results?.lighthouse || {}" />
</template>
<div v-else-if="domain">
No results could be fetched. Is it a valid domain?
</div>
<div class="flex flex-col gap-2 mt-auto md:mt-8">
<NuxtLink type="submit"
class="bg-green-400 text-black hover: hover:bg-white focus:bg-white active:bg-white text-xl md:text-2xl py-2 px-6 md:self-start mb-8"
:href="shareLink" @click.prevent="nativeShare">
Share results
</NuxtLink>
<a :href="`https://pagespeed.web.dev/analysis?url=https://${domain}`"
class="self-start underline text-gray-400 hover:text-green-400 focus:text-green-400 active:text-green-400">
See full results on PageSpeed Insights &raquo;
</a>
<span v-if="results?.timestamp" class="text-gray-400">
Last updated at
<NuxtTime :datetime="results.timestamp" dateStyle="full" timeStyle="medium" />.
</span>
</div>
<TheShareLink v-if="results" :domain="domain" :type="results.crux ? 'crux' : 'pagespeed-insights'"
:timestamp="results.crux?.timestamp || results.lighthouse.timestamp" />
</template>
</div>
<footer class="mt-auto p-3 text-gray-400">
Expand All @@ -58,7 +42,7 @@

<script lang="ts" setup>
import '@unocss/reset/tailwind-compat.css'
import { joinURL, withoutLeadingSlash, parseURL } from 'ufo'
import { joinURL, withoutLeadingSlash } from 'ufo'
const route = useRoute()
const domain = computed(() => withoutLeadingSlash(route.path).toLowerCase().replace(/(\/|\?).*$/, '').trim())
Expand All @@ -69,75 +53,26 @@ if (domain.value && !/^[a-zA-Z0-9.\-]+\.[a-zA-Z]{2,}$/g.test(domain.value)) {
}
const editing = ref(!domain.value)
const newDomain = ref('')
const input = ref<HTMLInputElement>()
function enableEditing () {
newDomain.value = domain.value
editing.value = true
watch(input, (input) => {
if (input) {
input.focus()
input.setSelectionRange(0, newDomain.value.length)
}
}, { once: true })
}
const { data: results, status, refresh } = await useFetch(() => `/api/run/${domain.value}`, {
const { data: results, status, refresh } = await useAsyncData(async () => {
const [lighthouse, crux] = await Promise.all([
$fetch(`/api/run/${domain.value}`),
$fetch(`/api/crux/${domain.value}`).catch(() => null)
])
return { lighthouse, crux }
}, {
watch: [domain],
immediate: !!domain.value,
})
if (!domain.value) {
watch(domain, () => refresh(), { once: true })
}
const favicon = computed(() => {
const radius = 80
const stroke = 14
const normalizedRadius = radius - stroke * 2
const circumference = normalizedRadius * 2 * Math.PI
const value = status.value === 'pending' || (domain.value && !results.value)
? undefined
: (results.value ? results.value.performance : 100)
const color = !value ? '#6b7280' : value >= 90 ? '#23c55e' : value >= 50 ? '#fbbf24' : '#ef4444'
const svg = `<svg xmlns="http://www.w3.org/2000/svg" height="${radius * 2}" width="${radius * 2}">
<style>@keyframes spin {
from {transform: rotate(0deg)}
to {transform: rotate(360deg)}
}</style>
<circle
stroke="${color}"
fill="transparent"
stroke-linecap="round"
stroke-dasharray="${circumference + ' ' + circumference}"
style="transform-origin:center;stroke-dashoffset:${circumference - (Math.floor((value || 85) / 4) * 4) / 100 * circumference};transform:rotate(270deg)${!value ? ';animation:spin 1s linear infinite' : ''}"
stroke-width="${stroke}"
r="${normalizedRadius}"
cx="${radius}"
cy="${radius}"
/>
<circle
fill="${color}"
stroke-width="${stroke}"
r="${normalizedRadius - 35}"
cx="${radius}"
cy="${radius}"
/>
</svg>`
return `data:image/svg+xml;base64,${btoa(svg)}`
})
useFavicon(() => status.value !== 'pending' && !!domain.value && (results.value ? results.value.lighthouse.performance : 100))
useHead({
title: () => domain.value ? domain.value : 'page-speed.dev',
link: [
() => ({
key: 'favicon',
rel: 'icon',
type: 'image/svg',
href: favicon.value
})
]
})
useServerHead({
Expand Down Expand Up @@ -186,49 +121,29 @@ useServerSeoMeta({
if (!domain.value) {
defineOgImageComponent('Home')
useServerSeoMeta({
description: 'See and share PageSpeed Insights results simply and easily.',
description: 'See and share Core Web Vitals and Page Speed Insights results simply and easily.',
})
} else if (results.value) {
useServerSeoMeta({
description:
`Performance: ${results.value?.performance} | ` +
`Accessibility: ${results.value?.accessibility} | ` +
`Best Practices: ${results.value?.bestPractices} | ` +
`SEO: ${results.value?.seo}`
results.value?.crux
?
`Core Web Vitals: ${results.value?.crux.cwv ? 'pass' : 'fail'} | ` +
`LCP: ${results.value?.crux.lcp.caption} | ` +
`CLS: ${results.value?.crux.cls.caption} | ` +
`INP: ${results.value?.crux.inp.caption}`
:
`Performance: ${results.value?.lighthouse.performance} | ` +
`Accessibility: ${results.value?.lighthouse.accessibility} | ` +
`Best Practices: ${results.value?.lighthouse.bestPractices} | ` +
`SEO: ${results.value?.lighthouse.seo}`
})
defineOgImageComponent('Lighthouse', {
performance: results.value?.performance,
accessibility: results.value?.accessibility,
bestPractices: results.value?.bestPractices,
seo: results.value?.seo,
lighthouse: results.value.lighthouse,
crux: results.value?.crux,
domain: domain.value,
})
}
function navigateToNewDomain () {
if (!newDomain.value) { return }
const host = parseURL(newDomain.value).host || newDomain.value
editing.value = false
return navigateTo('/' + withoutLeadingSlash(host).toLowerCase().replace(/(\/|\?).*$/, '').trim())
}
const shareLink = computed(() => domain.value ? `https://twitter.com/intent/tweet?text=${encodeURIComponent(`Check out the Page Speed results for ${domain.value.replace(/\./g, '.​')}` + `\n\nhttps://page-speed.dev/${domain.value}`)}` : 'See and share PageSpeed Insights results simply and easily.')
async function nativeShare () {
try {
if (navigator.share) {
return await navigator.share({
title: 'page-speed.dev',
text: `See page speed results for ${domain.value.replace(/\./g, '.​')}`,
url: canonicalURL.value,
})
}
} catch {
// ignore errors sharing to native share and fall back directly to Twitter
}
return await navigateTo(shareLink.value, { external: true, open: { target: '_blank' } })
}
</script>
78 changes: 78 additions & 0 deletions components/Histogram.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<script setup lang="ts">
const props = defineProps({
value: {
type: Object as () => {
caption?: string | number
segments: number[]
},
required: false
},
caption: {
type: String,
required: false
},
size: {
type: String as () => 'large' | 'normal',
default: 'large'
},
showP75: {
type: Boolean,
default: false
}
})
const radius = props.size === 'large' ? 132 : 75
const stroke = props.size === 'large' ? 14 : 9
const normalizedRadius = radius - stroke * 2
const circumference = normalizedRadius * 2 * Math.PI
const colours = [
'#ef4444',
'#fbbf24',
'#23c55e'
]
const p75Color = computed(() => {
let count = 0
for (const [index, segment] of props.value?.segments?.entries() || []) {
count += segment
if (count >= 75) {
return colours[colours.length - index - 1]
}
}
return '#6b7280'
})
</script>

<template>
<span class="flex flex-col items-center" :class="size === 'large' ? 'gap-10' : 'gap-4'">
<span class="relative rounded-full flex items-center justify-center" :class="[size === 'large' ? 'text-7xl h-60 w-60' : 'text-3xl h-36 w-36']">
<svg class="absolute -right-0 -bottom-0" :height="radius * 2" :width="radius * 2" :class="{ 'animate-spin': !value }">
<circle
v-for="segment, index of [...value?.segments || [80]].reverse()"
:stroke="value ? colours[index] : '#6b7280'"
fill="transparent"
class="transform-origin-center"
:stroke-dasharray="circumference + ' ' + circumference"
:style="{
strokeDashoffset: circumference - segment / 100 * circumference,
transform: `rotate(270deg)`,
}"
:stroke-width="stroke"
:r="normalizedRadius"
:cx="radius"
:cy="radius"
/>
<circle v-if="value && showP75" :fill="p75Color" stroke="#fff" stroke-width="5" cx="20" cy="20" r="10" style="transform: translateX(-2px) translateY(calc(50% - 20px))"></circle>
</svg>
<slot>
<span v-if="value?.caption" class="flex flex-row items-baseline gap-1">
<span :class="size === 'large' ? 'text-6xl' : 'text-3xl'">{{ value.caption.replace(/m?s/, '') }}</span>
<span v-if="value.caption.match(/m?s/)" :class="size === 'large' ? 'text-4xl' : 'text-lg'">{{ value.caption.match(/m?s/)![0] }}</span>
</span>
</slot>
</span>
<span :class="size === 'large' ? 'text-4xl' : 'text-2xl'">{{ caption }}</span>
</span>
</template>

44 changes: 24 additions & 20 deletions components/OgImage/Lighthouse.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,35 +4,39 @@ defineProps({
type: String,
required: true,
},
performance: {
type: Number,
required: true,
},
accessibility: {
type: Number,
required: true,
lighthouse: {
type: Object,
required: true
},
bestPractices: {
type: Number,
required: true,
},
seo: {
type: Number,
required: true,
crux: {
type: Object
},
})
</script>

<template>
<div class="h-full w-full flex items-start justify-start border-solid bg-[#212121] text-white">
<div class="flex flex-col items-center justify-between h-full w-full">
<div class="flex flex-row justify-center gap-12 pt-12 w-full">
<ProgressRing :value="performance" caption="performance" />
<ProgressRing :value="accessibility" caption="accessibility" />
<ProgressRing :value="bestPractices" caption="best practices" />
<ProgressRing :value="seo" caption="SEO" />
<div class="flex flex-col justify-center gap-8 pt-12 pb-6 w-full">
<div class="flex flex-row flex-wrap gap-4 lg:flex-row justify-around w-full">
<template v-if="crux">
<CoreWebVitals
:pass="crux.cwv"
:lcp="crux.lcp"
:cls="crux.cls"
:inp="crux.inp"
/>
</template>
<template v-else-if="lighthouse">
<ProgressRing :value="lighthouse.performance" caption="performance" />
<ProgressRing :value="lighthouse.accessibility" caption="accessibility" />
<ProgressRing :value="lighthouse.bestPractices" caption="best practices" />
<ProgressRing :value="lighthouse.seo" caption="SEO" />
</template>
</div>
<LighthouseTable v-if="crux" v-bind="lighthouse" class="text-2xl" />
</div>
<div class="flex flex-row gap-4 self-start text-white text-5xl pl-16 pb-24">
<div class="flex flex-row gap-4 self-end text-white text-5xl pr-8 pt-4 pb-24">
<span class="text-green-400">&raquo;</span> {{ domain }}
</div>
</div>
Expand Down
Loading

0 comments on commit 4f9bd16

Please sign in to comment.