feat(member): 회원 설정/헤더 상태 UI와 관리자 멤버 관리 추가
로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
305
components/comments/PostComments.vue
Normal file
305
components/comments/PostComments.vue
Normal file
@@ -0,0 +1,305 @@
|
||||
<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>
|
||||
|
||||
@@ -4,6 +4,7 @@ const menuUserOpen = ref(false)
|
||||
const userMenuRef = ref(null)
|
||||
const userMenuToggleRef = ref(null)
|
||||
const searchOpen = ref(false)
|
||||
const member = ref(null)
|
||||
|
||||
const { data: siteSettings } = await useFetch('/api/site-settings', {
|
||||
default: () => ({
|
||||
@@ -58,6 +59,31 @@ const toggleUserMenu = () => {
|
||||
menuUserOpen.value = !menuUserOpen.value
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 세션 정보를 조회한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMember = async () => {
|
||||
try {
|
||||
member.value = await $fetch('/api/auth/me')
|
||||
} catch {
|
||||
member.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 회원 로그아웃을 처리한다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const logoutMember = async () => {
|
||||
await $fetch('/api/auth/logout', {
|
||||
method: 'POST'
|
||||
})
|
||||
member.value = null
|
||||
closeUserMenu()
|
||||
await navigateTo('/')
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 클릭 시 사용자 메뉴 외부 영역이면 메뉴를 닫는다.
|
||||
* @param {MouseEvent} event - 클릭 이벤트
|
||||
@@ -113,6 +139,7 @@ const onGlobalKeydown = (event) => {
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchMember()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
document.addEventListener('keydown', onGlobalKeydown)
|
||||
})
|
||||
@@ -170,9 +197,6 @@ onBeforeUnmount(() => {
|
||||
<span class="site-header__search-key ml-auto rounded-md px-2 text-xs site-soft site-input">/</span>
|
||||
</button>
|
||||
<nav class="site-header__nav flex shrink-0 items-center gap-3 text-sm sm:gap-3.5">
|
||||
<NuxtLink class="site-header__buy site-accent-button shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold sm:px-4 sm:py-2 sm:text-sm" to="/pages/about">
|
||||
Subscribe
|
||||
</NuxtLink>
|
||||
<div class="site-header__user-menu relative">
|
||||
<button
|
||||
ref="userMenuToggleRef"
|
||||
@@ -182,11 +206,15 @@ onBeforeUnmount(() => {
|
||||
:aria-expanded="menuUserOpen.toString()"
|
||||
@click="toggleUserMenu"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" class="h-6 w-6 stroke-current stroke-[1.75] md:h-7 md:w-7 md:stroke-[1.5]">
|
||||
<path d="M12 12m-9 0a9 9 0 1 0 18 0a9 9 0 1 0 -18 0" />
|
||||
<path d="M12 10m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0" />
|
||||
<path d="M6.168 18.849a4 4 0 0 1 3.832 -2.849h4a4 4 0 0 1 3.834 2.855" />
|
||||
</svg>
|
||||
<img
|
||||
v-if="member?.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username || '회원 아바타'"
|
||||
class="h-full w-full rounded-full object-cover"
|
||||
>
|
||||
<span v-else class="grid h-full w-full place-items-center rounded-full bg-[var(--site-panel)] text-[11px] font-semibold">
|
||||
{{ (member?.username || member?.email || '@').slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Transition
|
||||
@@ -204,30 +232,62 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="mb-2 flex items-center gap-2 border-b border-[var(--site-line)] pb-3">
|
||||
<div class="site-header__avatar-wrap flex h-8 w-8 items-center justify-center overflow-hidden rounded-full bg-[var(--site-panel)] md:h-10 md:w-10">
|
||||
<span class="text-base font-normal uppercase md:text-lg">@</span>
|
||||
<img
|
||||
v-if="member?.avatarUrl"
|
||||
:src="member.avatarUrl"
|
||||
:alt="member.username || '회원 아바타'"
|
||||
class="h-full w-full object-cover"
|
||||
>
|
||||
<span v-else class="text-base font-normal uppercase md:text-lg">
|
||||
{{ (member?.username || member?.email || '@').slice(0, 1) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<div class="max-w-xs truncate leading-[1.15]">Anonymous</div>
|
||||
<div class="max-w-xs truncate leading-[1.15]">
|
||||
{{ member?.username || 'Anonymous' }}
|
||||
</div>
|
||||
<div v-if="member?.email" class="max-w-xs truncate text-xs site-muted">
|
||||
{{ member.email }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M15 15v-6h-6" />
|
||||
</svg>
|
||||
<span>Sign up</span>
|
||||
</NuxtLink>
|
||||
<template v-if="member">
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/settings" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M12 15.5A3.5 3.5 0 1 0 12 8.5a3.5 3.5 0 0 0 0 7Z" />
|
||||
<path d="M19.4 15a1.7 1.7 0 0 0 .34 1.87l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06A1.7 1.7 0 0 0 15 19.4a1.7 1.7 0 0 0-1 .6 1.7 1.7 0 0 1-2 0 1.7 1.7 0 0 0-1-.6 1.7 1.7 0 0 0-1.87.34l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06A1.7 1.7 0 0 0 4.6 15a1.7 1.7 0 0 0-.6-1 1.7 1.7 0 0 1 0-2 1.7 1.7 0 0 0 .6-1 1.7 1.7 0 0 0-.34-1.87l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06A1.7 1.7 0 0 0 9 4.6a1.7 1.7 0 0 0 1-.6 1.7 1.7 0 0 1 2 0 1.7 1.7 0 0 0 1 .6 1.7 1.7 0 0 0 1.87-.34l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06A1.7 1.7 0 0 0 19.4 9c.24.36.48.69.6 1 .18.45.18 1.55 0 2-.12.31-.36.64-.6 1Z" />
|
||||
</svg>
|
||||
<span>설정</span>
|
||||
</NuxtLink>
|
||||
<button class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 text-left transition-colors duration-150 hover:bg-[var(--site-panel)]" type="button" @click="logoutMember">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>로그아웃</span>
|
||||
</button>
|
||||
</template>
|
||||
<template v-else>
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signup/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M3 12a9 9 0 1 0 18 0a9 9 0 0 0 -18 0" />
|
||||
<path d="M15 9l-6 6" />
|
||||
<path d="M15 15v-6h-6" />
|
||||
</svg>
|
||||
<span>Sign up</span>
|
||||
</NuxtLink>
|
||||
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
</NuxtLink>
|
||||
<NuxtLink class="site-header__user-link flex items-center gap-1.5 rounded-[8px] px-2.5 py-1.5 transition-colors duration-150 hover:bg-[var(--site-panel)]" to="/signin/" @click="closeUserMenu">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="h-5 w-5">
|
||||
<path d="M15 8v-2a2 2 0 0 0 -2 -2h-7a2 2 0 0 0 -2 2v12a2 2 0 0 0 2 2h7a2 2 0 0 0 2 -2v-2" />
|
||||
<path d="M21 12h-13l3 -3" />
|
||||
<path d="M11 15l-3 -3" />
|
||||
</svg>
|
||||
<span>Sign in</span>
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user