Files
tier-maker/frontend/src/components/TierListCommentsCard.vue

690 lines
18 KiB
Vue

<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { api } from '../lib/api'
import { displayInitialFrom } from '../lib/display'
import { loginPath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
const props = defineProps({
tierListId: {
type: String,
default: '',
},
canWrite: {
type: Boolean,
default: false,
},
currentUserId: {
type: String,
default: '',
},
title: {
type: String,
default: '댓글',
},
description: {
type: String,
default: '티어표에 대한 의견을 남기고 답글로 대화를 이어갈 수 있어요.',
},
})
const route = useRoute()
const comments = ref([])
const isLoading = ref(false)
const error = ref('')
const commentDraft = ref('')
const replyDrafts = ref({})
const openedReplyComposerId = ref('')
const submittingTargetId = ref('')
const deletingCommentId = ref('')
const replyInputRefs = ref({})
const visibleRootCount = ref(10)
const visibleReplyCounts = ref({})
let activeCommentRetryTimer = 0
const totalCommentCount = computed(() =>
comments.value.reduce((count, comment) => count + 1 + (comment.replies?.length || 0), 0)
)
const visibleComments = computed(() => comments.value.slice(0, visibleRootCount.value))
const hasMoreRootComments = computed(() => comments.value.length > visibleRootCount.value)
const loginTarget = computed(() => loginPath(route.fullPath))
const highlightedCommentId = computed(() =>
typeof route.query.commentId === 'string' ? route.query.commentId.trim() : ''
)
function avatarUrlOf(user) {
return user?.authorAvatarSrc ? toApiUrl(user.authorAvatarSrc) : ''
}
function avatarFallbackOf(user) {
return displayInitialFrom(user?.authorName, user?.authorAccountName, '?')
}
function displayNameOf(user) {
return user?.authorName || '알 수 없음'
}
function formatDate(ts) {
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
function isOwnComment(comment) {
return !!props.currentUserId && props.currentUserId === comment?.authorId
}
function isHighlighted(commentId) {
return !!commentId && highlightedCommentId.value === commentId
}
function clearActiveCommentRetry() {
if (typeof window === 'undefined' || !activeCommentRetryTimer) return
window.clearTimeout(activeCommentRetryTimer)
activeCommentRetryTimer = 0
}
async function scrollToHighlightedComment(attempt = 0) {
clearActiveCommentRetry()
if (!highlightedCommentId.value || typeof document === 'undefined') return
await nextTick()
const target = document.querySelector(`[data-comment-id="${highlightedCommentId.value}"]`)
if (target) {
target.scrollIntoView({ behavior: attempt === 0 ? 'auto' : 'smooth', block: 'center' })
return
}
if (attempt >= 6 || typeof window === 'undefined') return
activeCommentRetryTimer = window.setTimeout(() => {
scrollToHighlightedComment(attempt + 1)
}, 180)
}
async function loadComments() {
if (!props.tierListId) {
comments.value = []
return
}
isLoading.value = true
error.value = ''
try {
const data = await api.listTierListComments(props.tierListId)
comments.value = Array.isArray(data.comments) ? data.comments : []
visibleRootCount.value = 10
visibleReplyCounts.value = {}
scrollToHighlightedComment()
} catch (loadError) {
error.value = '댓글을 불러오지 못했어요.'
} finally {
isLoading.value = false
}
}
function visibleRepliesOf(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
const limit = visibleReplyCounts.value[comment?.id] || 3
return replies.slice(0, limit)
}
function hasMoreReplies(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
return replies.length > (visibleReplyCounts.value[comment?.id] || 3)
}
function remainingReplyCount(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
return Math.max(0, replies.length - (visibleReplyCounts.value[comment?.id] || 3))
}
const remainingRootCount = computed(() => Math.max(0, comments.value.length - visibleRootCount.value))
function showMoreRootComments() {
visibleRootCount.value += 10
}
function showMoreReplies(commentId) {
if (!commentId) return
visibleReplyCounts.value = {
...visibleReplyCounts.value,
[commentId]: (visibleReplyCounts.value[commentId] || 3) + 3,
}
}
async function submitComment(parentCommentId = '') {
if (!props.canWrite || !props.tierListId) return
const isReply = !!parentCommentId
const draftValue = isReply ? replyDrafts.value[parentCommentId] || '' : commentDraft.value
const content = String(draftValue || '').trim()
if (!content) return
submittingTargetId.value = parentCommentId || 'root'
error.value = ''
try {
const data = await api.createTierListComment(props.tierListId, {
content,
parentCommentId,
})
comments.value = Array.isArray(data.comments) ? data.comments : []
if (isReply) {
replyDrafts.value = { ...replyDrafts.value, [parentCommentId]: '' }
openedReplyComposerId.value = ''
} else {
commentDraft.value = ''
}
await nextTick()
if (data.createdCommentId && typeof window !== 'undefined') {
const target = document.querySelector(`[data-comment-id="${data.createdCommentId}"]`)
if (target) target.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
} catch (submitError) {
const code = submitError?.data?.error
if (code === 'comment_reply_depth_invalid') {
error.value = '답글에는 다시 답글을 달 수 없어요.'
} else {
error.value = '댓글을 저장하지 못했어요.'
}
} finally {
submittingTargetId.value = ''
}
}
async function deleteComment(commentId) {
if (!commentId || !props.tierListId) return
deletingCommentId.value = commentId
error.value = ''
try {
const data = await api.deleteTierListComment(props.tierListId, commentId)
comments.value = Array.isArray(data.comments) ? data.comments : []
} catch (deleteError) {
error.value = '댓글을 삭제하지 못했어요.'
} finally {
deletingCommentId.value = ''
}
}
function registerReplyInput(commentId, element) {
if (!commentId) return
if (element) {
replyInputRefs.value[commentId] = element
return
}
delete replyInputRefs.value[commentId]
}
async function focusReplyInput(commentId) {
if (!commentId) return
await nextTick()
const target = replyInputRefs.value[commentId]
if (target && typeof target.focus === 'function') {
target.focus()
const value = target.value || ''
if (typeof target.setSelectionRange === 'function') {
target.setSelectionRange(value.length, value.length)
}
}
}
async function toggleReplyComposer(commentId) {
const nextId = openedReplyComposerId.value === commentId ? '' : commentId
openedReplyComposerId.value = nextId
if (!nextId) return
await focusReplyInput(nextId)
}
watch(() => props.tierListId, loadComments, { immediate: true })
watch(highlightedCommentId, () => {
scrollToHighlightedComment()
})
onBeforeUnmount(() => {
clearActiveCommentRetry()
})
</script>
<template>
<section class="commentsCard">
<header class="commentsCard__head">
<div class="commentsCard__headline">
<div class="commentsCard__eyebrow">Comments</div>
<h3 class="commentsCard__title">{{ title }}</h3>
<p class="commentsCard__desc">{{ description }}</p>
</div>
<div class="commentsCard__count">{{ totalCommentCount }}</div>
</header>
<div v-if="error" class="commentsCard__error">{{ error }}</div>
<div v-if="props.canWrite" class="commentsComposer">
<textarea
v-model="commentDraft"
class="commentsComposer__input"
maxlength="2000"
rows="3"
placeholder="이 티어표에 대한 의견을 남겨보세요."
/>
<div class="commentsComposer__footer">
<span class="commentsComposer__hint">{{ commentDraft.length }}/2000</span>
<button class="btn btn--save commentsComposer__submit" type="button" :disabled="!commentDraft.trim() || submittingTargetId === 'root'" @click="submitComment()">
댓글 등록
</button>
</div>
</div>
<div v-else class="commentsLoginCta">
<div class="commentsLoginCta__text">로그인하면 댓글과 답글을 남길 있어요.</div>
<RouterLink class="btn btn--ghost commentsComposer__submit" :to="loginTarget">로그인</RouterLink>
</div>
<div v-if="isLoading" class="commentsCard__empty">댓글을 불러오는 중이에요.</div>
<div v-else-if="comments.length === 0" class="commentsCard__empty">아직 댓글이 없어요. 댓글을 남겨보세요.</div>
<div v-else class="commentsThread">
<article
v-for="comment in visibleComments"
:key="comment.id"
class="commentItem"
:class="{ 'commentItem--highlighted': isHighlighted(comment.id) }"
:data-comment-id="comment.id"
>
<div class="commentItem__head">
<div class="commentItem__author">
<img v-if="avatarUrlOf(comment)" class="commentItem__avatar" :src="avatarUrlOf(comment)" :alt="displayNameOf(comment)" draggable="false" />
<div v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(comment) }}</div>
<div class="commentItem__meta">
<div class="commentItem__name">{{ displayNameOf(comment) }}</div>
<div class="commentItem__date">{{ formatDate(comment.createdAt) }}</div>
</div>
</div>
<div class="commentItem__actions">
<button v-if="props.canWrite" class="commentItem__action" type="button" @click="toggleReplyComposer(comment.id)">답글</button>
<button
v-if="isOwnComment(comment)"
class="commentItem__action commentItem__action--danger"
type="button"
:disabled="deletingCommentId === comment.id"
@click="deleteComment(comment.id)"
>
삭제
</button>
</div>
</div>
<div class="commentItem__body">{{ comment.content }}</div>
<div v-if="openedReplyComposerId === comment.id && props.canWrite" class="replyComposer">
<textarea
v-model="replyDrafts[comment.id]"
:ref="(element) => registerReplyInput(comment.id, element)"
class="commentsComposer__input commentsComposer__input--reply"
maxlength="2000"
rows="2"
placeholder="답글을 입력하세요."
/>
<div class="commentsComposer__footer">
<span class="commentsComposer__hint">{{ (replyDrafts[comment.id] || '').length }}/2000</span>
<button class="btn btn--save commentsComposer__submit" type="button" :disabled="!(replyDrafts[comment.id] || '').trim() || submittingTargetId === comment.id" @click="submitComment(comment.id)">
답글 등록
</button>
</div>
</div>
<div v-if="comment.replies?.length" class="replyList">
<article
v-for="reply in visibleRepliesOf(comment)"
:key="reply.id"
class="commentItem commentItem--reply"
:class="{ 'commentItem--highlighted': isHighlighted(reply.id) }"
:data-comment-id="reply.id"
>
<div class="commentItem__head">
<div class="commentItem__author">
<img v-if="avatarUrlOf(reply)" class="commentItem__avatar" :src="avatarUrlOf(reply)" :alt="displayNameOf(reply)" draggable="false" />
<div v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(reply) }}</div>
<div class="commentItem__meta">
<div class="commentItem__name">{{ displayNameOf(reply) }}</div>
<div class="commentItem__date">{{ formatDate(reply.createdAt) }}</div>
</div>
</div>
<div class="commentItem__actions">
<button
v-if="isOwnComment(reply)"
class="commentItem__action commentItem__action--danger"
type="button"
:disabled="deletingCommentId === reply.id"
@click="deleteComment(reply.id)"
>
삭제
</button>
</div>
</div>
<div class="commentItem__body">{{ reply.content }}</div>
</article>
<button v-if="hasMoreReplies(comment)" class="commentMoreButton" type="button" @click="showMoreReplies(comment.id)">
답글 {{ remainingReplyCount(comment) }} 보기
</button>
</div>
</article>
<button v-if="hasMoreRootComments" class="commentMoreButton commentMoreButton--root" type="button" @click="showMoreRootComments">
댓글 {{ remainingRootCount }} 보기
</button>
</div>
</section>
</template>
<style scoped>
.commentsCard {
margin-top: 24px;
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.commentsCard__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.commentsCard__headline {
min-width: 0;
}
.commentsCard__eyebrow {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.commentsCard__title {
margin: 6px 0 8px;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.04em;
}
.commentsCard__desc {
margin: 0;
color: var(--theme-text-muted);
font-size: 12px;
line-height: 1.55;
}
.commentsCard__count {
flex: 0 0 auto;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 800;
}
.commentsCard__error {
margin-bottom: 16px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--theme-danger-border);
background: var(--theme-danger-bg);
color: var(--theme-text);
}
.commentsComposer,
.commentsLoginCta {
margin-bottom: 18px;
padding: 14px;
border-radius: 18px;
background: var(--theme-surface-soft);
}
.commentsComposer__input {
width: 100%;
min-height: 92px;
resize: vertical;
padding: 14px 16px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-input-bg);
color: var(--theme-text);
box-sizing: border-box;
}
.commentsComposer__input--reply {
min-height: 72px;
background: var(--theme-input-bg);
}
.commentsComposer__input:focus {
outline: none;
border-color: color-mix(in srgb, var(--theme-accent) 60%, var(--theme-field-border));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 16%, transparent);
}
.commentsComposer__footer,
.commentsLoginCta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.commentsComposer__footer {
margin-top: 12px;
}
.commentsComposer__hint,
.commentsLoginCta__text {
color: var(--theme-text-muted);
font-size: 13px;
}
.commentsCard__empty {
padding: 20px 0;
color: var(--theme-text-muted);
}
.commentsThread {
display: grid;
gap: 10px;
}
.commentMoreButton {
justify-self: flex-start;
margin-top: 10px;
padding: 0;
border: 0;
background: transparent;
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.commentMoreButton--root {
margin-top: 4px;
}
.commentItem {
position: relative;
padding: 12px 0 0;
}
.commentItem--reply {
margin-top: 8px;
margin-left: 20px;
padding-top: 8px;
}
.commentItem--reply::before {
content: '';
position: absolute;
top: 0;
left: -12px;
width: 1px;
bottom: 0;
background: color-mix(in srgb, var(--theme-border) 82%, transparent);
}
.commentItem--highlighted {
border-radius: 18px;
background: color-mix(in srgb, var(--theme-accent) 10%, transparent);
}
.commentItem__head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
}
.commentItem__author {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.commentItem__avatar {
width: 38px;
height: 38px;
border-radius: 999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.commentItem__avatar--fallback {
display: grid;
place-items: center;
font-size: 14px;
font-weight: 900;
}
.commentItem__meta {
min-width: 0;
}
.commentItem__name {
font-size: 14px;
font-weight: 800;
}
.commentItem__date {
margin-top: 4px;
font-size: 12px;
color: var(--theme-text-faint);
}
.commentItem__actions {
display: flex;
align-items: flex-start;
gap: 8px;
}
.commentItem__action {
border: 0;
background: transparent;
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 700;
cursor: pointer;
}
.commentItem__action--danger {
color: var(--theme-danger-text, #ff8f8f);
}
.commentItem__body {
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
.replyComposer {
margin-top: 12px;
padding: 12px;
border-radius: 18px;
background: var(--theme-surface-soft);
}
.commentsComposer__submit {
min-width: 112px;
min-height: 44px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 12px 18px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
cursor: pointer;
font-size: 15px;
font-weight: 700;
transition: background 160ms ease;
}
.btn:hover {
background: var(--theme-surface-soft-3);
}
.btn:disabled {
opacity: 0.58;
cursor: default;
}
.btn--save {
min-width: 112px;
font-weight: 900;
background: rgba(96, 165, 250, 0.22);
border-color: rgba(96, 165, 250, 0.36);
}
.btn--save:hover {
background: rgba(96, 165, 250, 0.3);
}
.btn--ghost {
background: color-mix(in srgb, var(--theme-surface-soft) 86%, transparent);
}
@media (max-width: 860px) {
.commentsCard {
padding: 18px 16px;
}
.commentsCard__head,
.commentsComposer__footer,
.commentsLoginCta,
.commentItem__head {
flex-direction: column;
align-items: stretch;
}
.commentItem--reply {
margin-left: 14px;
}
.commentItem--reply::before {
left: -8px;
}
}
</style>