페이지 이동 전환 베일 추가
This commit is contained in:
@@ -104,6 +104,15 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html.site-route-veil body::before {
|
||||||
|
content: '';
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 2147483647;
|
||||||
|
background: var(--site-bg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
min-width: 320px;
|
min-width: 320px;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 배포 가이드
|
# 배포 가이드
|
||||||
|
|
||||||
> 로컬 기준 v1.10.45에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
> 로컬 기준 v1.10.46에서 `npm run lint`와 `npm run build` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다.
|
||||||
|
|
||||||
## 빌드 유형
|
## 빌드 유형
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-07-06 v1.10.46 — 즉시 전환을 유지하되 화면 교체 구간만 가린다
|
||||||
|
|
||||||
|
라우터 기본 스크롤을 끄고 Nuxt `page:finish` 이후로 스크롤 적용을 늦춰도, 홈에서 게시물로 이동할 때 이전 홈 화면의 헤더 이미지가 순간적으로 보일 수 있었다. 이는 페이지 컴포넌트 교체와 브라우저 페인트 사이의 짧은 구간에서 발생하는 시각적 문제이므로, 데이터 준비 완료까지 기다리는 방식으로 돌아가기 전에 화면 교체·스크롤 정리 구간만 덮는 전환 베일을 추가한다.
|
||||||
|
|
||||||
|
전환 베일은 전체 로딩 오버레이와 다르게 긴 데이터 대기 상태를 표현하지 않는다. 이동 시작부터 새 화면의 스크롤 위치가 정리된 직후까지만 배경색으로 화면을 가려, 즉시 전환 구조는 유지하면서 이전 화면의 상단 노출을 숨긴다.
|
||||||
|
|
||||||
## 2026-07-06 v1.10.45 — 스크롤 적용은 라우트 확정이 아니라 페이지 렌더 완료를 기준으로 한다
|
## 2026-07-06 v1.10.45 — 스크롤 적용은 라우트 확정이 아니라 페이지 렌더 완료를 기준으로 한다
|
||||||
|
|
||||||
Vue Router의 `afterEach`는 URL과 라우트 상태가 바뀐 직후 실행되지만, Nuxt의 실제 페이지 컴포넌트 교체가 끝났다는 뜻은 아니다. 이 시점에 최상단 스크롤을 적용하면 이전 화면의 헤더 이미지가 먼저 보인 뒤 새 페이지가 붙을 수 있다. 따라서 `afterEach`에서는 이동 정보를 저장만 하고, Nuxt `page:finish` 이후 렌더 프레임을 기다린 뒤 새 화면 기준으로 스크롤을 적용한다.
|
Vue Router의 `afterEach`는 URL과 라우트 상태가 바뀐 직후 실행되지만, Nuxt의 실제 페이지 컴포넌트 교체가 끝났다는 뜻은 아니다. 이 시점에 최상단 스크롤을 적용하면 이전 화면의 헤더 이미지가 먼저 보인 뒤 새 페이지가 붙을 수 있다. 따라서 `afterEach`에서는 이동 정보를 저장만 하고, Nuxt `page:finish` 이후 렌더 프레임을 기다린 뒤 새 화면 기준으로 스크롤을 적용한다.
|
||||||
|
|||||||
@@ -363,7 +363,7 @@
|
|||||||
| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 |
|
| plugins/site-analytics.client.js | 공개 라우트 pageview·heartbeat·read 클라이언트 전송 |
|
||||||
| plugins/bfcache-page-restore.client.js | 뒤로가기 캐시(bfcache) 복원 시 라우터 경로와 실제 URL 어긋남을 감지해 새로고침(HTML 문서 모드 페이지 이동 후 뒤로가기 잔상 방지) |
|
| plugins/bfcache-page-restore.client.js | 뒤로가기 캐시(bfcache) 복원 시 라우터 경로와 실제 URL 어긋남을 감지해 새로고침(HTML 문서 모드 페이지 이동 후 뒤로가기 잔상 방지) |
|
||||||
| plugins/navigation-lock.client.js | 페이지 이동 중 `html.site-navigating` 클래스로 링크·버튼 클릭을 잠가 중복 클릭 차단(안전 타임아웃 포함) |
|
| plugins/navigation-lock.client.js | 페이지 이동 중 `html.site-navigating` 클래스로 링크·버튼 클릭을 잠가 중복 클릭 차단(안전 타임아웃 포함) |
|
||||||
| plugins/site-route-scroll.client.js | 라우트 변경 정보를 저장한 뒤 Nuxt `page:finish` 이후 새 화면에서 최상단·해시 위치 스크롤 적용(이동 전 이전 화면 스크롤 튐 방지) |
|
| plugins/site-route-scroll.client.js | 라우트 변경 정보를 저장한 뒤 Nuxt `page:finish` 이후 새 화면에서 최상단·해시 위치 스크롤 적용, 이동 중 짧은 전환 베일로 이전 화면 상단 노출 방지 |
|
||||||
|
|
||||||
## 데이터베이스
|
## 데이터베이스
|
||||||
|
|
||||||
@@ -415,7 +415,7 @@
|
|||||||
| package.json | Nuxt 실행 스크립트와 의존성 |
|
| package.json | Nuxt 실행 스크립트와 의존성 |
|
||||||
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
|
| nuxt.config.js | Nuxt 앱 설정, `tailwindcss.cssPath`로 `main.css` 단일 엔트리, Tailwind 모듈, 관리자 QA를 위한 개발 도구 비활성화, 회원 썸네일 최소/최대 해상도·품질 런타임 설정 |
|
||||||
| tailwind.config.js | Tailwind 테마 설정 |
|
| tailwind.config.js | Tailwind 테마 설정 |
|
||||||
| assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 관리자 라이트 테마 격리(`admin-layout`), 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과, 게시물 카드 제목 공통 인터랙션(`post-title-link`), 이동 중 클릭 잠금(`site-navigating`) |
|
| assets/css/main.css | 전역 스타일, 공개 배경·사이드바 배경 통일 기준, 관리자 라이트 테마 격리(`admin-layout`), 좌측 사이드바/그리드 전환 애니메이션, 네비게이션 세로 바 hover 효과, 게시물 카드 제목 공통 인터랙션(`post-title-link`), 이동 중 클릭 잠금(`site-navigating`)·전환 베일(`site-route-veil`) |
|
||||||
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등), 모바일은 마운트 시 항상 닫힘 시작·`menuMounted`로 첫 페인트 슬라이드 플래시 방지 |
|
| composables/useMenuState.js | 좌측 메뉴 열림 상태·`closeMenu`(모바일 백드롭 등), 모바일은 마운트 시 항상 닫힘 시작·`menuMounted`로 첫 페인트 슬라이드 플래시 방지 |
|
||||||
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
| composables/useThemeMode.js | 사용자 화면 라이트/다크 테마 상태 관리 |
|
||||||
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
| middleware/admin-auth.global.js | 관리자 페이지 접근 인증 |
|
||||||
|
|||||||
@@ -144,7 +144,7 @@
|
|||||||
### 공개 URL 구조
|
### 공개 URL 구조
|
||||||
|
|
||||||
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 이전·다음 화살표와 페이지 번호 직접 이동을 함께 제공하며, 전체 페이지가 많으면 첫 페이지·끝 페이지·현재 페이지 주변 번호를 유지하고 중간 구간은 `...`으로 접는다.
|
- `/posts` - 게시물 전체 목록. 사이트 설정 POST 설정의 `postListPageSize` 값 기준으로 서버 페이지네이션을 사용하며, 페이지 번호가 바뀌면 화면을 상단으로 이동한다. 하단 페이지네이션은 이전·다음 화살표와 페이지 번호 직접 이동을 함께 제공하며, 전체 페이지가 많으면 첫 페이지·끝 페이지·현재 페이지 주변 번호를 유지하고 중간 구간은 `...`으로 접는다.
|
||||||
- `/post/:slug` - 개별 게시물 상세. 직접 접속·SSR은 본문을 먼저 조회해 SEO 메타를 완성하고, 클라이언트 내부 이동은 새 상세 화면으로 즉시 전환한 뒤 데이터 준비 전에는 제목·메타·이미지·본문 자리 스켈레톤을 표시한다. 이동 중에는 `NuxtLoadingIndicator` 상단 진행바와 중복 클릭 잠금으로 피드백을 제공한다. Vue Router 기본 스크롤 처리는 끄고 Nuxt 페이지 렌더링 완료 후 새 화면 기준으로 최상단 또는 해시 위치를 적용해 이동 전 이전 화면이 먼저 위로 튀지 않게 한다. 이전·다음 글은 상세 API 응답의 `previous`·`next`로 표시하며, `previous`는 더 과거 글, `next`는 더 최신 글을 뜻한다.
|
- `/post/:slug` - 개별 게시물 상세. 직접 접속·SSR은 본문을 먼저 조회해 SEO 메타를 완성하고, 클라이언트 내부 이동은 새 상세 화면으로 즉시 전환한 뒤 데이터 준비 전에는 제목·메타·이미지·본문 자리 스켈레톤을 표시한다. 이동 중에는 `NuxtLoadingIndicator` 상단 진행바와 중복 클릭 잠금으로 피드백을 제공한다. Vue Router 기본 스크롤 처리는 끄고 Nuxt 페이지 렌더링 완료 후 새 화면 기준으로 최상단 또는 해시 위치를 적용해 이동 전 이전 화면이 먼저 위로 튀지 않게 한다. 화면 교체와 스크롤 정리 사이에는 배경색 전환 베일을 짧게 표시해 이전 화면 상단 노출을 숨긴다. 이전·다음 글은 상세 API 응답의 `previous`·`next`로 표시하며, `previous`는 더 과거 글, `next`는 더 최신 글을 뜻한다.
|
||||||
- `/` - 홈. 클라이언트 복귀 시에도 주요 데이터를 블로킹 조회한 뒤 전환한다.
|
- `/` - 홈. 클라이언트 복귀 시에도 주요 데이터를 블로킹 조회한 뒤 전환한다.
|
||||||
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
|
- `/sitemap.xml` - 공개 sitemap XML. 홈, 게시물 목록, 태그 목록, 공개 발행글, 공개 고정 페이지, 글이 있는 태그 URL을 요청 시점 DB 기준으로 자동 생성한다. 멤버십·비공개·초안·예약 대기 글과 `noindex` 글은 포함하지 않는다.
|
||||||
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
|
- `/robots.txt` - 크롤러 안내 파일. 관리자·API·회원 설정·로그인 경로를 제외하고 `Sitemap: /sitemap.xml` 절대 URL을 제공한다.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.10.46
|
||||||
|
|
||||||
|
- 페이지 이동 시작부터 새 화면 스크롤 정리 완료까지 짧은 전환 베일을 표시하도록 추가.
|
||||||
|
- 메인에서 게시물로 이동할 때 이전 홈 헤더 이미지가 순간적으로 보이는 현상을 한 번 더 완화.
|
||||||
|
- 전환 베일은 데이터 로딩 완료를 기다리는 방식이 아니라 화면 교체·스크롤 정리 구간만 가리도록 정리.
|
||||||
|
|
||||||
## v1.10.45
|
## v1.10.45
|
||||||
|
|
||||||
- 페이지 이동 스크롤 적용 시점을 `router.afterEach` 직후에서 Nuxt `page:finish` 이후로 변경.
|
- 페이지 이동 스크롤 적용 시점을 `router.afterEach` 직후에서 Nuxt `page:finish` 이후로 변경.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.45",
|
"version": "1.10.46",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.45",
|
"version": "1.10.46",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.10.45",
|
"version": "1.10.46",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
@@ -3,14 +3,21 @@
|
|||||||
*
|
*
|
||||||
* Vue Router 기본 scrollBehavior는 전환 중 이전 화면을 먼저 움직일 수 있어
|
* Vue Router 기본 scrollBehavior는 전환 중 이전 화면을 먼저 움직일 수 있어
|
||||||
* `app/router.options.js`에서 끄고, 여기서는 Nuxt 페이지 렌더링이 끝난 뒤
|
* `app/router.options.js`에서 끄고, 여기서는 Nuxt 페이지 렌더링이 끝난 뒤
|
||||||
* 새 화면에서만 최상단 또는 해시 위치로 이동한다.
|
* 새 화면에서만 최상단 또는 해시 위치로 이동한다. 전환 중에는 짧은 베일을
|
||||||
|
* 씌워 이전 화면이 상단으로 튀는 순간이 사용자에게 보이지 않게 한다.
|
||||||
*/
|
*/
|
||||||
|
const ROUTE_VEIL_CLASS = 'site-route-veil'
|
||||||
|
const ROUTE_VEIL_TIMEOUT_MS = 8000
|
||||||
|
|
||||||
export default defineNuxtPlugin((nuxtApp) => {
|
export default defineNuxtPlugin((nuxtApp) => {
|
||||||
const router = nuxtApp.$router
|
const router = nuxtApp.$router
|
||||||
|
const root = document.documentElement
|
||||||
|
|
||||||
/** @type {{ id: number, to: import('vue-router').RouteLocationNormalizedLoaded, from: import('vue-router').RouteLocationNormalizedLoaded } | null} */
|
/** @type {{ id: number, to: import('vue-router').RouteLocationNormalizedLoaded, from: import('vue-router').RouteLocationNormalizedLoaded } | null} */
|
||||||
let pendingRouteScroll = null
|
let pendingRouteScroll = null
|
||||||
let routeScrollId = 0
|
let routeScrollId = 0
|
||||||
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
||||||
|
let veilTimer = null
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 브라우저 렌더 프레임을 기다린다.
|
* 브라우저 렌더 프레임을 기다린다.
|
||||||
@@ -22,6 +29,28 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 전환 베일을 표시한다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showRouteVeil = () => {
|
||||||
|
root.classList.add(ROUTE_VEIL_CLASS)
|
||||||
|
clearTimeout(veilTimer)
|
||||||
|
veilTimer = setTimeout(() => {
|
||||||
|
root.classList.remove(ROUTE_VEIL_CLASS)
|
||||||
|
}, ROUTE_VEIL_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 페이지 전환 베일을 숨긴다.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const hideRouteVeil = () => {
|
||||||
|
clearTimeout(veilTimer)
|
||||||
|
veilTimer = null
|
||||||
|
root.classList.remove(ROUTE_VEIL_CLASS)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 이동 완료 후 적용할 스크롤 정보를 저장한다.
|
* 이동 완료 후 적용할 스크롤 정보를 저장한다.
|
||||||
* @param {import('vue-router').RouteLocationNormalizedLoaded} to - 이동 대상 라우트
|
* @param {import('vue-router').RouteLocationNormalizedLoaded} to - 이동 대상 라우트
|
||||||
@@ -49,6 +78,7 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
const routeScroll = pendingRouteScroll
|
const routeScroll = pendingRouteScroll
|
||||||
|
|
||||||
if (!routeScroll) {
|
if (!routeScroll) {
|
||||||
|
hideRouteVeil()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,6 +98,8 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
target.scrollIntoView({ block: 'start' })
|
target.scrollIntoView({ block: 'start' })
|
||||||
|
await waitForFrame()
|
||||||
|
hideRouteVeil()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,11 +109,16 @@ export default defineNuxtPlugin((nuxtApp) => {
|
|||||||
left: 0,
|
left: 0,
|
||||||
behavior: 'auto'
|
behavior: 'auto'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
await waitForFrame()
|
||||||
|
hideRouteVeil()
|
||||||
}
|
}
|
||||||
|
|
||||||
router.afterEach((to, from) => {
|
router.afterEach((to, from) => {
|
||||||
queueRouteScroll(to, from)
|
queueRouteScroll(to, from)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
nuxtApp.hook('page:loading:start', showRouteVeil)
|
||||||
nuxtApp.hook('page:finish', applyPendingRouteScroll)
|
nuxtApp.hook('page:finish', applyPendingRouteScroll)
|
||||||
|
nuxtApp.hook('app:error', hideRouteVeil)
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user