Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d5575d3028 | |||
| 173f547d8b |
@@ -268,8 +268,10 @@ function mapCommentNotificationRow(row) {
|
|||||||
topicSlug: row.topic_slug || row.topic_id,
|
topicSlug: row.topic_slug || row.topic_id,
|
||||||
topicName: row.topic_name || '',
|
topicName: row.topic_name || '',
|
||||||
tierListTitle: row.tierlist_title || '',
|
tierListTitle: row.tierlist_title || '',
|
||||||
|
tierListThumbnailSrc: row.tierlist_thumbnail_src || '',
|
||||||
commentId: row.comment_id,
|
commentId: row.comment_id,
|
||||||
parentCommentId: row.parent_comment_id || '',
|
parentCommentId: row.parent_comment_id || '',
|
||||||
|
parentCommentContent: row.parent_comment_content || '',
|
||||||
notificationType: row.notification_type || 'tierlist_comment',
|
notificationType: row.notification_type || 'tierlist_comment',
|
||||||
isRead: !!row.is_read,
|
isRead: !!row.is_read,
|
||||||
readAt: Number(row.read_at || 0),
|
readAt: Number(row.read_at || 0),
|
||||||
@@ -2843,15 +2845,18 @@ async function listCommentNotifications(userId, { unreadOnly = false } = {}) {
|
|||||||
n.actor_user_id,
|
n.actor_user_id,
|
||||||
c.parent_comment_id,
|
c.parent_comment_id,
|
||||||
c.content AS comment_content,
|
c.content AS comment_content,
|
||||||
|
parent.content AS parent_comment_content,
|
||||||
t.topic_id,
|
t.topic_id,
|
||||||
tp.slug AS topic_slug,
|
tp.slug AS topic_slug,
|
||||||
tp.name AS topic_name,
|
tp.name AS topic_name,
|
||||||
t.title AS tierlist_title,
|
t.title AS tierlist_title,
|
||||||
|
t.thumbnail_src AS tierlist_thumbnail_src,
|
||||||
actor.nickname AS actor_nickname,
|
actor.nickname AS actor_nickname,
|
||||||
actor.email AS actor_email,
|
actor.email AS actor_email,
|
||||||
actor.avatar_src AS actor_avatar_src
|
actor.avatar_src AS actor_avatar_src
|
||||||
FROM comment_notifications n
|
FROM comment_notifications n
|
||||||
INNER JOIN tierlist_comments c ON c.id = n.comment_id
|
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 tierlists t ON t.id = n.tierlist_id
|
||||||
INNER JOIN topics tp ON tp.id = t.topic_id
|
INNER JOIN topics tp ON tp.id = t.topic_id
|
||||||
INNER JOIN users actor ON actor.id = n.actor_user_id
|
INNER JOIN users actor ON actor.id = n.actor_user_id
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.4
|
||||||
|
- 댓글 관리함은 단순 목록보다 `무슨 티어표에서`, `원래 어떤 댓글이 있었고`, `새로 무엇이 달렸는지`를 한눈에 이해하는 정보 구조가 중요하다고 판단했다. 그래서 썸네일 + 스레드 비교 블록을 기본 카드 문법으로 채택했다.
|
||||||
|
- 댓글 본문과 답글도 단순 들여쓰기보다 카드/말풍선/연결선으로 관계를 보여주는 쪽이 최신 UI 감각에 더 맞는다고 보고, reply depth 1단 구조에 맞춘 시각 문법을 적용했다.
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.3
|
||||||
|
- 댓글 답글 입력창은 포커스 상태에만 의존하지 않고, 비포커스 상태에서도 시각적 경계를 명확히 주기로 했다. 댓글 UI는 에디터 안의 부가 기능이지만 사용자가 바로 이해할 수 있어야 하므로 카드형 배경과 기본 테두리를 유지한다.
|
||||||
|
|
||||||
## 2026-04-07 v1.1.2
|
## 2026-04-07 v1.1.2
|
||||||
- 댓글/알림 기능처럼 새 테이블을 뒤늦게 붙이는 경우 `CREATE TABLE IF NOT EXISTS`만으로는 충분하지 않다고 판단했다. 이미 남아 있는 예전 스키마와 충돌할 수 있으므로, 서버 시작 시 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 형태의 점진 마이그레이션을 함께 넣는 방향으로 유지한다.
|
- 댓글/알림 기능처럼 새 테이블을 뒤늦게 붙이는 경우 `CREATE TABLE IF NOT EXISTS`만으로는 충분하지 않다고 판단했다. 이미 남아 있는 예전 스키마와 충돌할 수 있으므로, 서버 시작 시 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS` 형태의 점진 마이그레이션을 함께 넣는 방향으로 유지한다.
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
## `/comments`
|
## `/comments`
|
||||||
- 화면 파일: `frontend/src/views/CommentInboxView.vue`
|
- 화면 파일: `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`
|
- 연동 API: `GET /api/comments/inbox`, `GET /api/comments/inbox/unread-count`, `POST /api/comments/inbox/read`
|
||||||
|
|
||||||
## `/login`
|
## `/login`
|
||||||
|
|||||||
@@ -243,6 +243,7 @@
|
|||||||
- `DELETE /api/users/:userId/follow`
|
- `DELETE /api/users/:userId/follow`
|
||||||
- 댓글 알림
|
- 댓글 알림
|
||||||
- `GET /api/comments/inbox`
|
- `GET /api/comments/inbox`
|
||||||
|
- 알림 카드 렌더링을 위해 티어표 썸네일과 부모 댓글 내용도 함께 반환한다.
|
||||||
- `GET /api/comments/inbox/unread-count`
|
- `GET /api/comments/inbox/unread-count`
|
||||||
- `POST /api/comments/inbox/read`
|
- `POST /api/comments/inbox/read`
|
||||||
- 관리자
|
- 관리자
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.1.4` 이후 댓글 관리 카드에서 티어표 썸네일, 원댓글/새 댓글 비교 블록이 데스크톱과 모바일에서 모두 자연스럽게 보이는지 확인한다.
|
||||||
|
- 댓글 스레드 카드 리디자인 후 답글 연결선, 배지, 본문 말풍선 배경이 라이트/다크 모드 모두에서 과하지 않게 보이는지 확인한다.
|
||||||
|
- `v1.1.3` 이후 답글 작성 시 입력창이 열리자마자 포커스를 받고, 포커스 전에도 카드/입력 경계가 분명하게 보이는지 다크/라이트 모드 모두에서 확인한다.
|
||||||
- `v1.1.2` 반영 후 실제 운영/로컬 DB에서 서버를 다시 띄워 `comment_notifications.is_read` 컬럼이 자동 보강되는지, `댓글 관리` 메뉴 unread dot과 `/api/comments/inbox/unread-count`가 더 이상 SQL 오류 없이 동작하는지 확인한다.
|
- `v1.1.2` 반영 후 실제 운영/로컬 DB에서 서버를 다시 띄워 `comment_notifications.is_read` 컬럼이 자동 보강되는지, `댓글 관리` 메뉴 unread dot과 `/api/comments/inbox/unread-count`가 더 이상 SQL 오류 없이 동작하는지 확인한다.
|
||||||
- `v1.1.1` 댓글 복구 이후 다음 흐름을 우선 QA한다: 공개 티어표 프리뷰 하단 댓글 노출, 댓글 작성/답글 작성/본인 댓글 삭제, 댓글 관리 메뉴 red dot, 댓글 관리 화면에서 `안 읽은 댓글만 보기`와 `모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤.
|
- `v1.1.1` 댓글 복구 이후 다음 흐름을 우선 QA한다: 공개 티어표 프리뷰 하단 댓글 노출, 댓글 작성/답글 작성/본인 댓글 삭제, 댓글 관리 메뉴 red dot, 댓글 관리 화면에서 `안 읽은 댓글만 보기`와 `모두 읽음 처리`, 카드 클릭 후 해당 댓글 위치 스크롤.
|
||||||
- 작성자 본인 티어표 편집 화면과 타인 티어표 프리뷰 화면에서 같은 댓글 카드가 모두 자연스럽게 보이는지, 새로고침 후에도 기존 에디터 회귀 없이 댓글 카드만 안정적으로 붙는지 확인한다.
|
- 작성자 본인 티어표 편집 화면과 타인 티어표 프리뷰 화면에서 같은 댓글 카드가 모두 자연스럽게 보이는지, 새로고침 후에도 기존 에디터 회귀 없이 댓글 카드만 안정적으로 붙는지 확인한다.
|
||||||
|
|||||||
@@ -1,5 +1,16 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 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` 스타일로 맞췄다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
## 2026-04-07 v1.1.2
|
## 2026-04-07 v1.1.2
|
||||||
- 댓글 알림 테이블을 기존 DB에서도 안전하게 올릴 수 있도록 스키마 보정 로직을 추가했다. 예전 형태의 `comment_notifications` 또는 `tierlist_comments` 테이블이 이미 있어도 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`로 `is_read`, `read_at`, `notification_type`, `actor_user_id`, `parent_comment_id`, `updated_at`를 보강한다.
|
- 댓글 알림 테이블을 기존 DB에서도 안전하게 올릴 수 있도록 스키마 보정 로직을 추가했다. 예전 형태의 `comment_notifications` 또는 `tierlist_comments` 테이블이 이미 있어도 `ALTER TABLE ... ADD COLUMN IF NOT EXISTS`로 `is_read`, `read_at`, `notification_type`, `actor_user_id`, `parent_comment_id`, `updated_at`를 보강한다.
|
||||||
- 이 수정으로 기존 DB에서 `/api/comments/inbox/unread-count` 호출 시 `Unknown column 'is_read' in 'WHERE'`가 나던 문제를 해결한다.
|
- 이 수정으로 기존 DB에서 `/api/comments/inbox/unread-count` 호출 시 `Unknown column 'is_read' in 'WHERE'`가 나던 문제를 해결한다.
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const replyDrafts = ref({})
|
|||||||
const openedReplyComposerId = ref('')
|
const openedReplyComposerId = ref('')
|
||||||
const submittingTargetId = ref('')
|
const submittingTargetId = ref('')
|
||||||
const deletingCommentId = ref('')
|
const deletingCommentId = ref('')
|
||||||
|
const replyInputRefs = ref({})
|
||||||
let activeCommentRetryTimer = 0
|
let activeCommentRetryTimer = 0
|
||||||
|
|
||||||
const totalCommentCount = computed(() =>
|
const totalCommentCount = computed(() =>
|
||||||
@@ -169,8 +170,33 @@ async function deleteComment(commentId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleReplyComposer(commentId) {
|
function registerReplyInput(commentId, element) {
|
||||||
openedReplyComposerId.value = openedReplyComposerId.value === commentId ? '' : commentId
|
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(() => props.tierListId, loadComments, { immediate: true })
|
||||||
@@ -232,7 +258,10 @@ onBeforeUnmount(() => {
|
|||||||
<img v-if="avatarUrlOf(comment)" class="commentItem__avatar" :src="avatarUrlOf(comment)" :alt="displayNameOf(comment)" draggable="false" />
|
<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 v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(comment) }}</div>
|
||||||
<div class="commentItem__meta">
|
<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 class="commentItem__date">{{ formatDate(comment.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -254,6 +283,7 @@ onBeforeUnmount(() => {
|
|||||||
<div v-if="openedReplyComposerId === comment.id && props.canWrite" class="replyComposer">
|
<div v-if="openedReplyComposerId === comment.id && props.canWrite" class="replyComposer">
|
||||||
<textarea
|
<textarea
|
||||||
v-model="replyDrafts[comment.id]"
|
v-model="replyDrafts[comment.id]"
|
||||||
|
:ref="(element) => registerReplyInput(comment.id, element)"
|
||||||
class="commentsComposer__input commentsComposer__input--reply"
|
class="commentsComposer__input commentsComposer__input--reply"
|
||||||
maxlength="2000"
|
maxlength="2000"
|
||||||
rows="2"
|
rows="2"
|
||||||
@@ -261,7 +291,7 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="commentsComposer__footer">
|
<div class="commentsComposer__footer">
|
||||||
<span class="commentsComposer__hint">{{ (replyDrafts[comment.id] || '').length }}/2000</span>
|
<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 class="btn btn--save commentsComposer__submit" type="button" :disabled="!(replyDrafts[comment.id] || '').trim() || submittingTargetId === comment.id" @click="submitComment(comment.id)">
|
||||||
답글 등록
|
답글 등록
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -280,7 +310,10 @@ onBeforeUnmount(() => {
|
|||||||
<img v-if="avatarUrlOf(reply)" class="commentItem__avatar" :src="avatarUrlOf(reply)" :alt="displayNameOf(reply)" draggable="false" />
|
<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 v-else class="commentItem__avatar commentItem__avatar--fallback">{{ avatarFallbackOf(reply) }}</div>
|
||||||
<div class="commentItem__meta">
|
<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 class="commentItem__date">{{ formatDate(reply.createdAt) }}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,10 +418,20 @@ onBeforeUnmount(() => {
|
|||||||
background: var(--theme-input-bg);
|
background: var(--theme-input-bg);
|
||||||
color: var(--theme-text);
|
color: var(--theme-text);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-field-border) 45%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsComposer__input--reply {
|
.commentsComposer__input--reply {
|
||||||
min-height: 72px;
|
min-height: 72px;
|
||||||
|
background: color-mix(in srgb, var(--theme-input-bg) 82%, var(--theme-surface-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentsComposer__input:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: color-mix(in srgb, var(--theme-accent) 60%, var(--theme-field-border));
|
||||||
|
box-shadow:
|
||||||
|
inset 0 0 0 1px color-mix(in srgb, var(--theme-accent) 52%, transparent),
|
||||||
|
0 0 0 3px color-mix(in srgb, var(--theme-accent) 16%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsComposer__footer,
|
.commentsComposer__footer,
|
||||||
@@ -416,18 +459,36 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.commentsThread {
|
.commentsThread {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentItem {
|
.commentItem {
|
||||||
padding: 16px;
|
position: relative;
|
||||||
border-radius: 22px;
|
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 {
|
.commentItem--reply {
|
||||||
margin-top: 12px;
|
margin-top: 12px;
|
||||||
margin-left: 24px;
|
margin-left: 28px;
|
||||||
border-radius: 18px;
|
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 {
|
.commentItem--highlighted {
|
||||||
@@ -470,11 +531,37 @@ onBeforeUnmount(() => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commentItem__metaTop {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
.commentItem__name {
|
.commentItem__name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 800;
|
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 {
|
.commentItem__date {
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
@@ -501,6 +588,10 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.commentItem__body {
|
.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;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
@@ -508,6 +599,15 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.replyComposer {
|
.replyComposer {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
|
padding: 14px;
|
||||||
|
border-radius: 18px;
|
||||||
|
border: 1px solid var(--theme-card-border);
|
||||||
|
background: color-mix(in srgb, var(--theme-surface) 76%, var(--theme-surface-soft));
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentsComposer__submit {
|
||||||
|
min-width: 112px;
|
||||||
|
min-height: 42px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
@@ -524,7 +624,12 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.commentItem--reply {
|
.commentItem--reply {
|
||||||
margin-left: 12px;
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentItem--reply::before {
|
||||||
|
left: -10px;
|
||||||
|
width: 8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ function avatarUrlOf(notification) {
|
|||||||
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
|
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tierListThumbnailUrl(notification) {
|
||||||
|
return notification.tierListThumbnailSrc ? toApiUrl(notification.tierListThumbnailSrc) : ''
|
||||||
|
}
|
||||||
|
|
||||||
function avatarFallbackOf(notification) {
|
function avatarFallbackOf(notification) {
|
||||||
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
|
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
|
||||||
}
|
}
|
||||||
@@ -38,6 +42,10 @@ function notificationTitle(notification) {
|
|||||||
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
|
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notificationLead(notification) {
|
||||||
|
return notification.notificationType === 'comment_reply' ? '원래 댓글과 새 답글을 함께 확인해보세요.' : '내 티어표에 새로 남겨진 댓글입니다.'
|
||||||
|
}
|
||||||
|
|
||||||
function emitUnreadCount(unread) {
|
function emitUnreadCount(unread) {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
window.dispatchEvent(new CustomEvent('tier-maker:comment-inbox-updated', { detail: { unreadCount: unread } }))
|
window.dispatchEvent(new CustomEvent('tier-maker:comment-inbox-updated', { detail: { unreadCount: unread } }))
|
||||||
@@ -145,10 +153,26 @@ watch(unreadOnly, loadInbox)
|
|||||||
:class="{ 'commentInboxCard--unread': !notification.isRead }"
|
:class="{ 'commentInboxCard--unread': !notification.isRead }"
|
||||||
>
|
>
|
||||||
<button class="commentInboxCard__body" type="button" @click="openNotification(notification)">
|
<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__main">
|
||||||
<div class="commentInboxCard__titleRow">
|
<div class="commentInboxCard__titleRow">
|
||||||
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
<div>
|
||||||
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
|
<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>
|
||||||
<div class="commentInboxCard__meta">
|
<div class="commentInboxCard__meta">
|
||||||
<img
|
<img
|
||||||
@@ -167,7 +191,16 @@ watch(unreadOnly, loadInbox)
|
|||||||
{{ notification.tierListTitle || '제목 없는 티어표' }}
|
{{ notification.tierListTitle || '제목 없는 티어표' }}
|
||||||
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
|
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
@@ -230,20 +263,64 @@ watch(unreadOnly, loadInbox)
|
|||||||
color: inherit;
|
color: inherit;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
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 {
|
.commentInboxCard__titleRow {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard__title {
|
.commentInboxCard__title {
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
font-weight: 900;
|
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 {
|
.commentInboxCard__dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@@ -252,6 +329,18 @@ watch(unreadOnly, loadInbox)
|
|||||||
flex: 0 0 auto;
|
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 {
|
.commentInboxCard__meta {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -291,13 +380,40 @@ watch(unreadOnly, loadInbox)
|
|||||||
|
|
||||||
.commentInboxCard__content {
|
.commentInboxCard__content {
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
color: var(--theme-text-muted);
|
}
|
||||||
display: -webkit-box;
|
|
||||||
-webkit-line-clamp: 3;
|
.commentInboxCard__thread {
|
||||||
-webkit-box-orient: vertical;
|
margin-top: 14px;
|
||||||
overflow: hidden;
|
display: grid;
|
||||||
word-break: break-word;
|
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;
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
@@ -309,5 +425,9 @@ watch(unreadOnly, loadInbox)
|
|||||||
.commentInboxPanel {
|
.commentInboxPanel {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commentInboxCard__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user