Files
sori.studio/components/comments/PostComments.vue
zenn f5cd73b223 feat(member): 회원 설정/헤더 상태 UI와 관리자 멤버 관리 추가
로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-11 17:10:48 +09:00

306 lines
8.5 KiB
Vue

<script setup>
const props = defineProps({
slug: {
type: String,
required: true
}
})
const comments = ref([])
const member = ref(null)
const loadingComments = ref(false)
const submitting = ref(false)
const submittingReplyId = ref('')
const errorMessage = ref('')
const replyErrorMessage = ref('')
const newCommentBody = ref('')
const replyBody = ref('')
const activeReplyTargetId = ref('')
/**
* ISO 문자열을 표시용 날짜 문자열로 변환한다.
* @param {string} value - ISO 날짜 문자열
* @returns {string} 표시용 문자열
*/
const formatCommentDate = (value) => {
if (!value) {
return ''
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return ''
}
return date.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
})
}
/**
* 회원 세션을 조회한다.
* @returns {Promise<void>}
*/
const fetchMember = async () => {
try {
member.value = await $fetch('/api/auth/me')
} catch {
member.value = null
}
}
/**
* 댓글 목록을 조회한다.
* @returns {Promise<void>}
*/
const fetchComments = async () => {
loadingComments.value = true
errorMessage.value = ''
try {
const response = await $fetch(`/api/posts/${props.slug}/comments`)
comments.value = response.comments || []
} catch (error) {
comments.value = []
errorMessage.value = error?.data?.message || '댓글을 불러오지 못했습니다.'
} finally {
loadingComments.value = false
}
}
/**
* 루트 댓글을 작성한다.
* @returns {Promise<void>}
*/
const submitComment = async () => {
const body = newCommentBody.value.trim()
if (!body || submitting.value) {
return
}
submitting.value = true
errorMessage.value = ''
try {
await $fetch(`/api/posts/${props.slug}/comments`, {
method: 'POST',
body: {
body
}
})
newCommentBody.value = ''
await fetchComments()
} catch (error) {
errorMessage.value = error?.data?.message || '댓글 작성에 실패했습니다.'
} finally {
submitting.value = false
}
}
/**
* 답글 작성 UI를 연다.
* @param {string} commentId - 대상 댓글 ID
* @returns {void}
*/
const openReplyForm = (commentId) => {
activeReplyTargetId.value = commentId
replyBody.value = ''
replyErrorMessage.value = ''
}
/**
* 답글 작성 UI를 닫는다.
* @returns {void}
*/
const closeReplyForm = () => {
activeReplyTargetId.value = ''
replyBody.value = ''
replyErrorMessage.value = ''
}
/**
* 대댓글을 작성한다.
* @param {string} parentId - 부모 댓글 ID
* @returns {Promise<void>}
*/
const submitReply = async (parentId) => {
const body = replyBody.value.trim()
if (!body || submittingReplyId.value) {
return
}
submittingReplyId.value = parentId
replyErrorMessage.value = ''
try {
await $fetch(`/api/posts/${props.slug}/comments`, {
method: 'POST',
body: {
body,
parentId
}
})
closeReplyForm()
await fetchComments()
} catch (error) {
replyErrorMessage.value = error?.data?.message || '답글 작성에 실패했습니다.'
} finally {
submittingReplyId.value = ''
}
}
const rootComments = computed(() => comments.value.filter((item) => !item.parentId))
const repliesByParent = computed(() => {
/** @type {Record<string, Array<any>>} */
const grouped = {}
for (const item of comments.value) {
if (!item.parentId) {
continue
}
if (!grouped[item.parentId]) {
grouped[item.parentId] = []
}
grouped[item.parentId].push(item)
}
return grouped
})
onMounted(async () => {
await Promise.all([fetchMember(), fetchComments()])
})
</script>
<template>
<div class="post-comments text-sm">
<div class="flex items-center justify-between gap-2">
<p class="font-medium">Comments</p>
<span class="site-muted">{{ comments.length }}</span>
</div>
<div class="mt-4">
<div v-if="member" class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
<p class="mb-2 text-xs site-muted">
{{ member.username || member.email }} 님으로 댓글 작성
</p>
<textarea
v-model="newCommentBody"
rows="4"
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
placeholder="댓글을 입력해 주세요."
/>
<div class="mt-2 flex justify-end">
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="submitting"
@click="submitComment"
>
{{ submitting ? '등록 중...' : '댓글 등록' }}
</button>
</div>
</div>
<div v-else class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
<p class="site-muted">
댓글은 로그인한 회원만 작성할 있습니다.
</p>
<NuxtLink to="/signin" class="mt-2 inline-flex rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs font-semibold hover:opacity-80">
로그인하러 가기
</NuxtLink>
</div>
</div>
<p v-if="errorMessage" class="mt-3 text-xs text-red-500">
{{ errorMessage }}
</p>
<div class="mt-5">
<p v-if="loadingComments" class="text-xs site-muted">
댓글을 불러오는 중입니다.
</p>
<ul v-else-if="rootComments.length > 0" class="flex flex-col gap-3">
<li
v-for="comment in rootComments"
:key="comment.id"
class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3"
>
<div class="flex flex-wrap items-center gap-2">
<strong class="text-sm">{{ comment.user.username }}</strong>
<span class="text-xs site-muted">{{ formatCommentDate(comment.createdAt) }}</span>
</div>
<p class="mt-2 whitespace-pre-line leading-relaxed">
{{ comment.body }}
</p>
<div class="mt-2 flex items-center gap-2">
<button
v-if="member"
type="button"
class="text-xs site-muted hover:opacity-75"
@click="openReplyForm(comment.id)"
>
답글
</button>
</div>
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
<textarea
v-model="replyBody"
rows="3"
class="w-full rounded-[10px] border border-[var(--site-line)] bg-transparent px-3 py-2 outline-none focus-visible:border-[var(--site-accent)]"
placeholder="답글을 입력해 주세요."
/>
<p v-if="replyErrorMessage" class="mt-2 text-xs text-red-500">
{{ replyErrorMessage }}
</p>
<div class="mt-2 flex justify-end gap-2">
<button type="button" class="rounded-[10px] border border-[var(--site-line)] px-3 py-1.5 text-xs" @click="closeReplyForm">
취소
</button>
<button
type="button"
class="site-accent-button rounded-[10px] px-3 py-1.5 text-xs font-semibold disabled:opacity-60"
:disabled="submittingReplyId === comment.id"
@click="submitReply(comment.id)"
>
{{ submittingReplyId === comment.id ? '등록 중...' : '답글 등록' }}
</button>
</div>
</div>
<ul
v-if="repliesByParent[comment.id]?.length"
class="mt-3 flex flex-col gap-2 border-l border-[var(--site-line)] pl-3"
>
<li
v-for="reply in repliesByParent[comment.id]"
:key="reply.id"
class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-panel)] p-2.5"
>
<div class="flex flex-wrap items-center gap-2">
<strong class="text-sm">{{ reply.user.username }}</strong>
<span class="text-xs site-muted">{{ formatCommentDate(reply.createdAt) }}</span>
</div>
<p class="mt-1 whitespace-pre-line leading-relaxed">
{{ reply.body }}
</p>
</li>
</ul>
</li>
</ul>
<p v-else class="text-xs site-muted">
댓글을 남겨보세요.
</p>
</div>
</div>
</template>