게시물 이동 스켈레톤 전환 적용
This commit is contained in:
@@ -9,10 +9,17 @@ const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
|
||||
/**
|
||||
* 본문은 블로킹으로 받아 로딩 표시 후 완성된 화면으로 전환한다.
|
||||
* SSR 직접 접속은 본문을 먼저 받아 SEO를 유지하고, 클라이언트 내부 이동은 즉시 화면을 전환한다.
|
||||
* 이전·다음 글은 상세 응답에 포함되어 별도 목록 요청 없이 표시된다.
|
||||
*/
|
||||
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
||||
const {
|
||||
data: fetchedPost,
|
||||
pending: isPostPending,
|
||||
error: postFetchError
|
||||
} = await useFetch(() => `/api/posts/${slug.value}`, {
|
||||
lazy: import.meta.client,
|
||||
default: () => null
|
||||
})
|
||||
const { data: tags } = useLazyFetch('/api/tags?tagType=all', {
|
||||
default: () => []
|
||||
})
|
||||
@@ -22,14 +29,22 @@ const { data: siteSettings } = useLazyFetch('/api/site-settings', {
|
||||
})
|
||||
const postToc = useState('post-detail-toc', () => [])
|
||||
|
||||
if (!post.value) {
|
||||
const post = computed(() => (
|
||||
fetchedPost.value?.slug === slug.value
|
||||
? fetchedPost.value
|
||||
: null
|
||||
))
|
||||
|
||||
const isPostLoading = computed(() => isPostPending.value || (!post.value && !postFetchError.value))
|
||||
|
||||
if (import.meta.server && !post.value) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
|
||||
const primaryTagSlug = computed(() => post.value?.tags?.[0] || '')
|
||||
const primaryTagMeta = computed(() => {
|
||||
const slug = primaryTagSlug.value
|
||||
|
||||
@@ -50,7 +65,7 @@ const primaryTagMeta = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null))
|
||||
const publishedAtLabel = computed(() => formatPostDate(post.value?.publishedAt || null))
|
||||
const authorLabel = computed(() => 'sori.studio')
|
||||
const shareModalOpen = ref(false)
|
||||
const copyButtonLabel = ref('Copy link')
|
||||
@@ -59,7 +74,7 @@ const canEditPost = computed(() => Boolean(
|
||||
post.value?.authorId
|
||||
&& currentMember.value?.id === post.value.authorId
|
||||
))
|
||||
const postEditPath = computed(() => `/admin/posts/${post.value.id}`)
|
||||
const postEditPath = computed(() => (post.value ? `/admin/posts/${post.value.id}` : '/admin/posts'))
|
||||
|
||||
const previousPost = computed(() => {
|
||||
const candidate = post.value?.previous
|
||||
@@ -72,15 +87,15 @@ const nextPost = computed(() => {
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = computed(() => String(config.public.siteUrl || '').replace(/\/$/g, ''))
|
||||
const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
|
||||
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
|
||||
const ogImage = computed(() => post.value.featuredImage || '')
|
||||
const postSummary = computed(() => createPostSummary(post.value.excerpt, post.value.content))
|
||||
const seoDescription = computed(() => post.value.seoDescription || postSummary.value || 'sori.studio 개인 블로그')
|
||||
const pageUrl = computed(() => `${siteUrl.value}/post/${post.value?.slug || slug.value}`)
|
||||
const seoTitle = computed(() => post.value?.seoTitle || post.value?.title || 'sori.studio')
|
||||
const ogImage = computed(() => post.value?.featuredImage || '')
|
||||
const postSummary = computed(() => createPostSummary(post.value?.excerpt || '', post.value?.content || ''))
|
||||
const seoDescription = computed(() => post.value?.seoDescription || postSummary.value || 'sori.studio 개인 블로그')
|
||||
const shareMetadata = computed(() => ({
|
||||
title: post.value.title || 'sori.studio',
|
||||
title: post.value?.title || 'sori.studio',
|
||||
description: seoDescription.value,
|
||||
image: post.value.featuredImage || '',
|
||||
image: post.value?.featuredImage || '',
|
||||
url: pageUrl.value
|
||||
}))
|
||||
const tableOfContents = computed(() => extractMarkdownToc(post.value?.content || ''))
|
||||
@@ -133,6 +148,17 @@ onMounted(() => {
|
||||
fetchCurrentViewer()
|
||||
})
|
||||
|
||||
watch([isPostPending, postFetchError, post], () => {
|
||||
if (!import.meta.client || isPostPending.value || post.value) {
|
||||
return
|
||||
}
|
||||
|
||||
showError(createError({
|
||||
statusCode: postFetchError.value?.statusCode || 404,
|
||||
statusMessage: '게시물을 찾을 수 없습니다.'
|
||||
}))
|
||||
})
|
||||
|
||||
watchEffect(() => {
|
||||
postToc.value = tableOfContents.value
|
||||
})
|
||||
@@ -221,7 +247,7 @@ useHead(() => ({
|
||||
},
|
||||
{
|
||||
name: 'robots',
|
||||
content: post.value.noindex ? 'noindex, nofollow' : 'index, follow'
|
||||
content: post.value?.noindex ? 'noindex, nofollow' : 'index, follow'
|
||||
},
|
||||
{
|
||||
property: 'og:title',
|
||||
@@ -253,7 +279,8 @@ useHead(() => ({
|
||||
|
||||
<template>
|
||||
<div class="post-detail">
|
||||
<section class="mt-6 mb-8">
|
||||
<template v-if="post">
|
||||
<section class="mt-6 mb-8">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-2.5 px-4 sm:px-5">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] sm:text-2xl">
|
||||
{{ post.title }}
|
||||
@@ -381,15 +408,15 @@ useHead(() => ({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="shareModalOpen" class="post-detail__share-modal fixed inset-0 z-40 flex items-center justify-center bg-[rgba(0,0,0,0.45)] px-4" @click.self="closeShareModal">
|
||||
<Transition
|
||||
enter-active-class="transition duration-200 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-150 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div v-if="shareModalOpen" class="post-detail__share-modal fixed inset-0 z-40 flex items-center justify-center bg-[rgba(0,0,0,0.45)] px-4" @click.self="closeShareModal">
|
||||
<div class="relative mt-8 flex w-full max-w-[min(calc(100%-2rem),480px)] translate-y-0 scale-100 flex-col items-center rounded-[10px] bg-[var(--site-bg)] p-6 shadow-[0_20px_45px_rgba(0,0,0,0.18)]">
|
||||
<button class="absolute right-5 top-5 flex h-6 w-6 cursor-pointer items-center justify-center opacity-30 transition-opacity duration-200 hover:opacity-70" type="button" aria-label="Close share modal" @click="closeShareModal">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="h-4 w-4 fill-none stroke-current stroke-[1.2]">
|
||||
@@ -518,7 +545,51 @@ useHead(() => ({
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<section v-else class="post-detail__loading mt-6 mb-12" aria-live="polite" aria-busy="true">
|
||||
<div class="mx-auto flex min-h-[70vh] max-w-[720px] flex-col gap-5 px-4 sm:px-5">
|
||||
<div class="grid gap-3">
|
||||
<div class="post-detail__loading-shimmer h-8 w-11/12 rounded bg-[var(--site-panel-strong)] sm:h-10" />
|
||||
<div class="post-detail__loading-shimmer h-4 w-2/5 rounded bg-[var(--site-panel-strong)]" />
|
||||
</div>
|
||||
<div class="border-b border-[var(--site-line)] pb-4">
|
||||
<div class="post-detail__loading-shimmer h-3 w-3/5 rounded bg-[var(--site-panel-strong)]" />
|
||||
</div>
|
||||
<div class="post-detail__loading-shimmer aspect-video w-full rounded-[10px] bg-[var(--site-panel-strong)]" />
|
||||
<div class="mt-4 grid gap-3">
|
||||
<div class="post-detail__loading-shimmer h-4 w-full rounded bg-[var(--site-panel-strong)]" />
|
||||
<div class="post-detail__loading-shimmer h-4 w-[94%] rounded bg-[var(--site-panel-strong)]" />
|
||||
<div class="post-detail__loading-shimmer h-4 w-[88%] rounded bg-[var(--site-panel-strong)]" />
|
||||
<div class="post-detail__loading-shimmer h-4 w-[96%] rounded bg-[var(--site-panel-strong)]" />
|
||||
<div class="post-detail__loading-shimmer h-4 w-[72%] rounded bg-[var(--site-panel-strong)]" />
|
||||
</div>
|
||||
<span class="sr-only">{{ isPostLoading ? '게시물을 불러오는 중입니다.' : '게시물을 표시할 수 없습니다.' }}</span>
|
||||
</div>
|
||||
</Transition>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.post-detail__loading-shimmer {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.post-detail__loading-shimmer::after {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
content: '';
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.42), transparent);
|
||||
transform: translateX(-100%);
|
||||
animation: post-detail-loading-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes post-detail-loading-shimmer {
|
||||
to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user