feat(member): 회원 설정/헤더 상태 UI와 관리자 멤버 관리 추가

로그인 상태를 헤더에서 즉시 인지하고 계정 관리를 이어갈 수 있도록 사용자 설정과 관리자 멤버 관측 기능을 연결했다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-11 17:10:48 +09:00
parent 91573a31d6
commit f5cd73b223
34 changed files with 2093 additions and 107 deletions

View 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>

View File

@@ -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>