Files
sori.studio/pages/index.vue

1131 lines
40 KiB
Vue
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 usePublicSiteSettings()
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: () => []
})
const { data: tags } = await useFetch('/api/tags?tagType=all', {
default: () => []
})
const postFeedStyleStorageKey = 'POST_FEED_STYLE'
const postFeedStyleOpen = ref(false)
const postFeedStyle = ref('compact')
const homeInfeedAdInsertIndex = ref(-1)
/** @typedef {'list' | 'compact' | 'cards'} PostFeedStyle */
/**
* 저장·표시용 피드 보기 방식을 정규화한다.
* @param {string|null|undefined} value - 원본 값
* @returns {PostFeedStyle}
*/
const normalizePostFeedStyle = (value) => {
if (value === 'list' || value === 'cards') {
return value
}
return 'compact'
}
/**
* Latest 피드 보기 방식을 저장한다.
* @param {'list' | 'compact' | 'cards' | 'articles'} value - 보기 방식
* @returns {void}
*/
const setPostFeedStyle = (value) => {
const nextStyle = normalizePostFeedStyle(value === 'articles' ? 'compact' : value)
postFeedStyle.value = nextStyle
if (import.meta.client) {
localStorage.setItem(postFeedStyleStorageKey, nextStyle)
}
}
/** @type {import('vue').ComputedRef<boolean>} */
const isPostFeedCards = computed(() => postFeedStyle.value === 'cards')
/** @type {import('vue').ComputedRef<boolean>} */
const showPostFeedMedia = computed(() => postFeedStyle.value === 'compact' || postFeedStyle.value === 'cards')
/**
* Latest 피드 컨테이너 클래스
* @param {PostFeedStyle} style - 보기 방식
* @returns {string}
*/
const getPostFeedContainerClass = (style) => {
if (style === 'cards') {
return 'post-feed post-feed--cards mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2'
}
return 'post-feed post-feed--stack flex flex-col divide-y divide-[var(--site-line)]'
}
/**
* Latest 게시물 카드 클래스
* @param {PostFeedStyle} style - 보기 방식
* @returns {string}
*/
const getPostFeedArticleClass = (style) => {
if (style === 'cards') {
return 'post-feed__card group relative flex flex-col rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3'
}
if (style === 'compact') {
return 'post-feed__item post-feed__item--compact group relative flex flex-row gap-3 py-3'
}
return 'post-feed__item post-feed__item--list group relative flex flex-row gap-3 py-4'
}
/**
* 요약 줄 수 클래스
* @param {PostFeedStyle} style - 보기 방식
* @returns {string}
*/
const getPostFeedExcerptClass = (style) => {
if (style === 'list') {
return 'post-summary-clamp post-summary-clamp--two'
}
return 'post-summary-clamp post-summary-clamp--two'
}
const closePostFeedStyleMenu = () => {
postFeedStyleOpen.value = false
}
const onDocumentPointerDown = (event) => {
if (!postFeedStyleOpen.value) {
return
}
const target = /** @type {HTMLElement | null} */ (event.target instanceof HTMLElement ? event.target : null)
if (!target) {
closePostFeedStyleMenu()
return
}
if (target.closest('[data-feed-style-root]')) {
return
}
closePostFeedStyleMenu()
}
/**
* 태그 슬러그로 태그 정보 조회
* @param {string | undefined} slug - 태그 슬러그
* @returns {{name: string, color: string}} 태그 정보
*/
const getTagMeta = (slug) => {
if (!slug) {
return {
name: '',
color: '#4d4d4d'
}
}
const matchedTag = tags.value.find((item) => item.slug === slug)
return {
name: matchedTag?.name || String(slug).toUpperCase(),
color: matchedTag?.color || '#4d4d4d'
}
}
/**
* Latest 목록 데이터 변환
* @param {Object} post - API 게시물
* @returns {Object} 화면 표시 데이터
*/
const mapLatestPost = (post) => {
const primaryTagSlug = post.tags?.[0]
const tagMeta = getTagMeta(primaryTagSlug)
return {
title: post.title,
excerpt: createPostSummary(post.excerpt, '', {
maxLength: 320,
appendEllipsis: false
}),
featuredImage: post.featuredImage,
featuredImageThumbnail: post.featuredImageThumbnail || '',
tagName: tagMeta.name,
tagSlug: primaryTagSlug || '',
tagColor: tagMeta.color,
publishedAt: formatPostDate(post.publishedAt),
publishedAtIso: post.publishedAt || '',
to: `/post/${post.slug}`,
isFeatured: Boolean(post.isFeatured),
commentCount: Number(post.commentCount || 0)
}
}
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.listingImageThumbnail || 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,
dragThreshold: 10,
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 가로 스크롤 위치에 따라 이전/다음 버튼 상태를 갱신한다.
* @param {HTMLElement | null} el - 스크롤 컨테이너
* @returns {void}
*/
const updateFeaturedScrollEdges = (el) => {
const edges = getScrollEdges(el || featuredTrackRef.value)
featuredAtStart.value = edges.atStart
featuredAtEnd.value = edges.atEnd
}
/**
* 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
}
unbindPageShelfScroll(key)
delete pageShelfTrackRefs.value[key]
updatePageShelfScrollEdges(key, null)
}
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)
})
})
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
}
const storedStyle = localStorage.getItem(postFeedStyleStorageKey)
postFeedStyle.value = normalizePostFeedStyle(storedStyle)
randomizeHomeInfeedAdInsertIndex()
document.addEventListener('pointerdown', onDocumentPointerDown)
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
}
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 (direction === 'left' && featuredAtStart.value) {
return
}
if (direction === 'right' && featuredAtEnd.value) {
return
}
scrollCarouselTrack(featuredTrackRef.value, direction)
}
/**
* 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') {
return
}
if (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.dragThreshold = event.pointerType === 'mouse' ? 10 : 12
carouselDragState.moved = false
carouselDragTrack = track
bindCarouselDragDocumentEvents()
}
/**
* 가로 트랙 드래그 중 스크롤을 이동한다.
* @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 (!carouselDragState.moved && Math.abs(deltaX) >= carouselDragState.dragThreshold) {
carouselDragState.moved = true
if (track.setPointerCapture) {
track.setPointerCapture(event.pointerId)
}
}
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}
*/
const randomizeHomeInfeedAdInsertIndex = () => {
if (!hasHomeInfeedAd.value || latestPosts.value.length < 2) {
homeInfeedAdInsertIndex.value = -1
return
}
const minIndex = 0
const maxIndex = latestPosts.value.length - 2
homeInfeedAdInsertIndex.value = minIndex + Math.floor(Math.random() * (maxIndex - minIndex + 1))
}
watch([latestPosts, hasHomeInfeedAd], () => {
if (!import.meta.client) {
return
}
randomizeHomeInfeedAdInsertIndex()
})
</script>
<template>
<MainColumn>
<section v-if="siteSettings?.homeCoverImageUrl || siteSettings?.homeCoverDarkImageUrl" class="home-page__hero">
<HomeHero
:image-url="siteSettings.homeCoverImageUrl"
:dark-image-url="siteSettings.homeCoverDarkImageUrl"
:title="siteSettings.homeCoverTitle"
:text="siteSettings.homeCoverText"
/>
</section>
<section v-if="featuredPosts.length" class="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">
<h2 class="text-sm font-medium uppercase site-muted">Featured</h2>
<div class="flex justify-between gap-2">
<button
class="featured-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="Featured 이전"
:disabled="featuredAtStart"
@click="scrollFeatured('left')"
>
‹
</button>
<button
class="featured-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="Featured 다음"
:disabled="featuredAtEnd"
@click="scrollFeatured('right')"
>
›
</button>
</div>
</div>
<div
ref="featuredTrackRef"
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 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-carousel-slide
draggable="false"
>
<img
v-if="post.featuredImage"
:src="post.featuredImageThumbnail || post.featuredImage"
: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
class="post-card-media__placeholder flex h-full w-full items-center justify-center bg-[#F7F4EF] p-5 text-center text-sm font-medium leading-snug text-[var(--site-muted)] transition-all duration-200 group-hover:opacity-90"
:aria-label="post.title"
>
<span class="post-card-media__placeholder-text line-clamp-4">{{ post.title }}</span>
</div>
<h3 v-if="post.featuredImage" class="absolute right-0 bottom-2.5 left-0 px-3 text-sm font-medium leading-tight text-white line-clamp-2">
{{ post.title }}
</h3>
</NuxtLink>
</div>
</div>
</section>
<SiteAdSlot
class="home-page__ad-slot px-6 py-4"
:code="siteSettings?.adHomeFeedCode"
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 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-0.5 py-0.5 text-[10px] font-semibold text-white shadow-sm">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
</svg>
</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">
<h2 class="text-sm font-medium uppercase site-muted">Latest</h2>
<div class="relative flex flex-col gap-1 text-sm font-medium" data-feed-style-root>
<button
class="site-input relative flex cursor-pointer items-center justify-center gap-1 rounded-[10px] border px-2 py-1.5 pr-1.5 leading-none hover:bg-[var(--site-panel)]"
type="button"
aria-label="피드 보기 방식 선택"
data-feed-style-toggle
@click="postFeedStyleOpen = !postFeedStyleOpen"
>
<span class="pointer-events-none" v-show="postFeedStyle === 'list'">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6h11" />
<path d="M9 12h11" />
<path d="M9 18h11" />
<path d="M5 6v.01" />
<path d="M5 12v.01" />
<path d="M5 18v.01" />
</svg>
</span>
<span class="pointer-events-none" v-show="postFeedStyle === 'compact'">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 5h8" />
<path d="M13 9h5" />
<path d="M13 15h8" />
<path d="M13 19h5" />
<path d="M3 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5" />
<path d="M3 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
</svg>
</span>
<span class="pointer-events-none" v-show="postFeedStyle === 'cards'">
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6" />
<path d="M4 16a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2" />
</svg>
</span>
<span class="pointer-events-none opacity-75">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="m6 9 6 6 6-6" />
</svg>
</span>
</button>
<menu
class="absolute top-9 right-0 z-10 flex w-44 flex-col gap-0.5 rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-1.5 shadow transition-[transform,opacity,visibility,scale] duration-200"
:class="postFeedStyleOpen ? 'visible translate-y-0 scale-100 opacity-100' : 'invisible -translate-y-3 scale-95 opacity-0'"
>
<li class="w-full">
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('list'); closePostFeedStyleMenu()">
<span class="pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 6h11" />
<path d="M9 12h11" />
<path d="M9 18h11" />
<path d="M5 6v.01" />
<path d="M5 12v.01" />
<path d="M5 18v.01" />
</svg>
</span>
<span>List</span>
</button>
</li>
<li class="w-full">
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('compact'); closePostFeedStyleMenu()">
<span class="pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 5h8" />
<path d="M13 9h5" />
<path d="M13 15h8" />
<path d="M13 19h5" />
<path d="M3 5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V5" />
<path d="M3 15a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
</svg>
</span>
<span>Compact</span>
</button>
</li>
<li class="w-full">
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('cards'); closePostFeedStyleMenu()">
<span class="pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 6a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6" />
<path d="M4 16a2 2 0 0 1 2-2h12a2 2 0 0 1 2 2v2a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-2" />
</svg>
</span>
<span>Cards</span>
</button>
</li>
<li class="w-full">
<button class="flex w-full cursor-pointer items-center gap-1.5 rounded-[10px] px-2 py-1 hover:bg-[var(--site-panel)]" type="button" @click="setPostFeedStyle('compact'); closePostFeedStyleMenu()">
<span class="pointer-events-none">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M3.06 13a9 9 0 1 0 .49-4.087" />
<path d="M3 4.001v5h5" />
<path d="M11 12a1 1 0 1 0 2 0a1 1 0 1 0-2 0" />
</svg>
</span>
<span>Default</span>
</button>
</li>
</menu>
</div>
</div>
<div class="post-feed-section mb-8 flex flex-col">
<div
class="post-feed-section__list"
data-post-feed="latest"
:class="getPostFeedContainerClass(postFeedStyle)"
>
<template
v-for="(post, index) in latestPosts"
:key="post.to"
>
<article
data-post-card
:data-featured="post.isFeatured ? '' : undefined"
:class="getPostFeedArticleClass(postFeedStyle)"
>
<PostCardMedia
v-if="showPostFeedMedia"
:to="post.to"
:title="post.title"
:featured-image="post.featuredImage"
:thumbnail-image="post.featuredImageThumbnail"
:link-class="isPostFeedCards ? 'post-feed__media post-feed__media--cards mb-3 block aspect-video w-full' : 'post-feed__media post-feed__media--list relative h-20 w-20 shrink-0 self-start aspect-square sm:h-auto sm:w-auto sm:flex-1 sm:shrink sm:self-auto sm:min-w-16 sm:aspect-video'"
:aspect-class="isPostFeedCards ? 'aspect-video' : 'aspect-square sm:aspect-video'"
/>
<div
class="post-feed__content relative min-w-0"
:class="isPostFeedCards ? 'flex flex-col' : 'flex flex-[3] flex-col gap-1.5 md:flex-[4]'"
>
<div class="post-feed__content-inner flex min-h-0 flex-1 flex-col gap-1">
<h2 class="max-w-[90%] text-sm font-medium leading-tight">
<NuxtLink :to="post.to" class="post-title-link flex items-center">
<span v-if="post.isFeatured" class="post-feed__featured-icon mr-1 inline-flex h-4 w-4 items-center justify-center text-[var(--site-accent)]">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="currentColor" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M13 3v7h6l-8 11v-7H5l8-11" />
</svg>
</span>
{{ post.title }}
</NuxtLink>
</h2>
<p
v-if="post.excerpt"
class="text-[0.8rem] leading-tight site-muted text-[#6E6661]"
:class="getPostFeedExcerptClass(postFeedStyle)"
>
{{ post.excerpt }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
<time v-if="post.publishedAt" :datetime="post.publishedAtIso">{{ post.publishedAt }}</time>
<template v-if="post.tagName">
<span v-if="post.publishedAt" class="text-[var(--site-line)]">/</span>
<NuxtLink
v-if="post.tagSlug"
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
:style="{ backgroundColor: `${post.tagColor}1a` }"
:to="`/tag/${post.tagSlug}`"
>
{{ post.tagName }}
</NuxtLink>
<span
v-else
class="rounded-md px-1.5 py-px font-medium text-[var(--site-text)]"
:style="{ backgroundColor: `${post.tagColor}1a` }"
>
{{ post.tagName }}
</span>
</template>
<span v-if="post.publishedAt || post.tagName" class="text-[var(--site-line)]">/</span>
<span class="flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="-mt-px">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
<span>{{ post.commentCount }}</span>
</span>
</div>
</div>
<NuxtLink
class="absolute top-0 right-0 flex cursor-pointer items-center gap-1 hover:opacity-75"
:class="isPostFeedCards ? '' : 'md:top-auto md:right-0 md:bottom-0 md:invisible md:opacity-0 md:transition-[opacity,visibility] md:duration-200 md:group-hover:visible md:group-hover:opacity-100'"
:to="post.to"
aria-label="게시물 보기"
>
<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"
>
<path d="M17 7 7 17" />
<path d="M8 7h9v9" />
</svg>
</NuxtLink>
</div>
</article>
<SiteAdSlot
v-if="index === homeInfeedAdInsertIndex"
class="home-page__infeed-ad-slot py-3"
:class="isPostFeedCards ? 'sm:col-span-2' : ''"
:code="siteSettings?.adHomeInfeedCode"
location="home-infeed"
/>
</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>
</MainColumn>
</template>