로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
306 lines
8.5 KiB
Vue
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>
|
|
|