홈 Featured/Pages 화살표 스타일 통일 및 버전 반영

홈 Pages 섹션의 슬라이드 화살표를 Featured와 동일한 텍스트형 버튼 스타일로 맞춰 섹션 간 UI 일관성을 유지한다.
문서 이력과 패키지 버전을 v1.9.22로 갱신해 중간 배포 단위를 명확히 한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-30 18:46:34 +09:00
parent 4e2ec3d7a2
commit 6a3cc5d7ec
6 changed files with 752 additions and 30 deletions

View File

@@ -1,5 +1,72 @@
# 업데이트 요약
## v1.9.22
- 홈 Pages 섹션의 좌우 화살표 버튼을 Featured 섹션과 같은 스타일로 통일했다.
## v1.9.21
- 홈 Pages 진열장이 화면 밖으로 레이아웃을 밀어내지 않고, 정해진 영역 안에서 드래그와 좌우 버튼으로 움직이도록 보정했다.
## v1.9.20
- 홈 Latest의 `Posts 더보기` 링크가 실제 Latest 목록 하단에 보이도록 위치를 바로잡았다.
## v1.9.19
- 홈 Pages 진열장의 좌우 버튼 상태가 실제 가로 overflow와 맞게 갱신되도록 했다.
- Featured와 Pages 드래그가 카드 바깥으로 이어져도 끊기지 않게 보정했다.
- 페이지 순서 변경 모달에서 추천 페이지가 저장 전에 뒤로 밀려 보이지 않도록 했다.
## v1.9.18
- 홈 Featured와 Pages 진열장의 드래그가 이미지 선택에 막히지 않도록 했다.
- 가로 드래그 후 손을 떼면 가까운 카드 위치로 자연스럽게 붙도록 조정했다.
- Pages 진열장 카드 폭을 키워 좌우 탐색이 더 분명하게 동작하도록 했다.
## v1.9.17
- 공개 목록의 썸네일 영역 높이를 이미지 유무와 상관없이 고정했다.
- Posts와 태그 목록 페이지 이동 시 화면이 상단으로 올라가도록 했다.
- 홈 Latest 하단에 전체 Posts로 이동하는 더보기 링크를 추가했다.
- 관리자 설정 셀렉트 화살표와 페이지 섹션 순서 모달의 추천 표시를 정리했다.
## v1.9.16
- 관리자 설정의 POST 설정에서 Latest, Posts, 태그 게시물, Featured, Pages 표시 개수를 관리할 수 있게 했다.
- 홈과 공개 게시물·태그 목록이 설정된 개수만 불러오도록 정리했다.
- `/posts`와 `/tag/:slug`에 이전/다음 페이지네이션을 추가했다.
- 태그 전체 화면은 게시물 전체 조회 없이 태그 API의 공개 게시물 수를 사용한다.
## v1.9.15
- 홈 Featured와 Pages 진열장을 드래그로 넘기고 가까운 카드에 붙도록 개선했다.
- Pages 진열장 섹션마다 이전·다음 버튼을 추가하고 카드 hover 시 상단이 잘리지 않게 조정했다.
- 네비게이션 관리의 추가·저장 버튼 위치를 탭마다 같은 구조로 맞췄다.
- 페이지 섹션의 페이지 순서 변경을 썸네일 카드형 모달로 바꾸고, 모달에서 바로 섹션 제거가 가능하게 했다.
## v1.9.14
- 네비게이션 관리의 메인·하단 메뉴와 페이지 섹션 추가를 모두 모달 입력 방식으로 통일했다.
- 페이지 섹션 저장을 행별 저장에서 `페이지 섹션 저장` 단일 버튼으로 정리했다.
- 페이지 섹션 순서를 다른 네비게이션 목록처럼 드래그앤드롭으로 바꿨다.
## v1.9.13
- 페이지 섹션 관리를 네비게이션 관리 화면으로 옮기고 탭 이름을 메인 네비게이션·페이지 섹션·하단 네비게이션·추천 사이트 순서로 정리했다.
- 페이지 섹션별 페이지 표시 순서를 모달에서 드래그앤드롭으로 조정할 수 있게 했다.
- 페이지 작성 화면의 섹션 선택을 검색형 입력으로 바꾸고, 목록 노출 설정 문구와 정렬 순서 직접 입력을 정리했다.
## v1.9.12
- 페이지 섹션을 별도 관리 영역에서 추가·정렬하고, 페이지 작성 시 등록된 섹션을 선택하게 했다.
- 홈 Pages 진열장 커버 권장 비율을 3:4로 정리하고 HTML 문서 페이지 뒤로가기 동작을 보정했다.
## v1.9.11
- 페이지를 독립 화면으로 유지하면서도 홈 Pages 진열장과 검색 결과에 노출할 수 있게 했다.
- 관리자 페이지 설정에서 홈 표시, 추천 표시, 섹션명, 목록용 제목/설명/이미지/정렬을 관리할 수 있다.
## v1.9.10
- 관리자 로그인 상태에서 공개 글·페이지를 열어도 조회수, 읽음, 체류, 스크롤, 실시간 접속 통계에 포함되지 않게 했다.

View File

@@ -1,5 +1,53 @@
# 의사결정 이력
## 2026-06-30 v1.9.22 — 홈 선반 화살표 스타일은 섹션 간 일관성을 우선한다
같은 홈 진열장 패턴인 Featured와 Pages의 좌우 이동 버튼 모양이 다르면 기능은 같아도 다른 컴포넌트처럼 보인다. 따라서 Pages 선반 화살표를 Featured와 같은 텍스트형 버튼 스타일로 맞춰, 섹션 제목 오른쪽 네비게이션의 시각 규칙을 통일한다.
## 2026-06-30 v1.9.21 — Pages 선반은 고정 폭 안에서만 스크롤한다
Pages 진열장은 카드가 많아질수록 레일이 부모 grid item의 최소 너비를 밀어 넓힐 수 있다. 이 경우 화면 밖에 카드가 보여도 실제 스크롤 컨테이너 overflow가 생기지 않아 드래그와 좌우 버튼 상태 계산이 맞지 않는다. 따라서 선반 wrapper에 `min-w-0`과 `overflow-hidden`을 주고, 레일은 `w-full max-w-full` 안에서만 가로 스크롤되도록 제한한다.
## 2026-06-30 v1.9.20 — Latest 더보기는 Latest 문맥 안에 둔다
홈 `Posts 더보기` 링크는 Latest 표시 개수가 제한될 때 전체 게시물 목록으로 이어지는 보조 탐색이다. Pages 진열장 아래에 있으면 페이지 섹션의 다음 행동처럼 보이고, Pages가 없는 구성에서는 노출되지 않을 수 있으므로 Latest 목록 렌더링 직후에 배치한다.
## 2026-06-30 v1.9.19 — Pages 진열장은 실제 overflow 기준으로 조작 상태를 갱신한다
Pages 진열장은 이미지 로딩과 반응형 폭 변화 이후에야 실제 스크롤 가능 여부가 확정된다. 최초 렌더 시점의 `scrollWidth`만 기준으로 버튼 상태를 잡으면 화면을 넘어가도 버튼이 비활성처럼 보일 수 있으므로, 선반별 스크롤 컨테이너와 카드 크기를 `ResizeObserver`로 감시해 상태를 계속 갱신한다. 또한 드래그 중 포인터가 카드 바깥으로 이동해도 조작이 끊기지 않도록 문서 레벨 포인터 추적으로 바꾼다. 페이지 순서 변경 모달은 공개 홈의 추천 우선 정렬과 같은 규칙을 즉시 적용해 저장 전 화면과 실제 반영 결과가 어긋나지 않게 한다.
## 2026-06-30 v1.9.18 — 홈 진열장 드래그는 카드 탐색을 우선한다
홈 Featured와 Pages 진열장은 링크 카드 위에서 드래그하는 구조라 이미지의 기본 브라우저 드래그가 먼저 잡히면 캐러셀 조작이 끊긴다. 따라서 트랙 안의 이미지 기본 드래그와 텍스트 선택을 막고, 포인터 이동은 작은 거리부터 스크롤에 반영한다. 스냅은 반 칸 이상 이동했을 때만 다음 카드로 넘어가는 방식보다 작은 의도도 읽히는 임계값 기반으로 처리해, 손을 뗐을 때 가까운 카드 위치로 자연스럽게 붙게 한다.
## 2026-06-30 v1.9.17 — 공개 목록 탐색은 페이지 전환 맥락을 유지한다
게시물 목록과 태그 목록에 서버 페이지네이션을 적용하면 같은 화면 안에서 목록 데이터만 바뀌기 때문에, 스크롤 위치가 하단에 남아 있으면 새 페이지의 첫 게시물을 놓칠 수 있다. 따라서 페이지 번호가 바뀔 때 문서 상단으로 이동해 일반 페이지 이동과 같은 감각을 유지한다. 홈 Latest는 설정된 개수만 보여 주므로 전체 목록으로 이어지는 링크를 하단에 두고, 썸네일 placeholder는 이미지 유무와 관계없이 같은 영역을 차지하게 해 목록 높이를 안정화한다. 페이지 섹션 순서 모달은 공개 홈과 같은 추천 우선 정렬을 보여 주어 관리 화면과 실제 노출 차이를 줄인다.
## 2026-06-30 v1.9.16 — 공개 목록은 설정 기반 페이지네이션으로 제한한다
게시물이 늘어날수록 홈 Latest, 게시물 목록, 태그 상세가 전체 게시물을 한 번에 받아 프론트에서 필터링하면 초기 로딩과 메모리 사용량이 커진다. 따라서 사이트 설정의 POST 설정에서 공개 목록별 표시 개수를 관리하고, 홈은 제한된 최신·추천 글만 받으며 `/posts`와 `/tag/:slug`는 서버 페이지네이션을 사용한다. 태그 전체 화면도 게시물 전체 조회 대신 태그 API의 공개 게시물 수를 사용해 목록 화면의 데이터 규모를 안정적으로 유지한다.
## 2026-06-30 v1.9.15 — 홈 진열장과 관리 모달은 카드 기반 탐색으로 맞춘다
홈 Pages 진열장은 카드가 5개를 넘으면 스크롤바가 숨겨진 상태에서 접근성이 떨어지고, hover 상승 효과가 가로 스크롤 영역에 잘릴 수 있다. Featured와 같은 이전·다음 버튼과 드래그 스냅을 적용해 섹션이 여러 개여도 각 선반을 독립적으로 탐색하게 한다. 관리자 페이지 순서 변경도 단순 행 목록보다 실제 홈 노출 형태에 가까운 카드형 가로 목록이 판단하기 쉬우므로, 썸네일 카드로 순서를 조정하고 같은 모달 안에서 섹션 연결 제거까지 처리한다.
## 2026-06-30 v1.9.14 — 네비게이션 관리의 추가·저장 흐름을 탭 간 통일한다
같은 네비게이션 관리 화면 안에서 어떤 탭은 빈 행을 즉시 추가하고, 어떤 탭은 모달에서 정보를 받은 뒤 추가하며, 페이지 섹션은 행별 저장과 상단 저장 버튼이 함께 보이면 저장 대상이 모호해진다. 따라서 모든 추가 액션은 모달 입력 후 목록에 반영하는 방식으로 맞추고, 저장 버튼은 메뉴 항목용 `네비게이션 저장`과 페이지 섹션용 `페이지 섹션 저장`으로 분리한다. 페이지 섹션 순서도 다른 네비게이션 목록과 같이 드래그앤드롭으로 통일한다.
## 2026-06-30 v1.9.13 — 페이지 섹션과 페이지 순서는 네비게이션 구성으로 관리한다
페이지 섹션은 개별 페이지의 본문 작성 정보보다 홈 Pages 진열장의 네비게이션 구조에 가깝다. 페이지 목록 상단에서 섹션을 관리하면 페이지 수가 늘어날수록 목록 관리와 홈 구성 관리가 섞이므로, 섹션 생성·이름 변경·섹션 순서·섹션 내부 페이지 순서를 관리자 네비게이션 화면으로 옮긴다. 페이지 작성 화면은 기존 섹션을 검색해 선택하는 역할만 맡겨 콘텐츠 작성 흐름을 가볍게 유지한다.
## 2026-06-30 v1.9.12 — 페이지 섹션은 콘텐츠 입력과 분리해 관리한다
페이지마다 섹션명을 직접 입력하면 같은 섹션의 표기 흔들림과 홈 진열장 순서 관리 문제가 생긴다. 따라서 페이지 자체에는 선택된 섹션명을 저장하되, 섹션 생성·이름 변경·배치 순서는 `page_listing_sections` 관리 테이블과 관리자 페이지 목록의 섹션 관리 카드에서 다룬다. 페이지 작성 화면은 등록된 섹션 중 하나를 선택하는 역할만 맡겨 콘텐츠 작성과 홈 구성 관리를 분리한다.
## 2026-06-30 v1.9.11 — 페이지는 독립 화면을 유지하고 노출 메타만 추가한다
페이지를 게시물 피드에 그대로 섞으면 시간순 글 목록의 의미가 흐려질 수 있다. 대신 페이지는 기존처럼 `/pages/:slug` 독립 전체 화면으로 유지하고, 필요한 페이지에만 홈 표시·추천 표시·섹션명·목록 카드 메타를 부여해 섹션별 Pages 진열장과 검색 결과에서 발견할 수 있게 한다. 이렇게 하면 페이지의 랜딩/문서 성격과 게시물 피드의 시간 흐름을 함께 지킬 수 있다.
## 2026-06-30 v1.9.10 — 관리자 세션은 공개 통계에서 제외한다
운영자가 글 작성·수정·확인을 위해 공개 게시물과 페이지를 반복해서 열면 실제 방문자 행동보다 관리자 검수 동작이 조회수와 체류 통계에 크게 반영된다. 따라서 관리자 세션 쿠키가 있는 요청은 공개 경로라도 페이지뷰, 읽음, heartbeat, HTML 문서 페이지 직접 집계에서 제외해 통계가 방문자 기준에 더 가깝게 유지되도록 한다.

View File

@@ -1,5 +1,92 @@
# 업데이트 이력
## v1.9.22
- 홈 Pages 섹션 슬라이드 화살표를 Featured와 동일한 텍스트형 버튼 스타일로 통일.
## v1.9.21
- 홈 Pages 진열장 선반 폭이 카드 전체 너비로 늘어나 드래그·좌우 버튼이 비활성처럼 보이던 문제 수정.
- 홈 Pages 진열장 레일을 섹션 최대 폭 안에서 가로 스크롤되도록 정리.
## v1.9.20
- 홈 `Posts 더보기` 링크가 Pages 진열장 아래가 아니라 Latest 목록 하단에 표시되도록 위치 수정.
## v1.9.19
- 홈 Pages 진열장 스크롤 상태를 `ResizeObserver`와 스크롤 이벤트로 계속 갱신해 좌우 버튼 표시 상태가 실제 overflow와 맞도록 수정.
- 홈 Featured·Pages 드래그를 문서 레벨 포인터 추적으로 변경해 카드 밖으로 포인터가 이동해도 드래그가 유지되도록 수정.
- 관리자 페이지 순서 변경 모달에서 추천 페이지가 비추천 페이지 뒤로 드롭되어도 즉시 추천 우선 순서로 보정되도록 수정.
## v1.9.18
- 홈 Featured·Pages 가로 트랙에서 이미지 기본 드래그가 발생하지 않도록 수정.
- 홈 Featured·Pages 가로 트랙의 작은 드래그에도 스크롤이 반응하고 드래그 종료 시 가까운 카드로 스냅되도록 수정.
- 홈 Pages 진열장 카드 폭을 키워 여러 페이지가 있을 때 좌우 버튼과 드래그 탐색이 동작하도록 조정.
## v1.9.17
- 관리자 설정 표시개수 셀렉트 화살표를 기존 chevron SVG 패턴으로 통일.
- 공개 게시물 목록 썸네일 placeholder 높이가 대표 이미지 유무와 관계없이 고정되도록 수정.
- `/posts`와 `/tag/:slug` 페이지네이션 이동 시 화면이 목록 상단으로 이동하도록 수정.
- 홈 Latest 하단에 `/posts` 더보기 링크 추가.
- 관리자 네비게이션 페이지 섹션 순서 모달에 추천 페이지 우선 정렬과 추천 배지 반영.
## v1.9.16
- 사이트 설정 POST 설정에 공개 목록 표시개수 설정 추가.
- 홈 Latest·Featured·Pages 진열장 표시 개수를 사이트 설정 기준으로 제한.
- 공개 게시물 목록 `/posts`에 서버 페이지네이션 적용.
- 태그 상세 `/tag/:slug` 게시물 목록에 서버 태그 필터와 페이지네이션 적용.
- 태그 전체 `/tags`에서 게시물 전체 조회 제거 및 태그 API의 공개 게시물 수 사용.
- DB 마이그레이션 `062_site_settings_display_counts.sql` 추가.
## v1.9.15
- 홈 Featured와 Pages 진열장에 드래그 슬라이드와 스냅 이동 적용.
- 홈 Pages 진열장 섹션별 이전·다음 버튼 추가 및 카드 hover 상단 잘림 수정.
- 네비게이션 관리 각 탭의 추가·저장 버튼을 패널 작업줄의 좌우 배치로 통일.
- 페이지 섹션의 페이지 순서 변경 모달을 썸네일 카드형 가로 목록으로 정리.
- 페이지 순서 변경 모달에서 페이지를 해당 섹션에서 바로 제거하는 기능 추가.
## v1.9.14
- 네비게이션 관리의 메인·하단 메뉴 추가 흐름을 모달 입력 후 목록 반영 방식으로 통일.
- 추천 사이트 추가 모달 취소 시 빈 임시 항목이 목록에 남지 않도록 수정.
- 페이지 섹션 추가를 인라인 입력에서 모달 입력 방식으로 변경.
- 페이지 섹션 행별 저장 제거 및 `페이지 섹션 저장` 단일 액션으로 이름·섹션 순서 저장 흐름 정리.
- 페이지 섹션 자체 순서를 버튼 대신 드래그앤드롭으로 변경.
- 네비게이션 저장 버튼을 페이지 섹션 탭에서 숨기고 저장 대상별 버튼 라벨 정리.
## v1.9.13
- 페이지 섹션 관리를 관리자 페이지 목록에서 네비게이션 관리의 `페이지 섹션` 탭으로 이동.
- 네비게이션 탭 이름과 순서를 `메인 네비게이션`·`페이지 섹션`·`하단 네비게이션`·`추천 사이트`로 정리.
- 페이지 섹션 탭에 섹션별 페이지 순서 변경 모달 추가.
- 페이지 작성·수정 화면의 섹션 선택을 등록 섹션 검색형 입력으로 변경.
- 페이지 작성·수정 화면의 목록 노출 설명 제거, 목록 이미지 라벨을 `썸네일 등록`으로 변경.
- 페이지 작성·수정 화면의 정렬 순서 직접 입력 제거 및 HTML 자산 영역 위치 정리.
- `PUT /admin/api/page-sections/:id/pages/reorder` API 추가.
## v1.9.12
- 페이지 섹션을 `page_listing_sections` 관리 테이블로 분리.
- 관리자 페이지 목록에 페이지 섹션 추가·수정·삭제·순서 변경 관리 카드 추가.
- 페이지 작성·수정 화면의 섹션 입력을 등록된 섹션 선택 방식으로 변경.
- 홈 Pages 진열장 섹션 정렬을 페이지 섹션 관리 순서 기준으로 변경.
- HTML 문서 모드 페이지에서 브라우저 뒤로가기 후 화면이 갱신되지 않을 수 있는 클라이언트 재진입 방식을 수정.
- Pages 진열장 썸네일 권장 비율과 제작 크기 문서화.
- DB 마이그레이션 `061_page_listing_sections.sql` 추가.
## v1.9.11
- 페이지 노출 메타 추가: 홈 표시, 추천 표시, 섹션명, 목록 제목, 목록 설명, 목록 이미지, 정렬 순서 저장.
- 공개 홈에 노출 설정된 페이지를 섹션별 도서 진열장형 Pages 선반으로 표시하도록 추가.
- 공개 검색 결과에 홈 표시 페이지 섹션 추가.
- 관리자 페이지 목록에 홈 표시·섹션·추천 배지 추가.
- DB 마이그레이션 `060_page_listing_metadata.sql` 추가.
## v1.9.10
- 관리자 세션으로 공개 게시물·페이지를 조회할 때 페이지뷰·읽음·체류·스크롤·실시간 접속 통계에 포함되지 않도록 수정.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -1,5 +1,39 @@
<script setup>
import {
DEFAULT_FEATURED_POST_LIMIT,
DEFAULT_HOME_LATEST_POST_LIMIT,
DEFAULT_PAGE_SECTION_ITEM_LIMIT,
normalizeDisplayCount
} from '~/lib/display-count-settings.js'
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const homeLatestPostLimit = computed(() => normalizeDisplayCount(siteSettings.value?.homeLatestPostLimit, DEFAULT_HOME_LATEST_POST_LIMIT))
const featuredPostLimit = computed(() => normalizeDisplayCount(siteSettings.value?.featuredPostLimit, DEFAULT_FEATURED_POST_LIMIT))
const pageSectionItemLimit = computed(() => normalizeDisplayCount(siteSettings.value?.pageSectionItemLimit, DEFAULT_PAGE_SECTION_ITEM_LIMIT))
const { data: posts } = await useFetch('/api/posts', {
query: computed(() => ({
limit: homeLatestPostLimit.value
})),
default: () => []
})
const { data: featuredPostsData } = await useFetch('/api/posts', {
query: computed(() => ({
featured: '1',
limit: featuredPostLimit.value
})),
default: () => []
})
const { data: pages } = await useFetch('/api/pages', {
query: computed(() => ({
listed: '1',
limit: pageSectionItemLimit.value
})),
default: () => []
})
@@ -7,10 +41,6 @@ const { data: tags } = await useFetch('/api/tags?tagType=all', {
default: () => []
})
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const postFeedStyleStorageKey = 'POST_FEED_STYLE'
const postFeedStyleOpen = ref(false)
@@ -166,17 +196,80 @@ const mapLatestPost = (post) => {
}
}
const featuredPosts = computed(() => posts.value.filter((post) => post.isFeatured).slice(0, 6))
const featuredPosts = computed(() => featuredPostsData.value)
const latestPosts = computed(() => posts.value.map(mapLatestPost))
const pageShelves = computed(() => {
const shelves = new Map()
for (const page of pages.value.filter((item) => item.status === 'published' && item.isListed)) {
const sectionTitle = String(page.listingSection || '').trim() || 'Pages'
const shelf = shelves.get(sectionTitle) || {
title: sectionTitle,
pages: []
}
shelf.pages.push({
title: page.listingTitle || page.title,
image: page.featuredImage || '',
to: `/pages/${page.slug}`,
isFeatured: Boolean(page.isFeatured)
})
shelves.set(sectionTitle, shelf)
}
return [...shelves.values()].map((shelf, index) => ({
...shelf,
id: `page-shelf-${index}-${String(shelf.title || 'pages')
.normalize('NFKD')
.replace(/[^\w가-힣-]+/g, '-')
.replace(/^-+|-+$/g, '')}`
}))
})
const hasHomeInfeedAd = computed(() => Boolean(String(siteSettings.value?.adHomeInfeedCode || '').trim()))
const featuredTrackRef = ref(null)
const pageShelfTrackRefs = ref({})
const pageShelfScrollStates = reactive({})
const carouselDragState = reactive({
key: '',
pointerId: -1,
startX: 0,
startScrollLeft: 0,
lastDeltaX: 0,
moved: false,
suppressClick: false
})
/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */
const featuredAtStart = ref(true)
/** Featured 트랙이 스크롤 끝에 붙었는지 — 다음 화살표 비활성 */
const featuredAtEnd = ref(true)
let unbindFeaturedScroll = () => {}
let carouselDragTrack = null
const pageShelfScrollUnbinders = new Map()
/**
* 가로 트랙의 양 끝 상태를 계산한다.
* @param {HTMLElement | null} el - 스크롤 컨테이너
* @returns {{ atStart: boolean, atEnd: boolean }} 양 끝 상태
*/
const getScrollEdges = (el) => {
if (!el) {
return {
atStart: true,
atEnd: true
}
}
const { scrollLeft, scrollWidth, clientWidth } = el
const maxScroll = Math.max(0, scrollWidth - clientWidth)
const epsilon = 2
return {
atStart: scrollLeft <= epsilon,
atEnd: scrollLeft >= maxScroll - epsilon
}
}
/**
* Featured 가로 스크롤 위치에 따라 이전/다음 버튼 상태를 갱신한다.
@@ -184,20 +277,101 @@ let unbindFeaturedScroll = () => {}
* @returns {void}
*/
const updateFeaturedScrollEdges = (el) => {
const target = el || featuredTrackRef.value
const edges = getScrollEdges(el || featuredTrackRef.value)
featuredAtStart.value = edges.atStart
featuredAtEnd.value = edges.atEnd
}
if (!target) {
featuredAtStart.value = true
featuredAtEnd.value = true
/**
* Pages 선반 스크롤 상태를 반환한다.
* @param {string} key - 선반 키
* @returns {{ atStart: boolean, atEnd: boolean }} 스크롤 상태
*/
const getPageShelfScrollState = (key) => {
if (!pageShelfScrollStates[key]) {
pageShelfScrollStates[key] = {
atStart: true,
atEnd: true
}
}
return pageShelfScrollStates[key]
}
/**
* Pages 선반 스크롤 상태를 갱신한다.
* @param {string} key - 선반 키
* @param {HTMLElement | null} el - 스크롤 컨테이너
* @returns {void}
*/
const updatePageShelfScrollEdges = (key, el) => {
const state = getPageShelfScrollState(key)
const edges = getScrollEdges(el || pageShelfTrackRefs.value[key] || null)
state.atStart = edges.atStart
state.atEnd = edges.atEnd
}
/**
* Pages 선반 스크롤/크기 변경 감시를 해제한다.
* @param {string} key - 선반 키
* @returns {void}
*/
const unbindPageShelfScroll = (key) => {
const unbind = pageShelfScrollUnbinders.get(key)
if (typeof unbind === 'function') {
unbind()
}
pageShelfScrollUnbinders.delete(key)
}
/**
* Pages 선반 스크롤/크기 변경 감시를 등록한다.
* @param {string} key - 선반 키
* @param {HTMLElement} el - 스크롤 컨테이너
* @returns {void}
*/
const bindPageShelfScroll = (key, el) => {
unbindPageShelfScroll(key)
const onScroll = () => {
updatePageShelfScrollEdges(key, el)
}
el.addEventListener('scroll', onScroll, { passive: true })
const resizeObserver = new ResizeObserver(onScroll)
resizeObserver.observe(el)
for (const child of el.children) {
if (child instanceof HTMLElement) {
resizeObserver.observe(child)
}
}
requestAnimationFrame(onScroll)
pageShelfScrollUnbinders.set(key, () => {
el.removeEventListener('scroll', onScroll)
resizeObserver.disconnect()
})
}
/**
* Pages 선반 트랙 참조를 저장한다.
* @param {string} key - 선반 키
* @param {Element | import('vue').ComponentPublicInstance | null} el - 트랙 엘리먼트
* @returns {void}
*/
const setPageShelfTrackRef = (key, el) => {
if (el instanceof HTMLElement) {
pageShelfTrackRefs.value[key] = el
bindPageShelfScroll(key, el)
nextTick(() => {
updatePageShelfScrollEdges(key, el)
})
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
unbindPageShelfScroll(key)
delete pageShelfTrackRefs.value[key]
updatePageShelfScrollEdges(key, null)
}
watch(featuredTrackRef, (el) => {
@@ -233,6 +407,18 @@ watch(featuredPosts, () => {
})
})
watch(pageShelves, () => {
if (!import.meta.client) {
return
}
nextTick(() => {
for (const shelf of pageShelves.value) {
updatePageShelfScrollEdges(shelf.id, pageShelfTrackRefs.value[shelf.id] || null)
}
})
})
onMounted(() => {
if (!import.meta.client) {
return
@@ -251,19 +437,52 @@ onBeforeUnmount(() => {
}
unbindFeaturedScroll()
for (const key of pageShelfScrollUnbinders.keys()) {
unbindPageShelfScroll(key)
}
unbindCarouselDragDocumentEvents()
document.removeEventListener('pointerdown', onDocumentPointerDown)
})
/**
* 트랙의 카드 한 칸 폭을 계산한다.
* @param {HTMLElement} track - 스크롤 트랙
* @returns {number} 이동 폭
*/
const getCarouselStep = (track) => {
const firstCard = track.querySelector('[data-carousel-slide]')
const cardWidth = firstCard instanceof HTMLElement ? firstCard.getBoundingClientRect().width : 244
const style = window.getComputedStyle(track)
const gap = Number.parseFloat(style.columnGap || style.gap || '0') || 0
return cardWidth + gap
}
/**
* 가로 트랙을 좌우 한 칸 이동한다.
* @param {HTMLElement | null} track - 스크롤 트랙
* @param {'left' | 'right'} direction - 이동 방향
* @returns {void}
*/
const scrollCarouselTrack = (track, direction) => {
if (!track) {
return
}
const offset = direction === 'left' ? -getCarouselStep(track) : getCarouselStep(track)
track.scrollBy({
left: offset,
behavior: 'smooth'
})
}
/**
* Featured 가로 트랙을 좌우로 이동한다.
* @param {'left' | 'right'} direction - 이동 방향
* @returns {void}
*/
const scrollFeatured = (direction) => {
if (!featuredTrackRef.value) {
return
}
if (direction === 'left' && featuredAtStart.value) {
return
}
@@ -272,17 +491,219 @@ const scrollFeatured = (direction) => {
return
}
const firstCard = featuredTrackRef.value.querySelector('[data-featured-slide]')
const cardWidth = firstCard ? firstCard.getBoundingClientRect().width : 244
const gap = 24
const offset = direction === 'left' ? -(cardWidth + gap) : cardWidth + gap
scrollCarouselTrack(featuredTrackRef.value, direction)
}
featuredTrackRef.value.scrollBy({
left: offset,
/**
* Pages 선반 가로 트랙을 좌우로 이동한다.
* @param {string} key - 선반 키
* @param {'left' | 'right'} direction - 이동 방향
* @returns {void}
*/
const scrollPageShelf = (key, direction) => {
const state = getPageShelfScrollState(key)
if (direction === 'left' && state.atStart) {
return
}
if (direction === 'right' && state.atEnd) {
return
}
scrollCarouselTrack(pageShelfTrackRefs.value[key] || null, direction)
}
/**
* 가로 트랙을 가까운 카드 위치로 붙인다.
* @param {HTMLElement} track - 스크롤 트랙
* @returns {void}
*/
const snapCarouselTrack = (track) => {
const step = getCarouselStep(track)
if (step <= 0) {
return
}
const maxScroll = Math.max(0, track.scrollWidth - track.clientWidth)
const startIndex = Math.round(carouselDragState.startScrollLeft / step)
let targetIndex = Math.round(track.scrollLeft / step)
const snapThreshold = Math.min(48, Math.max(14, step * 0.12))
if (Math.abs(carouselDragState.lastDeltaX) >= snapThreshold && targetIndex === startIndex) {
targetIndex += carouselDragState.lastDeltaX < 0 ? 1 : -1
}
const targetLeft = Math.min(maxScroll, Math.max(0, targetIndex * step))
track.scrollTo({
left: targetLeft,
behavior: 'smooth'
})
}
/**
* 드래그 대상에 맞는 스크롤 상태를 갱신한다.
* @param {string} key - 트랙 키
* @param {HTMLElement | null} track - 스크롤 트랙
* @returns {void}
*/
const updateCarouselTrackEdges = (key, track) => {
if (key === 'featured') {
updateFeaturedScrollEdges(track)
return
}
updatePageShelfScrollEdges(key, track)
}
/**
* 문서 레벨 캐러셀 드래그 이벤트를 해제한다.
* @returns {void}
*/
function unbindCarouselDragDocumentEvents() {
if (!import.meta.client) {
return
}
document.removeEventListener('pointermove', moveCarouselDrag)
document.removeEventListener('pointerup', endCarouselDrag)
document.removeEventListener('pointercancel', endCarouselDrag)
}
/**
* 문서 레벨 캐러셀 드래그 이벤트를 등록한다.
* @returns {void}
*/
const bindCarouselDragDocumentEvents = () => {
if (!import.meta.client) {
return
}
unbindCarouselDragDocumentEvents()
document.addEventListener('pointermove', moveCarouselDrag, { passive: false })
document.addEventListener('pointerup', endCarouselDrag)
document.addEventListener('pointercancel', endCarouselDrag)
}
/**
* 가로 트랙 드래그를 시작한다.
* @param {PointerEvent} event - 포인터 이벤트
* @param {string} key - 트랙 키
* @returns {void}
*/
const startCarouselDrag = (event, key) => {
if (event.pointerType === 'mouse' && event.button !== 0) {
return
}
const track = event.currentTarget
if (!(track instanceof HTMLElement)) {
return
}
carouselDragState.key = key
carouselDragState.pointerId = event.pointerId
carouselDragState.startX = event.clientX
carouselDragState.startScrollLeft = track.scrollLeft
carouselDragState.lastDeltaX = 0
carouselDragState.moved = false
carouselDragTrack = track
bindCarouselDragDocumentEvents()
if (track.setPointerCapture) {
track.setPointerCapture(event.pointerId)
}
event.preventDefault()
}
/**
* 가로 트랙 드래그 중 스크롤을 이동한다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const moveCarouselDrag = (event) => {
if (carouselDragState.pointerId !== event.pointerId) {
return
}
const track = carouselDragTrack
if (!(track instanceof HTMLElement)) {
return
}
const deltaX = event.clientX - carouselDragState.startX
carouselDragState.lastDeltaX = deltaX
if (Math.abs(deltaX) > 1) {
carouselDragState.moved = true
}
if (!carouselDragState.moved) {
return
}
event.preventDefault()
track.scrollLeft = carouselDragState.startScrollLeft - deltaX
updateCarouselTrackEdges(carouselDragState.key, track)
}
/**
* 가로 트랙 드래그를 종료한다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const endCarouselDrag = (event) => {
if (carouselDragState.pointerId !== event.pointerId) {
return
}
const track = carouselDragTrack
const key = carouselDragState.key
if (track instanceof HTMLElement) {
if (track.hasPointerCapture?.(event.pointerId)) {
track.releasePointerCapture(event.pointerId)
}
if (carouselDragState.moved) {
snapCarouselTrack(track)
carouselDragState.suppressClick = true
requestAnimationFrame(() => {
requestAnimationFrame(() => {
carouselDragState.suppressClick = false
})
})
}
}
updateCarouselTrackEdges(key, track instanceof HTMLElement ? track : null)
unbindCarouselDragDocumentEvents()
carouselDragTrack = null
carouselDragState.key = ''
carouselDragState.pointerId = -1
carouselDragState.lastDeltaX = 0
carouselDragState.moved = false
}
/**
* 브라우저 기본 이미지·링크 드래그를 막는다.
* @param {DragEvent} event - 드래그 이벤트
* @returns {void}
*/
const preventNativeCarouselDrag = (event) => {
event.preventDefault()
}
/**
* 드래그 직후 링크 클릭을 막는다.
* @param {MouseEvent} event - 클릭 이벤트
* @returns {void}
*/
const preventCarouselClickAfterDrag = (event) => {
if (!carouselDragState.suppressClick) {
return
}
event.preventDefault()
event.stopPropagation()
}
/**
* 메인 인피드 광고 삽입 위치를 현재 브라우저 렌더에서 한 번 정한다.
* @returns {void}
@@ -345,14 +766,18 @@ watch([latestPosts, hasHomeInfeedAd], () => {
</div>
<div
ref="featuredTrackRef"
class="featured-posts-track mt-4 flex snap-x snap-mandatory gap-6 overflow-x-auto overscroll-x-contain scroll-smooth pb-1 touch-pan-x [-webkit-overflow-scrolling:touch] [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
class="featured-posts-track mt-4 flex cursor-grab select-none snap-x snap-mandatory gap-6 overflow-x-auto overscroll-x-contain scroll-smooth pb-1 touch-pan-x active:cursor-grabbing [-webkit-overflow-scrolling:touch] [--slides:1.4] sm:[--slides:1.6] lg:[--slides:2.6] [-ms-overflow-style:none] [scrollbar-width:none] [&_*]:select-none [&_img]:pointer-events-none [&_img]:select-none [&::-webkit-scrollbar]:hidden"
@pointerdown="startCarouselDrag($event, 'featured')"
@click.capture="preventCarouselClickAfterDrag"
@dragstart="preventNativeCarouselDrag"
>
<NuxtLink
v-for="post in featuredPosts"
:key="`featured-${post.slug}`"
:to="`/post/${post.slug}`"
class="group relative block aspect-video w-[calc((100%-(24px*(var(--slides)-1)))/var(--slides))] shrink-0 snap-start overflow-hidden rounded-[10px]"
data-featured-slide
data-carousel-slide
draggable="false"
>
<img
v-if="post.featuredImage"
@@ -360,6 +785,7 @@ watch([latestPosts, hasHomeInfeedAd], () => {
:alt="post.title"
class="h-full w-full object-cover brightness-75 contrast-125 transition-all duration-200 group-hover:brightness-90 group-hover:contrast-110"
loading="lazy"
draggable="false"
>
<div
v-else
@@ -382,6 +808,77 @@ watch([latestPosts, hasHomeInfeedAd], () => {
location="home-feed"
/>
<section v-if="pageShelves.length" class="home-pages-section py-4 px-6">
<div class="mx-auto grid w-full max-w-[720px] gap-6">
<div
v-for="shelf in pageShelves"
:key="shelf.title"
class="home-pages-section__shelf min-w-0 overflow-hidden"
>
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
<h2 class="text-sm font-medium uppercase site-muted">{{ shelf.title }}</h2>
<div class="flex justify-between gap-2">
<button
class="home-pages-section__nav-prev cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
type="button"
:aria-label="`${shelf.title} 이전`"
:disabled="getPageShelfScrollState(shelf.id).atStart"
@click="scrollPageShelf(shelf.id, 'left')"
>
‹
</button>
<button
class="home-pages-section__nav-next cursor-pointer p-1 text-[var(--site-text)] hover:opacity-75 disabled:cursor-not-allowed disabled:opacity-35 disabled:hover:opacity-35"
type="button"
:aria-label="`${shelf.title} 다음`"
:disabled="getPageShelfScrollState(shelf.id).atEnd"
@click="scrollPageShelf(shelf.id, 'right')"
>
›
</button>
</div>
</div>
<div
:ref="(el) => setPageShelfTrackRef(shelf.id, el)"
class="home-pages-section__rail -mt-2 flex w-full max-w-full cursor-grab select-none snap-x snap-mandatory gap-4 overflow-x-auto overscroll-x-contain scroll-smooth pb-3 pt-6 touch-pan-x active:cursor-grabbing [-webkit-overflow-scrolling:touch] [scrollbar-width:none] [&_*]:select-none [&_img]:pointer-events-none [&_img]:select-none [&::-webkit-scrollbar]:hidden"
@scroll.passive="updatePageShelfScrollEdges(shelf.id, $event.currentTarget)"
@pointerdown="startCarouselDrag($event, shelf.id)"
@click.capture="preventCarouselClickAfterDrag"
@dragstart="preventNativeCarouselDrag"
>
<NuxtLink
v-for="page in shelf.pages"
:key="page.to"
class="home-pages-section__book group relative block w-[148px] shrink-0 snap-start sm:w-[164px]"
:to="page.to"
data-carousel-slide
draggable="false"
>
<div class="home-pages-section__cover relative aspect-[3/4] overflow-hidden rounded-[6px] border border-[var(--site-line)] bg-[var(--site-panel)] shadow-sm transition-transform duration-200 group-hover:-translate-y-1">
<img
v-if="page.image"
class="h-full w-full object-cover"
:src="page.image"
:alt="page.title"
loading="lazy"
draggable="false"
>
<div v-else class="flex h-full w-full items-center justify-center p-3 text-center text-sm font-semibold leading-tight site-muted">
{{ page.title }}
</div>
<span v-if="page.isFeatured" class="absolute left-2 top-2 rounded-full bg-[var(--site-accent)] px-2 py-0.5 text-[10px] font-semibold text-white shadow-sm">
추천
</span>
</div>
<h3 class="mt-2 line-clamp-2 text-xs font-semibold leading-snug text-[var(--site-text)]">
{{ page.title }}
</h3>
</NuxtLink>
</div>
</div>
</div>
</section>
<section class="latest-posts-section min-h-[360px] py-4 px-6">
<div class="mx-auto max-w-[720px]">
<div class="flex items-end justify-between gap-2 border-b border-[var(--site-line)] pb-2">
@@ -598,6 +1095,29 @@ watch([latestPosts, hasHomeInfeedAd], () => {
/>
</template>
</div>
<div v-if="latestPosts.length" class="latest-posts-section__more mt-5 flex justify-end border-t border-[var(--site-line)] pt-4">
<NuxtLink
class="inline-flex items-center gap-1 text-xs font-semibold uppercase tracking-wide site-muted transition-colors hover:text-[var(--site-accent)]"
to="/posts"
>
<span>Posts 더보기</span>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
class="h-4 w-4"
aria-hidden="true"
>
<path d="M17 7 7 17" />
<path d="M8 7h9v9" />
</svg>
</NuxtLink>
</div>
</div>
</div>
</section>