Compare commits

..

1 Commits

Author SHA1 Message Date
d5575d3028 댓글 카드 디자인 개선 2026-04-07 13:36:39 +09:00
8 changed files with 217 additions and 20 deletions

View File

@@ -268,8 +268,10 @@ function mapCommentNotificationRow(row) {
topicSlug: row.topic_slug || row.topic_id,
topicName: row.topic_name || '',
tierListTitle: row.tierlist_title || '',
tierListThumbnailSrc: row.tierlist_thumbnail_src || '',
commentId: row.comment_id,
parentCommentId: row.parent_comment_id || '',
parentCommentContent: row.parent_comment_content || '',
notificationType: row.notification_type || 'tierlist_comment',
isRead: !!row.is_read,
readAt: Number(row.read_at || 0),
@@ -2843,15 +2845,18 @@ async function listCommentNotifications(userId, { unreadOnly = false } = {}) {
n.actor_user_id,
c.parent_comment_id,
c.content AS comment_content,
parent.content AS parent_comment_content,
t.topic_id,
tp.slug AS topic_slug,
tp.name AS topic_name,
t.title AS tierlist_title,
t.thumbnail_src AS tierlist_thumbnail_src,
actor.nickname AS actor_nickname,
actor.email AS actor_email,
actor.avatar_src AS actor_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
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.4
- 댓글 관리함은 단순 목록보다 `무슨 티어표에서`, `원래 어떤 댓글이 있었고`, `새로 무엇이 달렸는지`를 한눈에 이해하는 정보 구조가 중요하다고 판단했다. 그래서 썸네일 + 스레드 비교 블록을 기본 카드 문법으로 채택했다.
- 댓글 본문과 답글도 단순 들여쓰기보다 카드/말풍선/연결선으로 관계를 보여주는 쪽이 최신 UI 감각에 더 맞는다고 보고, reply depth 1단 구조에 맞춘 시각 문법을 적용했다.
## 2026-04-07 v1.1.3
- 댓글 답글 입력창은 포커스 상태에만 의존하지 않고, 비포커스 상태에서도 시각적 경계를 명확히 주기로 했다. 댓글 UI는 에디터 안의 부가 기능이지만 사용자가 바로 이해할 수 있어야 하므로 카드형 배경과 기본 테두리를 유지한다.

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

@@ -243,6 +243,7 @@
- `DELETE /api/users/:userId/follow`
- 댓글 알림
- `GET /api/comments/inbox`
- 알림 카드 렌더링을 위해 티어표 썸네일과 부모 댓글 내용도 함께 반환한다.
- `GET /api/comments/inbox/unread-count`
- `POST /api/comments/inbox/read`
- 관리자

View File

@@ -1,6 +1,8 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.4` 이후 댓글 관리 카드에서 티어표 썸네일, 원댓글/새 댓글 비교 블록이 데스크톱과 모바일에서 모두 자연스럽게 보이는지 확인한다.
- 댓글 스레드 카드 리디자인 후 답글 연결선, 배지, 본문 말풍선 배경이 라이트/다크 모드 모두에서 과하지 않게 보이는지 확인한다.
- `v1.1.3` 이후 답글 작성 시 입력창이 열리자마자 포커스를 받고, 포커스 전에도 카드/입력 경계가 분명하게 보이는지 다크/라이트 모드 모두에서 확인한다.
- `v1.1.2` 반영 후 실제 운영/로컬 DB에서 서버를 다시 띄워 `comment_notifications.is_read` 컬럼이 자동 보강되는지, `댓글 관리` 메뉴 unread dot과 `/api/comments/inbox/unread-count`가 더 이상 SQL 오류 없이 동작하는지 확인한다.
- `v1.1.1` 댓글 복구 이후 다음 흐름을 우선 QA한다: 공개 티어표 프리뷰 하단 댓글 노출, 댓글 작성/답글 작성/본인 댓글 삭제, 댓글 관리 메뉴 red dot, 댓글 관리 화면에서 `안 읽은 댓글만 보기``모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤.

View File

@@ -1,5 +1,11 @@
# 업데이트 로그
## 2026-04-07 v1.1.4
- 댓글 관리 카드 디자인을 확장했다. 각 카드에 해당 티어표 썸네일을 붙이고, `원래 댓글``새 댓글/새 답글`을 한 번에 비교해서 볼 수 있게 스레드 블록 구조로 바꿨다.
- 댓글 알림 조회 API는 이제 티어표 썸네일과 부모 댓글 내용을 함께 내려준다. 답글 알림에서는 어떤 댓글에 어떤 답글이 달렸는지 바로 읽을 수 있다.
- 일반 댓글 카드(`commentItem`)도 더 카드형이고 세련된 톤으로 정리했다. 본문은 말풍선처럼 분리하고, 답글은 얇은 연결선과 보조 배지로 관계가 자연스럽게 읽히도록 다듬었다.
- 확인: `node --check backend/src/db.js`, `npm run build`
## 2026-04-07 v1.1.3
- 댓글 답글 입력 UX를 다듬었다. `답글` 버튼을 누르면 입력창이 열리자마자 자동으로 포커스가 이동하고, 포커스 전에도 구분이 되도록 답글 입력 영역 카드와 textarea 기본 경계선을 보강했다.
- 답글 등록 버튼도 기존의 작은 기본형 버튼 대신 프로젝트 전반의 저장 계열 CTA 문법과 같은 `btn--save` 스타일로 맞췄다.

View File

@@ -258,7 +258,10 @@ onBeforeUnmount(() => {
<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__metaTop">
<div class="commentItem__name">{{ displayNameOf(comment) }}</div>
<span class="commentItem__badge">댓글</span>
</div>
<div class="commentItem__date">{{ formatDate(comment.createdAt) }}</div>
</div>
</div>
@@ -307,7 +310,10 @@ onBeforeUnmount(() => {
<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__metaTop">
<div class="commentItem__name">{{ displayNameOf(reply) }}</div>
<span class="commentItem__badge commentItem__badge--reply">답글</span>
</div>
<div class="commentItem__date">{{ formatDate(reply.createdAt) }}</div>
</div>
</div>
@@ -453,18 +459,36 @@ onBeforeUnmount(() => {
.commentsThread {
display: grid;
gap: 14px;
gap: 16px;
}
.commentItem {
padding: 16px;
border-radius: 22px;
position: relative;
padding: 18px 18px 16px;
border-radius: 24px;
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-surface) 88%, var(--theme-surface-soft)) 0%, var(--theme-surface) 100%);
box-shadow:
inset 0 1px 0 color-mix(in srgb, white 6%, transparent),
0 14px 32px rgba(0, 0, 0, 0.08);
}
.commentItem--reply {
margin-top: 12px;
margin-left: 24px;
border-radius: 18px;
margin-left: 28px;
border-radius: 20px;
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-accent) 6%, var(--theme-surface)) 0%, var(--theme-surface) 100%);
}
.commentItem--reply::before {
content: '';
position: absolute;
top: 22px;
left: -16px;
width: 12px;
height: calc(100% - 44px);
border-left: 2px solid color-mix(in srgb, var(--theme-accent) 36%, var(--theme-card-border));
border-bottom: 2px solid color-mix(in srgb, var(--theme-accent) 36%, var(--theme-card-border));
border-bottom-left-radius: 14px;
}
.commentItem--highlighted {
@@ -507,11 +531,37 @@ onBeforeUnmount(() => {
min-width: 0;
}
.commentItem__metaTop {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.commentItem__name {
font-size: 14px;
font-weight: 800;
}
.commentItem__badge {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 0 9px;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-surface-soft) 84%, transparent);
color: var(--theme-text-muted);
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.commentItem__badge--reply {
background: color-mix(in srgb, var(--theme-accent) 15%, var(--theme-surface-soft));
color: var(--theme-text);
}
.commentItem__date {
margin-top: 4px;
font-size: 12px;
@@ -538,6 +588,10 @@ onBeforeUnmount(() => {
}
.commentItem__body {
padding: 14px 15px;
border-radius: 18px;
background: color-mix(in srgb, var(--theme-input-bg) 82%, var(--theme-surface));
border: 1px solid color-mix(in srgb, var(--theme-card-border) 76%, transparent);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
@@ -570,7 +624,12 @@ onBeforeUnmount(() => {
}
.commentItem--reply {
margin-left: 12px;
margin-left: 14px;
}
.commentItem--reply::before {
left: -10px;
width: 8px;
}
}
</style>

View File

@@ -20,6 +20,10 @@ function avatarUrlOf(notification) {
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
}
function tierListThumbnailUrl(notification) {
return notification.tierListThumbnailSrc ? toApiUrl(notification.tierListThumbnailSrc) : ''
}
function avatarFallbackOf(notification) {
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
}
@@ -38,6 +42,10 @@ 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 } }))
@@ -145,10 +153,26 @@ 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>
<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__title">{{ notificationTitle(notification) }}</div>
<div class="commentInboxCard__lead">{{ notificationLead(notification) }}</div>
</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
@@ -167,7 +191,16 @@ watch(unreadOnly, loadInbox)
{{ notification.tierListTitle || '제목 없는 티어표' }}
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
</div>
<div class="commentInboxCard__content">{{ notification.commentContent }}</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>
<div class="commentInboxCard__threadBlock commentInboxCard__threadBlock--accent">
<div class="commentInboxCard__threadLabel">{{ notification.notificationType === 'comment_reply' ? '새 답글' : '새 댓글' }}</div>
<div class="commentInboxCard__threadText">{{ notification.commentContent }}</div>
</div>
</div>
</div>
</button>
</article>
@@ -230,20 +263,64 @@ watch(unreadOnly, loadInbox)
color: inherit;
text-align: left;
cursor: pointer;
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 18px;
align-items: stretch;
}
.commentInboxCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 10;
border-radius: 18px;
overflow: hidden;
background: var(--theme-thumb-fallback-bg);
border: 1px solid color-mix(in srgb, var(--theme-card-border) 78%, transparent);
}
.commentInboxCard__thumb,
.commentInboxCard__thumbFallback {
width: 100%;
height: 100%;
}
.commentInboxCard__thumb {
object-fit: cover;
display: block;
}
.commentInboxCard__thumbFallback {
display: grid;
place-items: center;
color: var(--theme-text-faint);
font-size: 13px;
font-weight: 800;
}
.commentInboxCard__titleRow {
display: flex;
align-items: center;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.commentInboxCard__title {
font-size: 18px;
font-size: 17px;
font-weight: 900;
}
.commentInboxCard__lead {
margin-top: 6px;
color: var(--theme-text-muted);
font-size: 13px;
}
.commentInboxCard__status {
display: inline-flex;
align-items: center;
gap: 10px;
}
.commentInboxCard__dot {
width: 10px;
height: 10px;
@@ -252,6 +329,18 @@ watch(unreadOnly, loadInbox)
flex: 0 0 auto;
}
.commentInboxCard__badge {
display: inline-flex;
align-items: center;
min-height: 28px;
padding: 0 12px;
border-radius: 999px;
background: color-mix(in srgb, var(--theme-accent) 18%, var(--theme-surface-soft));
color: var(--theme-text);
font-size: 12px;
font-weight: 800;
}
.commentInboxCard__meta {
margin-top: 10px;
display: flex;
@@ -291,13 +380,40 @@ watch(unreadOnly, loadInbox)
.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;
}
.commentInboxCard__thread {
margin-top: 14px;
display: grid;
gap: 10px;
}
.commentInboxCard__threadBlock {
padding: 14px 15px;
border-radius: 18px;
border: 1px solid var(--theme-card-border);
background: color-mix(in srgb, var(--theme-surface) 88%, var(--theme-surface-soft));
}
.commentInboxCard__threadBlock--accent {
border-color: color-mix(in srgb, var(--theme-accent) 38%, var(--theme-card-border));
background: color-mix(in srgb, var(--theme-accent) 10%, var(--theme-surface));
}
.commentInboxCard__threadLabel {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--theme-text-faint);
}
.commentInboxCard__threadText {
margin-top: 8px;
color: var(--theme-text);
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 860px) {
@@ -309,5 +425,9 @@ watch(unreadOnly, loadInbox)
.commentInboxPanel {
padding: 20px;
}
.commentInboxCard__body {
grid-template-columns: 1fr;
}
}
</style>