v1.4.5: 게시물 작성자·편집 링크·목록 요약 보정

- posts.author_id 마이그레이션 및 owner/admin 단일 계정 환경에서만 기존 글 backfill
- 공개 상세: 글쓴이 본인일 때만 공유 옆 수정 링크 표시, 수정 시각 제거
- 목록 요약: excerpt 없을 때 본문 fallback, post-summary-clamp로 말줄임 처리
- 회원 세션 API에 isAdmin·role 추가
This commit is contained in:
2026-05-22 14:43:22 +09:00
parent 8f53210756
commit 38ca3a4709
16 changed files with 215 additions and 47 deletions

View File

@@ -88,14 +88,10 @@ const getPostFeedArticleClass = (style) => {
*/
const getPostFeedExcerptClass = (style) => {
if (style === 'list') {
return 'line-clamp-3'
return 'post-summary-clamp post-summary-clamp--three'
}
if (style === 'compact') {
return 'line-clamp-1'
}
return 'line-clamp-2'
return 'post-summary-clamp post-summary-clamp--two'
}
const closePostFeedStyleMenu = () => {
@@ -152,7 +148,10 @@ const mapLatestPost = (post) => {
return {
title: post.title,
excerpt: post.excerpt,
excerpt: createPostSummary(post.excerpt, post.content, {
maxLength: 320,
appendEllipsis: false
}),
featuredImage: post.featuredImage,
tagName: tagMeta.name,
tagColor: tagMeta.color,
@@ -479,7 +478,7 @@ const scrollFeatured = (direction) => {
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.5">
<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)]">
@@ -492,6 +491,7 @@ const scrollFeatured = (direction) => {
</h2>
<p
v-if="post.excerpt"
class="flex-1 text-[0.8rem] leading-tight site-muted text-[#6E6661]"
:class="getPostFeedExcerptClass(postFeedStyle)"
>

View File

@@ -13,9 +13,6 @@ const { data: tags } = await useFetch('/api/tags', {
const { data: posts } = await useFetch('/api/posts', {
default: () => []
})
const { data: siteSettings } = await useFetch('/api/site-settings', {
default: () => ({ showPostUpdatedAt: false })
})
if (!post.value) {
throw createError({
@@ -46,16 +43,19 @@ const primaryTagMeta = computed(() => {
})
const publishedAtLabel = computed(() => formatPostDate(post.value.publishedAt || null))
const updatedAtLabel = computed(() => {
if (!siteSettings.value?.showPostUpdatedAt || !wasPostUpdatedAfterPublish(post.value)) {
return ''
}
return `수정: ${formatPostDateTime(post.value.updatedAt)}`
})
const authorLabel = computed(() => 'sori.studio')
const shareModalOpen = ref(false)
const copyButtonLabel = ref('Copy link')
const currentMember = ref(null)
const currentAdmin = ref(null)
const canEditPost = computed(() => Boolean(
post.value?.authorId
&& (
currentMember.value?.id === post.value.authorId
|| currentAdmin.value?.userId === post.value.authorId
)
))
const postEditPath = computed(() => `/admin/posts/${post.value.id}`)
const currentIndex = computed(() => posts.value.findIndex((item) => item.slug === post.value.slug))
const previousPost = computed(() => {
@@ -79,11 +79,12 @@ const config = useRuntimeConfig()
const siteUrl = computed(() => String(config.public.siteUrl || '').replace(/\/$/g, ''))
const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
const ogImage = computed(() => post.value.featuredImage || '')
const postSummary = computed(() => createPostSummary(post.value.excerpt, post.value.content))
const seoDescription = computed(() => post.value.seoDescription || postSummary.value || 'sori.studio 개인 블로그')
const shareMetadata = computed(() => ({
title: post.value.title || 'sori.studio',
description: post.value.excerpt || 'sori.studio 개인 블로그',
description: seoDescription.value,
image: post.value.featuredImage || '',
url: pageUrl.value
}))
@@ -120,6 +121,28 @@ const shareLinks = computed(() => [
}
])
/**
* 현재 로그인 회원·관리자 정보를 불러온다.
* @returns {Promise<void>}
*/
const fetchCurrentViewer = async () => {
try {
currentMember.value = await $fetch('/api/auth/me')
} catch {
currentMember.value = null
}
try {
currentAdmin.value = await $fetch('/admin/api/auth/me')
} catch {
currentAdmin.value = null
}
}
onMounted(() => {
fetchCurrentViewer()
})
/**
* 절대 URL 생성
* @param {string} value - 원본 URL
@@ -244,10 +267,6 @@ useHead(() => ({
{{ publishedAtLabel }}
</time>
<time v-if="updatedAtLabel" :datetime="post.updatedAt" class="admin-post-detail__updated-at">
{{ updatedAtLabel }}
</time>
<a href="#" class="hover:opacity-75">
{{ authorLabel }}
</a>
@@ -272,11 +291,26 @@ useHead(() => ({
</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" data-post-share-toggle @click="openShareModal">
<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 class="absolute right-0 bottom-4 flex items-center gap-3">
<NuxtLink
v-if="canEditPost"
class="flex cursor-pointer items-center gap-1 hover:opacity-75"
:to="postEditPath"
target="_blank"
rel="noreferrer"
aria-label="Edit this post"
data-post-edit-link
>
<svg xmlns="http://www.w3.org/2000/svg" height="18" viewBox="0 -960 960 960" width="18" fill="currentColor" aria-hidden="true">
<path d="M200-200h57l391-391-57-57-391 391v57Zm-80 80v-170l528-527q12-11 26.5-17t30.5-6q16 0 31 6t26 18l55 56q12 11 17.5 26t5.5 30q0 16-5.5 30.5T817-647L290-120H120Zm640-584-56-56 56 56Zm-141 85-28-29 57 57-29-28Z" />
</svg>
</NuxtLink>
<button class="flex cursor-pointer items-center gap-1 hover:opacity-75" type="button" aria-label="Share this post" data-post-share-toggle @click="openShareModal">
<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>
</div>
<figure v-if="post.featuredImage" class="relative mt-2.5 w-full">

View File

@@ -5,7 +5,10 @@ const { data: posts } = await useFetch('/api/posts', {
const postCards = computed(() => posts.value.map((post) => ({
title: post.title,
excerpt: post.excerpt,
excerpt: createPostSummary(post.excerpt, post.content, {
maxLength: 320,
appendEllipsis: false
}),
featuredImage: post.featuredImage,
tag: post.tags?.[0] ? String(post.tags[0]).toUpperCase() : '',
publishedAt: formatPostDate(post.publishedAt),

View File

@@ -16,7 +16,10 @@ const tagPosts = computed(() => posts.value
.filter((post) => post.tags.includes(slug.value))
.map((post) => ({
title: post.title,
excerpt: post.excerpt,
excerpt: createPostSummary(post.excerpt, post.content, {
maxLength: 320,
appendEllipsis: false
}),
featuredImage: post.featuredImage,
tag: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.name || (post.tags?.[0] || slug.value).toUpperCase(),
tagColor: tags.value.find((item) => item.slug === (post.tags?.[0] || slug.value))?.color || '#4d4d4d',
@@ -72,7 +75,10 @@ const tagPosts = computed(() => posts.value
{{ post.title }}
</NuxtLink>
</h2>
<p class="flex-1 line-clamp-2 text-[0.8rem] leading-tight site-muted">
<p
v-if="post.excerpt"
class="post-summary-clamp post-summary-clamp--two flex-1 text-[0.8rem] leading-tight site-muted"
>
{{ post.excerpt }}
</p>
<div class="flex flex-wrap items-center gap-2 text-xs site-muted sm:gap-1.5">