Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2273fa723 | |||
| de304c98a7 | |||
| 68481c3ebf | |||
| 31e266e79e | |||
| 63dc8f871c | |||
| d9aa6a6922 | |||
| 09b9036bbe | |||
| 9f143d4a89 | |||
| d5575d3028 |
@@ -268,8 +268,19 @@ 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 || '',
|
||||||
|
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',
|
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),
|
||||||
@@ -2738,6 +2749,11 @@ async function listTierListComments(tierListId) {
|
|||||||
}
|
}
|
||||||
roots.push(comment)
|
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
|
return roots
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2843,15 +2859,23 @@ 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,
|
||||||
|
parent.created_at AS parent_comment_created_at,
|
||||||
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,
|
||||||
|
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
|
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
|
||||||
|
LEFT JOIN users parent_author ON parent_author.id = parent.author_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,41 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.12
|
||||||
|
- `viewToggle`은 특정 주제 화면에만 남겨둘 기능이 아니라, 카드형/리스트형을 공통 문법으로 쓰는 주요 목록 화면 전반에서 일관되게 제공하는 편이 맞다고 정리했다.
|
||||||
|
- 현재 주요 목록 화면은 데이터 규모가 아직 크지 않아 전부 한 번에 조회하는 구조를 유지하되, 이후 공개 티어표와 즐겨찾기 수가 늘어나면 페이지네이션이나 점진 로딩을 후속 과제로 검토하기로 했다.
|
||||||
|
- 티어표 즐겨찾기는 “다른 사람 작품 보관”만이 아니라 “내가 자주 참고하는 내 작업 고정” 용도로도 쓸 수 있으므로, 작성자 본인 티어표도 프런트에서 막지 않는 방향이 더 자연스럽다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.11
|
||||||
|
- 즐겨찾기 페이지는 단순 모아보기만으로 끝나면 관리 화면 역할이 약하므로, 카드 안에서 바로 해제할 수 있게 두는 편이 맞다고 정리했다. 별도 상세 화면으로 들어가서 해제하는 흐름은 불필요하게 길다.
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.10
|
||||||
|
- 댓글 관리함은 기본적으로 “안 읽은 것부터 처리하는 공간”이므로, 첫 진입 기본값을 전체 목록보다 `안 읽은 댓글만 보기 활성화`로 두는 편이 맞다고 정리했다.
|
||||||
|
- 댓글 관리 카드의 상단 배지는 정보 라벨보다 행동 버튼이 더 유용하다고 판단했다. `댓글/답글` 구분은 제목과 본문 구조만으로 충분히 이해되므로, 같은 자리는 `읽음 처리`처럼 즉시 처리 가능한 액션에 쓰는 쪽이 효율적이다.
|
||||||
|
- 티어표 즐겨찾기는 이미 API와 목록 화면이 있으므로 새 기능을 늘리기보다, 보기 화면 우측 레일에 단독 CTA로 명확히 드러내는 편이 더 중요하다고 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.9
|
||||||
|
- 댓글 관리함은 단순 알림 문구보다 `어느 티어표에서 어떤 루트 댓글이 있었고 그 아래 어떤 새 댓글/답글이 달렸는지`를 카드 한 장 안에서 읽히게 하는 편이 더 중요하다고 정리했다. 그래서 좌측에는 대상 티어표 정보, 우측에는 댓글 흐름 자체를 배치하는 2열 구조를 기본으로 삼는다.
|
||||||
|
- `commentInboxCard__lead`처럼 제목을 다시 설명하는 보조 문구는 상태 전달에 비해 공간만 차지하므로 제거하고, 대신 실제 댓글 작성자/시간/본문 정보를 바로 보여주는 방향이 낫다고 판단했다.
|
||||||
|
|
||||||
|
## 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단 구조에 맞춘 시각 문법을 적용했다.
|
||||||
|
|
||||||
## 2026-04-07 v1.1.3
|
## 2026-04-07 v1.1.3
|
||||||
- 댓글 답글 입력창은 포커스 상태에만 의존하지 않고, 비포커스 상태에서도 시각적 경계를 명확히 주기로 했다. 댓글 UI는 에디터 안의 부가 기능이지만 사용자가 바로 이해할 수 있어야 하므로 카드형 배경과 기본 테두리를 유지한다.
|
- 댓글 답글 입력창은 포커스 상태에만 의존하지 않고, 비포커스 상태에서도 시각적 경계를 명확히 주기로 했다. 댓글 UI는 에디터 안의 부가 기능이지만 사용자가 바로 이해할 수 있어야 하므로 카드형 배경과 기본 테두리를 유지한다.
|
||||||
|
|
||||||
|
|||||||
12
docs/map.md
12
docs/map.md
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
## `/`
|
## `/`
|
||||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||||
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 카드 클릭 시 해당 티어표 화면으로 이동
|
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 카드 클릭 시 해당 티어표 화면으로 이동
|
||||||
- 연동 API: `GET /api/tierlists/public?q=...`
|
- 연동 API: `GET /api/tierlists/public?q=...`
|
||||||
|
|
||||||
## `/templates`
|
## `/templates`
|
||||||
- 화면 파일: `frontend/src/views/TemplatesView.vue`
|
- 화면 파일: `frontend/src/views/TemplatesView.vue`
|
||||||
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링
|
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환
|
||||||
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
|
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
|
||||||
|
|
||||||
## `/topics/:topicId`
|
## `/topics/:topicId`
|
||||||
@@ -17,12 +17,12 @@
|
|||||||
|
|
||||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||||
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링
|
- 역할: 주제 slug 기반 에디터 진입, 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 아이템 클릭 선택 후 셀/풀 재배치, 아이템 우클릭 메뉴 기반 복제본 생성, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 읽기 전용 상태의 즐겨찾기 단독 CTA, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청, 댓글 카드 표시, `?preview=1` 진입 시 공통 앱 셸은 유지한 채 중앙 본문에서 완성본 프리뷰와 하단 댓글 카드를 렌더링하며, 우측 뷰어 카드(`공유 티어표 보기`)는 스폰서 카드 바로 아래에서 유지하고 즐겨찾기 CTA도 함께 노출
|
||||||
- 연동 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`
|
- 연동 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`
|
## `/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`
|
||||||
@@ -32,12 +32,12 @@
|
|||||||
|
|
||||||
## `/me`
|
## `/me`
|
||||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||||
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
- 역할: 내 티어표 목록 조회, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||||
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
||||||
|
|
||||||
## `/favorites`
|
## `/favorites`
|
||||||
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
|
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
|
||||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
|
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면 이동, 카드 우측 상단 `즐겨찾기 해제` 버튼으로 즉시 제거
|
||||||
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
||||||
|
|
||||||
## `/following`
|
## `/following`
|
||||||
|
|||||||
12
docs/spec.md
12
docs/spec.md
@@ -48,8 +48,18 @@
|
|||||||
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
|
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
|
||||||
- `featuredTierLists`: 상단 추천 티어표
|
- `featuredTierLists`: 상단 추천 티어표
|
||||||
- `tierLists`: 추천 제외 최신 공개 티어표
|
- `tierLists`: 추천 제외 최신 공개 티어표
|
||||||
|
- 홈, 템플릿, 나의 티어표, 즐겨찾기 화면은 공통 `viewToggle`로 `그리드 / 리스트` 보기를 전환하며, 상태는 현재 라우트의 `?view=list` 쿼리로 반영한다.
|
||||||
|
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
|
||||||
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
||||||
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
||||||
|
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
|
||||||
|
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
|
||||||
|
- 댓글 표시 밀도 제어를 위해 기본 노출 개수는 루트 댓글 10개, 각 루트의 답글 3개로 제한하고 `더 보기` 버튼으로 추가 노출한다.
|
||||||
|
- 댓글 관리 카드(`/comments`)는 좌측 `16:9 썸네일 + 티어표 제목 + 템플릿 이름`, 우측 `알림 제목 + 루트 댓글 정보 + 새 댓글/답글 정보`의 2열 구조를 사용한다.
|
||||||
|
- 댓글 관리 카드의 상단 우측 배지는 상태 라벨이 아니라 개별 `읽음 처리` 액션으로 사용한다.
|
||||||
|
- 티어표 즐겨찾기 API(`POST/DELETE /api/tierlists/:id/favorite`)는 이미 존재하며, 보기 화면 우측 레일에는 이를 직접 호출하는 단독 CTA를 노출한다.
|
||||||
|
- 티어표 즐겨찾기는 작성자 본인 저장 티어표에도 사용할 수 있다.
|
||||||
|
- `/favorites` 목록 카드에서도 같은 `DELETE /api/tierlists/:id/favorite`를 직접 호출해 즉시 해제할 수 있다.
|
||||||
- 우측 패널
|
- 우측 패널
|
||||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||||
@@ -170,6 +180,7 @@
|
|||||||
- `readAt`: number
|
- `readAt`: number
|
||||||
- `createdAt`: number
|
- `createdAt`: number
|
||||||
- 기존 운영 DB에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
|
- 기존 운영 DB에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
|
||||||
|
- 댓글 관리 카드 구성을 위해 조회 응답에는 `parentCommentContent`, `parentCommentCreatedAt`, `parentAuthorName`, `parentAuthorAccountName`, `parentAuthorAvatarSrc`를 함께 내려준다.
|
||||||
- `templateRequests`
|
- `templateRequests`
|
||||||
- `id`: string
|
- `id`: string
|
||||||
- `type`: string
|
- `type`: string
|
||||||
@@ -243,6 +254,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`
|
||||||
- 관리자
|
- 관리자
|
||||||
|
|||||||
19
docs/todo.md
19
docs/todo.md
@@ -1,6 +1,25 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.1.12` 이후 홈/템플릿/나의 티어표/즐겨찾기에서 공통 `viewToggle`이 모두 같은 위치/같은 동작으로 보이는지 확인한다.
|
||||||
|
- 리스트형 보기에서 홈/템플릿/나의 티어표/즐겨찾기 카드가 데스크톱과 모바일 모두에서 썸네일 비율과 제목 overflow 없이 안정적으로 보이는지 확인한다.
|
||||||
|
- 내가 만든 저장 티어표도 즐겨찾기에 추가되고 `/favorites`에 나타나는지, 비공개 내 티어표를 즐겨찾기했을 때 접근/표시 규칙이 자연스러운지 확인한다.
|
||||||
|
- 현재 주요 목록 화면은 전체 데이터를 한 번에 가져오는 구조이므로, 실제 데이터가 많아졌을 때 페이지네이션 또는 무한 스크롤이 필요한 시점을 추후 점검한다.
|
||||||
|
- `v1.1.11` 이후 즐겨찾기 페이지 카드 우측 상단 `즐겨찾기 해제` 버튼이 카드 열기와 충돌하지 않는지, 해제 직후 목록에서 즉시 빠지고 새로고침 후에도 유지되는지 확인한다.
|
||||||
|
- `v1.1.10` 이후 댓글 관리 화면이 기본적으로 안 읽은 댓글만 보이므로, 사용자가 처음 들어왔을 때 빈 화면처럼 느끼지 않는지와 `전체 보기`로 돌렸을 때도 자연스러운지 확인한다.
|
||||||
|
- 개별 `읽음 처리` 버튼을 눌렀을 때 카드가 즉시 사라지고 좌측 메뉴 unread dot도 함께 줄어드는지, 마지막 unread 카드까지 처리하면 dot이 사라지는지 확인한다.
|
||||||
|
- 티어표 보기 화면 우측 즐겨찾기 단독 CTA가 편집 라우트의 읽기 전용 상태와 `preview=1` 뷰어 모드 양쪽에서 모두 자연스럽게 보이는지 확인한다.
|
||||||
|
- `v1.1.9` 이후 댓글 관리 카드에서 좌측 썸네일/티어표 정보와 우측 루트 댓글/새 댓글 정보가 실제로 한눈에 읽히는지, 특히 답글 알림에서 부모 댓글 작성자 정보가 자연스럽게 보이는지 확인한다.
|
||||||
|
- `v1.1.9` 이후 `commentInboxCard__lead` 제거로 정보가 부족해지지 않았는지, 제목과 댓글 블록만으로 상태를 이해할 수 있는지 데스크톱/모바일에서 다시 확인한다.
|
||||||
|
- `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` 이후 답글 작성 시 입력창이 열리자마자 포커스를 받고, 포커스 전에도 카드/입력 경계가 분명하게 보이는지 다크/라이트 모드 모두에서 확인한다.
|
- `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,63 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.12
|
||||||
|
- 티어표 즐겨찾기 프런트 제한을 풀었다. 이제 내가 만든 저장된 티어표도 즐겨찾기에 넣을 수 있고, 같은 즐겨찾기 목록(`/favorites`)에서 다시 확인할 수 있다.
|
||||||
|
- 홈, 템플릿, 나의 티어표, 즐겨찾기 화면에 공통 `viewToggle`을 다시 연결했다. 기존처럼 카드형 그리드와 가로 리스트형 보기 전환을 URL `?view=list` 기준으로 같은 방식으로 유지한다.
|
||||||
|
- 홈/나의 티어표/즐겨찾기 화면에는 리스트형 레이아웃을 추가했고, 템플릿 화면도 같은 토글로 카드형과 리스트형을 오갈 수 있게 맞췄다.
|
||||||
|
- 즐겨찾기 페이지 카드에서 썸네일/제목/메타가 카드 밖으로 넘치거나 잘려 보이던 구조를 `min-width`, overflow, title row grid 정리로 보정했다.
|
||||||
|
- 현재 홈, 템플릿, 나의 티어표, 즐겨찾기 목록은 모두 페이지네이션이나 무한 스크롤 없이 “현재 조회 결과 전체를 한 번에 로드”하는 구조임을 다시 확인했다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.11
|
||||||
|
- `즐겨찾기` 페이지 카드에서도 바로 해제할 수 있게 정리했다. 이제 목록 화면에서 각 카드 우측 상단 `즐겨찾기 해제` 버튼으로 해당 티어표를 즉시 제거할 수 있다.
|
||||||
|
- 카드 본문 열기와 해제 버튼 동작이 섞이지 않도록 분리했다. 버튼은 카드 클릭과 독립적으로 처리되고, 성공 시 목록에서도 바로 빠져 정리 흐름이 자연스럽다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 2026-04-07 v1.1.10
|
||||||
|
- 댓글 관리 화면은 기본 진입 시 `안 읽은 댓글만 보기`가 켜진 상태로 시작하도록 바꿨다. 처음 들어왔을 때 가장 중요한 미확인 알림만 먼저 보이게 하는 쪽이 관리 흐름에 더 자연스럽다.
|
||||||
|
- 댓글 관리 카드의 우측 배지는 더 이상 `댓글/답글` 구분용이 아니라 개별 `읽음 처리` 버튼으로 동작한다. 이제 해당 티어표 화면으로 들어가지 않아도 카드 단위로 바로 읽음 처리할 수 있다.
|
||||||
|
- 개별 읽음 처리는 `안 읽은 댓글만 보기`가 켜진 상태와 자연스럽게 연결되도록, 처리한 카드는 즉시 목록에서 빠지고 좌측 메뉴 unread dot도 함께 갱신되게 정리했다.
|
||||||
|
- 티어표 보기 화면의 즐겨찾기 액션을 더 명확하게 드러냈다. 일반 보기 화면 우측 사이드에는 `즐겨찾기에 추가하기 / 즐겨찾기 해제하기` 단독 버튼을 노출하고, 편집 라우트의 읽기 전용 상태에서도 같은 톤의 단일 CTA로 정리했다.
|
||||||
|
- 즐겨찾기 수는 버튼 안 숫자 대신 보조 문구로 분리해, 액션 자체를 더 또렷하게 읽히게 바꿨다.
|
||||||
|
- 확인: `npm run build`
|
||||||
|
|
||||||
|
## 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개 더 보기` 버튼으로 단계적으로 펼친다.
|
||||||
|
- 댓글 카드 세로 밀도도 함께 낮췄다. 댓글/답글 카드 간격과 본문 패딩, 답글 입력 카드 여백을 줄여 긴 스레드에서도 화면을 덜 차지하게 정리했다.
|
||||||
|
- 확인: `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는 이제 티어표 썸네일과 부모 댓글 내용을 함께 내려준다. 답글 알림에서는 어떤 댓글에 어떤 답글이 달렸는지 바로 읽을 수 있다.
|
||||||
|
- 일반 댓글 카드(`commentItem`)도 더 카드형이고 세련된 톤으로 정리했다. 본문은 말풍선처럼 분리하고, 답글은 얇은 연결선과 보조 배지로 관계가 자연스럽게 읽히도록 다듬었다.
|
||||||
|
- 확인: `node --check backend/src/db.js`, `npm run build`
|
||||||
|
|
||||||
## 2026-04-07 v1.1.3
|
## 2026-04-07 v1.1.3
|
||||||
- 댓글 답글 입력 UX를 다듬었다. `답글` 버튼을 누르면 입력창이 열리자마자 자동으로 포커스가 이동하고, 포커스 전에도 구분이 되도록 답글 입력 영역 카드와 textarea 기본 경계선을 보강했다.
|
- 댓글 답글 입력 UX를 다듬었다. `답글` 버튼을 누르면 입력창이 열리자마자 자동으로 포커스가 이동하고, 포커스 전에도 구분이 되도록 답글 입력 영역 카드와 textarea 기본 경계선을 보강했다.
|
||||||
- 답글 등록 버튼도 기존의 작은 기본형 버튼 대신 프로젝트 전반의 저장 계열 CTA 문법과 같은 `btn--save` 스타일로 맞췄다.
|
- 답글 등록 버튼도 기존의 작은 기본형 버튼 대신 프로젝트 전반의 저장 계열 CTA 문법과 같은 `btn--save` 스타일로 맞췄다.
|
||||||
|
|||||||
@@ -157,7 +157,7 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
|||||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||||
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
const showTopicViewToggle = computed(() => ['home', 'templates', 'topicHub', 'me', 'favorites'].includes(String(route.name || '')))
|
||||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||||
@@ -547,7 +547,7 @@ function toggleRightRail() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTopicViewMode(mode) {
|
function setTopicViewMode(mode) {
|
||||||
if (route.name !== 'topicHub') return
|
if (!showTopicViewToggle.value) return
|
||||||
const nextQuery = { ...route.query }
|
const nextQuery = { ...route.query }
|
||||||
if (mode === 'list') nextQuery.view = 'list'
|
if (mode === 'list') nextQuery.view = 'list'
|
||||||
else delete nextQuery.view
|
else delete nextQuery.view
|
||||||
@@ -2004,6 +2004,8 @@ function reloadApp() {
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: stretch;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,11 +39,15 @@ const openedReplyComposerId = ref('')
|
|||||||
const submittingTargetId = ref('')
|
const submittingTargetId = ref('')
|
||||||
const deletingCommentId = ref('')
|
const deletingCommentId = ref('')
|
||||||
const replyInputRefs = ref({})
|
const replyInputRefs = ref({})
|
||||||
|
const visibleRootCount = ref(10)
|
||||||
|
const visibleReplyCounts = ref({})
|
||||||
let activeCommentRetryTimer = 0
|
let activeCommentRetryTimer = 0
|
||||||
|
|
||||||
const totalCommentCount = computed(() =>
|
const totalCommentCount = computed(() =>
|
||||||
comments.value.reduce((count, comment) => count + 1 + (comment.replies?.length || 0), 0)
|
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 loginTarget = computed(() => loginPath(route.fullPath))
|
||||||
const highlightedCommentId = computed(() =>
|
const highlightedCommentId = computed(() =>
|
||||||
typeof route.query.commentId === 'string' ? route.query.commentId.trim() : ''
|
typeof route.query.commentId === 'string' ? route.query.commentId.trim() : ''
|
||||||
@@ -110,6 +114,8 @@ async function loadComments() {
|
|||||||
try {
|
try {
|
||||||
const data = await api.listTierListComments(props.tierListId)
|
const data = await api.listTierListComments(props.tierListId)
|
||||||
comments.value = Array.isArray(data.comments) ? data.comments : []
|
comments.value = Array.isArray(data.comments) ? data.comments : []
|
||||||
|
visibleRootCount.value = 10
|
||||||
|
visibleReplyCounts.value = {}
|
||||||
scrollToHighlightedComment()
|
scrollToHighlightedComment()
|
||||||
} catch (loadError) {
|
} catch (loadError) {
|
||||||
error.value = '댓글을 불러오지 못했어요.'
|
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 = '') {
|
async function submitComment(parentCommentId = '') {
|
||||||
if (!props.canWrite || !props.tierListId) return
|
if (!props.canWrite || !props.tierListId) return
|
||||||
const isReply = !!parentCommentId
|
const isReply = !!parentCommentId
|
||||||
@@ -212,7 +248,7 @@ onBeforeUnmount(() => {
|
|||||||
<template>
|
<template>
|
||||||
<section class="commentsCard">
|
<section class="commentsCard">
|
||||||
<header class="commentsCard__head">
|
<header class="commentsCard__head">
|
||||||
<div>
|
<div class="commentsCard__headline">
|
||||||
<div class="commentsCard__eyebrow">Comments</div>
|
<div class="commentsCard__eyebrow">Comments</div>
|
||||||
<h3 class="commentsCard__title">{{ title }}</h3>
|
<h3 class="commentsCard__title">{{ title }}</h3>
|
||||||
<p class="commentsCard__desc">{{ description }}</p>
|
<p class="commentsCard__desc">{{ description }}</p>
|
||||||
@@ -232,14 +268,14 @@ onBeforeUnmount(() => {
|
|||||||
/>
|
/>
|
||||||
<div class="commentsComposer__footer">
|
<div class="commentsComposer__footer">
|
||||||
<span class="commentsComposer__hint">{{ commentDraft.length }}/2000</span>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="commentsLoginCta">
|
<div v-else class="commentsLoginCta">
|
||||||
<div class="commentsLoginCta__text">로그인하면 댓글과 답글을 남길 수 있어요.</div>
|
<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>
|
||||||
|
|
||||||
<div v-if="isLoading" class="commentsCard__empty">댓글을 불러오는 중이에요.</div>
|
<div v-if="isLoading" class="commentsCard__empty">댓글을 불러오는 중이에요.</div>
|
||||||
@@ -247,7 +283,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div v-else class="commentsThread">
|
<div v-else class="commentsThread">
|
||||||
<article
|
<article
|
||||||
v-for="comment in comments"
|
v-for="comment in visibleComments"
|
||||||
:key="comment.id"
|
:key="comment.id"
|
||||||
class="commentItem"
|
class="commentItem"
|
||||||
:class="{ 'commentItem--highlighted': isHighlighted(comment.id) }"
|
:class="{ 'commentItem--highlighted': isHighlighted(comment.id) }"
|
||||||
@@ -296,7 +332,7 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
<div v-if="comment.replies?.length" class="replyList">
|
<div v-if="comment.replies?.length" class="replyList">
|
||||||
<article
|
<article
|
||||||
v-for="reply in comment.replies"
|
v-for="reply in visibleRepliesOf(comment)"
|
||||||
:key="reply.id"
|
:key="reply.id"
|
||||||
class="commentItem commentItem--reply"
|
class="commentItem commentItem--reply"
|
||||||
:class="{ 'commentItem--highlighted': isHighlighted(reply.id) }"
|
:class="{ 'commentItem--highlighted': isHighlighted(reply.id) }"
|
||||||
@@ -325,8 +361,14 @@ onBeforeUnmount(() => {
|
|||||||
</div>
|
</div>
|
||||||
<div class="commentItem__body">{{ reply.content }}</div>
|
<div class="commentItem__body">{{ reply.content }}</div>
|
||||||
</article>
|
</article>
|
||||||
|
<button v-if="hasMoreReplies(comment)" class="commentMoreButton" type="button" @click="showMoreReplies(comment.id)">
|
||||||
|
답글 {{ remainingReplyCount(comment) }}개 더 보기
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
<button v-if="hasMoreRootComments" class="commentMoreButton commentMoreButton--root" type="button" @click="showMoreRootComments">
|
||||||
|
댓글 {{ remainingRootCount }}개 더 보기
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
@@ -334,18 +376,22 @@ onBeforeUnmount(() => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.commentsCard {
|
.commentsCard {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
padding: 24px;
|
padding: 18px;
|
||||||
border-radius: 28px;
|
border-radius: 22px;
|
||||||
border: 1px solid var(--theme-card-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-card-bg);
|
background: var(--theme-pill-bg);
|
||||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsCard__head {
|
.commentsCard__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 16px;
|
gap: 16px;
|
||||||
margin-bottom: 18px;
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentsCard__headline {
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsCard__eyebrow {
|
.commentsCard__eyebrow {
|
||||||
@@ -360,18 +406,21 @@ onBeforeUnmount(() => {
|
|||||||
margin: 6px 0 8px;
|
margin: 6px 0 8px;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsCard__desc {
|
.commentsCard__desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: var(--theme-text-muted);
|
color: var(--theme-text-muted);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.55;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsCard__count {
|
.commentsCard__count {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
align-self: flex-start;
|
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-surface-soft);
|
background: var(--theme-surface-soft);
|
||||||
color: var(--theme-text-muted);
|
color: var(--theme-text-muted);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -387,19 +436,12 @@ onBeforeUnmount(() => {
|
|||||||
color: var(--theme-text);
|
color: var(--theme-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsComposer,
|
|
||||||
.commentsLoginCta,
|
|
||||||
.commentItem,
|
|
||||||
.commentItem--reply {
|
|
||||||
border: 1px solid var(--theme-card-border);
|
|
||||||
background: var(--theme-surface);
|
|
||||||
}
|
|
||||||
|
|
||||||
.commentsComposer,
|
.commentsComposer,
|
||||||
.commentsLoginCta {
|
.commentsLoginCta {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
padding: 16px;
|
padding: 14px;
|
||||||
border-radius: 22px;
|
border-radius: 18px;
|
||||||
|
background: var(--theme-surface-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsComposer__input {
|
.commentsComposer__input {
|
||||||
@@ -407,25 +449,22 @@ onBeforeUnmount(() => {
|
|||||||
min-height: 92px;
|
min-height: 92px;
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 18px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--theme-field-border);
|
border: 1px solid var(--theme-border);
|
||||||
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));
|
background: var(--theme-input-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsComposer__input:focus {
|
.commentsComposer__input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: color-mix(in srgb, var(--theme-accent) 60%, var(--theme-field-border));
|
border-color: color-mix(in srgb, var(--theme-accent) 60%, var(--theme-field-border));
|
||||||
box-shadow:
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--theme-accent) 16%, transparent);
|
||||||
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,
|
||||||
@@ -453,30 +492,56 @@ onBeforeUnmount(() => {
|
|||||||
|
|
||||||
.commentsThread {
|
.commentsThread {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
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 {
|
.commentItem {
|
||||||
padding: 16px;
|
position: relative;
|
||||||
border-radius: 22px;
|
padding: 12px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentItem--reply {
|
.commentItem--reply {
|
||||||
margin-top: 12px;
|
margin-top: 8px;
|
||||||
margin-left: 24px;
|
margin-left: 20px;
|
||||||
border-radius: 18px;
|
padding-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentItem--reply::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: -12px;
|
||||||
|
width: 1px;
|
||||||
|
bottom: 0;
|
||||||
|
background: color-mix(in srgb, var(--theme-border) 82%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentItem--highlighted {
|
.commentItem--highlighted {
|
||||||
border-color: color-mix(in srgb, var(--theme-accent) 65%, var(--theme-card-border));
|
border-radius: 18px;
|
||||||
box-shadow: 0 0 0 1px color-mix(in srgb, var(--theme-accent) 38%, transparent);
|
background: color-mix(in srgb, var(--theme-accent) 10%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentItem__head {
|
.commentItem__head {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentItem__author {
|
.commentItem__author {
|
||||||
@@ -538,27 +603,71 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.commentItem__body {
|
.commentItem__body {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 16px;
|
||||||
|
border: 1px solid var(--theme-border);
|
||||||
|
background: var(--theme-surface);
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replyComposer {
|
.replyComposer {
|
||||||
margin-top: 14px;
|
margin-top: 12px;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
border: 1px solid var(--theme-card-border);
|
background: var(--theme-surface-soft);
|
||||||
background: color-mix(in srgb, var(--theme-surface) 76%, var(--theme-surface-soft));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsComposer__submit {
|
.commentsComposer__submit {
|
||||||
min-width: 112px;
|
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) {
|
@media (max-width: 860px) {
|
||||||
.commentsCard {
|
.commentsCard {
|
||||||
padding: 20px;
|
padding: 18px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentsCard__head,
|
.commentsCard__head,
|
||||||
@@ -570,7 +679,11 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.commentItem--reply {
|
.commentItem--reply {
|
||||||
margin-left: 12px;
|
margin-left: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentItem--reply::before {
|
||||||
|
left: -8px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ const router = useRouter()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const notifications = ref([])
|
const notifications = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const unreadOnly = ref(false)
|
const unreadOnly = ref(true)
|
||||||
const isMarkingAllRead = ref(false)
|
const isMarkingAllRead = ref(false)
|
||||||
|
const markingNotificationId = ref('')
|
||||||
|
|
||||||
const unreadCount = computed(() => notifications.value.filter((item) => !item.isRead).length)
|
const unreadCount = computed(() => notifications.value.filter((item) => !item.isRead).length)
|
||||||
|
|
||||||
@@ -20,10 +21,26 @@ function avatarUrlOf(notification) {
|
|||||||
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
|
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parentAvatarUrlOf(notification) {
|
||||||
|
return notification.parentAuthorAvatarSrc ? toApiUrl(notification.parentAuthorAvatarSrc) : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
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, '?')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function parentAvatarFallbackOf(notification) {
|
||||||
|
return displayInitialFrom(notification.parentAuthorName, notification.parentAuthorAccountName, '?')
|
||||||
|
}
|
||||||
|
|
||||||
|
function parentDisplayNameOf(notification) {
|
||||||
|
return notification.parentAuthorName || '알 수 없음'
|
||||||
|
}
|
||||||
|
|
||||||
function formatDate(ts) {
|
function formatDate(ts) {
|
||||||
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
|
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
@@ -59,16 +76,30 @@ async function loadInbox() {
|
|||||||
async function markOneAsRead(notificationId) {
|
async function markOneAsRead(notificationId) {
|
||||||
const target = notifications.value.find((item) => item.id === notificationId)
|
const target = notifications.value.find((item) => item.id === notificationId)
|
||||||
if (!target || target.isRead) return
|
if (!target || target.isRead) return
|
||||||
|
const original = notifications.value.map((item) => ({ ...item }))
|
||||||
target.isRead = true
|
target.isRead = true
|
||||||
|
if (unreadOnly.value) {
|
||||||
|
notifications.value = notifications.value.filter((item) => item.id !== notificationId)
|
||||||
|
}
|
||||||
emitUnreadCount(unreadCount.value)
|
emitUnreadCount(unreadCount.value)
|
||||||
try {
|
try {
|
||||||
await api.markCommentInboxRead({ notificationIds: [notificationId] })
|
await api.markCommentInboxRead({ notificationIds: [notificationId] })
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
target.isRead = false
|
notifications.value = original
|
||||||
emitUnreadCount(unreadCount.value)
|
emitUnreadCount(unreadCount.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function markNotificationButton(notificationId) {
|
||||||
|
if (!notificationId || markingNotificationId.value) return
|
||||||
|
markingNotificationId.value = notificationId
|
||||||
|
try {
|
||||||
|
await markOneAsRead(notificationId)
|
||||||
|
} finally {
|
||||||
|
markingNotificationId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function markAllAsRead() {
|
async function markAllAsRead() {
|
||||||
if (!unreadCount.value) return
|
if (!unreadCount.value) return
|
||||||
isMarkingAllRead.value = true
|
isMarkingAllRead.value = true
|
||||||
@@ -122,11 +153,12 @@ watch(unreadOnly, loadInbox)
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="commentInboxToolbar">
|
<section class="commentInboxToolbar">
|
||||||
<label class="commentInboxToolbar__toggle">
|
<label class="toggleSwitch commentInboxToolbar__toggle">
|
||||||
<input v-model="unreadOnly" type="checkbox" />
|
<input v-model="unreadOnly" type="checkbox" />
|
||||||
<span>안 읽은 댓글만 보기</span>
|
<span class="toggleSwitch__label">안 읽은 댓글만 보기</span>
|
||||||
|
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||||
</label>
|
</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>
|
</button>
|
||||||
</section>
|
</section>
|
||||||
@@ -145,29 +177,80 @@ 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__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__main">
|
||||||
<div class="commentInboxCard__titleRow">
|
<div class="commentInboxCard__titleRow">
|
||||||
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
||||||
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
|
<div class="commentInboxCard__status">
|
||||||
|
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
|
||||||
|
<button
|
||||||
|
v-if="!notification.isRead"
|
||||||
|
class="commentInboxCard__badge"
|
||||||
|
type="button"
|
||||||
|
:disabled="!!markingNotificationId"
|
||||||
|
@click.stop="markNotificationButton(notification.id)"
|
||||||
|
>
|
||||||
|
{{ markingNotificationId === notification.id ? '처리 중...' : '읽음 처리' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="commentInboxCard__meta">
|
<div class="commentInboxCard__thread">
|
||||||
<img
|
<div v-if="notification.parentCommentContent" class="commentInboxThread">
|
||||||
v-if="avatarUrlOf(notification)"
|
<div class="commentInboxThread__label">루트 댓글</div>
|
||||||
class="commentInboxCard__avatar"
|
<div class="commentInboxThread__body">
|
||||||
:src="avatarUrlOf(notification)"
|
<img
|
||||||
:alt="notification.actorName || '작성자'"
|
v-if="parentAvatarUrlOf(notification)"
|
||||||
draggable="false"
|
class="commentInboxThread__avatar"
|
||||||
/>
|
:src="parentAvatarUrlOf(notification)"
|
||||||
<div v-else class="commentInboxCard__avatar commentInboxCard__avatar--fallback">{{ avatarFallbackOf(notification) }}</div>
|
:alt="parentDisplayNameOf(notification)"
|
||||||
<span class="commentInboxCard__actor">{{ notification.actorName }}</span>
|
draggable="false"
|
||||||
<span class="commentInboxCard__separator">·</span>
|
/>
|
||||||
<span class="commentInboxCard__date">{{ formatDate(notification.createdAt) }}</span>
|
<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="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>
|
||||||
<div class="commentInboxCard__target">
|
|
||||||
{{ notification.tierListTitle || '제목 없는 티어표' }}
|
|
||||||
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
|
|
||||||
</div>
|
|
||||||
<div class="commentInboxCard__content">{{ notification.commentContent }}</div>
|
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
</article>
|
</article>
|
||||||
@@ -186,20 +269,18 @@ watch(unreadOnly, loadInbox)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxToolbar__toggle {
|
.commentInboxToolbar__toggle {
|
||||||
display: inline-flex;
|
min-width: 220px;
|
||||||
align-items: center;
|
}
|
||||||
gap: 10px;
|
|
||||||
color: var(--theme-text-muted);
|
.commentInboxToolbar__action {
|
||||||
font-size: 14px;
|
min-width: 148px;
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxPanel {
|
.commentInboxPanel {
|
||||||
border-radius: 28px;
|
padding: 18px;
|
||||||
border: 1px solid var(--theme-card-border);
|
border-radius: 22px;
|
||||||
background: var(--theme-card-bg);
|
border: 1px solid var(--theme-border);
|
||||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
background: var(--theme-pill-bg);
|
||||||
padding: 24px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxEmpty {
|
.commentInboxEmpty {
|
||||||
@@ -212,14 +293,15 @@ watch(unreadOnly, loadInbox)
|
|||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard {
|
.commentInboxCard {
|
||||||
border-radius: 22px;
|
border-radius: 20px;
|
||||||
border: 1px solid var(--theme-card-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-surface);
|
background: var(--theme-surface);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard--unread {
|
.commentInboxCard--unread {
|
||||||
border-color: color-mix(in srgb, var(--theme-accent) 48%, var(--theme-card-border));
|
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 {
|
.commentInboxCard__body {
|
||||||
@@ -230,20 +312,79 @@ 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: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInboxCard__aside {
|
||||||
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInboxCard__thumbWrap {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
align-self: start;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
border-radius: 18px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--theme-thumb-fallback-bg);
|
||||||
|
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--theme-card-border) 26%, 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__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 {
|
.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__status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
.commentInboxCard__dot {
|
.commentInboxCard__dot {
|
||||||
width: 10px;
|
width: 10px;
|
||||||
height: 10px;
|
height: 10px;
|
||||||
@@ -252,18 +393,61 @@ watch(unreadOnly, loadInbox)
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard__meta {
|
.commentInboxCard__badge {
|
||||||
margin-top: 10px;
|
display: inline-flex;
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
justify-content: center;
|
||||||
color: var(--theme-text-muted);
|
min-height: 28px;
|
||||||
font-size: 13px;
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--theme-accent) 22%, var(--theme-border));
|
||||||
|
background: color-mix(in srgb, var(--theme-accent) 18%, var(--theme-surface-soft));
|
||||||
|
color: var(--theme-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard__avatar {
|
.commentInboxCard__badge:disabled {
|
||||||
width: 24px;
|
cursor: default;
|
||||||
height: 24px;
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInboxCard__thread {
|
||||||
|
margin-top: 14px;
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInboxThread {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInboxThread__label {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--theme-text-faint);
|
||||||
|
}
|
||||||
|
|
||||||
|
.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;
|
border-radius: 999px;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1px solid var(--theme-avatar-border);
|
border: 1px solid var(--theme-avatar-border);
|
||||||
@@ -271,33 +455,145 @@ watch(unreadOnly, loadInbox)
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard__avatar--fallback {
|
.commentInboxThread__avatar--fallback {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
font-size: 11px;
|
font-size: 13px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard__target {
|
.commentInboxThread__content {
|
||||||
margin-top: 12px;
|
min-width: 0;
|
||||||
font-size: 15px;
|
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;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard__targetMeta {
|
.commentInboxThread__separator {
|
||||||
color: var(--theme-text-faint);
|
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;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
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;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
.commentInboxCard__content {
|
.toggleSwitch input:checked ~ .toggleSwitch__track {
|
||||||
margin-top: 10px;
|
background: rgba(96, 165, 250, 0.34);
|
||||||
color: var(--theme-text-muted);
|
border-color: rgba(96, 165, 250, 0.46);
|
||||||
display: -webkit-box;
|
}
|
||||||
-webkit-line-clamp: 3;
|
|
||||||
-webkit-box-orient: vertical;
|
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
|
||||||
overflow: hidden;
|
transform: translate(18px, -50%);
|
||||||
word-break: break-word;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 860px) {
|
||||||
@@ -309,5 +605,17 @@ watch(unreadOnly, loadInbox)
|
|||||||
.commentInboxPanel {
|
.commentInboxPanel {
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.commentInboxCard__body {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInboxCard__titleRow {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.commentInboxThread__body {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
@@ -8,11 +8,14 @@ import { editorPath, loginPath } from '../lib/paths'
|
|||||||
import { displayInitialFrom } from '../lib/display'
|
import { displayInitialFrom } from '../lib/display'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
|
||||||
const favorites = ref([])
|
const favorites = ref([])
|
||||||
const query = ref('')
|
const query = ref('')
|
||||||
const sort = ref('favorited')
|
const sort = ref('favorited')
|
||||||
|
const busyTierListId = ref('')
|
||||||
|
const isListView = computed(() => route.query.view === 'list')
|
||||||
|
|
||||||
function fmt(ts) {
|
function fmt(ts) {
|
||||||
return new Date(ts).toLocaleDateString(undefined, {
|
return new Date(ts).toLocaleDateString(undefined, {
|
||||||
@@ -52,6 +55,22 @@ function openTierList(tierList) {
|
|||||||
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
router.push(editorPath(tierList.topicSlug || tierList.topicId, tierList.id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function removeFavorite(tierListId) {
|
||||||
|
if (!tierListId || busyTierListId.value) return
|
||||||
|
busyTierListId.value = tierListId
|
||||||
|
const original = favorites.value.slice()
|
||||||
|
favorites.value = favorites.value.filter((tierList) => tierList.id !== tierListId)
|
||||||
|
try {
|
||||||
|
await api.unfavoriteTierList(tierListId)
|
||||||
|
toast.success('즐겨찾기에서 제거했어요.')
|
||||||
|
} catch (error) {
|
||||||
|
favorites.value = original
|
||||||
|
toast.error('즐겨찾기 해제에 실패했어요.')
|
||||||
|
} finally {
|
||||||
|
busyTierListId.value = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(loadFavorites)
|
onMounted(loadFavorites)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -75,9 +94,17 @@ onMounted(loadFavorites)
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
|
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
|
||||||
<div v-else class="list">
|
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
<button
|
||||||
|
class="boardCard__favoriteAction"
|
||||||
|
type="button"
|
||||||
|
:disabled="!!busyTierListId"
|
||||||
|
@click.stop="removeFavorite(tierList.id)"
|
||||||
|
>
|
||||||
|
{{ busyTierListId === tierList.id ? '처리 중...' : '즐겨찾기 해제' }}
|
||||||
|
</button>
|
||||||
|
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(tierList)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||||
@@ -133,7 +160,12 @@ onMounted(loadFavorites)
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
.list--table {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.boardCard {
|
.boardCard {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
border: 1px solid var(--theme-card-border);
|
border: 1px solid var(--theme-card-border);
|
||||||
background: var(--theme-card-bg);
|
background: var(--theme-card-bg);
|
||||||
@@ -149,6 +181,7 @@ onMounted(loadFavorites)
|
|||||||
background: var(--theme-card-bg-hover);
|
background: var(--theme-card-bg-hover);
|
||||||
}
|
}
|
||||||
.boardCard__body {
|
.boardCard__body {
|
||||||
|
min-width: 0;
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: inherit;
|
color: inherit;
|
||||||
@@ -156,8 +189,30 @@ onMounted(loadFavorites)
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.boardCard__favoriteAction {
|
||||||
|
position: absolute;
|
||||||
|
top: 14px;
|
||||||
|
right: 14px;
|
||||||
|
z-index: 1;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--theme-danger-border) 70%, var(--theme-border));
|
||||||
|
background: color-mix(in srgb, var(--theme-surface) 92%, var(--theme-danger-bg));
|
||||||
|
color: var(--theme-text);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 800;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.boardCard__favoriteAction:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.72;
|
||||||
}
|
}
|
||||||
.boardCard__thumbWrap {
|
.boardCard__thumbWrap {
|
||||||
|
min-width: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
padding: 14px 14px 0;
|
padding: 14px 14px 0;
|
||||||
@@ -182,16 +237,35 @@ onMounted(loadFavorites)
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.boardCard__head {
|
.boardCard__head {
|
||||||
|
min-width: 0;
|
||||||
padding: 16px 18px 18px;
|
padding: 16px 18px 18px;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.boardCard--list .boardCard__head {
|
||||||
|
align-content: center;
|
||||||
|
padding: 16px 18px 16px 0;
|
||||||
|
}
|
||||||
|
.boardCard__body--list {
|
||||||
|
grid-template-columns: 200px minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.boardCard__body--list .boardCard__thumbWrap {
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.boardCard__body--list .boardCard__thumb,
|
||||||
|
.boardCard__body--list .boardCard__thumbPlaceholder {
|
||||||
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
display: flex;
|
min-width: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
@@ -201,17 +275,23 @@ onMounted(loadFavorites)
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
white-space: nowrap;
|
line-height: 1.35;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.boardCard__author {
|
.boardCard__author {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
opacity: 0.86;
|
opacity: 0.86;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.boardCard__authorName {
|
.boardCard__authorName {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -237,9 +317,13 @@ onMounted(loadFavorites)
|
|||||||
.boardCard__date,
|
.boardCard__date,
|
||||||
.favoriteStat {
|
.favoriteStat {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
color: var(--theme-text-faint);
|
color: var(--theme-text-faint);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
}
|
}
|
||||||
|
|
||||||
.boardCard__date {
|
.boardCard__date {
|
||||||
@@ -259,6 +343,15 @@ onMounted(loadFavorites)
|
|||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__body--list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boardCard--list .boardCard__head {
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
|
|
||||||
.toolbar {
|
.toolbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const featuredTierLists = ref([])
|
|||||||
const tierLists = ref([])
|
const tierLists = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
||||||
|
const isListView = computed(() => route.query.view === 'list')
|
||||||
const brokenThumbnailIds = ref({})
|
const brokenThumbnailIds = ref({})
|
||||||
|
|
||||||
function fmt(ts) {
|
function fmt(ts) {
|
||||||
@@ -85,9 +86,9 @@ watch(() => route.query.q, loadHomeFeed)
|
|||||||
</div>
|
</div>
|
||||||
<div class="featuredHead__count">{{ featuredTierLists.length }}개</div>
|
<div class="featuredHead__count">{{ featuredTierLists.length }}개</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="list">
|
<div class="list" :class="{ 'list--table': isListView }">
|
||||||
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured">
|
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured" :class="{ 'boardCard--list': isListView }">
|
||||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img
|
<img
|
||||||
v-if="tierListThumbnailUrl(tierList)"
|
v-if="tierListThumbnailUrl(tierList)"
|
||||||
@@ -122,9 +123,9 @@ watch(() => route.query.q, loadHomeFeed)
|
|||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div class="sectionLabel">최신 공개 티어표</div>
|
<div class="sectionLabel">최신 공개 티어표</div>
|
||||||
<div v-if="tierLists.length === 0" class="empty">{{ query ? '검색어에 맞는 공개 티어표가 없어요.' : '아직 공개 티어표가 없어요.' }}</div>
|
<div v-if="tierLists.length === 0" class="empty">{{ query ? '검색어에 맞는 공개 티어표가 없어요.' : '아직 공개 티어표가 없어요.' }}</div>
|
||||||
<div v-else class="list">
|
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img
|
<img
|
||||||
v-if="tierListThumbnailUrl(tierList)"
|
v-if="tierListThumbnailUrl(tierList)"
|
||||||
@@ -222,6 +223,9 @@ watch(() => route.query.q, loadHomeFeed)
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
.list--table {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.boardCard {
|
.boardCard {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -282,6 +286,22 @@ watch(() => route.query.q, loadHomeFeed)
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.boardCard--list .boardCard__head {
|
||||||
|
align-content: center;
|
||||||
|
padding: 16px 18px 16px 0;
|
||||||
|
}
|
||||||
|
.boardCard__body--list {
|
||||||
|
grid-template-columns: 200px minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.boardCard__body--list .boardCard__thumbWrap {
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.boardCard__body--list .boardCard__thumb,
|
||||||
|
.boardCard__body--list .boardCard__thumbPlaceholder {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -370,5 +390,13 @@ watch(() => route.query.q, loadHomeFeed)
|
|||||||
.list {
|
.list {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__body--list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boardCard--list .boardCard__head {
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref, watch } from 'vue'
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
@@ -8,10 +8,12 @@ import { editorPath, loginPath } from '../lib/paths'
|
|||||||
import { displayInitialFrom } from '../lib/display'
|
import { displayInitialFrom } from '../lib/display'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const myLists = ref([])
|
const myLists = ref([])
|
||||||
const error = ref('')
|
const error = ref('')
|
||||||
const brokenThumbnailIds = ref({})
|
const brokenThumbnailIds = ref({})
|
||||||
|
const isListView = computed(() => route.query.view === 'list')
|
||||||
|
|
||||||
watch(error, (message) => {
|
watch(error, (message) => {
|
||||||
if (!message) return
|
if (!message) return
|
||||||
@@ -76,9 +78,9 @@ function openList(t) {
|
|||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||||
<div v-else class="list">
|
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
<article v-for="t in myLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||||
<button class="boardCard__body" @click="openList(t)">
|
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openList(t)">
|
||||||
<div class="boardCard__thumbWrap">
|
<div class="boardCard__thumbWrap">
|
||||||
<img
|
<img
|
||||||
v-if="tierListThumbnailUrl(t)"
|
v-if="tierListThumbnailUrl(t)"
|
||||||
@@ -124,6 +126,9 @@ function openList(t) {
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
.list--table {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.boardCard {
|
.boardCard {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
border-radius: 22px;
|
border-radius: 22px;
|
||||||
@@ -196,6 +201,22 @@ function openList(t) {
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
.boardCard--list .boardCard__head {
|
||||||
|
align-content: center;
|
||||||
|
padding: 16px 18px 16px 0;
|
||||||
|
}
|
||||||
|
.boardCard__body--list {
|
||||||
|
grid-template-columns: 200px minmax(0, 1fr);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.boardCard__body--list .boardCard__thumbWrap {
|
||||||
|
height: 100%;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.boardCard__body--list .boardCard__thumb,
|
||||||
|
.boardCard__body--list .boardCard__thumbPlaceholder {
|
||||||
|
min-height: 100%;
|
||||||
|
}
|
||||||
.boardCard__titleRow,
|
.boardCard__titleRow,
|
||||||
.boardCard__metaRow {
|
.boardCard__metaRow {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -268,5 +289,13 @@ function openList(t) {
|
|||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.boardCard__body--list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.boardCard--list .boardCard__head {
|
||||||
|
padding: 16px 18px 18px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const templateRecords = ref([])
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const loadingFavoriteId = ref('')
|
const loadingFavoriteId = ref('')
|
||||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||||
|
const isListView = computed(() => route.query.view === 'list')
|
||||||
const templates = computed(() => {
|
const templates = computed(() => {
|
||||||
const filtered = templateRecords.value
|
const filtered = templateRecords.value
|
||||||
.filter((item) => item.id !== 'freeform')
|
.filter((item) => item.id !== 'freeform')
|
||||||
@@ -88,8 +89,8 @@ function templateThumbUrl(template) {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid" :class="{ 'libraryGrid--list': isListView }">
|
||||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
<article v-for="template in templates" :key="template.id" class="libraryCard" :class="{ 'libraryCard--list': isListView }">
|
||||||
<button
|
<button
|
||||||
class="libraryCard__favorite"
|
class="libraryCard__favorite"
|
||||||
type="button"
|
type="button"
|
||||||
@@ -99,7 +100,7 @@ function templateThumbUrl(template) {
|
|||||||
>
|
>
|
||||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||||
</button>
|
</button>
|
||||||
<button class="libraryCard__main" type="button" @click="openTopic(template)">
|
<button class="libraryCard__main" :class="{ 'libraryCard__main--list': isListView }" type="button" @click="openTopic(template)">
|
||||||
<div class="libraryCard__thumbWrap">
|
<div class="libraryCard__thumbWrap">
|
||||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||||
@@ -120,6 +121,9 @@ function templateThumbUrl(template) {
|
|||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
gap: 18px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
|
.libraryGrid--list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
.error {
|
.error {
|
||||||
margin: 0 0 16px;
|
margin: 0 0 16px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -161,6 +165,17 @@ function templateThumbUrl(template) {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.libraryCard__main--list {
|
||||||
|
grid-template-columns: 200px minmax(0, 1fr);
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.libraryCard__main--list .libraryCard__thumbWrap {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
.libraryCard--list .libraryCard__favorite {
|
||||||
|
top: 14px;
|
||||||
|
bottom: auto;
|
||||||
|
}
|
||||||
.libraryCard__favorite {
|
.libraryCard__favorite {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 24px;
|
bottom: 24px;
|
||||||
@@ -259,5 +274,9 @@ function templateThumbUrl(template) {
|
|||||||
.libraryGrid {
|
.libraryGrid {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.libraryCard__main--list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -138,11 +138,12 @@ const untitledWarning = computed(
|
|||||||
!hasCustomTitle.value &&
|
!hasCustomTitle.value &&
|
||||||
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||||||
)
|
)
|
||||||
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
const canFavorite = computed(() => !!auth.user && hasSavedTierList.value && !isNewTierList.value)
|
||||||
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
|
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
|
||||||
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
||||||
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
||||||
const duplicateActionLabel = computed(() => (isOwnTierList.value ? '복사본 만들기' : '내 티어표로 복사'))
|
const duplicateActionLabel = computed(() => (isOwnTierList.value ? '복사본 만들기' : '내 티어표로 복사'))
|
||||||
|
const favoriteActionLabel = computed(() => (isFavorited.value ? '즐겨찾기 해제하기' : '즐겨찾기에 추가하기'))
|
||||||
const canOpenAuthorProfile = computed(() => !!ownerId.value && hasSavedTierList.value)
|
const canOpenAuthorProfile = computed(() => !!ownerId.value && hasSavedTierList.value)
|
||||||
const copiedFromLabel = computed(() => {
|
const copiedFromLabel = computed(() => {
|
||||||
if (!sourceTierListId.value) return ''
|
if (!sourceTierListId.value) return ''
|
||||||
@@ -1502,6 +1503,9 @@ onUnmounted(() => {
|
|||||||
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
|
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
|
||||||
공유하기
|
공유하기
|
||||||
</button>
|
</button>
|
||||||
|
<button v-if="canFavorite" class="btn btn--save viewerSidebar__button" type="button" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
||||||
|
{{ favoriteActionLabel }}
|
||||||
|
</button>
|
||||||
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
|
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
|
||||||
{{ duplicateActionLabel }}
|
{{ duplicateActionLabel }}
|
||||||
</button>
|
</button>
|
||||||
@@ -1944,10 +1948,10 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canFavorite" class="editorSidebar__section">
|
<div v-if="canFavorite" class="editorSidebar__section">
|
||||||
<button class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
<button class="btn btn--save editorSidebar__button editorSidebar__favoriteButton" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
||||||
<span>♡ 즐겨찾기</span>
|
{{ favoriteActionLabel }}
|
||||||
<span>{{ favoriteCount }}</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
<div class="editorSidebar__favoriteMeta">현재 {{ favoriteCount }}명이 이 티어표를 즐겨찾기했어요.</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
|
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
|
||||||
@@ -2199,7 +2203,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.viewerSidebar__section {
|
.viewerSidebar__section {
|
||||||
margin-top: auto;
|
margin-top: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
padding: 18px;
|
padding: 18px;
|
||||||
@@ -2996,19 +3000,15 @@ onUnmounted(() => {
|
|||||||
color: var(--theme-text-soft);
|
color: var(--theme-text-soft);
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
.editorSidebar__favorite {
|
.editorSidebar__favoriteButton {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 10px;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 11px 12px;
|
}
|
||||||
border-radius: 14px;
|
|
||||||
border: 1px solid var(--theme-border);
|
.editorSidebar__favoriteMeta {
|
||||||
background: var(--theme-pill-bg);
|
margin-top: 10px;
|
||||||
color: var(--theme-text);
|
color: var(--theme-text-muted);
|
||||||
font-weight: 800;
|
font-size: 13px;
|
||||||
cursor: pointer;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.editorSidebar__section--footer {
|
.editorSidebar__section--footer {
|
||||||
padding-top: 12px;
|
padding-top: 12px;
|
||||||
|
|||||||
Reference in New Issue
Block a user