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

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

57
app.vue
View File

@@ -18,31 +18,6 @@ const siteAccentStyle = computed(() => ({
'--site-accent': siteAccentColor.value
}))
const isSitePageNavigating = ref(false)
if (import.meta.client) {
const nuxtApp = useNuxtApp()
/**
* 공개 페이지 이동 로딩 오버레이를 표시한다.
* @returns {void}
*/
const showSitePageNavigationOverlay = () => {
isSitePageNavigating.value = true
}
/**
* 공개 페이지 이동 로딩 오버레이를 숨긴다.
* @returns {void}
*/
const hideSitePageNavigationOverlay = () => {
isSitePageNavigating.value = false
}
nuxtApp.hook('page:loading:start', showSitePageNavigationOverlay)
nuxtApp.hook('page:loading:end', hideSitePageNavigationOverlay)
}
useHead(() => ({
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
link: appSiteSettings.value.faviconUrl
@@ -60,40 +35,8 @@ useHead(() => ({
<template>
<div class="site-app" :style="siteAccentStyle">
<NuxtLoadingIndicator :color="siteAccentColor" :height="3" :throttle="80" :duration="5000" />
<Transition
enter-active-class="transition duration-100 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="isSitePageNavigating"
class="site-page-loading fixed inset-0 z-[90] grid place-items-center bg-[var(--site-bg)]/78 text-[var(--site-text)] backdrop-blur-[2px]"
role="status"
aria-live="polite"
>
<div class="site-page-loading__panel grid place-items-center gap-3 text-center">
<span class="site-page-loading__spinner size-8 rounded-full border-2 border-[var(--site-line)] border-t-[var(--site-accent)]" aria-hidden="true" />
<span class="text-xs font-semibold uppercase tracking-[0.18em] opacity-70">Loading</span>
</div>
</div>
</Transition>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
<style scoped>
.site-page-loading__spinner {
animation: site-page-loading-spin 0.8s linear infinite;
}
@keyframes site-page-loading-spin {
to {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,6 +1,6 @@
# 배포 가이드
> 로컬 기준 v1.10.42에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
> 로컬 기준 v1.10.43에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
## 빌드 유형

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-07-06 v1.10.43 — 게시물 이동은 새 화면을 먼저 보여 주고 내부에서 로딩한다
전체 화면 오버레이는 이전 화면의 스크롤 튐을 숨기는 데는 효과적이지만, 문서형 사이트에서는 클릭 후 새 페이지가 바로 열린다는 감각이 더 중요하다. 따라서 게시물 상세의 클라이언트 내부 이동은 라우트를 먼저 전환하고, 아직 상세 데이터가 준비되지 않았을 때는 이전 글을 보여 주지 않고 새 상세 화면 안에서 제목·메타·이미지·본문 자리 스켈레톤을 표시한다.
직접 접속과 검색엔진 수집에는 여전히 SSR 본문 선조회가 필요하므로 서버 렌더링에서는 기존처럼 게시물을 먼저 받아 SEO 메타를 완성한다. 이 방식은 사용자 이동 체감과 SSR 안정성을 함께 유지한다.
## 2026-07-06 v1.10.42 — 이동 중 화면은 이전 화면을 가리고 준비 후 보여 준다
게시물 상세 이동을 블로킹 조회로 되돌려도 브라우저 스크롤 제어 타이밍에 따라 이전 화면이 순간적으로 최상단처럼 보이면 사용자는 화면이 튄다고 느낀다. 이번 수정은 즉시 전환·스켈레톤 방식으로 다시 흔들기보다, 이동 중 전체 화면 로딩 오버레이를 표시해 이전 화면의 중간 상태를 가리고 준비된 다음 화면만 보이게 한다.

View File

@@ -10,7 +10,7 @@
| layouts/post.vue | 개별 게시물 — `default`와 동일, 오른쪽 컬럼 전체 높이 구분선 |
| layouts/admin.vue | 관리자 전체, 320px 밝은 Ghost형 사이드바(대시보드 `/admin` 활성 링크·사이트 보기 새 창·콘텐츠 구간 여백), 우측 전체 높이 캔버스, 멤버 수 표시, 하단 사용자 드롭다운·설정, `내 프로필` 멤버 편집 이동, 게시글 `+` 새 글 진입, 글 작성/수정 화면의 전체 화면 편집 모드와 문서 스크롤 잠금, **`/admin/settings`에서는 사이드바 숨김·본문 전체 화면·설정용 문서 스크롤 잠금** |
| layouts/page.vue | 고정 페이지 전체 화면 |
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크, 페이지 제목이 있으면 제목만 쓰고 없으면 사이트 이름을 쓰는 title template, 브랜드 컬러 `--site-accent` 적용, 페이지 이동 로딩 진행바(`NuxtLoadingIndicator`, 브랜드 컬러)와 전체 화면 이동 오버레이 |
| app.vue | 공개 사이트 설정 기반 파비콘 head 링크, 페이지 제목이 있으면 제목만 쓰고 없으면 사이트 이름을 쓰는 title template, 브랜드 컬러 `--site-accent` 적용, 페이지 이동 로딩 진행바(`NuxtLoadingIndicator`, 브랜드 컬러) |
| error.vue | 공개 404/서버 오류 화면, 홈 이동 버튼 |
## Composables
@@ -218,7 +218,7 @@
| pages/index.vue | 홈, `site_settings` 커버가 있을 때만 라이트·다크 이미지 지원 `HomeHero`, Featured/Latest, 사이트 설정 표시개수 기준 Latest·Featured·Pages 데이터 제한, 홈 표시 페이지를 섹션별 도서 진열장형 Pages 선반으로 노출, Pages 추천 카드는 왼쪽 위 SVG 아이콘 배지 표시, Featured와 Pages 선반의 독립 이전·다음 버튼 및 문서 레벨 포인터 추적 기반 드래그 슬라이드·스냅(이동 임계값 미만 클릭은 링크 이동, 포인터 캡처는 드래그 확정 뒤 적용), Pages 선반 ResizeObserver 기반 버튼 상태 갱신, Pages 카드 hover 상단 여백 보정, Latest 피드 Compact 기본값·List·Cards 보기, Compact 모바일 썸네일 80px 정사각형·Compact/List 설명 2줄 제한, Latest 메타는 태그가 있을 때만 링크 배지 표시, 카드 오른쪽 아래 화살표는 게시물 상세 링크, Featured는 추천 글이 있을 때만 표시하며 이미지 없는 추천 글은 제목 placeholder 썸네일과 모바일 터치 가로 스크롤·스냅, 끝에서 화살표 비활성 |
| pages/posts/index.vue | 게시물 전체 목록, 사이트 설정 Posts 표시개수 기준 서버 페이지네이션, 공용 번호형 페이지네이션, 태그는 있을 때만 카드 메타에 링크로 표시 |
| pages/posts/[slug].vue | `/post/:slug` 리다이렉트 |
| pages/post/[slug].vue | 블로그 글 상세, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/post/[slug].vue | 블로그 글 상세, SSR 직접 접속은 본문 선조회·클라이언트 내부 이동은 즉시 화면 전환 후 상세 스켈레톤 표시, 첫 태그가 있을 때만 메타 행에 태그 링크, H1~H3 본문 제목을 오른쪽 TOC 상태로 제공, 공개 회원 로그인 세션의 회원이 글쓴이(`author_id`)이면 공유 버튼 옆 새 탭 편집 링크 표시, 게시물 SEO/OG 메타 출력(요약 없으면 본문 fallback), 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사), 회원 댓글 섹션 |
| pages/tags/index.vue | 태그 전체 목록, 중앙 히어로와 3열 태그 카드, 좌측 컬러 보더/hover 오버레이 |
| pages/tags/[slug].vue | `/tag/:slug` 리다이렉트 |
| pages/tag/[slug].vue | 태그별 글 목록, 사이트 설정 태그 게시물 표시개수 기준 서버 태그 필터·공용 번호형 페이지네이션, 태그 헤더 이미지가 있으면 상단 이미지 헤더 표시, 없으면 기본 태그명·설명 + 공통 섹션 패딩을 쓰는 리스트형 게시물 카드, 카드 태그 배지와 오른쪽 아래 화살표 링크 |

View File

@@ -144,7 +144,7 @@
### 공개 URL 구조
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 이전·다음 화살표와 페이지 번호 직접 이동을 함께 제공하며, 전체 페이지가 많으면 첫 페이지·끝 페이지·현재 페이지 주변 번호를 유지하고 중간 구간은 `...`으로 접는다.
- `/post/:slug` - 개별 게시물 상세. 클라이언트 내부 이동도 본문을 블로킹 조회한 뒤 전환하며, 이동 중 `NuxtLoadingIndicator` 상단 진행바와 전체 화면 로딩 오버레이·중복 클릭 잠금으로 피드백을 제공한다. 이전·다음 글은 상세 API 응답의 `previous`·`next`로 표시하며, `previous`는 더 과거 글, `next`는 더 최신 글을 뜻한다.
- `/post/:slug` - 개별 게시물 상세. 직접 접속·SSR은 본문을 먼저 조회해 SEO 메타를 완성하고, 클라이언트 내부 이동은 새 상세 화면으로 즉시 전환한 뒤 데이터 준비 전에는 제목·메타·이미지·본문 자리 스켈레톤을 표시한다. 이동 중에는 `NuxtLoadingIndicator` 상단 진행바와 중복 클릭 잠금으로 피드백을 제공한다. 이전·다음 글은 상세 API 응답의 `previous`·`next`로 표시하며, `previous`는 더 과거 글, `next`는 더 최신 글을 뜻한다.
- `/` - 홈. 클라이언트 복귀 시에도 주요 데이터를 블로킹 조회한 뒤 전환한다.
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 이력
## v1.10.43
- 사용자 화면 게시물 상세 클라이언트 이동을 즉시 전환 방식으로 변경.
- 게시물 데이터가 준비되기 전에는 이전 화면이 아니라 새 게시물 화면 안의 스켈레톤을 표시하도록 수정.
- 전역 전체 화면 이동 오버레이를 제거하고 `NuxtLoadingIndicator` 상단 진행바만 유지.
- 직접 접속·SSR은 기존처럼 게시물 본문을 먼저 받아 SEO 메타를 유지하도록 정리.
## v1.10.42
- 사용자 화면 페이지 이동 중 전체 로딩 오버레이를 표시해 이전 화면 스크롤 튐이 보이지 않도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.42",
"version": "1.10.43",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.42",
"version": "1.10.43",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.42",
"version": "1.10.43",
"private": true,
"type": "module",
"imports": {

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>