231 lines
5.9 KiB
Vue
231 lines
5.9 KiB
Vue
<script setup lang="ts">
|
|
import { formatDate } from 'date-fns'
|
|
import chestAnimation from 'assets/images/chest.avif'
|
|
import { render } from 'vue'
|
|
import { CodeControls } from '#components'
|
|
|
|
const config = useAppConfig()
|
|
const router = useRouter()
|
|
const route = useRoute()
|
|
|
|
let slug = route.params.slug
|
|
|
|
if (Array.isArray(slug)) slug = slug.join('/')
|
|
|
|
const { data, status } = await useAsyncData('home', () =>
|
|
queryContent(`/blog/${slug}`).findOne(),
|
|
)
|
|
|
|
const meta = {
|
|
title: data.value ? data.value.title : 'Page not found',
|
|
description: data.value
|
|
? data.value.description
|
|
: 'The page you are looking for does not exist. It might have been removed, had its name changed, or is temporarily unavailable.',
|
|
url: `${config.url}/blog`,
|
|
}
|
|
|
|
// When defineOgImage is used, useSeoMeta must exclude ogImage and twitterImage properties.
|
|
useSeoMeta({
|
|
title: meta.title,
|
|
description: meta.description,
|
|
ogTitle: meta.title,
|
|
ogDescription: meta.description,
|
|
// ogImage: EXCLUDED
|
|
ogUrl: meta.url,
|
|
ogType: 'article',
|
|
twitterTitle: meta.title,
|
|
twitterDescription: meta.description,
|
|
// twitterImage: EXCLUDED
|
|
twitterCard: 'summary_large_image',
|
|
})
|
|
|
|
const clipboard = (el: HTMLElement) => {
|
|
if (el.textContent)
|
|
navigator.clipboard.writeText(el.textContent).catch(() => {
|
|
console.error(
|
|
'Failed to copy element data to the clipboard! Element data:',
|
|
el,
|
|
)
|
|
})
|
|
}
|
|
|
|
const content = ref<Element | null>(null)
|
|
const thumbnail: string =
|
|
data.value!.thumbnail ??
|
|
`/images/blog/thumbnails/${data.value!._path!.split('/').at(-1)}.png`
|
|
|
|
// Generate the article's Open Graph image.
|
|
defineOgImageComponent(
|
|
'ArticleThumbnail',
|
|
{
|
|
title: data.value!.title,
|
|
description: data.value!.description,
|
|
logo: '/images/logo.png',
|
|
sectionLogo: '/images/chest.png',
|
|
thumbnail,
|
|
alt: data.value!.title,
|
|
},
|
|
{
|
|
fonts: ['Alexandria:700', 'Lato:700'],
|
|
},
|
|
)
|
|
|
|
// Hydrate the rendered items.
|
|
onMounted(() => {
|
|
if (route.hash)
|
|
content.value
|
|
?.querySelector(route.hash.toLowerCase())
|
|
?.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
content.value
|
|
?.querySelectorAll('code:not(pre *)')
|
|
.forEach((code: Element) => {
|
|
if (code instanceof HTMLElement)
|
|
code.addEventListener('click', () => clipboard(code))
|
|
})
|
|
|
|
content.value?.querySelectorAll('pre').forEach((pre: HTMLPreElement) => {
|
|
const overlay = h(CodeControls, { onCopy: () => clipboard(pre) })
|
|
|
|
render(overlay, pre)
|
|
})
|
|
|
|
content.value?.querySelectorAll('a').forEach((a: HTMLAnchorElement) => {
|
|
if (
|
|
a.hash &&
|
|
a.host === window.location.host &&
|
|
a.pathname.toLowerCase() === route.path.toLowerCase()
|
|
)
|
|
a.addEventListener('click', (e: Event) => {
|
|
e.preventDefault()
|
|
|
|
content.value
|
|
?.querySelector(a.hash.toLowerCase())
|
|
?.scrollIntoView({ behavior: 'smooth' })
|
|
|
|
history.replaceState(history.state, '', a.hash)
|
|
})
|
|
})
|
|
})
|
|
|
|
useHead({
|
|
title: data.value!.title,
|
|
htmlAttrs: {
|
|
lang: config.locale || 'en',
|
|
},
|
|
link: [
|
|
{
|
|
rel: 'icon',
|
|
type: 'image/x-icon',
|
|
href: '/favicon.ico',
|
|
},
|
|
],
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
v-if="!data"
|
|
class="flex flex-col items-center justify-center gap-4 w-full select-none text-center"
|
|
>
|
|
<img draggable="false" :src="chestAnimation" alt="The Ender Chest" />
|
|
<div>
|
|
<h1 class="font-enchant">Loading document...</h1>
|
|
<span class="opacity-0 hover:opacity-100 transition-ease">
|
|
<em>Loading document...</em>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<article
|
|
v-else-if="data"
|
|
class="flex-grow post snap-normal fade-mask-sm flex flex-col gap-4 overflow-y-auto sm:py-4 sm:pe-4"
|
|
>
|
|
<div class="grid-thumbnail grid max-w-[768px] snap-end">
|
|
<img
|
|
draggable="false"
|
|
:src="thumbnail!"
|
|
alt="Thumbnail"
|
|
class="grid-thumbnail-image object-cover object-center rounded-xl aspect-[16/9] select-none"
|
|
/>
|
|
<NuxtLink to="/blog" class="grid-thumbnail-start button-back p-2">
|
|
<iconify-icon
|
|
icon="icon-park-solid:back"
|
|
width="2em"
|
|
height="2em"
|
|
class="drop-shadow text-pink-200"
|
|
/>
|
|
</NuxtLink>
|
|
<NuxtLink
|
|
class="grid-thumbnail-end button-twitter p-2"
|
|
:href="`https://twitter.com/share?url=${config.url}/blog/${slug}&text=${data.title}&hashtags=${data.tags.slice(0, 3).join(',').replace(/ /g, '')}`"
|
|
target="_blank"
|
|
>
|
|
<iconify-icon
|
|
icon="logos:twitter"
|
|
width="2em"
|
|
height="2em"
|
|
class="drop-shadow"
|
|
/>
|
|
</NuxtLink>
|
|
<div
|
|
class="grid-thumbnail-title accent-fade w-full rounded-b-lg p-2 mt-16"
|
|
>
|
|
<h3 class="mb-1">{{ data.title }}</h3>
|
|
<div class="flex flex-row flex-wrap gap-x-2 gap-y-0">
|
|
<small class="flex flex-row items-center gap-1 whitespace-nowrap">
|
|
<iconify-icon icon="mdi:calendar" inline />
|
|
<strong>
|
|
{{ formatDate(data.created, 'HH:mm • LLLL do, y') }}
|
|
</strong>
|
|
</small>
|
|
<small class="flex flex-row items-center gap-1 whitespace-nowrap">
|
|
<iconify-icon icon="mdi:clock-outline" inline />
|
|
<strong>
|
|
{{ data!.readingTime.text.split(' ')[0] + ' minutes to read' }}
|
|
</strong>
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<section ref="content" class="page snap-start">
|
|
<ContentRenderer :value="data" />
|
|
</section>
|
|
</article>
|
|
<NotFound v-else />
|
|
</template>
|
|
|
|
<style scoped lang="scss">
|
|
.grid-thumbnail {
|
|
&-image {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
|
|
place-self: start center;
|
|
|
|
max-width: clamp(1px, 100%, 768px);
|
|
min-height: 100%;
|
|
}
|
|
|
|
&-title {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
|
|
place-self: end center;
|
|
}
|
|
|
|
&-start {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
|
|
place-self: start start;
|
|
}
|
|
|
|
&-end {
|
|
grid-column: 1;
|
|
grid-row: 1;
|
|
|
|
place-self: start end;
|
|
}
|
|
}
|
|
</style>
|