댓글 시스템 복구
This commit is contained in:
@@ -2,8 +2,9 @@
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath, templatesPath } from './lib/paths'
|
||||
import { commentsPath, editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath, templatesPath } from './lib/paths'
|
||||
import { displayInitialFrom } from './lib/display'
|
||||
import { api } from './lib/api'
|
||||
import { toApiUrl } from './lib/runtime'
|
||||
import { useToast } from './composables/useToast'
|
||||
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
||||
@@ -40,6 +41,7 @@ const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 14
|
||||
const backendState = ref('online')
|
||||
const backendMessage = ref('')
|
||||
const isFullscreenActive = ref(false)
|
||||
const unreadCommentCount = ref(0)
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
@@ -76,6 +78,7 @@ const leftNavItems = computed(() => {
|
||||
{ key: 'me', label: '나의 티어표', path: '/me', iconSrc: iconLists, requiresAuth: true },
|
||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', iconSrc: iconFavorite, requiresAuth: true },
|
||||
{ key: 'followingFeed', label: '팔로우 피드', path: '/following', iconSrc: iconKidStar, requiresAuth: true },
|
||||
{ key: 'comments', label: '댓글 관리', path: commentsPath(), iconSrc: iconMenuBook, requiresAuth: true, showDot: unreadCommentCount.value > 0 },
|
||||
{ key: 'profile', label: '설정', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
|
||||
]
|
||||
return items.filter((item) => !item.requiresAuth || (authReady.value && auth.user))
|
||||
@@ -261,6 +264,16 @@ const routeMeta = computed(() => {
|
||||
action: () => router.push(favoritesPath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'comments') {
|
||||
return {
|
||||
title: '댓글 관리',
|
||||
subtitle: '댓글과 답글 확인',
|
||||
contextTitle: '알림',
|
||||
contextText: '내 티어표에 달린 댓글과 내 댓글에 달린 답글을 확인하고 바로 해당 위치로 이동할 수 있어요.',
|
||||
actionLabel: '나의 티어표 보기',
|
||||
action: () => router.push(mePath()),
|
||||
}
|
||||
}
|
||||
if (route.name === 'userProfile') {
|
||||
return {
|
||||
title: '작성자 프로필',
|
||||
@@ -313,6 +326,23 @@ function handleBackendStatus(event) {
|
||||
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
|
||||
}
|
||||
|
||||
function handleCommentInboxUpdated(event) {
|
||||
unreadCommentCount.value = Math.max(0, Number(event?.detail?.unreadCount || 0))
|
||||
}
|
||||
|
||||
async function refreshUnreadCommentCount() {
|
||||
if (!authReady.value || !auth.user) {
|
||||
unreadCommentCount.value = 0
|
||||
return
|
||||
}
|
||||
try {
|
||||
const data = await api.getCommentInboxUnreadCount()
|
||||
unreadCommentCount.value = Math.max(0, Number(data.unreadCount || 0))
|
||||
} catch (error) {
|
||||
unreadCommentCount.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function syncFullscreenState() {
|
||||
if (typeof document === 'undefined') return
|
||||
isFullscreenActive.value = !!(document.fullscreenElement || document.webkitFullscreenElement)
|
||||
@@ -344,6 +374,7 @@ onMounted(async () => {
|
||||
syncViewportWidth()
|
||||
syncFullscreenState()
|
||||
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.addEventListener('tier-maker:comment-inbox-updated', handleCommentInboxUpdated)
|
||||
window.addEventListener('resize', syncViewportWidth)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
document.addEventListener('fullscreenchange', syncFullscreenState)
|
||||
@@ -360,6 +391,7 @@ onMounted(async () => {
|
||||
rightRailOpen.value = true
|
||||
}
|
||||
searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
|
||||
await refreshUnreadCommentCount()
|
||||
})
|
||||
|
||||
function handleGlobalKeydown(event) {
|
||||
@@ -423,6 +455,7 @@ async function toggleFullscreen() {
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.removeEventListener('tier-maker:comment-inbox-updated', handleCommentInboxUpdated)
|
||||
window.removeEventListener('resize', syncViewportWidth)
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
document.removeEventListener('fullscreenchange', syncFullscreenState)
|
||||
@@ -441,6 +474,14 @@ watch(
|
||||
mobileLeftNavOpen.value = false
|
||||
rightRailOpen.value = false
|
||||
}
|
||||
refreshUnreadCommentCount()
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => auth.user?.id,
|
||||
() => {
|
||||
refreshUnreadCommentCount()
|
||||
}
|
||||
)
|
||||
|
||||
@@ -656,6 +697,7 @@ function reloadApp() {
|
||||
<span class="leftNav__glyph">
|
||||
<SvgIcon v-if="item.iconSrc" :src="item.iconSrc" :size="24" />
|
||||
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
<span v-if="item.showDot" class="leftNav__dot" aria-hidden="true"></span>
|
||||
</span>
|
||||
<span class="leftNav__label">{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
@@ -1260,12 +1302,24 @@ function reloadApp() {
|
||||
/* width: 28px; */
|
||||
/* height: 28px; */
|
||||
/* border-radius: 10px; */
|
||||
position: relative;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
/* background: rgba(255, 255, 255, 0.06); */
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.leftNav__dot {
|
||||
position: absolute;
|
||||
top: -2px;
|
||||
right: -3px;
|
||||
width: 9px;
|
||||
height: 9px;
|
||||
border-radius: 999px;
|
||||
background: #ff4d67;
|
||||
box-shadow: 0 0 0 2px var(--theme-surface);
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__top {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
530
frontend/src/components/TierListCommentsCard.vue
Normal file
530
frontend/src/components/TierListCommentsCard.vue
Normal file
@@ -0,0 +1,530 @@
|
||||
<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('')
|
||||
let activeCommentRetryTimer = 0
|
||||
|
||||
const totalCommentCount = computed(() =>
|
||||
comments.value.reduce((count, comment) => count + 1 + (comment.replies?.length || 0), 0)
|
||||
)
|
||||
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 : []
|
||||
scrollToHighlightedComment()
|
||||
} catch (loadError) {
|
||||
error.value = '댓글을 불러오지 못했어요.'
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
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 toggleReplyComposer(commentId) {
|
||||
openedReplyComposerId.value = openedReplyComposerId.value === commentId ? '' : commentId
|
||||
}
|
||||
|
||||
watch(() => props.tierListId, loadComments, { immediate: true })
|
||||
watch(highlightedCommentId, () => {
|
||||
scrollToHighlightedComment()
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
clearActiveCommentRetry()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="commentsCard">
|
||||
<header class="commentsCard__head">
|
||||
<div>
|
||||
<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 btn--small" 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 btn--small" :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 comments"
|
||||
: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]"
|
||||
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 btn--small" 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 comment.replies"
|
||||
: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>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.commentsCard {
|
||||
margin-top: 24px;
|
||||
padding: 24px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.commentsCard__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.commentsCard__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
|
||||
.commentsCard__count {
|
||||
flex: 0 0 auto;
|
||||
align-self: flex-start;
|
||||
padding: 8px 12px;
|
||||
border-radius: 999px;
|
||||
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,
|
||||
.commentItem,
|
||||
.commentItem--reply {
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-surface);
|
||||
}
|
||||
|
||||
.commentsComposer,
|
||||
.commentsLoginCta {
|
||||
margin-bottom: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.commentsComposer__input {
|
||||
width: 100%;
|
||||
min-height: 92px;
|
||||
resize: vertical;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid var(--theme-field-border);
|
||||
background: var(--theme-input-bg);
|
||||
color: var(--theme-text);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.commentsComposer__input--reply {
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.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: 14px;
|
||||
}
|
||||
|
||||
.commentItem {
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
}
|
||||
|
||||
.commentItem--reply {
|
||||
margin-top: 12px;
|
||||
margin-left: 24px;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.commentItem--highlighted {
|
||||
border-color: color-mix(in srgb, var(--theme-accent) 65%, var(--theme-card-border));
|
||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--theme-accent) 38%, transparent);
|
||||
}
|
||||
|
||||
.commentItem__head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.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 {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.replyComposer {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.commentsCard {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.commentsCard__head,
|
||||
.commentsComposer__footer,
|
||||
.commentsLoginCta,
|
||||
.commentItem__head {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.commentItem--reply {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -170,10 +170,19 @@ export const api = {
|
||||
followUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'POST', body: {} }),
|
||||
unfollowUser: (userId) => request(`/api/users/${encodeURIComponent(userId)}/follow`, { method: 'DELETE' }),
|
||||
getTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`),
|
||||
listTierListComments: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/comments`),
|
||||
createTierListComment: (id, payload) =>
|
||||
request(`/api/tierlists/${encodeURIComponent(id)}/comments`, { method: 'POST', body: payload }),
|
||||
deleteTierListComment: (id, commentId) =>
|
||||
request(`/api/tierlists/${encodeURIComponent(id)}/comments/${encodeURIComponent(commentId)}`, { method: 'DELETE' }),
|
||||
favoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'POST' }),
|
||||
unfavoriteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/favorite`, { method: 'DELETE' }),
|
||||
deleteTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}`, { method: 'DELETE' }),
|
||||
duplicateTierList: (id) => request(`/api/tierlists/${encodeURIComponent(id)}/duplicate`, { method: 'POST' }),
|
||||
listCommentInbox: ({ unreadOnly = false } = {}) =>
|
||||
request(`/api/comments/inbox?unreadOnly=${encodeURIComponent(unreadOnly ? '1' : '0')}`),
|
||||
getCommentInboxUnreadCount: () => request('/api/comments/inbox/unread-count'),
|
||||
markCommentInboxRead: (payload) => request('/api/comments/inbox/read', { method: 'POST', body: payload }),
|
||||
requestTierListTemplate: (payload) => request('/api/tierlists/template-request', { method: 'POST', body: payload }),
|
||||
saveTierList: (payload) => request('/api/tierlists', { method: 'POST', body: payload }),
|
||||
uploadTierListThumbnail: async (file) => {
|
||||
|
||||
@@ -46,6 +46,10 @@ export function followingFeedPath() {
|
||||
return '/following'
|
||||
}
|
||||
|
||||
export function commentsPath() {
|
||||
return '/comments'
|
||||
}
|
||||
|
||||
export function profilePath() {
|
||||
return '/profile'
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import LoginView from '../views/LoginView.vue'
|
||||
import MyTierListsView from '../views/MyTierListsView.vue'
|
||||
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
|
||||
import FollowingFeedView from '../views/FollowingFeedView.vue'
|
||||
import CommentInboxView from '../views/CommentInboxView.vue'
|
||||
import UserProfileView from '../views/UserProfileView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
@@ -27,6 +28,7 @@ export function createRouter() {
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
{ path: '/following', name: 'followingFeed', component: FollowingFeedView },
|
||||
{ path: '/comments', name: 'comments', component: CommentInboxView },
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', redirect: '/admin/featured' },
|
||||
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
|
||||
|
||||
313
frontend/src/views/CommentInboxView.vue
Normal file
313
frontend/src/views/CommentInboxView.vue
Normal file
@@ -0,0 +1,313 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
import { editorPath, loginPath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
const notifications = ref([])
|
||||
const isLoading = ref(false)
|
||||
const unreadOnly = ref(false)
|
||||
const isMarkingAllRead = ref(false)
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter((item) => !item.isRead).length)
|
||||
|
||||
function avatarUrlOf(notification) {
|
||||
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(notification) {
|
||||
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
|
||||
}
|
||||
|
||||
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 notificationTitle(notification) {
|
||||
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
|
||||
}
|
||||
|
||||
function emitUnreadCount(unread) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:comment-inbox-updated', { detail: { unreadCount: unread } }))
|
||||
}
|
||||
|
||||
async function loadInbox() {
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await api.listCommentInbox({ unreadOnly: unreadOnly.value })
|
||||
notifications.value = Array.isArray(data.notifications) ? data.notifications : []
|
||||
emitUnreadCount(unreadCount.value)
|
||||
} catch (error) {
|
||||
toast.error('댓글 알림을 불러오지 못했어요.')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function markOneAsRead(notificationId) {
|
||||
const target = notifications.value.find((item) => item.id === notificationId)
|
||||
if (!target || target.isRead) return
|
||||
target.isRead = true
|
||||
emitUnreadCount(unreadCount.value)
|
||||
try {
|
||||
await api.markCommentInboxRead({ notificationIds: [notificationId] })
|
||||
} catch (error) {
|
||||
target.isRead = false
|
||||
emitUnreadCount(unreadCount.value)
|
||||
}
|
||||
}
|
||||
|
||||
async function markAllAsRead() {
|
||||
if (!unreadCount.value) return
|
||||
isMarkingAllRead.value = true
|
||||
const original = notifications.value.map((item) => ({ ...item }))
|
||||
notifications.value = notifications.value.map((item) => ({ ...item, isRead: true }))
|
||||
emitUnreadCount(0)
|
||||
try {
|
||||
await api.markCommentInboxRead({ all: true })
|
||||
if (unreadOnly.value) {
|
||||
notifications.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
notifications.value = original
|
||||
emitUnreadCount(unreadCount.value)
|
||||
toast.error('읽음 처리를 완료하지 못했어요.')
|
||||
} finally {
|
||||
isMarkingAllRead.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openNotification(notification) {
|
||||
await markOneAsRead(notification.id)
|
||||
router.push({
|
||||
path: editorPath(notification.topicSlug || notification.topicId, notification.tierListId),
|
||||
query: { commentId: notification.commentId },
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await api.me()
|
||||
} catch (error) {
|
||||
toast.error('로그인이 필요해요.')
|
||||
router.push(loginPath('/comments'))
|
||||
return
|
||||
}
|
||||
loadInbox()
|
||||
})
|
||||
|
||||
watch(unreadOnly, loadInbox)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="pageWrap">
|
||||
<section class="pageHead">
|
||||
<div class="pageHead__main">
|
||||
<div class="pageHead__eyebrow">Inbox</div>
|
||||
<h1 class="pageHead__title">댓글 관리</h1>
|
||||
<p class="pageHead__desc">내 티어표에 달린 댓글과, 내 댓글에 달린 답글을 한곳에서 확인하고 바로 이동할 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="commentInboxToolbar">
|
||||
<label class="commentInboxToolbar__toggle">
|
||||
<input v-model="unreadOnly" type="checkbox" />
|
||||
<span>안 읽은 댓글만 보기</span>
|
||||
</label>
|
||||
<button class="btn btn--ghost btn--small" type="button" :disabled="!unreadCount || isMarkingAllRead" @click="markAllAsRead">
|
||||
모두 읽음 처리
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section class="commentInboxPanel">
|
||||
<div v-if="isLoading" class="commentInboxEmpty">댓글 알림을 불러오는 중이에요.</div>
|
||||
<div v-else-if="notifications.length === 0" class="commentInboxEmpty">
|
||||
{{ unreadOnly ? '안 읽은 댓글 알림이 없어요.' : '아직 도착한 댓글 알림이 없어요.' }}
|
||||
</div>
|
||||
|
||||
<div v-else class="commentInboxList">
|
||||
<article
|
||||
v-for="notification in notifications"
|
||||
:key="notification.id"
|
||||
class="commentInboxCard"
|
||||
:class="{ 'commentInboxCard--unread': !notification.isRead }"
|
||||
>
|
||||
<button class="commentInboxCard__body" type="button" @click="openNotification(notification)">
|
||||
<div class="commentInboxCard__main">
|
||||
<div class="commentInboxCard__titleRow">
|
||||
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
||||
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
|
||||
</div>
|
||||
<div class="commentInboxCard__meta">
|
||||
<img
|
||||
v-if="avatarUrlOf(notification)"
|
||||
class="commentInboxCard__avatar"
|
||||
:src="avatarUrlOf(notification)"
|
||||
:alt="notification.actorName || '작성자'"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="commentInboxCard__avatar commentInboxCard__avatar--fallback">{{ avatarFallbackOf(notification) }}</div>
|
||||
<span class="commentInboxCard__actor">{{ notification.actorName }}</span>
|
||||
<span class="commentInboxCard__separator">·</span>
|
||||
<span class="commentInboxCard__date">{{ formatDate(notification.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="commentInboxCard__target">
|
||||
{{ notification.tierListTitle || '제목 없는 티어표' }}
|
||||
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
|
||||
</div>
|
||||
<div class="commentInboxCard__content">{{ notification.commentContent }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.commentInboxToolbar {
|
||||
margin-bottom: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.commentInboxToolbar__toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentInboxPanel {
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.commentInboxEmpty {
|
||||
color: var(--theme-text-muted);
|
||||
}
|
||||
|
||||
.commentInboxList {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.commentInboxCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-surface);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.commentInboxCard--unread {
|
||||
border-color: color-mix(in srgb, var(--theme-accent) 48%, var(--theme-card-border));
|
||||
}
|
||||
|
||||
.commentInboxCard__body {
|
||||
width: 100%;
|
||||
padding: 18px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.commentInboxCard__titleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.commentInboxCard__title {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxCard__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: #ff4d67;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentInboxCard__meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.commentInboxCard__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentInboxCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxCard__target {
|
||||
margin-top: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commentInboxCard__targetMeta {
|
||||
color: var(--theme-text-faint);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentInboxCard__content {
|
||||
margin-top: 10px;
|
||||
color: var(--theme-text-muted);
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 3;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
word-break: break-word;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.commentInboxToolbar {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.commentInboxPanel {
|
||||
padding: 20px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import TierListCommentsCard from '../components/TierListCommentsCard.vue'
|
||||
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
@@ -159,6 +160,8 @@ const canRequestTemplateCreate = computed(
|
||||
const canRequestTemplateUpdate = computed(
|
||||
() => canEdit.value && hasSavedTierList.value && templateId.value !== 'freeform' && customItems.value.length > 0
|
||||
)
|
||||
const activeTierListId = computed(() => persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : ''))
|
||||
const currentUserId = computed(() => auth.user?.id || '')
|
||||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||||
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
|
||||
@@ -1061,12 +1064,20 @@ async function copyShareUrl() {
|
||||
|
||||
function openViewerMode() {
|
||||
if (!canSwitchToViewerMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
|
||||
router.push({
|
||||
path: editorPath(templateId.value, persistedTierListId.value || tierListId.value),
|
||||
query: { ...route.query, preview: '1' },
|
||||
})
|
||||
}
|
||||
|
||||
function openEditMode() {
|
||||
if (!canSwitchToEditMode.value) return
|
||||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
|
||||
const nextQuery = { ...route.query }
|
||||
delete nextQuery.preview
|
||||
router.push({
|
||||
path: editorPath(templateId.value, persistedTierListId.value || tierListId.value),
|
||||
query: nextQuery,
|
||||
})
|
||||
}
|
||||
|
||||
function closeNavigationConfirmModal() {
|
||||
@@ -1354,7 +1365,10 @@ async function loadEditorState() {
|
||||
isFavorited.value = !!t.isFavorited
|
||||
|
||||
if (!previewMode.value && !canEdit.value) {
|
||||
router.replace(editorPath(templateId.value, t.id, { preview: true }))
|
||||
router.replace({
|
||||
path: editorPath(templateId.value, t.id),
|
||||
query: { ...route.query, preview: '1' },
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1465,6 +1479,14 @@ onUnmounted(() => {
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<TierListCommentsCard
|
||||
v-if="activeTierListId"
|
||||
:tier-list-id="activeTierListId"
|
||||
:can-write="!!auth.user"
|
||||
:current-user-id="currentUserId"
|
||||
title="댓글"
|
||||
description="이 티어표에 대한 의견을 남기고 답글로 대화를 이어갈 수 있어요."
|
||||
/>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<template v-if="globalRightRailOpen">
|
||||
@@ -1797,6 +1819,14 @@ onUnmounted(() => {
|
||||
<li>아이템이 많아 한 번에 보기 어렵다면 브라우저 확대/축소(`Ctrl +`, `Ctrl -`)로 화면 밀도를 조절해보세요.</li>
|
||||
</ul>
|
||||
</div>
|
||||
<TierListCommentsCard
|
||||
v-if="activeTierListId"
|
||||
:tier-list-id="activeTierListId"
|
||||
:can-write="!!auth.user"
|
||||
:current-user-id="currentUserId"
|
||||
title="댓글"
|
||||
description="이 티어표에 대한 의견을 남기고 답글로 대화를 이어갈 수 있어요."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="sidebarStickyFrame">
|
||||
|
||||
Reference in New Issue
Block a user