게시물 이동 스켈레톤 전환 적용

This commit is contained in:
2026-07-06 11:41:48 +09:00
parent 9952a05ec2
commit a59d17fefb
9 changed files with 116 additions and 89 deletions

View File

@@ -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>