홈 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,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>