홈 Featured/Pages 화살표 스타일 통일 및 버전 반영
홈 Pages 섹션의 슬라이드 화살표를 Featured와 동일한 텍스트형 버튼 스타일로 맞춰 섹션 간 UI 일관성을 유지한다. 문서 이력과 패키지 버전을 v1.9.22로 갱신해 중간 배포 단위를 명확히 한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
574
pages/index.vue
574
pages/index.vue
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user