v0.0.46 공개 화면 피드/포스트 UI 정리
Latest 보기 방식 토글과 아이콘을 SVG 기반으로 정리하고, 게시물 상세 헤더를 Thred 패턴으로 재구성했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -7,6 +7,12 @@ const route = useRoute()
|
||||
const slug = computed(() => String(route.params.slug || ''))
|
||||
|
||||
const { data: post } = await useFetch(() => `/api/posts/${slug.value}`)
|
||||
const { data: tags } = await useFetch('/api/tags', {
|
||||
default: () => []
|
||||
})
|
||||
const { data: posts } = await useFetch('/api/posts', {
|
||||
default: () => []
|
||||
})
|
||||
|
||||
if (!post.value) {
|
||||
throw createError({
|
||||
@@ -15,7 +21,55 @@ if (!post.value) {
|
||||
})
|
||||
}
|
||||
|
||||
const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST')
|
||||
/**
|
||||
* 게시물 날짜 표시 형식 변환 (Thred 참고)
|
||||
* @param {string | null} value - ISO 날짜 문자열
|
||||
* @returns {string} 화면 표시 날짜
|
||||
*/
|
||||
const formatPostDate = (value) => {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric'
|
||||
}).format(new Date(value))
|
||||
}
|
||||
|
||||
const primaryTagSlug = computed(() => post.value.tags?.[0] || '')
|
||||
const primaryTagMeta = computed(() => {
|
||||
const matchedTag = tags.value.find((item) => item.slug === primaryTagSlug.value)
|
||||
|
||||
return {
|
||||
name: matchedTag?.name || (primaryTagSlug.value ? primaryTagSlug.value.toUpperCase() : 'POST'),
|
||||
color: matchedTag?.color || '#4d4d4d',
|
||||
to: primaryTagSlug.value ? `/tag/${primaryTagSlug.value}` : '/tags'
|
||||
}
|
||||
})
|
||||
|
||||
const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null))
|
||||
const authorLabel = computed(() => 'sori.studio')
|
||||
|
||||
const currentIndex = computed(() => posts.value.findIndex((item) => item.slug === post.value.slug))
|
||||
const previousPost = computed(() => {
|
||||
if (currentIndex.value <= 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = posts.value[currentIndex.value - 1]
|
||||
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
||||
})
|
||||
const nextPost = computed(() => {
|
||||
if (currentIndex.value === -1 || currentIndex.value >= posts.value.length - 1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = posts.value[currentIndex.value + 1]
|
||||
return candidate ? { title: candidate.title, to: `/post/${candidate.slug}` } : null
|
||||
})
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const siteUrl = computed(() => String(config.public.siteUrl || '').replace(/\/$/g, ''))
|
||||
const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
|
||||
@@ -86,16 +140,107 @@ useHead(() => ({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ContentRenderer>
|
||||
<ProseHeaderCard>
|
||||
<p class="post-detail__eyebrow text-sm uppercase text-white/70">
|
||||
{{ postTag }}
|
||||
</p>
|
||||
<h1 class="post-detail__title mt-3 text-4xl font-semibold leading-tight">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
</ProseHeaderCard>
|
||||
<div class="post-detail">
|
||||
<section class="px-4 mt-6 mb-8 sm:px-5">
|
||||
<div class="mx-auto flex max-w-[720px] flex-col gap-2.5">
|
||||
<h1 class="text-xl font-semibold leading-[1.125] sm:text-2xl">
|
||||
{{ post.title }}
|
||||
</h1>
|
||||
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
||||
</ContentRenderer>
|
||||
<div class="relative border-b border-[var(--site-line)] pb-4">
|
||||
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">
|
||||
<time v-if="publishedAtLabel" :datetime="post.publishedAt">
|
||||
{{ publishedAtLabel }}
|
||||
</time>
|
||||
|
||||
<a href="#" class="hover:opacity-75">
|
||||
{{ authorLabel }}
|
||||
</a>
|
||||
|
||||
<ul class="flex flex-wrap items-center font-medium">
|
||||
<li v-if="primaryTagMeta.name" :style="{ '--color-accent': primaryTagMeta.color }">
|
||||
<NuxtLink
|
||||
class="rounded-sm px-1.5 py-px text-[var(--site-text)] hover:opacity-75"
|
||||
:style="{ backgroundColor: `${primaryTagMeta.color}1a` }"
|
||||
:to="primaryTagMeta.to"
|
||||
>
|
||||
{{ primaryTagMeta.name }}
|
||||
</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<a class="flex items-center gap-0.75 hover:opacity-75" :href="`${pageUrl}#comments`">
|
||||
<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 class="pointer-events-none">0</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<button class="absolute right-0 bottom-4 flex cursor-pointer items-center gap-1 hover:opacity-75" type="button" aria-label="Share this post">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M13 4v4c-6.575 1.028-9.02 6.788-10 12c-.037.206 5.384-5.962 10-6v4l8-7-8-7z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<figure v-if="post.featuredImage" class="relative mt-2.5 w-full">
|
||||
<img
|
||||
class="aspect-video w-full rounded-[10px] object-cover bg-[var(--site-panel-strong)]"
|
||||
:src="post.featuredImage"
|
||||
:alt="post.title"
|
||||
loading="eager"
|
||||
>
|
||||
</figure>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="px-4 sm:px-5">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<ContentRenderer>
|
||||
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
|
||||
</ContentRenderer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="comments" class="mb-6 border-y border-[var(--site-line)] bg-[var(--site-panel-strong)] px-5 py-5 scroll-mt-14 sm:px-6">
|
||||
<div class="mx-auto max-w-[720px] text-sm">
|
||||
<p class="font-medium">Comments</p>
|
||||
<p class="mt-2 site-muted">
|
||||
댓글 UI는 추후 연결 예정입니다.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="mb-6 px-5 sm:px-6" aria-label="Previous and next post">
|
||||
<div class="mx-auto max-w-[720px]">
|
||||
<div class="grid gap-4 text-sm font-medium leading-tight md:grid-cols-2">
|
||||
<NuxtLink v-if="previousPost" :to="previousPost.to" class="flex flex-col items-start gap-1 hover:opacity-75">
|
||||
<span class="flex items-center gap-1 text-[0.7rem] font-medium uppercase opacity-75 site-muted">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M15 6 9 12l6 6" />
|
||||
</svg>
|
||||
Previous post
|
||||
</span>
|
||||
<h3 class="ml-4 text-left">
|
||||
{{ previousPost.title }}
|
||||
</h3>
|
||||
</NuxtLink>
|
||||
<div v-else />
|
||||
|
||||
<NuxtLink v-if="nextPost" :to="nextPost.to" class="flex flex-col items-end gap-1 hover:opacity-75">
|
||||
<span class="flex items-center gap-1 text-[0.7rem] font-medium uppercase opacity-75 site-muted">
|
||||
Next post
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="m9 6 6 6-6 6" />
|
||||
</svg>
|
||||
</span>
|
||||
<h3 class="mr-4 text-right">
|
||||
{{ nextPost.title }}
|
||||
</h3>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user