diff --git a/docs/history.md b/docs/history.md index 053cdce..3e0deae 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-11 v0.0.60 + +### 홈 Featured 모바일 스크롤·화살표 상태 + +가로 오버플로 트랙은 기본적으로 스크롤 가능하지만, 카드 전체가 링크일 때 브라우저가 세로 제스처에 가깝게 해석하거나 체인 스크롤이 나는 경우가 있어 `touch-pan-x`와 `overscroll-x-contain`으로 가로 우선·부모 스크롤 전파를 줄였다. 화살표는 스크롤 한계에서 의미 없는 클릭을 막기 위해 `scrollLeft`와 `scrollWidth - clientWidth` 비교로 `disabled`를 두고, 레이아웃 변화에 맞추기 위해 `ResizeObserver`를 함께 썼다. + ## 2026-05-11 v0.0.59 ### Nuxt `#internal/nuxt/paths` Node 해석 오류 diff --git a/docs/map.md b/docs/map.md index 71a6634..6beefd9 100644 --- a/docs/map.md +++ b/docs/map.md @@ -89,7 +89,7 @@ | 파일 | 화면 | |------|------| -| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드 | +| pages/index.vue | 홈, 중앙 Hero/Featured/Latest 섹션의 내부 컨테이너 보더 정렬과 리스트형 latest 카드, Featured는 모바일 터치 가로 스크롤·스냅과 끝에서 화살표 비활성 | | pages/posts/index.vue | 게시물 전체 목록 | | pages/posts/[slug].vue | `/post/:slug` 리다이렉트 | | pages/post/[slug].vue | 블로그 글 상세, 게시물 SEO/OG 메타 출력, 공유 모달(X/Bluesky/Facebook/LinkedIn/Email/링크복사) | diff --git a/docs/spec.md b/docs/spec.md index e19f1a5..0205f57 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -46,6 +46,12 @@ - 사용자 수동 테마 전환은 `html[data-theme]`와 `localStorage.SITE_THEME`로 관리 - Thred 참고 화면처럼 사이드바와 본문은 같은 화면 안에서 구분되는 패널과 라인으로 표현 +### 홈 Featured (인덱스) + +- 가로 카드 트랙은 `overflow-x-auto`와 `snap-x`/`snap-mandatory`로 슬라이드 느낌을 낸다. +- 모바일 터치: `touch-pan-x`, `-webkit-overflow-scrolling: touch`, `overscroll-x-contain`으로 가로 스크롤 우선·부모로의 스크롤 전파 완화. +- 헤더의 이전·다음 화살표는 `scrollLeft`와 최대 스크롤 거리로 양 끝에서 `disabled` 처리하며, `scroll` 이벤트와 `ResizeObserver`로 동기화한다. + ### Post 페이지 - Main 좌우 패딩: 24px → 20px diff --git a/docs/update.md b/docs/update.md index 56a31ad..90f8058 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v0.0.60 + +- 홈 Featured 가로 트랙에 `touch-pan-x`·`-webkit-overflow-scrolling:touch`·`overscroll-x-contain`을 두어 모바일에서 손가락으로 가로 슬라이드(스크롤·스냅)가 잘 먹도록 함. +- Featured 이전/다음 화살표는 스크롤 시작·끝에 따라 `disabled`와 시각적 비활성 처리, `ResizeObserver`·`scroll`로 상태 동기화. + ## v0.0.59 - Nuxt 3.21 SSR이 `#internal/nuxt/paths`를 외부 import로 두는데 `.nuxt/paths.mjs`가 기본적으로 디스크에 쓰이지 않아 Node가 루트 `package.json`에서 해석하지 못하던 오류 수정. diff --git a/package.json b/package.json index ddccc9d..9a7a7c3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.59", + "version": "0.0.60", "private": true, "type": "module", "imports": { diff --git a/pages/index.vue b/pages/index.vue index 42f4c7a..a686cec 100644 --- a/pages/index.vue +++ b/pages/index.vue @@ -88,6 +88,67 @@ const featuredPosts = computed(() => posts.value.slice(0, 6)) const latestPosts = computed(() => posts.value.map(mapLatestPost)) const featuredTrackRef = ref(null) +/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */ +const featuredAtStart = ref(true) +/** Featured 트랙이 스크롤 끝에 붙었는지 — 다음 화살표 비활성 */ +const featuredAtEnd = ref(true) + +let unbindFeaturedScroll = () => {} + +/** + * Featured 가로 스크롤 위치에 따라 이전/다음 버튼 상태를 갱신한다. + * @param {HTMLElement | null} el - 스크롤 컨테이너 + * @returns {void} + */ +const updateFeaturedScrollEdges = (el) => { + const target = el || featuredTrackRef.value + + if (!target) { + featuredAtStart.value = true + featuredAtEnd.value = true + return + } + + const { scrollLeft, scrollWidth, clientWidth } = target + const maxScroll = Math.max(0, scrollWidth - clientWidth) + const epsilon = 2 + + featuredAtStart.value = scrollLeft <= epsilon + featuredAtEnd.value = scrollLeft >= maxScroll - epsilon +} + +watch(featuredTrackRef, (el) => { + unbindFeaturedScroll() + unbindFeaturedScroll = () => {} + + if (!import.meta.client || !el) { + updateFeaturedScrollEdges(null) + return + } + + const onScroll = () => { + updateFeaturedScrollEdges(el) + } + + onScroll() + el.addEventListener('scroll', onScroll, { passive: true }) + const resizeObserver = new ResizeObserver(onScroll) + resizeObserver.observe(el) + unbindFeaturedScroll = () => { + el.removeEventListener('scroll', onScroll) + resizeObserver.disconnect() + } +}, { immediate: true }) + +watch(featuredPosts, () => { + if (!import.meta.client) { + return + } + + nextTick(() => { + updateFeaturedScrollEdges(featuredTrackRef.value) + }) +}) onMounted(() => { if (!import.meta.client) { @@ -107,6 +168,7 @@ onBeforeUnmount(() => { return } + unbindFeaturedScroll() document.removeEventListener('pointerdown', onDocumentPointerDown) }) @@ -120,6 +182,14 @@ const scrollFeatured = (direction) => { return } + if (direction === 'left' && featuredAtStart.value) { + return + } + + if (direction === 'right' && featuredAtEnd.value) { + return + } + const firstCard = featuredTrackRef.value.querySelector('[data-featured-slide]') const cardWidth = firstCard ? firstCard.getBoundingClientRect().width : 244 const gap = 24 @@ -161,13 +231,29 @@ const scrollFeatured = (direction) => {

Featured

- - + +