Files
sori.studio/pages/index.vue

557 lines
22 KiB
Vue
Raw 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>
const { data: posts } = await useFetch('/api/posts', {
default: () => []
})
const { data: tags } = await useFetch('/api/tags', {
default: () => []
})
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({})
})
const postFeedStyleStorageKey = 'POST_FEED_STYLE'
const postFeedStyleOpen = ref(false)
const postFeedStyle = ref('compact')
/** @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--three'
}
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,
tagName: tagMeta.name,
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(() => posts.value.filter((post) => post.isFeatured).slice(0, 6))
const latestPosts = computed(() => posts.value.map(mapLatestPost))
const featuredTrackRef = ref(null)
/** Featured 트랙이 스크롤 시작에 붙었는지 — 이전 화살표 비활성 */
const featuredAtStart = ref(true)
/** Featured 트랙이 스크롤 끝에 붙었는지 — 다음 화살표 비활성 */
const featuredAtEnd = ref(true)
let unbindFeaturedScroll = () => {}
/**
* Featured 가로 스크롤 위치에 따라 이전/다음 버튼 상태를 갱신한다.
* @param {HTMLElement | null} el - 스크롤 컨테이너
* @returns {void}
*/
const updateFeaturedScrollEdges = (el) => {
const target = el || featuredTrackRef.value
if (!target) {
featuredAtStart.value = true
featuredAtEnd.value = true
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
}
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)
})
})
onMounted(() => {
if (!import.meta.client) {
return
}
const storedStyle = localStorage.getItem(postFeedStyleStorageKey)
postFeedStyle.value = normalizePostFeedStyle(storedStyle)
document.addEventListener('pointerdown', onDocumentPointerDown)
})
onBeforeUnmount(() => {
if (!import.meta.client) {
return
}
unbindFeaturedScroll()
document.removeEventListener('pointerdown', onDocumentPointerDown)
})
/**
* Featured 가로 트랙을 좌우로 이동한다.
* @param {'left' | 'right'} direction - 이동 방향
* @returns {void}
*/
const scrollFeatured = (direction) => {
if (!featuredTrackRef.value) {
return
}
if (direction === 'left' && featuredAtStart.value) {
return
}
if (direction === 'right' && featuredAtEnd.value) {
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
featuredTrackRef.value.scrollBy({
left: offset,
behavior: 'smooth'
})
}
</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 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"
>
<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
>
<img
v-if="post.featuredImage"
:src="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"
>
<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 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)"
>
<article
v-for="post in latestPosts"
:key="post.to"
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"
: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 flex-1 aspect-square 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="flex-1 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>
<span
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>
<button
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'"
type="button"
aria-label="Share this post"
>
<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>
</button>
</div>
</article>
</div>
</div>
</div>
</section>
</MainColumn>
</template>