태그를 관리용/일반용으로 분리하고 관리자 드래그 정렬을 추가.
댓글/회원/관리자 인증·프로필 흐름 보완과 관련 마이그레이션 및 문서를 함께 반영해 운영 동선을 안정화. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,14 +11,17 @@ const member = ref(null)
|
||||
const loadingComments = ref(false)
|
||||
const submitting = ref(false)
|
||||
const submittingReplyId = ref('')
|
||||
const likingCommentIds = ref([])
|
||||
const errorMessage = ref('')
|
||||
const replyErrorMessage = ref('')
|
||||
const newCommentBody = ref('')
|
||||
const replyBody = ref('')
|
||||
const activeReplyTargetId = ref('')
|
||||
const sortOption = ref('best')
|
||||
const brokenAvatarCommentIds = ref([])
|
||||
|
||||
/**
|
||||
* ISO 문자열을 표시용 날짜 문자열로 변환한다.
|
||||
* 댓글 시간을 상대 시간 형식으로 변환한다.
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {string} 표시용 문자열
|
||||
*/
|
||||
@@ -32,13 +35,74 @@ const formatCommentDate = (value) => {
|
||||
return ''
|
||||
}
|
||||
|
||||
return date.toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
const now = Date.now()
|
||||
const diffMs = now - date.getTime()
|
||||
|
||||
if (diffMs < 60 * 1000) {
|
||||
return '방금 전'
|
||||
}
|
||||
|
||||
if (diffMs < 60 * 60 * 1000) {
|
||||
return `${Math.max(1, Math.floor(diffMs / (60 * 1000)))}분 전`
|
||||
}
|
||||
|
||||
if (diffMs < 24 * 60 * 60 * 1000) {
|
||||
return `${Math.max(1, Math.floor(diffMs / (60 * 60 * 1000)))}시간 전`
|
||||
}
|
||||
|
||||
const sameYear = date.getFullYear() === new Date(now).getFullYear()
|
||||
return date.toLocaleDateString('ko-KR', sameYear
|
||||
? { month: 'short', day: 'numeric' }
|
||||
: { year: 'numeric', month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 작성자 아바타 이니셜을 생성한다.
|
||||
* @param {{ username?: string, email?: string }} user - 작성자 정보
|
||||
* @returns {string} 아바타 이니셜
|
||||
*/
|
||||
const getAvatarInitials = (user) => {
|
||||
const baseText = String(user?.username || user?.email || '').trim()
|
||||
if (!baseText) {
|
||||
return '@'
|
||||
}
|
||||
const tokens = baseText.split(/\s+/g).filter(Boolean)
|
||||
if (tokens.length >= 2) {
|
||||
return `${tokens[0].slice(0, 1)}${tokens[1].slice(0, 1)}`.toUpperCase()
|
||||
}
|
||||
return baseText.slice(0, 2).toUpperCase()
|
||||
}
|
||||
|
||||
/**
|
||||
* 댓글 작성 시각 숫자값 반환
|
||||
* @param {string} value - ISO 날짜 문자열
|
||||
* @returns {number} 시간 숫자값
|
||||
*/
|
||||
const toTimeValue = (value) => {
|
||||
const date = new Date(value)
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return 0
|
||||
}
|
||||
return date.getTime()
|
||||
}
|
||||
|
||||
/**
|
||||
* 아바타 이미지 깨짐 여부 확인
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {boolean} 깨짐 여부
|
||||
*/
|
||||
const isAvatarBroken = (commentId) => brokenAvatarCommentIds.value.includes(commentId)
|
||||
|
||||
/**
|
||||
* 아바타 이미지 로드 실패를 기록한다.
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {void}
|
||||
*/
|
||||
const markAvatarBroken = (commentId) => {
|
||||
if (isAvatarBroken(commentId)) {
|
||||
return
|
||||
}
|
||||
brokenAvatarCommentIds.value = [...brokenAvatarCommentIds.value, commentId]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -153,7 +217,68 @@ const submitReply = async (parentId) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 댓글의 좋아요 요청 진행 여부
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {boolean} 진행 여부
|
||||
*/
|
||||
const isLikingComment = (commentId) => likingCommentIds.value.includes(commentId)
|
||||
|
||||
/**
|
||||
* 댓글 좋아요를 토글한다.
|
||||
* @param {string} commentId - 댓글 ID
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const toggleLike = async (commentId) => {
|
||||
if (!member.value || isLikingComment(commentId)) {
|
||||
return
|
||||
}
|
||||
|
||||
likingCommentIds.value = [...likingCommentIds.value, commentId]
|
||||
errorMessage.value = ''
|
||||
|
||||
try {
|
||||
const result = await $fetch(`/api/posts/${props.slug}/comments/${commentId}/like`, {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
comments.value = comments.value.map((item) => {
|
||||
if (item.id !== commentId) {
|
||||
return item
|
||||
}
|
||||
return {
|
||||
...item,
|
||||
likeCount: Number(result.likeCount || 0),
|
||||
likedByMe: Boolean(result.liked)
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
errorMessage.value = error?.data?.message || '좋아요 처리에 실패했습니다.'
|
||||
} finally {
|
||||
likingCommentIds.value = likingCommentIds.value.filter((id) => id !== commentId)
|
||||
}
|
||||
}
|
||||
|
||||
const rootComments = computed(() => comments.value.filter((item) => !item.parentId))
|
||||
const sortedRootComments = computed(() => {
|
||||
const copied = [...rootComments.value]
|
||||
|
||||
if (sortOption.value === 'latest') {
|
||||
return copied.sort((left, right) => toTimeValue(right.createdAt) - toTimeValue(left.createdAt))
|
||||
}
|
||||
|
||||
if (sortOption.value === 'oldest') {
|
||||
return copied.sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||
}
|
||||
|
||||
return copied.sort((left, right) => {
|
||||
const likeDiff = Number(right.likeCount || 0) - Number(left.likeCount || 0)
|
||||
if (likeDiff !== 0) {
|
||||
return likeDiff
|
||||
}
|
||||
return toTimeValue(left.createdAt) - toTimeValue(right.createdAt)
|
||||
})
|
||||
})
|
||||
const repliesByParent = computed(() => {
|
||||
/** @type {Record<string, Array<any>>} */
|
||||
const grouped = {}
|
||||
@@ -168,6 +293,10 @@ const repliesByParent = computed(() => {
|
||||
grouped[item.parentId].push(item)
|
||||
}
|
||||
|
||||
for (const parentId of Object.keys(grouped)) {
|
||||
grouped[parentId] = grouped[parentId].sort((left, right) => toTimeValue(left.createdAt) - toTimeValue(right.createdAt))
|
||||
}
|
||||
|
||||
return grouped
|
||||
})
|
||||
|
||||
@@ -179,12 +308,25 @@ onMounted(async () => {
|
||||
<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>
|
||||
<p class="font-medium"><span class="site-muted">{{ comments.length }}</span> Comments</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-2 text-xs site-muted">
|
||||
<label for="comment-sort">Sort by:</label>
|
||||
<select
|
||||
id="comment-sort"
|
||||
v-model="sortOption"
|
||||
class="rounded-md border border-[var(--site-line)] bg-transparent px-2 py-1 text-xs font-semibold text-[var(--site-ink)] outline-none"
|
||||
>
|
||||
<option value="best">Best</option>
|
||||
<option value="latest">Latest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mt-4">
|
||||
<div v-if="member" class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3">
|
||||
<div v-if="member" class="rounded-[10px] p-3">
|
||||
<p class="mb-2 text-xs site-muted">
|
||||
{{ member.username || member.email }} 님으로 댓글 작성
|
||||
</p>
|
||||
@@ -225,29 +367,85 @@ onMounted(async () => {
|
||||
댓글을 불러오는 중입니다.
|
||||
</p>
|
||||
|
||||
<ul v-else-if="rootComments.length > 0" class="flex flex-col gap-3">
|
||||
<ul v-else-if="sortedRootComments.length > 0" class="flex flex-col divide-y divide-[var(--site-line)]">
|
||||
<li
|
||||
v-for="comment in rootComments"
|
||||
v-for="comment in sortedRootComments"
|
||||
:key="comment.id"
|
||||
class="rounded-[10px] border border-[var(--site-line)] bg-[var(--site-bg)] p-3"
|
||||
class="py-4"
|
||||
>
|
||||
<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="flex gap-3">
|
||||
<div class="flex w-8 flex-none flex-col items-center">
|
||||
<div class="h-8 w-8 min-h-8 min-w-8 overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-panel)]">
|
||||
<img
|
||||
v-if="comment.user.avatarUrl && !isAvatarBroken(comment.id)"
|
||||
:src="comment.user.avatarUrl"
|
||||
:alt="`${comment.user.username} 아바타`"
|
||||
class="block h-full w-full object-cover"
|
||||
@error="markAvatarBroken(comment.id)"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||
>
|
||||
{{ getAvatarInitials(comment.user) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<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 class="mt-2 flex items-center gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||
:disabled="!member || isLikingComment(comment.id)"
|
||||
@click="toggleLike(comment.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||
:fill="comment.likedByMe ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ comment.likeCount || 0 }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="member"
|
||||
type="button"
|
||||
class="group inline-flex items-center gap-1 site-muted hover:opacity-75"
|
||||
@click="openReplyForm(comment.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m1.5 7 4.7-4.2v2.6c4.7 0 8 2 8.3 6.5-1.5-2.3-3.3-3.1-8.3-3.1v2.4L1.5 7Z"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
답글
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeReplyTargetId === comment.id" class="mt-2 rounded-[10px] border border-[var(--site-line)] p-2">
|
||||
@@ -277,20 +475,63 @@ onMounted(async () => {
|
||||
|
||||
<ul
|
||||
v-if="repliesByParent[comment.id]?.length"
|
||||
class="mt-3 flex flex-col gap-2 border-l border-[var(--site-line)] pl-3"
|
||||
class="mt-3 ml-4 flex flex-col gap-3 border-l border-[var(--site-line)]/90 pl-4"
|
||||
>
|
||||
<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"
|
||||
class="relative rounded-[10px] 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>
|
||||
<span class="absolute -left-4 top-5 h-px w-3 bg-[var(--site-line)]/90" />
|
||||
<div class="flex gap-2.5">
|
||||
<div class="h-8 w-8 min-h-8 min-w-8 flex-none overflow-hidden rounded-full border border-[var(--site-line)] bg-[var(--site-bg)]">
|
||||
<img
|
||||
v-if="reply.user.avatarUrl && !isAvatarBroken(reply.id)"
|
||||
:src="reply.user.avatarUrl"
|
||||
:alt="`${reply.user.username} 아바타`"
|
||||
class="block h-full w-full object-cover"
|
||||
@error="markAvatarBroken(reply.id)"
|
||||
>
|
||||
<span
|
||||
v-else
|
||||
class="grid h-full w-full place-items-center text-[11px] font-semibold site-muted"
|
||||
>
|
||||
{{ getAvatarInitials(reply.user) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<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>
|
||||
<div class="mt-1.5 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 site-muted hover:opacity-75 disabled:opacity-50"
|
||||
:disabled="!member || isLikingComment(reply.id)"
|
||||
@click="toggleLike(reply.id)"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
class="h-3.5 w-3.5"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M8 13.2 2.9 8.1a3.4 3.4 0 0 1 0-4.8 3.4 3.4 0 0 1 4.8 0L8 3.6l.3-.3a3.4 3.4 0 0 1 4.8 4.8L8 13.2Z"
|
||||
:fill="reply.likedByMe ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="1.2"
|
||||
/>
|
||||
</svg>
|
||||
<span>{{ reply.likeCount || 0 }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-1 whitespace-pre-line leading-relaxed">
|
||||
{{ reply.body }}
|
||||
</p>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user