Compare commits

...

4 Commits

Author SHA1 Message Date
63dc8f871c 댓글 더보기와 컨트롤 정리 2026-04-07 14:09:45 +09:00
d9aa6a6922 댓글 썸네일 비율 고정 2026-04-07 14:06:51 +09:00
09b9036bbe 댓글 정렬과 뷰어 레일 정리 2026-04-07 14:00:29 +09:00
9f143d4a89 댓글 UI 톤 정리 2026-04-07 13:45:50 +09:00
10 changed files with 333 additions and 115 deletions

View File

@@ -2740,6 +2740,11 @@ async function listTierListComments(tierListId) {
}
roots.push(comment)
})
roots.sort((a, b) => {
const timeDiff = Number(b.createdAt || 0) - Number(a.createdAt || 0)
if (timeDiff !== 0) return timeDiff
return String(b.id || '').localeCompare(String(a.id || ''))
})
return roots
}

View File

@@ -1,5 +1,20 @@
# 의사결정 이력
## 2026-04-07 v1.1.8
- 댓글은 처음부터 전부 렌더링하지 않고 일부만 보여준 뒤 `더 보기`로 확장하는 방향을 채택했다. 이 프로젝트는 본문이 긴 티어표 프리뷰와 함께 댓글을 보여주므로, 기본 노출 개수를 제한하는 편이 가독성과 레일 안정성에 모두 유리하다.
- 댓글 관리 화면 컨트롤은 별도 체크박스 문법을 만들지 않고, 설정/에디터에서 이미 쓰는 토글 스위치와 저장 CTA 톤을 재사용하는 것이 일관성에 맞다고 판단했다.
## 2026-04-07 v1.1.7
- 카드 내부 그리드에서 썸네일 비율을 맞출 때는 `aspect-ratio`만 두지 않고, 부모 그리드의 `stretch` 영향을 함께 차단해야 한다고 정리했다. 댓글 관리 카드 썸네일은 16:9 규칙을 CSS 정렬까지 포함해 고정한다.
## 2026-04-07 v1.1.6
- 댓글 영역은 과하게 화려한 새로운 카드보다, 우측 뷰어 카드와 통일되는 단정한 서비스 톤이 더 적합하다고 판단했다. 같은 화면 안에서 카드 문법이 지나치게 갈라지면 오히려 UI 완성도가 떨어지므로 댓글 카드도 공통 서비스 톤에 맞춘다.
- 댓글 정렬은 `루트 최신순 / 답글 오래된순`으로 고정한다. 최신 댓글을 먼저 보는 편이 전체 참여 흐름엔 유리하고, 답글은 작성 순서가 유지되어야 문맥 이해가 쉽다.
- 뷰어 우측 레일은 본문 길이와 독립적으로 위에서부터 쌓이도록 유지한다. 댓글처럼 본문이 길어질 수 있는 요소가 생겨도, 공유/복사 같은 보조 액션은 스폰서 카드 아래에서 바로 보여야 한다.
## 2026-04-07 v1.1.5
- 댓글 UI는 정보를 구분하기 위해 모든 레이어에 border를 두기보다, 큰 카드만 최소 테두리를 두고 내부는 surface 톤과 그림자 차이로 나누는 방향이 더 낫다고 판단했다. 댓글/답글 구조는 구분보다 과밀감이 먼저 느껴지면 안 되므로 이 원칙을 유지한다.
## 2026-04-07 v1.1.4
- 댓글 관리함은 단순 목록보다 `무슨 티어표에서`, `원래 어떤 댓글이 있었고`, `새로 무엇이 달렸는지`를 한눈에 이해하는 정보 구조가 중요하다고 판단했다. 그래서 썸네일 + 스레드 비교 블록을 기본 카드 문법으로 채택했다.
- 댓글 본문과 답글도 단순 들여쓰기보다 카드/말풍선/연결선으로 관계를 보여주는 쪽이 최신 UI 감각에 더 맞는다고 보고, reply depth 1단 구조에 맞춘 시각 문법을 적용했다.

View File

@@ -17,7 +17,7 @@
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/:id`, `GET /api/tierlists/:id/comments`, `POST /api/tierlists/:id/comments`, `DELETE /api/tierlists/:id/comments/:commentId`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
## `/comments`

View File

@@ -50,6 +50,8 @@
- `tierLists`: 추천 제외 최신 공개 티어표
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
- 댓글 표시 밀도 제어를 위해 기본 노출 개수는 루트 댓글 10개, 각 루트의 답글 3개로 제한하고 `더 보기` 버튼으로 추가 노출한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.

View File

@@ -1,6 +1,13 @@
# 할 일 및 이슈
## 단기 확인
- `v1.1.8` 이후 댓글 더 보기 규칙(루트 10개, 답글 3개)과 남은 개수 표기가 실제 데이터에서 자연스럽게 동작하는지 확인한다.
- 댓글 관리 화면의 `안 읽은 댓글만 보기` 토글과 `모두 읽음 처리` 버튼이 설정/에디터의 공통 컨트롤 톤과 이질감이 없는지 확인한다.
- `v1.1.7` 이후 댓글 관리 카드 썸네일이 실제로 모든 카드에서 16:9로 유지되는지 데스크톱/모바일에서 다시 확인한다.
- `v1.1.6` 이후 루트 댓글이 최신순으로, 답글은 오래된순으로 정확히 보이는지 실제 댓글 데이터를 여러 개 넣어 확인한다.
- 뷰어 모드에서 댓글이 길어져도 우측 `공유 티어표 보기` 카드가 스폰서 카드 바로 아래에서 유지되고, 더 이상 하단으로 밀려 보이지 않는지 확인한다.
- `v1.1.5` 이후 댓글 카드/댓글 관리 카드에서 보더가 과해 보이지 않고, surface/shadow 중심 레이어가 다크/라이트 모드 모두에서 자연스러운지 확인한다.
- 댓글 등록/답글 등록 버튼이 실제 저장 CTA 톤으로 보이고 hover/disabled 상태도 다른 저장 버튼들과 이질감이 없는지 확인한다.
- `v1.1.4` 이후 댓글 관리 카드에서 티어표 썸네일, 원댓글/새 댓글 비교 블록이 데스크톱과 모바일에서 모두 자연스럽게 보이는지 확인한다.
- 댓글 스레드 카드 리디자인 후 답글 연결선, 배지, 본문 말풍선 배경이 라이트/다크 모드 모두에서 과하지 않게 보이는지 확인한다.
- `v1.1.3` 이후 답글 작성 시 입력창이 열리자마자 포커스를 받고, 포커스 전에도 카드/입력 경계가 분명하게 보이는지 다크/라이트 모드 모두에서 확인한다.

View File

@@ -1,5 +1,29 @@
# 업데이트 로그
## 2026-04-07 v1.1.8
- 댓글 관리 화면의 상단 컨트롤을 정리했다. `안 읽은 댓글만 보기`는 체크박스 대신 프로젝트 공통 토글 스위치 문법으로 바꾸고, `모두 읽음 처리` 버튼도 저장 CTA 계열과 같은 톤으로 맞췄다.
- 댓글 영역에는 `더 보기` 흐름을 추가했다. 루트 댓글은 처음 10개, 답글은 처음 3개만 보여주고, 남은 개수를 표시하는 `댓글 n개 더 보기`, `답글 n개 더 보기` 버튼으로 단계적으로 펼친다.
- 댓글 카드 세로 밀도도 함께 낮췄다. 댓글/답글 카드 간격과 본문 패딩, 답글 입력 카드 여백을 줄여 긴 스레드에서도 화면을 덜 차지하게 정리했다.
- 확인: `npm run build`
## 2026-04-07 v1.1.7
- 댓글 관리 카드 썸네일이 CSS grid의 세로 stretch 영향으로 16:9로 보이지 않을 수 있어, 썸네일 셀을 `align-self: start`로 고정하고 카드 본문 정렬도 `align-items: start`로 바꿨다. 이제 댓글 관리 썸네일은 항상 16:9 비율로 표시된다.
- 확인: `npm run build`
## 2026-04-07 v1.1.6
- 댓글 영역 스타일을 다시 전면 정리했다. 과한 장식/그림자 중심 디자인 대신 `viewerSidebar__section` 계열과 같은 단정한 카드 문법으로 맞추고, 댓글/답글은 배경과 간격 위주로 읽히게 재구성했다.
- 댓글 등록/답글 등록 버튼은 불필요한 shadow 없이 에디터 저장 계열과 같은 `btn--save` 톤으로 다시 맞췄다.
- 댓글 정렬 규칙을 조정했다. 루트 댓글은 `최신 댓글이 가장 위`, 답글은 `가장 먼저 달린 답글이 가장 위`로 유지되도록 바꿨다.
- 뷰어 모드 우측 카드가 댓글 길이에 따라 아래로 밀려 보이지 않도록, 우측 로컬 레일 루트와 `viewerSidebar__section` 정렬을 `스폰서 카드 바로 아래` 기준으로 고정했다.
- 확인: `npm run build`
## 2026-04-07 v1.1.5
- 댓글 카드에서 과도하게 겹치던 보더 문법을 줄이고, 배경 톤과 그림자 중심으로 레이어를 구분하도록 다시 정리했다. 바깥 카드, 댓글 본문, 답글 입력 영역은 border 대신 surface/shadow 조합으로 읽히게 했다.
- 댓글 관리 카드의 티어표 썸네일은 항상 `16:9` 비율로 고정되도록 수정했다. 화면 크기에 따라 높이만 달라지고 이미지 인상 자체는 바뀌지 않게 맞췄다.
- 댓글 등록/답글 등록 버튼은 컴포넌트 내부에서도 실제 `btn--save` 스타일이 적용되도록 공통 save CTA 문법을 직접 정의해, 에디터 저장 버튼과 같은 톤으로 보이게 했다.
- `commentsCard__desc` 안내 문구 폰트 크기를 `12px`로 줄여 본문보다 덜 강조되게 정리했다.
- 확인: `npm run build`
## 2026-04-07 v1.1.4
- 댓글 관리 카드 디자인을 확장했다. 각 카드에 해당 티어표 썸네일을 붙이고, `원래 댓글``새 댓글/새 답글`을 한 번에 비교해서 볼 수 있게 스레드 블록 구조로 바꿨다.
- 댓글 알림 조회 API는 이제 티어표 썸네일과 부모 댓글 내용을 함께 내려준다. 답글 알림에서는 어떤 댓글에 어떤 답글이 달렸는지 바로 읽을 수 있다.

View File

@@ -2004,6 +2004,8 @@ function reloadApp() {
min-height: 100%;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: stretch;
gap: 14px;
}

View File

@@ -39,11 +39,15 @@ const openedReplyComposerId = ref('')
const submittingTargetId = ref('')
const deletingCommentId = ref('')
const replyInputRefs = ref({})
const visibleRootCount = ref(10)
const visibleReplyCounts = ref({})
let activeCommentRetryTimer = 0
const totalCommentCount = computed(() =>
comments.value.reduce((count, comment) => count + 1 + (comment.replies?.length || 0), 0)
)
const visibleComments = computed(() => comments.value.slice(0, visibleRootCount.value))
const hasMoreRootComments = computed(() => comments.value.length > visibleRootCount.value)
const loginTarget = computed(() => loginPath(route.fullPath))
const highlightedCommentId = computed(() =>
typeof route.query.commentId === 'string' ? route.query.commentId.trim() : ''
@@ -110,6 +114,8 @@ async function loadComments() {
try {
const data = await api.listTierListComments(props.tierListId)
comments.value = Array.isArray(data.comments) ? data.comments : []
visibleRootCount.value = 10
visibleReplyCounts.value = {}
scrollToHighlightedComment()
} catch (loadError) {
error.value = '댓글을 불러오지 못했어요.'
@@ -118,6 +124,36 @@ async function loadComments() {
}
}
function visibleRepliesOf(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
const limit = visibleReplyCounts.value[comment?.id] || 3
return replies.slice(0, limit)
}
function hasMoreReplies(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
return replies.length > (visibleReplyCounts.value[comment?.id] || 3)
}
function remainingReplyCount(comment) {
const replies = Array.isArray(comment?.replies) ? comment.replies : []
return Math.max(0, replies.length - (visibleReplyCounts.value[comment?.id] || 3))
}
const remainingRootCount = computed(() => Math.max(0, comments.value.length - visibleRootCount.value))
function showMoreRootComments() {
visibleRootCount.value += 10
}
function showMoreReplies(commentId) {
if (!commentId) return
visibleReplyCounts.value = {
...visibleReplyCounts.value,
[commentId]: (visibleReplyCounts.value[commentId] || 3) + 3,
}
}
async function submitComment(parentCommentId = '') {
if (!props.canWrite || !props.tierListId) return
const isReply = !!parentCommentId
@@ -212,7 +248,7 @@ onBeforeUnmount(() => {
<template>
<section class="commentsCard">
<header class="commentsCard__head">
<div>
<div class="commentsCard__headline">
<div class="commentsCard__eyebrow">Comments</div>
<h3 class="commentsCard__title">{{ title }}</h3>
<p class="commentsCard__desc">{{ description }}</p>
@@ -232,14 +268,14 @@ onBeforeUnmount(() => {
/>
<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 class="btn btn--save commentsComposer__submit" 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>
<RouterLink class="btn btn--ghost commentsComposer__submit" :to="loginTarget">로그인</RouterLink>
</div>
<div v-if="isLoading" class="commentsCard__empty">댓글을 불러오는 중이에요.</div>
@@ -247,7 +283,7 @@ onBeforeUnmount(() => {
<div v-else class="commentsThread">
<article
v-for="comment in comments"
v-for="comment in visibleComments"
:key="comment.id"
class="commentItem"
:class="{ 'commentItem--highlighted': isHighlighted(comment.id) }"
@@ -258,10 +294,7 @@ 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__metaTop">
<div class="commentItem__name">{{ displayNameOf(comment) }}</div>
<span class="commentItem__badge">댓글</span>
</div>
<div class="commentItem__name">{{ displayNameOf(comment) }}</div>
<div class="commentItem__date">{{ formatDate(comment.createdAt) }}</div>
</div>
</div>
@@ -299,7 +332,7 @@ onBeforeUnmount(() => {
<div v-if="comment.replies?.length" class="replyList">
<article
v-for="reply in comment.replies"
v-for="reply in visibleRepliesOf(comment)"
:key="reply.id"
class="commentItem commentItem--reply"
:class="{ 'commentItem--highlighted': isHighlighted(reply.id) }"
@@ -310,10 +343,7 @@ 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__metaTop">
<div class="commentItem__name">{{ displayNameOf(reply) }}</div>
<span class="commentItem__badge commentItem__badge--reply">답글</span>
</div>
<div class="commentItem__name">{{ displayNameOf(reply) }}</div>
<div class="commentItem__date">{{ formatDate(reply.createdAt) }}</div>
</div>
</div>
@@ -331,8 +361,14 @@ onBeforeUnmount(() => {
</div>
<div class="commentItem__body">{{ reply.content }}</div>
</article>
<button v-if="hasMoreReplies(comment)" class="commentMoreButton" type="button" @click="showMoreReplies(comment.id)">
답글 {{ remainingReplyCount(comment) }} 보기
</button>
</div>
</article>
<button v-if="hasMoreRootComments" class="commentMoreButton commentMoreButton--root" type="button" @click="showMoreRootComments">
댓글 {{ remainingRootCount }} 보기
</button>
</div>
</section>
</template>
@@ -340,18 +376,22 @@ onBeforeUnmount(() => {
<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);
padding: 18px;
border-radius: 22px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.commentsCard__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 18px;
margin-bottom: 16px;
}
.commentsCard__headline {
min-width: 0;
}
.commentsCard__eyebrow {
@@ -366,18 +406,21 @@ onBeforeUnmount(() => {
margin: 6px 0 8px;
font-size: 22px;
font-weight: 900;
letter-spacing: -0.04em;
}
.commentsCard__desc {
margin: 0;
color: var(--theme-text-muted);
font-size: 12px;
line-height: 1.55;
}
.commentsCard__count {
flex: 0 0 auto;
align-self: flex-start;
padding: 8px 12px;
border-radius: 999px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text-muted);
font-size: 13px;
@@ -393,19 +436,12 @@ onBeforeUnmount(() => {
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;
padding: 14px;
border-radius: 18px;
background: var(--theme-surface-soft);
}
.commentsComposer__input {
@@ -413,25 +449,22 @@ onBeforeUnmount(() => {
min-height: 92px;
resize: vertical;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid var(--theme-field-border);
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-input-bg);
color: var(--theme-text);
box-sizing: border-box;
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-field-border) 45%, transparent);
}
.commentsComposer__input--reply {
min-height: 72px;
background: color-mix(in srgb, var(--theme-input-bg) 82%, var(--theme-surface-soft));
background: var(--theme-input-bg);
}
.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);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 16%, transparent);
}
.commentsComposer__footer,
@@ -459,48 +492,56 @@ onBeforeUnmount(() => {
.commentsThread {
display: grid;
gap: 16px;
gap: 10px;
}
.commentMoreButton {
justify-self: flex-start;
margin-top: 10px;
padding: 0;
border: 0;
background: transparent;
color: var(--theme-text-muted);
font-size: 13px;
font-weight: 800;
cursor: pointer;
}
.commentMoreButton--root {
margin-top: 4px;
}
.commentItem {
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);
padding: 12px 0 0;
}
.commentItem--reply {
margin-top: 12px;
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%);
margin-top: 8px;
margin-left: 20px;
padding-top: 8px;
}
.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;
top: 0;
left: -12px;
width: 1px;
bottom: 0;
background: color-mix(in srgb, var(--theme-border) 82%, transparent);
}
.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);
border-radius: 18px;
background: color-mix(in srgb, var(--theme-accent) 10%, transparent);
}
.commentItem__head {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
margin-bottom: 6px;
}
.commentItem__author {
@@ -531,37 +572,11 @@ 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;
@@ -588,31 +603,71 @@ 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);
padding: 12px 14px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface);
white-space: pre-wrap;
word-break: break-word;
line-height: 1.6;
}
.replyComposer {
margin-top: 14px;
padding: 14px;
margin-top: 12px;
padding: 12px;
border-radius: 18px;
border: 1px solid var(--theme-card-border);
background: color-mix(in srgb, var(--theme-surface) 76%, var(--theme-surface-soft));
background: var(--theme-surface-soft);
}
.commentsComposer__submit {
min-width: 112px;
min-height: 42px;
min-height: 44px;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 12px 18px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
cursor: pointer;
font-size: 15px;
font-weight: 700;
transition: background 160ms ease;
}
.btn:hover {
background: var(--theme-surface-soft-3);
}
.btn:disabled {
opacity: 0.58;
cursor: default;
}
.btn--save {
min-width: 112px;
font-weight: 900;
background: rgba(96, 165, 250, 0.22);
border-color: rgba(96, 165, 250, 0.36);
}
.btn--save:hover {
background: rgba(96, 165, 250, 0.3);
}
.btn--ghost {
background: color-mix(in srgb, var(--theme-surface-soft) 86%, transparent);
}
@media (max-width: 860px) {
.commentsCard {
padding: 20px;
padding: 18px 16px;
}
.commentsCard__head,
@@ -628,8 +683,7 @@ onBeforeUnmount(() => {
}
.commentItem--reply::before {
left: -10px;
width: 8px;
left: -8px;
}
}
</style>

View File

@@ -130,11 +130,12 @@ watch(unreadOnly, loadInbox)
</section>
<section class="commentInboxToolbar">
<label class="commentInboxToolbar__toggle">
<label class="toggleSwitch commentInboxToolbar__toggle">
<input v-model="unreadOnly" type="checkbox" />
<span> 읽은 댓글만 보기</span>
<span class="toggleSwitch__label"> 읽은 댓글만 보기</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<button class="btn btn--ghost btn--small" type="button" :disabled="!unreadCount || isMarkingAllRead" @click="markAllAsRead">
<button class="btn btn--save commentInboxToolbar__action" type="button" :disabled="!unreadCount || isMarkingAllRead" @click="markAllAsRead">
모두 읽음 처리
</button>
</section>
@@ -219,12 +220,11 @@ watch(unreadOnly, loadInbox)
}
.commentInboxToolbar__toggle {
display: inline-flex;
align-items: center;
gap: 10px;
color: var(--theme-text-muted);
font-size: 14px;
font-weight: 700;
min-width: 220px;
}
.commentInboxToolbar__action {
min-width: 148px;
}
.commentInboxPanel {
@@ -246,13 +246,17 @@ watch(unreadOnly, loadInbox)
.commentInboxCard {
border-radius: 22px;
border: 1px solid var(--theme-card-border);
background: var(--theme-surface);
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-surface) 92%, var(--theme-surface-soft)) 0%, var(--theme-surface) 100%);
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 {
border-color: color-mix(in srgb, var(--theme-accent) 48%, var(--theme-card-border));
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);
}
.commentInboxCard__body {
@@ -266,16 +270,18 @@ watch(unreadOnly, loadInbox)
display: grid;
grid-template-columns: 140px minmax(0, 1fr);
gap: 18px;
align-items: stretch;
align-items: start;
}
.commentInboxCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 10;
aspect-ratio: 16 / 9;
align-self: start;
flex: 0 0 auto;
border-radius: 18px;
overflow: hidden;
background: var(--theme-thumb-fallback-bg);
border: 1px solid color-mix(in srgb, var(--theme-card-border) 78%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-card-border) 26%, transparent);
}
.commentInboxCard__thumb,
@@ -391,13 +397,17 @@ watch(unreadOnly, loadInbox)
.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));
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);
}
.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));
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 {
@@ -416,6 +426,105 @@ watch(unreadOnly, loadInbox)
word-break: break-word;
}
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
min-height: 44px;
padding: 12px 18px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
cursor: pointer;
font-size: 15px;
font-weight: 700;
transition: background 160ms ease;
}
.btn:hover {
background: var(--theme-surface-soft-3);
}
.btn:disabled {
opacity: 0.58;
cursor: default;
}
.btn--save {
min-width: 112px;
font-weight: 900;
background: rgba(96, 165, 250, 0.22);
border-color: rgba(96, 165, 250, 0.36);
}
.btn--save:hover {
background: rgba(96, 165, 250, 0.3);
}
.toggleSwitch {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
cursor: pointer;
user-select: none;
}
.toggleSwitch input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.toggleSwitch__track {
position: relative;
width: 42px;
height: 24px;
border-radius: 999px;
background: var(--theme-surface-soft-3);
border: 1px solid var(--theme-border-strong);
transition: background 160ms ease, border-color 160ms ease;
flex: 0 0 auto;
}
.toggleSwitch__thumb {
position: absolute;
top: 50%;
left: 3px;
width: 16px;
height: 16px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.92);
transform: translateY(-50%);
transition: transform 180ms ease;
}
:root[data-theme='light'] .toggleSwitch__thumb {
background: rgba(15, 23, 42, 0.82);
}
.toggleSwitch__label {
min-width: 0;
color: var(--theme-text-muted);
font-size: 14px;
font-weight: 700;
}
.toggleSwitch input:checked ~ .toggleSwitch__track {
background: rgba(96, 165, 250, 0.34);
border-color: rgba(96, 165, 250, 0.46);
}
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
transform: translate(18px, -50%);
}
@media (max-width: 860px) {
.commentInboxToolbar {
flex-direction: column;

View File

@@ -2199,7 +2199,7 @@ onUnmounted(() => {
}
.viewerSidebar__section {
margin-top: auto;
margin-top: 0;
display: grid;
gap: 10px;
padding: 18px;