1129 lines
40 KiB
Vue
1129 lines
40 KiB
Vue
<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: () => []
|
||
})
|
||
|
||
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, post.content, {
|
||
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' && 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 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-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 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-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="flex items-center transition-opacity duration-200 hover:opacity-75">
|
||
<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>
|