diff --git a/backend/src/db.js b/backend/src/db.js index a6cc530..09966ca 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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 diff --git a/docs/history.md b/docs/history.md index ede45eb..69c7b6b 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-07 v1.1.4 +- 댓글 관리함은 단순 목록보다 `무슨 티어표에서`, `원래 어떤 댓글이 있었고`, `새로 무엇이 달렸는지`를 한눈에 이해하는 정보 구조가 중요하다고 판단했다. 그래서 썸네일 + 스레드 비교 블록을 기본 카드 문법으로 채택했다. +- 댓글 본문과 답글도 단순 들여쓰기보다 카드/말풍선/연결선으로 관계를 보여주는 쪽이 최신 UI 감각에 더 맞는다고 보고, reply depth 1단 구조에 맞춘 시각 문법을 적용했다. + ## 2026-04-07 v1.1.3 - 댓글 답글 입력창은 포커스 상태에만 의존하지 않고, 비포커스 상태에서도 시각적 경계를 명확히 주기로 했다. 댓글 UI는 에디터 안의 부가 기능이지만 사용자가 바로 이해할 수 있어야 하므로 카드형 배경과 기본 테두리를 유지한다. diff --git a/docs/map.md b/docs/map.md index 526ad28..567f274 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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` diff --git a/docs/spec.md b/docs/spec.md index 5ee03ec..0dcca5c 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -243,6 +243,7 @@ - `DELETE /api/users/:userId/follow` - 댓글 알림 - `GET /api/comments/inbox` + - 알림 카드 렌더링을 위해 티어표 썸네일과 부모 댓글 내용도 함께 반환한다. - `GET /api/comments/inbox/unread-count` - `POST /api/comments/inbox/read` - 관리자 diff --git a/docs/todo.md b/docs/todo.md index a40895a..76bc2a0 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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, 댓글 관리 화면에서 `안 읽은 댓글만 보기`와 `모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤. diff --git a/docs/update.md b/docs/update.md index 9dfa195..5ed9916 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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` 스타일로 맞췄다. diff --git a/frontend/src/components/TierListCommentsCard.vue b/frontend/src/components/TierListCommentsCard.vue index d75f17d..48203cd 100644 --- a/frontend/src/components/TierListCommentsCard.vue +++ b/frontend/src/components/TierListCommentsCard.vue @@ -258,7 +258,10 @@ onBeforeUnmount(() => {
{{ avatarFallbackOf(comment) }}
-
{{ displayNameOf(comment) }}
+
+
{{ displayNameOf(comment) }}
+ 댓글 +
{{ formatDate(comment.createdAt) }}
@@ -307,7 +310,10 @@ onBeforeUnmount(() => {
{{ avatarFallbackOf(reply) }}
-
{{ displayNameOf(reply) }}
+
+
{{ displayNameOf(reply) }}
+ 답글 +
{{ formatDate(reply.createdAt) }}
@@ -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; } } diff --git a/frontend/src/views/CommentInboxView.vue b/frontend/src/views/CommentInboxView.vue index 8da7431..2dc4147 100644 --- a/frontend/src/views/CommentInboxView.vue +++ b/frontend/src/views/CommentInboxView.vue @@ -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 }" > @@ -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; + } }