댓글 관리 카드 정리

This commit is contained in:
2026-04-07 14:19:05 +09:00
parent 63dc8f871c
commit 31e266e79e
7 changed files with 196 additions and 119 deletions

View File

@@ -272,6 +272,15 @@ function mapCommentNotificationRow(row) {
commentId: row.comment_id,
parentCommentId: row.parent_comment_id || '',
parentCommentContent: row.parent_comment_content || '',
parentCommentCreatedAt: Number(row.parent_comment_created_at || 0),
parentAuthorName: getUserDisplayName({
nickname: row.parent_author_nickname,
email: row.parent_author_email,
}),
parentAuthorAccountName: getUserAccountName({
email: row.parent_author_email,
}),
parentAuthorAvatarSrc: row.parent_author_avatar_src || '',
notificationType: row.notification_type || 'tierlist_comment',
isRead: !!row.is_read,
readAt: Number(row.read_at || 0),
@@ -2851,6 +2860,7 @@ async function listCommentNotifications(userId, { unreadOnly = false } = {}) {
c.parent_comment_id,
c.content AS comment_content,
parent.content AS parent_comment_content,
parent.created_at AS parent_comment_created_at,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
@@ -2858,10 +2868,14 @@ async function listCommentNotifications(userId, { unreadOnly = false } = {}) {
t.thumbnail_src AS tierlist_thumbnail_src,
actor.nickname AS actor_nickname,
actor.email AS actor_email,
actor.avatar_src AS actor_avatar_src
actor.avatar_src AS actor_avatar_src,
parent_author.nickname AS parent_author_nickname,
parent_author.email AS parent_author_email,
parent_author.avatar_src AS parent_author_avatar_src
FROM comment_notifications n
INNER JOIN tierlist_comments c ON c.id = n.comment_id
LEFT JOIN tierlist_comments parent ON parent.id = c.parent_comment_id
LEFT JOIN users parent_author ON parent_author.id = parent.author_id
INNER JOIN tierlists t ON t.id = n.tierlist_id
INNER JOIN topics tp ON tp.id = t.topic_id
INNER JOIN users actor ON actor.id = n.actor_user_id

View File

@@ -1,5 +1,9 @@
# 의사결정 이력
## 2026-04-07 v1.1.9
- 댓글 관리함은 단순 알림 문구보다 `어느 티어표에서 어떤 루트 댓글이 있었고 그 아래 어떤 새 댓글/답글이 달렸는지`를 카드 한 장 안에서 읽히게 하는 편이 더 중요하다고 정리했다. 그래서 좌측에는 대상 티어표 정보, 우측에는 댓글 흐름 자체를 배치하는 2열 구조를 기본으로 삼는다.
- `commentInboxCard__lead`처럼 제목을 다시 설명하는 보조 문구는 상태 전달에 비해 공간만 차지하므로 제거하고, 대신 실제 댓글 작성자/시간/본문 정보를 바로 보여주는 방향이 낫다고 판단했다.
## 2026-04-07 v1.1.8
- 댓글은 처음부터 전부 렌더링하지 않고 일부만 보여준 뒤 `더 보기`로 확장하는 방향을 채택했다. 이 프로젝트는 본문이 긴 티어표 프리뷰와 함께 댓글을 보여주므로, 기본 노출 개수를 제한하는 편이 가독성과 레일 안정성에 모두 유리하다.
- 댓글 관리 화면 컨트롤은 별도 체크박스 문법을 만들지 않고, 설정/에디터에서 이미 쓰는 토글 스위치와 저장 CTA 톤을 재사용하는 것이 일관성에 맞다고 판단했다.

View File

@@ -22,7 +22,7 @@
## `/comments`
- 화면 파일: `frontend/src/views/CommentInboxView.vue`
- 역할: 내 티어표에 달린 댓글과 내 댓글에 달린 답글을 시간순 카드로 확인, 안 읽은 댓글만 보기 필터, 모두 읽음 처리, 카드별 red dot 표시, 티어표 썸네일과 원댓글/새 댓글 비교 블록 제공, 카드 클릭 시 해당 티어표의 특정 댓글 위치로 이동
- 역할: 내 티어표에 달린 댓글과 내 댓글에 달린 답글을 시간순 카드로 확인, 안 읽은 댓글만 보기 필터, 모두 읽음 처리, 카드별 red dot 표시, 좌측 `썸네일/티어표 제목/템플릿 이름`과 우측 `루트 댓글/새 댓글 또는 답글` 비교 구조 제공, 카드 클릭 시 해당 티어표의 특정 댓글 위치로 이동
- 연동 API: `GET /api/comments/inbox`, `GET /api/comments/inbox/unread-count`, `POST /api/comments/inbox/read`
## `/login`

View File

@@ -52,6 +52,7 @@
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
- 댓글 표시 밀도 제어를 위해 기본 노출 개수는 루트 댓글 10개, 각 루트의 답글 3개로 제한하고 `더 보기` 버튼으로 추가 노출한다.
- 댓글 관리 카드(`/comments`)는 좌측 `16:9 썸네일 + 티어표 제목 + 템플릿 이름`, 우측 `알림 제목 + 루트 댓글 정보 + 새 댓글/답글 정보`의 2열 구조를 사용한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
@@ -172,6 +173,7 @@
- `readAt`: number
- `createdAt`: number
- 기존 운영 DB에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
- 댓글 관리 카드 구성을 위해 조회 응답에는 `parentCommentContent`, `parentCommentCreatedAt`, `parentAuthorName`, `parentAuthorAccountName`, `parentAuthorAvatarSrc`를 함께 내려준다.
- `templateRequests`
- `id`: string
- `type`: string

View File

@@ -1,6 +1,8 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.9` 이후 댓글 관리 카드에서 좌측 썸네일/티어표 정보와 우측 루트 댓글/새 댓글 정보가 실제로 한눈에 읽히는지, 특히 답글 알림에서 부모 댓글 작성자 정보가 자연스럽게 보이는지 확인한다.
- `v1.1.9` 이후 `commentInboxCard__lead` 제거로 정보가 부족해지지 않았는지, 제목과 댓글 블록만으로 상태를 이해할 수 있는지 데스크톱/모바일에서 다시 확인한다.
- `v1.1.8` 이후 댓글 더 보기 규칙(루트 10개, 답글 3개)과 남은 개수 표기가 실제 데이터에서 자연스럽게 동작하는지 확인한다.
- 댓글 관리 화면의 `안 읽은 댓글만 보기` 토글과 `모두 읽음 처리` 버튼이 설정/에디터의 공통 컨트롤 톤과 이질감이 없는지 확인한다.
- `v1.1.7` 이후 댓글 관리 카드 썸네일이 실제로 모든 카드에서 16:9로 유지되는지 데스크톱/모바일에서 다시 확인한다.

View File

@@ -1,5 +1,12 @@
# 업데이트 로그
## 2026-04-07 v1.1.9
- 댓글 관리 화면의 패널과 카드 톤을 댓글 카드(`commentsCard`) 계열과 더 가깝게 다시 정리했다. 바깥 패널은 같은 배경/보더 문법을 쓰고, 개별 알림 카드는 장식성 그림자 대신 단정한 카드 레이어로 맞췄다.
- 댓글 관리 카드의 정보 구조를 다시 설계했다. 왼쪽에는 `16:9 썸네일 / 티어표 제목 / 템플릿 이름`만 모으고, 오른쪽에는 `알림 제목 / 루트 댓글 / 새 댓글 또는 새 답글` 흐름으로 읽히게 정리했다.
- 중복 설명 역할이던 `commentInboxCard__lead`는 제거했다. 이제 카드 제목만 봐도 상태를 이해할 수 있고, 실제 내용 이해는 바로 아래 댓글 정보 블록이 담당한다.
- 댓글 관리 API는 답글 알림에서 부모 댓글 작성자 아바타/이름/작성시간도 함께 내려주도록 확장했다. 그래서 프런트는 `루트 댓글``새 답글`을 각각 작성자 단위로 한눈에 비교해 보여줄 수 있다.
- 확인: `node --check backend/src/db.js`, `npm run build`
## 2026-04-07 v1.1.8
- 댓글 관리 화면의 상단 컨트롤을 정리했다. `안 읽은 댓글만 보기`는 체크박스 대신 프로젝트 공통 토글 스위치 문법으로 바꾸고, `모두 읽음 처리` 버튼도 저장 CTA 계열과 같은 톤으로 맞췄다.
- 댓글 영역에는 `더 보기` 흐름을 추가했다. 루트 댓글은 처음 10개, 답글은 처음 3개만 보여주고, 남은 개수를 표시하는 `댓글 n개 더 보기`, `답글 n개 더 보기` 버튼으로 단계적으로 펼친다.

View File

@@ -20,6 +20,10 @@ function avatarUrlOf(notification) {
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
}
function parentAvatarUrlOf(notification) {
return notification.parentAuthorAvatarSrc ? toApiUrl(notification.parentAuthorAvatarSrc) : ''
}
function tierListThumbnailUrl(notification) {
return notification.tierListThumbnailSrc ? toApiUrl(notification.tierListThumbnailSrc) : ''
}
@@ -28,6 +32,14 @@ function avatarFallbackOf(notification) {
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
}
function parentAvatarFallbackOf(notification) {
return displayInitialFrom(notification.parentAuthorName, notification.parentAuthorAccountName, '?')
}
function parentDisplayNameOf(notification) {
return notification.parentAuthorName || '알 수 없음'
}
function formatDate(ts) {
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
year: 'numeric',
@@ -42,10 +54,6 @@ function notificationTitle(notification) {
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
}
function notificationLead(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 } }))
@@ -154,52 +162,70 @@ watch(unreadOnly, loadInbox)
:class="{ 'commentInboxCard--unread': !notification.isRead }"
>
<button class="commentInboxCard__body" type="button" @click="openNotification(notification)">
<div class="commentInboxCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(notification)"
class="commentInboxCard__thumb"
:src="tierListThumbnailUrl(notification)"
:alt="notification.tierListTitle || '티어표 썸네일'"
draggable="false"
/>
<div v-else class="commentInboxCard__thumbFallback">티어표</div>
<div class="commentInboxCard__aside">
<div class="commentInboxCard__thumbWrap">
<img
v-if="tierListThumbnailUrl(notification)"
class="commentInboxCard__thumb"
:src="tierListThumbnailUrl(notification)"
:alt="notification.tierListTitle || '티어표 썸네일'"
draggable="false"
/>
<div v-else class="commentInboxCard__thumbFallback">티어표</div>
</div>
<div class="commentInboxCard__targetTitle">{{ notification.tierListTitle || '제목 없는 티어표' }}</div>
<div class="commentInboxCard__targetMeta">{{ notification.topicName || notification.topicSlug || notification.topicId }}</div>
</div>
<div class="commentInboxCard__main">
<div class="commentInboxCard__titleRow">
<div>
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
<div class="commentInboxCard__lead">{{ notificationLead(notification) }}</div>
</div>
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
<div class="commentInboxCard__status">
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
<span class="commentInboxCard__badge">{{ notification.notificationType === 'comment_reply' ? '답글' : '댓글' }}</span>
</div>
</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__thread">
<div v-if="notification.parentCommentContent" class="commentInboxCard__threadBlock">
<div class="commentInboxCard__threadLabel">원래 댓글</div>
<div class="commentInboxCard__threadText">{{ notification.parentCommentContent }}</div>
<div v-if="notification.parentCommentContent" class="commentInboxThread">
<div class="commentInboxThread__label">루트 댓글</div>
<div class="commentInboxThread__body">
<img
v-if="parentAvatarUrlOf(notification)"
class="commentInboxThread__avatar"
:src="parentAvatarUrlOf(notification)"
:alt="parentDisplayNameOf(notification)"
draggable="false"
/>
<div v-else class="commentInboxThread__avatar commentInboxThread__avatar--fallback">{{ parentAvatarFallbackOf(notification) }}</div>
<div class="commentInboxThread__content">
<div class="commentInboxThread__meta">
<span class="commentInboxThread__name">{{ parentDisplayNameOf(notification) }}</span>
<span class="commentInboxThread__separator">·</span>
<span class="commentInboxThread__date">{{ formatDate(notification.parentCommentCreatedAt) }}</span>
</div>
<div class="commentInboxThread__text">{{ notification.parentCommentContent }}</div>
</div>
</div>
</div>
<div class="commentInboxCard__threadBlock commentInboxCard__threadBlock--accent">
<div class="commentInboxCard__threadLabel">{{ notification.notificationType === 'comment_reply' ? '새 답글' : '새 댓글' }}</div>
<div class="commentInboxCard__threadText">{{ notification.commentContent }}</div>
<div class="commentInboxThread commentInboxThread--accent">
<div class="commentInboxThread__label">{{ notification.notificationType === 'comment_reply' ? '새 답글' : '새 댓글' }}</div>
<div class="commentInboxThread__body">
<img
v-if="avatarUrlOf(notification)"
class="commentInboxThread__avatar"
:src="avatarUrlOf(notification)"
:alt="notification.actorName || '작성자'"
draggable="false"
/>
<div v-else class="commentInboxThread__avatar commentInboxThread__avatar--fallback">{{ avatarFallbackOf(notification) }}</div>
<div class="commentInboxThread__content">
<div class="commentInboxThread__meta">
<span class="commentInboxThread__name">{{ notification.actorName }}</span>
<span class="commentInboxThread__separator">·</span>
<span class="commentInboxThread__date">{{ formatDate(notification.createdAt) }}</span>
</div>
<div class="commentInboxThread__text">{{ notification.commentContent }}</div>
</div>
</div>
</div>
</div>
</div>
@@ -228,11 +254,10 @@ watch(unreadOnly, loadInbox)
}
.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;
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.commentInboxEmpty {
@@ -245,18 +270,15 @@ watch(unreadOnly, loadInbox)
}
.commentInboxCard {
border-radius: 22px;
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-surface) 92%, var(--theme-surface-soft)) 0%, var(--theme-surface) 100%);
border-radius: 20px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
overflow: hidden;
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 5%, transparent),
0 14px 28px rgba(0, 0, 0, 0.06);
}
.commentInboxCard--unread {
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--theme-accent) 38%, transparent),
0 14px 28px rgba(0, 0, 0, 0.08);
border-color: color-mix(in srgb, var(--theme-accent) 28%, var(--theme-border));
background: color-mix(in srgb, var(--theme-surface) 92%, var(--theme-accent) 8%);
}
.commentInboxCard__body {
@@ -273,6 +295,12 @@ watch(unreadOnly, loadInbox)
align-items: start;
}
.commentInboxCard__aside {
min-width: 0;
display: grid;
gap: 10px;
}
.commentInboxCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
@@ -303,6 +331,19 @@ watch(unreadOnly, loadInbox)
font-weight: 800;
}
.commentInboxCard__targetTitle {
font-size: 15px;
font-weight: 800;
line-height: 1.45;
word-break: break-word;
}
.commentInboxCard__targetMeta {
color: var(--theme-text-faint);
font-size: 12px;
font-weight: 700;
}
.commentInboxCard__titleRow {
display: flex;
align-items: flex-start;
@@ -315,12 +356,6 @@ watch(unreadOnly, loadInbox)
font-weight: 900;
}
.commentInboxCard__lead {
margin-top: 6px;
color: var(--theme-text-muted);
font-size: 13px;
}
.commentInboxCard__status {
display: inline-flex;
align-items: center;
@@ -347,70 +382,18 @@ watch(unreadOnly, loadInbox)
font-weight: 800;
}
.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;
}
.commentInboxCard__thread {
margin-top: 14px;
display: grid;
gap: 10px;
gap: 12px;
}
.commentInboxCard__threadBlock {
padding: 14px 15px;
border-radius: 18px;
background: color-mix(in srgb, var(--theme-surface) 88%, var(--theme-surface-soft));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--theme-card-border) 20%, transparent),
0 8px 18px rgba(0, 0, 0, 0.04);
.commentInboxThread {
display: grid;
gap: 8px;
}
.commentInboxCard__threadBlock--accent {
background: color-mix(in srgb, var(--theme-accent) 10%, var(--theme-surface));
box-shadow:
inset 0 0 0 1px color-mix(in srgb, var(--theme-accent) 24%, transparent),
0 10px 20px rgba(59, 130, 246, 0.08);
}
.commentInboxCard__threadLabel {
.commentInboxThread__label {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.14em;
@@ -418,7 +401,64 @@ watch(unreadOnly, loadInbox)
color: var(--theme-text-faint);
}
.commentInboxCard__threadText {
.commentInboxThread__body {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 14px;
border-radius: 18px;
background: var(--theme-pill-bg);
}
.commentInboxThread--accent .commentInboxThread__body {
background: color-mix(in srgb, var(--theme-accent) 10%, var(--theme-pill-bg));
}
.commentInboxThread__avatar {
width: 36px;
height: 36px;
border-radius: 999px;
object-fit: cover;
border: 1px solid var(--theme-avatar-border);
background: var(--theme-border);
flex: 0 0 auto;
}
.commentInboxThread__avatar--fallback {
display: grid;
place-items: center;
font-size: 13px;
font-weight: 900;
}
.commentInboxThread__content {
min-width: 0;
flex: 1 1 auto;
}
.commentInboxThread__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
color: var(--theme-text-muted);
font-size: 13px;
}
.commentInboxThread__name {
color: var(--theme-text);
font-weight: 800;
}
.commentInboxThread__separator {
color: var(--theme-text-faint);
}
.commentInboxThread__date {
color: var(--theme-text-faint);
}
.commentInboxThread__text {
margin-top: 8px;
color: var(--theme-text);
line-height: 1.6;
@@ -538,5 +578,13 @@ watch(unreadOnly, loadInbox)
.commentInboxCard__body {
grid-template-columns: 1fr;
}
.commentInboxCard__titleRow {
flex-direction: column;
}
.commentInboxThread__body {
padding: 12px;
}
}
</style>