Compare commits
11 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6fdd780859 | |||
| f273233c41 | |||
| bc5a34bbb7 | |||
| ede348be96 | |||
| a952d2a062 | |||
| d2273fa723 | |||
| de304c98a7 | |||
| 68481c3ebf | |||
| 31e266e79e | |||
| 63dc8f871c | |||
| d9aa6a6922 |
@@ -272,6 +272,15 @@ function mapCommentNotificationRow(row) {
|
||||
commentId: row.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',
|
||||
isRead: !!row.is_read,
|
||||
readAt: Number(row.read_at || 0),
|
||||
@@ -2851,6 +2860,7 @@ async function listCommentNotifications(userId, { unreadOnly = false } = {}) {
|
||||
c.parent_comment_id,
|
||||
c.content AS comment_content,
|
||||
parent.content AS parent_comment_content,
|
||||
parent.created_at AS parent_comment_created_at,
|
||||
t.topic_id,
|
||||
tp.slug AS topic_slug,
|
||||
tp.name AS topic_name,
|
||||
@@ -2858,10 +2868,14 @@ async function listCommentNotifications(userId, { unreadOnly = false } = {}) {
|
||||
t.thumbnail_src AS tierlist_thumbnail_src,
|
||||
actor.nickname AS actor_nickname,
|
||||
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
|
||||
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 topics tp ON tp.id = t.topic_id
|
||||
INNER JOIN users actor ON actor.id = n.actor_user_id
|
||||
|
||||
@@ -1,5 +1,47 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-07 v1.1.17
|
||||
- 가이드 모달은 같은 기능의 이동 수단을 중복으로 두기보다, 화살표와 점 네비게이션만 유지하는 편이 더 깔끔하다고 정리했다.
|
||||
- 설명 길이 때문에 페이지 전환마다 미디어 영역이 출렁이면 완성도가 떨어져 보이므로, 설명 블록에 최소 높이를 두는 방식으로 페이지 간 높이를 최대한 통일하기로 했다.
|
||||
|
||||
## 2026-04-07 v1.1.16
|
||||
- 전역 단축키는 영문 키만 처리하지 말고 두벌식 한글 자판 입력도 같은 의미로 받아들이는 편이 실제 사용성에 더 맞다고 정리했다.
|
||||
- `S/ㄴ`은 화면 문맥에 따라 “편집기에서는 아이템 검색, 일반 목록에서는 공통 검색창 포커스”로 나누는 것이 기존 습관과 새 검색 동선을 모두 살리는 절충안이라고 판단했다.
|
||||
|
||||
## 2026-04-07 v1.1.15
|
||||
- `나의 티어표`도 주요 목록 화면 중 하나이므로 왼쪽 공통 검색창 범위에서 빼는 것보다 포함하는 편이 더 일관적이라고 정리했다.
|
||||
|
||||
## 2026-04-07 v1.1.14
|
||||
- 티어표 목록 화면마다 별도 검색창을 두기보다, 왼쪽 공통 검색창이 “현재 보고 있는 화면 범위만 검색한다”는 규칙으로 통일하는 편이 더 단순하고 예측 가능하다고 정리했다.
|
||||
- 즐겨찾기 목록에서는 해제 버튼을 즉시 노출하기보다, 해당 티어표를 방문해 한 번 더 확인한 뒤 우측 사이드 CTA로 해제하는 흐름이 실수 방지 측면에서 낫다고 판단했다.
|
||||
|
||||
## 2026-04-07 v1.1.13
|
||||
- 팔로우 피드도 본질적으로는 “공개 티어표 목록 화면”이므로, 홈/템플릿/즐겨찾기와 같은 `viewToggle` 문법을 공유하는 편이 맞다고 정리했다.
|
||||
|
||||
## 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 완성도가 떨어지므로 댓글 카드도 공통 서비스 톤에 맞춘다.
|
||||
- 댓글 정렬은 `루트 최신순 / 답글 오래된순`으로 고정한다. 최신 댓글을 먼저 보는 편이 전체 참여 흐름엔 유리하고, 답글은 작성 순서가 유지되어야 문맥 이해가 쉽다.
|
||||
|
||||
18
docs/map.md
18
docs/map.md
@@ -2,27 +2,27 @@
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 카드 클릭 시 해당 티어표 화면으로 이동
|
||||
- 역할: 공개 티어표 홈 피드, 상단 `추천 티어표`와 아래 `최신 공개 티어표` 목록을 같은 카드 문법으로 표시, 검색어(`q`)가 있으면 공개 티어표 제목/작성자 기준으로 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 카드 클릭 시 해당 티어표 화면으로 이동
|
||||
- 연동 API: `GET /api/tierlists/public?q=...`
|
||||
|
||||
## `/templates`
|
||||
- 화면 파일: `frontend/src/views/TemplatesView.vue`
|
||||
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링
|
||||
- 역할: 공개 템플릿 전용 목록, 관리자 수동 순서와 즐겨찾기 여부를 반영한 주제 템플릿 카드 목록 표시, 템플릿 즐겨찾기 토글, 왼쪽 공통 검색창의 검색어(`q`)가 있으면 템플릿 이름/slug 기준으로 즉시 필터링, 상단 공통 `viewToggle`로 카드형/리스트형 전환
|
||||
- 연동 API: `GET /api/topics`, `POST /api/topics/:topicId/favorite`, `DELETE /api/topics/:topicId/favorite`
|
||||
|
||||
## `/topics/:topicId`
|
||||
- 화면 파일: `frontend/src/views/TopicHubView.vue`
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 역할: 선택한 주제 slug 기준 정보 표시, 관리자 추천 티어표 상단 강조 섹션과 일반 공개 티어표 목록 분리 표시, 왼쪽 공통 검색창으로 해당 주제의 공개 티어표만 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/topics/:topicId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:topicId/new`, `/editor/:topicId/:tierListId`
|
||||
- 화면 파일: `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`
|
||||
|
||||
## `/comments`
|
||||
- 화면 파일: `frontend/src/views/CommentInboxView.vue`
|
||||
- 역할: 내 티어표에 달린 댓글과 내 댓글에 달린 답글을 시간순 카드로 확인, 안 읽은 댓글만 보기 필터, 모두 읽음 처리, 카드별 red dot 표시, 티어표 썸네일과 원댓글/새 댓글 비교 블록 제공, 카드 클릭 시 해당 티어표의 특정 댓글 위치로 이동
|
||||
- 역할: 내 티어표에 달린 댓글과 내 댓글에 달린 답글을 시간순 카드로 확인, 기본값이 켜진 `안 읽은 댓글만 보기` 필터, 모두 읽음 처리, 카드별 red dot 표시, 좌측 `썸네일/티어표 제목/템플릿 이름`과 우측 `루트 댓글/새 댓글 또는 답글` 비교 구조 제공, 카드 상단 `읽음 처리` 버튼, 카드 클릭 시 해당 티어표의 특정 댓글 위치로 이동
|
||||
- 연동 API: `GET /api/comments/inbox`, `GET /api/comments/inbox/unread-count`, `POST /api/comments/inbox/read`
|
||||
|
||||
## `/login`
|
||||
@@ -32,17 +32,17 @@
|
||||
|
||||
## `/me`
|
||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 역할: 내 티어표 목록 조회, 왼쪽 공통 검색창으로 내 저장 티어표 범위만 검색, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
||||
|
||||
## `/favorites`
|
||||
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 왼쪽 공통 검색창으로 내 즐겨찾기 범위만 검색, 정렬 셀렉트 유지, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 편집 화면 이동
|
||||
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/following`
|
||||
- 화면 파일: `frontend/src/views/FollowingFeedView.vue`
|
||||
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 제목/주제/작성자 검색, 티어표 상세 이동, 작성자 프로필 이동
|
||||
- 역할: 팔로우한 작성자의 공개 티어표를 최신 업데이트순 카드 목록으로 모아보기, 상단 공통 `viewToggle`로 카드형/리스트형 전환, 왼쪽 공통 검색창으로 팔로우 피드 범위만 검색, 티어표 상세 이동, 작성자 프로필 이동
|
||||
- 연동 API: `GET /api/users/following-feed`
|
||||
|
||||
## `/users/:userId`
|
||||
@@ -67,7 +67,7 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿 / 댓글 관리` 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 댓글 알림 unread dot, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, `홈 / 템플릿 / 댓글 관리` 네비게이션과 화면별 검색 placeholder 전환, `preview=1` 공유 프리뷰에서도 같은 좌우 레일과 중앙 헤더 유지, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 댓글 알림 unread dot, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링, 관리자/에디터 화면이 Teleport로 사용하는 `#local-right-rail-root` 대상 DOM을 상시 유지해 라우트 전환 중 우측 레일 언마운트 순서를 안정화, `S/ㄴ`, `G/ㅎ`, `L/ㅣ`, `A/ㅁ` 같은 전역 단축키 처리, 설정 가이드 모달 단계 이동/높이 안정화
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker`와 서비스 설명이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 안에서 항상 보이도록 유지하면서 아래쪽 패딩으로 여백을 확보한다.
|
||||
|
||||
## 백엔드 진입점
|
||||
|
||||
22
docs/spec.md
22
docs/spec.md
@@ -48,9 +48,30 @@
|
||||
- 홈 피드(`/`)는 `GET /api/tierlists/public?q=...`를 사용한다.
|
||||
- `featuredTierLists`: 상단 추천 티어표
|
||||
- `tierLists`: 추천 제외 최신 공개 티어표
|
||||
- 홈, 템플릿, 나의 티어표, 즐겨찾기, 팔로우 피드 화면은 공통 `viewToggle`로 `그리드 / 리스트` 보기를 전환하며, 상태는 현재 라우트의 `?view=list` 쿼리로 반영한다.
|
||||
- 전역 단축키
|
||||
- `S/ㄴ`: 검색 포커스. 편집 화면에서는 아이템 검색창, 그 외 화면에서는 왼쪽 공통 검색창
|
||||
- `G/ㅎ`: 그리드 보기
|
||||
- `L/ㅣ`: 리스트 보기
|
||||
- `A/ㅁ`: 관리자 계정일 때 관리자 화면으로 이동
|
||||
- 설정의 가이드 모달은 좌우 화살표, 점 네비게이션, 좌측 단계 목록으로만 이동하고, 설명 영역은 최소 4줄 높이를 유지해 페이지별 높이 차이를 줄인다.
|
||||
- 왼쪽 공통 검색창은 현재 화면 범위만 검색한다.
|
||||
- 홈: 전체 공개 티어표
|
||||
- 템플릿: 공개 템플릿
|
||||
- 나의 티어표: 내 저장 티어표
|
||||
- 특정 주제 화면: 해당 주제의 공개 티어표
|
||||
- 팔로우 피드: 팔로우한 작성자의 공개 티어표
|
||||
- 즐겨찾기: 내가 즐겨찾기한 티어표
|
||||
- 위 네 화면의 목록 데이터는 현재 페이지네이션이나 무한 스크롤 없이 조회 결과 전체를 한 번에 렌더링한다.
|
||||
- 저장된 티어표에는 댓글 스레드가 붙을 수 있다. 작성자 본인 편집 화면에서는 `작업 팁` 아래, 작성자가 아닌 사용자의 보기 전용 화면에서는 `preview` 보드 아래에서 같은 댓글 카드를 사용한다.
|
||||
- 댓글 알림 메뉴는 좌측 사이드 `댓글 관리`로 노출하며, 읽지 않은 댓글이 하나라도 있으면 빨간 dot을 표시한다.
|
||||
- 댓글 관리(`/comments`)는 기본적으로 `안 읽은 댓글만 보기`를 켠 상태로 시작한다.
|
||||
- 댓글 정렬은 루트 댓글 최신순, 각 루트 내부의 답글은 오래된순을 기본 규칙으로 유지한다.
|
||||
- 댓글 표시 밀도 제어를 위해 기본 노출 개수는 루트 댓글 10개, 각 루트의 답글 3개로 제한하고 `더 보기` 버튼으로 추가 노출한다.
|
||||
- 댓글 관리 카드(`/comments`)는 좌측 `16:9 썸네일 + 티어표 제목 + 템플릿 이름`, 우측 `알림 제목 + 루트 댓글 정보 + 새 댓글/답글 정보`의 2열 구조를 사용한다.
|
||||
- 댓글 관리 카드의 상단 우측 배지는 상태 라벨이 아니라 개별 `읽음 처리` 액션으로 사용한다.
|
||||
- 티어표 즐겨찾기 API(`POST/DELETE /api/tierlists/:id/favorite`)는 이미 존재하며, 보기 화면 우측 레일에는 이를 직접 호출하는 단독 CTA를 노출한다.
|
||||
- 티어표 즐겨찾기는 작성자 본인 저장 티어표에도 사용할 수 있다.
|
||||
- 우측 패널
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
@@ -171,6 +192,7 @@
|
||||
- `readAt`: number
|
||||
- `createdAt`: number
|
||||
- 기존 운영 DB에 예전 형태 테이블이 남아 있어도 서버 시작 시 스키마 보정으로 누락 컬럼을 자동 추가한다.
|
||||
- 댓글 관리 카드 구성을 위해 조회 응답에는 `parentCommentContent`, `parentCommentCreatedAt`, `parentAuthorName`, `parentAuthorAccountName`, `parentAuthorAvatarSrc`를 함께 내려준다.
|
||||
- `templateRequests`
|
||||
- `id`: string
|
||||
- `type`: string
|
||||
|
||||
23
docs/todo.md
23
docs/todo.md
@@ -1,6 +1,29 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `v1.1.17` 이후 설정의 가이드 모달에서 페이지를 넘길 때 썸네일 영역 위치가 이전보다 안정적으로 유지되는지 확인한다.
|
||||
- `v1.1.17` 이후 가이드 하단 `다음` 버튼이 사라지고, 좌우 화살표/점 네비게이션만으로도 단계 이동이 충분히 자연스러운지 확인한다.
|
||||
- `v1.1.16` 이후 `S/ㄴ`이 편집 화면에서는 아이템 검색, 일반 목록 화면에서는 왼쪽 공통 검색창 포커스로 정확히 나뉘어 동작하는지 확인한다.
|
||||
- `v1.1.16` 이후 `G/ㅎ`, `L/ㅣ`가 목록 화면에서만 그리드/리스트 전환을 수행하고, 입력칸을 타이핑 중일 때는 단축키가 발동하지 않는지 확인한다.
|
||||
- 관리자 계정에서만 `A/ㅁ`이 관리자 화면으로 이동하고, 일반 계정에서는 무시되는지 확인한다.
|
||||
- `v1.1.15` 이후 `나의 티어표`에서도 왼쪽 공통 검색창이 정상 동작하고, 검색 결과가 없을 때 전용 빈 상태 문구가 자연스럽게 보이는지 확인한다.
|
||||
- `v1.1.14` 이후 왼쪽 공통 검색창이 홈/템플릿/주제/팔로우 피드/즐겨찾기 각각의 범위만 정확히 검색하는지 확인한다.
|
||||
- `v1.1.14` 이후 주제 허브, 팔로우 피드, 즐겨찾기 화면 상단에서 중복 검색창이 모두 사라졌는지 확인한다.
|
||||
- 즐겨찾기 목록에서는 해제 버튼이 숨겨지고, 실제 해제는 해당 티어표 화면 우측 CTA에서만 가능한 흐름이 사용자 의도와 맞는지 확인한다.
|
||||
- `v1.1.13` 이후 팔로우 피드에서도 공통 `viewToggle`이 보이고, 리스트형 보기에서 작성자 카드와 썸네일 정렬이 어색하지 않은지 확인한다.
|
||||
- `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 중심 레이어가 다크/라이트 모드 모두에서 자연스러운지 확인한다.
|
||||
|
||||
@@ -1,5 +1,75 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-07 v1.1.17
|
||||
- 설정의 `가이드 보기` 모달 9페이지 단축키 설명은 최근 추가된 전역 단축키 기준으로 최신 상태를 유지하도록 다시 확인했다.
|
||||
- 가이드 하단 `다음` 버튼은 좌우 화살표와 역할이 겹쳐 제거했다. 이제 단계 이동은 좌우 화살표, 점 네비게이션, 좌측 단계 목록만 사용한다.
|
||||
- `guideModal__stepDescription`에는 최소 4줄 높이를 부여해, 설명 길이에 따라 페이지를 넘길 때 미디어 영역이 위아래로 튀어 보이던 현상을 줄였다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.16
|
||||
- 전역 단축키를 보강했다. `S/ㄴ`은 검색 포커스로 동작하며, 편집 화면에서는 기존처럼 아이템 검색창에, 그 외 화면에서는 왼쪽 공통 검색창에 포커스를 준다.
|
||||
- 새 보기 전환 단축키를 추가했다. `G/ㅎ`는 그리드 보기, `L/ㅣ`는 리스트 보기로 전환하며, 공통 `viewToggle`이 있는 목록 화면에서만 동작한다.
|
||||
- 관리자 계정에서는 `A/ㅁ` 단축키로 바로 관리자 화면(`/admin/featured`)으로 이동할 수 있게 했다.
|
||||
- 한글 두벌식 자판 상태에서도 `ㄴ/ㅎ/ㅣ/ㅁ`이 각각 `S/G/L/A`와 같은 의미로 처리되도록 맞췄다.
|
||||
- 가이드 모달의 단축키 설명도 현재 동작에 맞게 갱신했다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.15
|
||||
- `나의 티어표`도 왼쪽 공통 검색창 범위에 포함했다. 이제 `/me` 화면에서 입력한 검색어는 내 저장 티어표 제목/주제명/작성자 이름 기준으로 즉시 필터링된다.
|
||||
- `MyTierListsView`는 `route.query.q`를 받아 클라이언트에서 목록을 필터링하고, 검색 결과가 없으면 전용 빈 상태 문구를 표시한다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.14
|
||||
- 왼쪽 공통 검색창이 현재 화면 문맥만 검색하도록 라우팅을 정리했다. 이제 홈은 전체 공개 티어표, 템플릿은 공개 템플릿, 특정 템플릿 화면은 해당 주제의 공개 티어표, 팔로우 피드는 팔로우한 작성자의 공개 티어표, 즐겨찾기는 내가 즐겨찾기한 티어표 안에서만 검색한다.
|
||||
- `TopicHubView`, `FollowingFeedView`, `FavoriteTierListsView` 상단의 중복 검색 입력창은 제거했다. 검색은 왼쪽 공통 검색창 하나로만 수행하도록 정리했다.
|
||||
- 즐겨찾기 목록에서는 카드 우측 상단 `즐겨찾기 해제` 버튼을 다시 숨겼다. 실수 방지를 위해 목록에서는 방문만 하고, 실제 해제는 해당 티어표 화면 우측 즐겨찾기 CTA에서 하도록 흐름을 되돌렸다.
|
||||
- 즐겨찾기 화면은 검색 입력을 제거하고 정렬 셀렉트만 유지한다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 2026-04-07 v1.1.13
|
||||
- 팔로우 피드도 다른 티어표 목록 화면과 같은 성격이므로, 공통 `viewToggle` 대상에 포함했다.
|
||||
- `FollowingFeedView`에 그리드형/리스트형 보기 전환을 추가했다. 이제 팔로우한 작성자의 공개 티어표도 상단 공통 토글로 카드형과 가로 리스트형을 오갈 수 있다.
|
||||
- 팔로우 피드 리스트형 레이아웃에서는 썸네일 좌측, 정보 우측 구조로 정리해 다른 목록 화면과 같은 문법을 따른다.
|
||||
- 확인: `npm run build`
|
||||
|
||||
## 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` 톤으로 다시 맞췄다.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { commentsPath, editorNewPath, favoritesPath, followingFeedPath, homePath, loginPath, mePath, templatesPath } from './lib/paths'
|
||||
@@ -32,7 +32,16 @@ const leftRailCollapsed = ref(false)
|
||||
const mobileLeftNavOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
const searchQuery = ref('')
|
||||
const leftRailSearchPlaceholder = computed(() => (route.name === 'templates' ? '주제 템플릿 검색' : '공개 티어표 검색'))
|
||||
const searchInputEl = ref(null)
|
||||
const collapsedSearchInputEl = ref(null)
|
||||
const leftRailSearchPlaceholder = computed(() => {
|
||||
if (route.name === 'templates') return '주제 템플릿 검색'
|
||||
if (route.name === 'topicHub') return '이 템플릿의 공개 티어표 검색'
|
||||
if (route.name === 'followingFeed') return '팔로우한 사람의 공개 티어표 검색'
|
||||
if (route.name === 'favorites') return '즐겨찾기한 티어표 검색'
|
||||
if (route.name === 'me') return '내 티어표 검색'
|
||||
return '공개 티어표 검색'
|
||||
})
|
||||
const isCollapsedSearchOpen = ref(false)
|
||||
const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
@@ -148,7 +157,7 @@ const guideSteps = [
|
||||
title: '단축키로 빠른 조작',
|
||||
summary: '사이드 패널과 전체 화면을 키보드로 빠르게 전환합니다.',
|
||||
description:
|
||||
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F 키는 전체 화면 보기 토글, S 키는 티어표 편집 화면의 아이템 검색창으로 바로 이동할 때 사용할 수 있어요. 한글 입력 상태에서는 F 자리의 ㄹ, S 자리의 ㄴ 키도 같은 단축키로 처리됩니다. 각종 모달은 Esc 키로 닫을 수 있습니다. 단, 검색창이나 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
|
||||
'[ 키는 왼쪽 사이드를 열고 닫고, ] 키는 오른쪽 사이드를 열고 닫습니다. F/ㄹ은 전체 화면, S/ㄴ은 검색 포커스(편집 화면에서는 아이템 검색), G/ㅎ은 그리드 보기, L/ㅣ는 리스트 보기, A/ㅁ은 관리자 계정일 때 관리자 화면으로 이동합니다. 각종 모달은 Esc 키로 닫을 수 있고, 입력칸에 글을 쓰는 중에는 단축키가 동작하지 않도록 처리되어 있어요.',
|
||||
},
|
||||
]
|
||||
const currentGuideStep = computed(() => guideSteps[guideStepIndex.value] || guideSteps[0])
|
||||
@@ -157,7 +166,7 @@ const isGuideNextDisabled = computed(() => guideStepIndex.value >= guideSteps.le
|
||||
const isLightTheme = computed(() => themeMode.value === 'light')
|
||||
const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' : '라이트 모드'))
|
||||
const showSettingsThemePanel = computed(() => route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const showTopicViewToggle = computed(() => ['home', 'templates', 'topicHub', 'me', 'favorites', 'followingFeed'].includes(String(route.name || '')))
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const shouldLockRightRailBodyScroll = computed(() => isRightRailOverlay.value && rightRailOpen.value && !showBackendFallback.value)
|
||||
@@ -395,6 +404,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function handleGlobalKeydown(event) {
|
||||
const normalizedKey = String(event.key || '').toLowerCase()
|
||||
if (event.key === 'Escape' && isGuideModalOpen.value) {
|
||||
closeGuideModal()
|
||||
return
|
||||
@@ -416,14 +426,33 @@ function handleGlobalKeydown(event) {
|
||||
toggleRightRail()
|
||||
return
|
||||
}
|
||||
if (['f', 'ㄹ'].includes(String(event.key || '').toLowerCase())) {
|
||||
if (['f', 'ㄹ'].includes(normalizedKey)) {
|
||||
event.preventDefault()
|
||||
toggleFullscreen()
|
||||
return
|
||||
}
|
||||
if (['s', 'ㄴ'].includes(String(event.key || '').toLowerCase()) && ['editEditor', 'newEditor'].includes(String(route.name || ''))) {
|
||||
if (['s', 'ㄴ'].includes(normalizedKey)) {
|
||||
event.preventDefault()
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:focus-editor-item-search'))
|
||||
if (['editEditor', 'newEditor'].includes(String(route.name || ''))) {
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:focus-editor-item-search'))
|
||||
return
|
||||
}
|
||||
focusGlobalSearch()
|
||||
return
|
||||
}
|
||||
if (['g', 'ㅎ'].includes(normalizedKey) && showTopicViewToggle.value) {
|
||||
event.preventDefault()
|
||||
setTopicViewMode('grid')
|
||||
return
|
||||
}
|
||||
if (['l', 'ㅣ'].includes(normalizedKey) && showTopicViewToggle.value) {
|
||||
event.preventDefault()
|
||||
setTopicViewMode('list')
|
||||
return
|
||||
}
|
||||
if (['a', 'ㅁ'].includes(normalizedKey) && isAdmin.value) {
|
||||
event.preventDefault()
|
||||
router.push('/admin/featured')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,7 +576,7 @@ function toggleRightRail() {
|
||||
}
|
||||
|
||||
function setTopicViewMode(mode) {
|
||||
if (route.name !== 'topicHub') return
|
||||
if (!showTopicViewToggle.value) return
|
||||
const nextQuery = { ...route.query }
|
||||
if (mode === 'list') nextQuery.view = 'list'
|
||||
else delete nextQuery.view
|
||||
@@ -563,6 +592,23 @@ function closeCollapsedSearch() {
|
||||
isCollapsedSearchOpen.value = false
|
||||
}
|
||||
|
||||
async function focusGlobalSearch() {
|
||||
if (leftRailCollapsed.value && !isMobileLayout.value) {
|
||||
openCollapsedSearch()
|
||||
await nextTick()
|
||||
if (collapsedSearchInputEl.value?.focus) {
|
||||
collapsedSearchInputEl.value.focus()
|
||||
collapsedSearchInputEl.value.select?.()
|
||||
}
|
||||
return
|
||||
}
|
||||
await nextTick()
|
||||
if (searchInputEl.value?.focus) {
|
||||
searchInputEl.value.focus()
|
||||
searchInputEl.value.select?.()
|
||||
}
|
||||
}
|
||||
|
||||
function openGuideModal(stepIndex = 0) {
|
||||
guideStepIndex.value = Math.min(Math.max(Number(stepIndex) || 0, 0), guideSteps.length - 1)
|
||||
isGuideModalOpen.value = true
|
||||
@@ -597,6 +643,13 @@ function handleLeftRailSearch() {
|
||||
function submitGlobalSearch() {
|
||||
const query = (searchQuery.value || '').trim()
|
||||
isCollapsedSearchOpen.value = false
|
||||
if (['home', 'templates', 'topicHub', 'followingFeed', 'favorites', 'me'].includes(String(route.name || ''))) {
|
||||
const nextQuery = { ...route.query }
|
||||
if (query) nextQuery.q = query
|
||||
else delete nextQuery.q
|
||||
router.push({ path: route.path, query: nextQuery })
|
||||
return
|
||||
}
|
||||
router.push(route.name === 'templates' ? templatesPath(query) : homePath(query))
|
||||
}
|
||||
|
||||
@@ -676,7 +729,7 @@ function reloadApp() {
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
</button>
|
||||
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
<input ref="searchInputEl" v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
|
||||
</form>
|
||||
|
||||
<nav
|
||||
@@ -757,7 +810,7 @@ function reloadApp() {
|
||||
<span class="collapsedSearchBar__icon">
|
||||
<SvgIcon :src="iconSearch" :size="24" />
|
||||
</span>
|
||||
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
|
||||
<input ref="collapsedSearchInputEl" v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -816,9 +869,6 @@ function reloadApp() {
|
||||
@click="selectGuideStep(index)"
|
||||
></button>
|
||||
</div>
|
||||
<button class="guideModal__next" type="button" @click="isGuideNextDisabled ? closeGuideModal() : showNextGuideStep()">
|
||||
{{ isGuideNextDisabled ? '닫기' : '다음' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="guideModal__arrow" type="button" aria-label="다음 단계" :disabled="isGuideNextDisabled" @click="showNextGuideStep">›</button>
|
||||
@@ -1862,6 +1912,7 @@ function reloadApp() {
|
||||
.guideModal__text {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.guideModal__stepLabel {
|
||||
@@ -1886,6 +1937,7 @@ function reloadApp() {
|
||||
.guideModal__stepDescription {
|
||||
margin: 0;
|
||||
max-width: 720px;
|
||||
min-height: calc(1.7em * 4);
|
||||
line-height: 1.7;
|
||||
color: var(--theme-text-soft);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -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) }"
|
||||
@@ -296,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) }"
|
||||
@@ -325,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>
|
||||
@@ -450,18 +492,34 @@ onBeforeUnmount(() => {
|
||||
|
||||
.commentsThread {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
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: 14px 0 0;
|
||||
padding: 12px 0 0;
|
||||
}
|
||||
|
||||
.commentItem--reply {
|
||||
margin-top: 10px;
|
||||
margin-left: 22px;
|
||||
padding-top: 10px;
|
||||
margin-top: 8px;
|
||||
margin-left: 20px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
|
||||
.commentItem--reply::before {
|
||||
@@ -483,7 +541,7 @@ onBeforeUnmount(() => {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.commentItem__author {
|
||||
@@ -545,8 +603,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.commentItem__body {
|
||||
padding: 14px 15px;
|
||||
border-radius: 18px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface);
|
||||
white-space: pre-wrap;
|
||||
@@ -555,8 +613,8 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.replyComposer {
|
||||
margin-top: 14px;
|
||||
padding: 14px;
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 18px;
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ const router = useRouter()
|
||||
const toast = useToast()
|
||||
const notifications = ref([])
|
||||
const isLoading = ref(false)
|
||||
const unreadOnly = ref(false)
|
||||
const unreadOnly = ref(true)
|
||||
const isMarkingAllRead = ref(false)
|
||||
const markingNotificationId = ref('')
|
||||
|
||||
const unreadCount = computed(() => notifications.value.filter((item) => !item.isRead).length)
|
||||
|
||||
@@ -20,6 +21,10 @@ function avatarUrlOf(notification) {
|
||||
return notification.actorAvatarSrc ? toApiUrl(notification.actorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function parentAvatarUrlOf(notification) {
|
||||
return notification.parentAuthorAvatarSrc ? toApiUrl(notification.parentAuthorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(notification) {
|
||||
return notification.tierListThumbnailSrc ? toApiUrl(notification.tierListThumbnailSrc) : ''
|
||||
}
|
||||
@@ -28,6 +33,14 @@ function avatarFallbackOf(notification) {
|
||||
return displayInitialFrom(notification.actorName, notification.actorAccountName, '?')
|
||||
}
|
||||
|
||||
function parentAvatarFallbackOf(notification) {
|
||||
return displayInitialFrom(notification.parentAuthorName, notification.parentAuthorAccountName, '?')
|
||||
}
|
||||
|
||||
function parentDisplayNameOf(notification) {
|
||||
return notification.parentAuthorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function formatDate(ts) {
|
||||
return new Date(Number(ts || 0)).toLocaleString('ko-KR', {
|
||||
year: 'numeric',
|
||||
@@ -42,10 +55,6 @@ function notificationTitle(notification) {
|
||||
return notification.notificationType === 'comment_reply' ? '내 댓글에 답글이 달렸어요.' : '내 티어표에 새 댓글이 달렸어요.'
|
||||
}
|
||||
|
||||
function notificationLead(notification) {
|
||||
return notification.notificationType === 'comment_reply' ? '원래 댓글과 새 답글을 함께 확인해보세요.' : '내 티어표에 새로 남겨진 댓글입니다.'
|
||||
}
|
||||
|
||||
function emitUnreadCount(unread) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:comment-inbox-updated', { detail: { unreadCount: unread } }))
|
||||
@@ -67,16 +76,30 @@ async function loadInbox() {
|
||||
async function markOneAsRead(notificationId) {
|
||||
const target = notifications.value.find((item) => item.id === notificationId)
|
||||
if (!target || target.isRead) return
|
||||
const original = notifications.value.map((item) => ({ ...item }))
|
||||
target.isRead = true
|
||||
if (unreadOnly.value) {
|
||||
notifications.value = notifications.value.filter((item) => item.id !== notificationId)
|
||||
}
|
||||
emitUnreadCount(unreadCount.value)
|
||||
try {
|
||||
await api.markCommentInboxRead({ notificationIds: [notificationId] })
|
||||
} catch (error) {
|
||||
target.isRead = false
|
||||
notifications.value = original
|
||||
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() {
|
||||
if (!unreadCount.value) return
|
||||
isMarkingAllRead.value = true
|
||||
@@ -130,11 +153,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>
|
||||
@@ -153,52 +177,78 @@ watch(unreadOnly, loadInbox)
|
||||
:class="{ 'commentInboxCard--unread': !notification.isRead }"
|
||||
>
|
||||
<button class="commentInboxCard__body" type="button" @click="openNotification(notification)">
|
||||
<div class="commentInboxCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(notification)"
|
||||
class="commentInboxCard__thumb"
|
||||
:src="tierListThumbnailUrl(notification)"
|
||||
:alt="notification.tierListTitle || '티어표 썸네일'"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="commentInboxCard__thumbFallback">티어표</div>
|
||||
<div 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__titleRow">
|
||||
<div>
|
||||
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
||||
<div class="commentInboxCard__lead">{{ notificationLead(notification) }}</div>
|
||||
</div>
|
||||
<div class="commentInboxCard__title">{{ notificationTitle(notification) }}</div>
|
||||
<div class="commentInboxCard__status">
|
||||
<span v-if="!notification.isRead" class="commentInboxCard__dot" aria-label="안 읽음"></span>
|
||||
<span class="commentInboxCard__badge">{{ notification.notificationType === 'comment_reply' ? '답글' : '댓글' }}</span>
|
||||
<button
|
||||
v-if="!notification.isRead"
|
||||
class="commentInboxCard__badge"
|
||||
type="button"
|
||||
:disabled="!!markingNotificationId"
|
||||
@click.stop="markNotificationButton(notification.id)"
|
||||
>
|
||||
{{ markingNotificationId === notification.id ? '처리 중...' : '읽음 처리' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="commentInboxCard__meta">
|
||||
<img
|
||||
v-if="avatarUrlOf(notification)"
|
||||
class="commentInboxCard__avatar"
|
||||
:src="avatarUrlOf(notification)"
|
||||
:alt="notification.actorName || '작성자'"
|
||||
draggable="false"
|
||||
/>
|
||||
<div v-else class="commentInboxCard__avatar commentInboxCard__avatar--fallback">{{ avatarFallbackOf(notification) }}</div>
|
||||
<span class="commentInboxCard__actor">{{ notification.actorName }}</span>
|
||||
<span class="commentInboxCard__separator">·</span>
|
||||
<span class="commentInboxCard__date">{{ formatDate(notification.createdAt) }}</span>
|
||||
</div>
|
||||
<div class="commentInboxCard__target">
|
||||
{{ notification.tierListTitle || '제목 없는 티어표' }}
|
||||
<span class="commentInboxCard__targetMeta">/ {{ notification.topicName || notification.topicSlug || notification.topicId }}</span>
|
||||
</div>
|
||||
<div class="commentInboxCard__thread">
|
||||
<div v-if="notification.parentCommentContent" class="commentInboxCard__threadBlock">
|
||||
<div class="commentInboxCard__threadLabel">원래 댓글</div>
|
||||
<div class="commentInboxCard__threadText">{{ notification.parentCommentContent }}</div>
|
||||
<div v-if="notification.parentCommentContent" class="commentInboxThread">
|
||||
<div class="commentInboxThread__label">루트 댓글</div>
|
||||
<div class="commentInboxThread__body">
|
||||
<img
|
||||
v-if="parentAvatarUrlOf(notification)"
|
||||
class="commentInboxThread__avatar"
|
||||
:src="parentAvatarUrlOf(notification)"
|
||||
:alt="parentDisplayNameOf(notification)"
|
||||
draggable="false"
|
||||
/>
|
||||
<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="commentInboxCard__threadBlock commentInboxCard__threadBlock--accent">
|
||||
<div class="commentInboxCard__threadLabel">{{ notification.notificationType === 'comment_reply' ? '새 답글' : '새 댓글' }}</div>
|
||||
<div class="commentInboxCard__threadText">{{ notification.commentContent }}</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>
|
||||
@@ -219,20 +269,18 @@ 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 {
|
||||
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: 24px;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
}
|
||||
|
||||
.commentInboxEmpty {
|
||||
@@ -245,18 +293,15 @@ watch(unreadOnly, loadInbox)
|
||||
}
|
||||
|
||||
.commentInboxCard {
|
||||
border-radius: 22px;
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-surface) 92%, var(--theme-surface-soft)) 0%, var(--theme-surface) 100%);
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface);
|
||||
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 {
|
||||
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);
|
||||
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 {
|
||||
@@ -270,12 +315,20 @@ watch(unreadOnly, loadInbox)
|
||||
display: grid;
|
||||
grid-template-columns: 140px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
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);
|
||||
@@ -301,6 +354,19 @@ watch(unreadOnly, loadInbox)
|
||||
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 {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -313,12 +379,6 @@ watch(unreadOnly, loadInbox)
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxCard__lead {
|
||||
margin-top: 6px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.commentInboxCard__status {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -336,79 +396,35 @@ watch(unreadOnly, loadInbox)
|
||||
.commentInboxCard__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 28px;
|
||||
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__meta {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.commentInboxCard__avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentInboxCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxCard__target {
|
||||
margin-top: 12px;
|
||||
font-size: 15px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.commentInboxCard__targetMeta {
|
||||
color: var(--theme-text-faint);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.commentInboxCard__content {
|
||||
margin-top: 10px;
|
||||
.commentInboxCard__badge:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.commentInboxCard__thread {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.commentInboxCard__threadBlock {
|
||||
padding: 14px 15px;
|
||||
border-radius: 18px;
|
||||
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);
|
||||
.commentInboxThread {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.commentInboxCard__threadBlock--accent {
|
||||
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 {
|
||||
.commentInboxThread__label {
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.14em;
|
||||
@@ -416,7 +432,64 @@ watch(unreadOnly, loadInbox)
|
||||
color: var(--theme-text-faint);
|
||||
}
|
||||
|
||||
.commentInboxCard__threadText {
|
||||
.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;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-avatar-border);
|
||||
background: var(--theme-border);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.commentInboxThread__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 13px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.commentInboxThread__content {
|
||||
min-width: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
.commentInboxThread__separator {
|
||||
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;
|
||||
@@ -424,6 +497,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;
|
||||
@@ -437,5 +609,13 @@ watch(unreadOnly, loadInbox)
|
||||
.commentInboxCard__body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.commentInboxCard__titleRow {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.commentInboxThread__body {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -8,11 +8,13 @@ import { editorPath, loginPath } from '../lib/paths'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const favorites = ref([])
|
||||
const query = ref('')
|
||||
const sort = ref('favorited')
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
@@ -53,6 +55,7 @@ function openTierList(tierList) {
|
||||
}
|
||||
|
||||
onMounted(loadFavorites)
|
||||
watch([query, sort], loadFavorites)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -64,20 +67,18 @@ onMounted(loadFavorites)
|
||||
<div class="pageHead__desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFavorites" />
|
||||
<select v-model="sort" class="select" @change="loadFavorites">
|
||||
<select v-model="sort" class="select">
|
||||
<option value="favorited">즐겨찾기한 순</option>
|
||||
<option value="updated">최신 업데이트순</option>
|
||||
<option value="favorites">인기순</option>
|
||||
</select>
|
||||
<button class="btn" @click="loadFavorites">검색</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" draggable="false" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
@@ -116,15 +117,6 @@ onMounted(loadFavorites)
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
color: var(--theme-text);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.76;
|
||||
}
|
||||
@@ -133,7 +125,12 @@ onMounted(loadFavorites)
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
@@ -149,6 +146,7 @@ onMounted(loadFavorites)
|
||||
background: var(--theme-card-bg-hover);
|
||||
}
|
||||
.boardCard__body {
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -156,8 +154,11 @@ onMounted(loadFavorites)
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
@@ -182,16 +183,35 @@ onMounted(loadFavorites)
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
min-width: 0;
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
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__metaRow {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.boardCard__metaRow {
|
||||
@@ -201,17 +221,23 @@ onMounted(loadFavorites)
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
line-height: 1.35;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
word-break: break-word;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
@@ -237,9 +263,13 @@ onMounted(loadFavorites)
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--theme-text-faint);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.boardCard__date {
|
||||
@@ -259,12 +289,19 @@ onMounted(loadFavorites)
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
.input,
|
||||
.select,
|
||||
.btn {
|
||||
.select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { editorPath, loginPath, userProfilePath } from '../lib/paths'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -8,13 +8,15 @@ import { useToast } from '../composables/useToast'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
|
||||
const tierLists = ref([])
|
||||
const query = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
||||
const isLoading = ref(false)
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -76,6 +78,7 @@ function openAuthorProfile(tierList) {
|
||||
}
|
||||
|
||||
onMounted(loadFollowingFeed)
|
||||
watch(query, loadFollowingFeed)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -86,18 +89,14 @@ onMounted(loadFollowingFeed)
|
||||
<h2 class="pageHead__title">팔로우 피드</h2>
|
||||
<div class="pageHead__desc">팔로우한 작성자가 공개한 티어표를 최신 업데이트순으로 모아봅니다.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목, 주제, 작성자 검색" @keydown.enter.prevent="loadFollowingFeed" />
|
||||
<button class="btn" :disabled="isLoading" @click="loadFollowingFeed">{{ isLoading ? '검색중...' : '검색' }}</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div v-if="isLoading" class="empty">팔로우 피드를 불러오고 있어요.</div>
|
||||
<div v-else-if="tierLists.length === 0" class="empty">아직 팔로우한 작성자의 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
@@ -144,28 +143,6 @@ onMounted(loadFollowingFeed)
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.input {
|
||||
min-width: 240px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
color: var(--theme-text);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.75;
|
||||
}
|
||||
@@ -174,6 +151,9 @@ onMounted(loadFollowingFeed)
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
@@ -200,6 +180,19 @@ onMounted(loadFollowingFeed)
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
overflow: hidden;
|
||||
}
|
||||
.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__thumbWrap {
|
||||
width: 100%;
|
||||
@@ -230,6 +223,11 @@ onMounted(loadFollowingFeed)
|
||||
padding: 16px 18px 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.boardCard--list .boardCard__head {
|
||||
align-content: center;
|
||||
padding: 16px 18px 16px 0;
|
||||
}
|
||||
.boardCard__titleRow {
|
||||
min-width: 0;
|
||||
@@ -321,9 +319,13 @@ onMounted(loadFollowingFeed)
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.input {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 0;
|
||||
}
|
||||
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -13,6 +13,7 @@ const featuredTierLists = ref([])
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const brokenThumbnailIds = ref({})
|
||||
|
||||
function fmt(ts) {
|
||||
@@ -85,9 +86,9 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
</div>
|
||||
<div class="featuredHead__count">{{ featuredTierLists.length }}개</div>
|
||||
</div>
|
||||
<div class="list">
|
||||
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in featuredTierLists" :key="`featured-${tierList.id}`" class="boardCard boardCard--featured" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
@@ -122,9 +123,9 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
<section class="panel">
|
||||
<div class="sectionLabel">최신 공개 티어표</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">{{ query ? '검색어에 맞는 공개 티어표가 없어요.' : '아직 공개 티어표가 없어요.' }}</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" type="button" @click="openTierList(tierList)">
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" type="button" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(tierList)"
|
||||
@@ -222,6 +223,9 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
@@ -282,6 +286,22 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
gap: 8px;
|
||||
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__metaRow {
|
||||
min-width: 0;
|
||||
@@ -370,5 +390,13 @@ watch(() => route.query.q, loadHomeFeed)
|
||||
.list {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { onMounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useToast } from '../composables/useToast'
|
||||
@@ -8,10 +8,20 @@ import { editorPath, loginPath } from '../lib/paths'
|
||||
import { displayInitialFrom } from '../lib/display'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const toast = useToast()
|
||||
const myLists = ref([])
|
||||
const error = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const filteredMyLists = computed(() => {
|
||||
if (!query.value) return myLists.value
|
||||
return myLists.value.filter((tierList) => {
|
||||
const haystack = `${tierList.title || ''} ${tierList.topicName || ''} ${tierList.authorName || ''}`.toLowerCase()
|
||||
return haystack.includes(query.value)
|
||||
})
|
||||
})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
@@ -76,9 +86,10 @@ function openList(t) {
|
||||
|
||||
<section class="panel">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div v-else-if="filteredMyLists.length === 0" class="empty">검색어에 맞는 내 티어표가 없어요.</div>
|
||||
<div v-else class="list" :class="{ 'list--table': isListView }">
|
||||
<article v-for="t in filteredMyLists" :key="t.id" class="boardCard" :class="{ 'boardCard--list': isListView }">
|
||||
<button class="boardCard__body" :class="{ 'boardCard__body--list': isListView }" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img
|
||||
v-if="tierListThumbnailUrl(t)"
|
||||
@@ -124,6 +135,9 @@ function openList(t) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.list--table {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.boardCard {
|
||||
min-width: 0;
|
||||
border-radius: 22px;
|
||||
@@ -196,6 +210,22 @@ function openList(t) {
|
||||
gap: 8px;
|
||||
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__metaRow {
|
||||
min-width: 0;
|
||||
@@ -268,5 +298,13 @@ function openList(t) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard__body--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.boardCard--list .boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,6 +16,7 @@ const templateRecords = ref([])
|
||||
const error = ref('')
|
||||
const loadingFavoriteId = ref('')
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim().toLowerCase() : ''))
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
const templates = computed(() => {
|
||||
const filtered = templateRecords.value
|
||||
.filter((item) => item.id !== 'freeform')
|
||||
@@ -88,8 +89,8 @@ function templateThumbUrl(template) {
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<TransitionGroup v-if="templates.length" name="libraryCard" tag="section" class="libraryGrid">
|
||||
<article v-for="template in templates" :key="template.id" class="libraryCard">
|
||||
<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" :class="{ 'libraryCard--list': isListView }">
|
||||
<button
|
||||
class="libraryCard__favorite"
|
||||
type="button"
|
||||
@@ -99,7 +100,7 @@ function templateThumbUrl(template) {
|
||||
>
|
||||
<SvgIcon class="libraryCard__favoriteIcon" :src="kidStarIcon" :size="18" />
|
||||
</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">
|
||||
<img v-if="templateThumbUrl(template)" class="libraryCard__thumb" :src="templateThumbUrl(template)" :alt="template.name" draggable="false" />
|
||||
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
|
||||
@@ -120,6 +121,9 @@ function templateThumbUrl(template) {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.libraryGrid--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
@@ -161,6 +165,17 @@ function templateThumbUrl(template) {
|
||||
text-align: left;
|
||||
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 {
|
||||
position: absolute;
|
||||
bottom: 24px;
|
||||
@@ -259,5 +274,9 @@ function templateThumbUrl(template) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.libraryCard__main--list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -138,11 +138,12 @@ const untitledWarning = computed(
|
||||
!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 canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
||||
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
||||
const duplicateActionLabel = computed(() => (isOwnTierList.value ? '복사본 만들기' : '내 티어표로 복사'))
|
||||
const favoriteActionLabel = computed(() => (isFavorited.value ? '즐겨찾기 해제하기' : '즐겨찾기에 추가하기'))
|
||||
const canOpenAuthorProfile = computed(() => !!ownerId.value && hasSavedTierList.value)
|
||||
const copiedFromLabel = computed(() => {
|
||||
if (!sourceTierListId.value) return ''
|
||||
@@ -1502,6 +1503,9 @@ onUnmounted(() => {
|
||||
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
|
||||
공유하기
|
||||
</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">
|
||||
{{ duplicateActionLabel }}
|
||||
</button>
|
||||
@@ -1944,10 +1948,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
|
||||
<div v-if="canFavorite" class="editorSidebar__section">
|
||||
<button class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
||||
<span>♡ 즐겨찾기</span>
|
||||
<span>{{ favoriteCount }}</span>
|
||||
<button class="btn btn--save editorSidebar__button editorSidebar__favoriteButton" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
||||
{{ favoriteActionLabel }}
|
||||
</button>
|
||||
<div class="editorSidebar__favoriteMeta">현재 {{ favoriteCount }}명이 이 티어표를 즐겨찾기했어요.</div>
|
||||
</div>
|
||||
|
||||
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
|
||||
@@ -2996,19 +3000,15 @@ onUnmounted(() => {
|
||||
color: var(--theme-text-soft);
|
||||
word-break: break-word;
|
||||
}
|
||||
.editorSidebar__favorite {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
.editorSidebar__favoriteButton {
|
||||
width: 100%;
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
color: var(--theme-text);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.editorSidebar__favoriteMeta {
|
||||
margin-top: 10px;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.editorSidebar__section--footer {
|
||||
padding-top: 12px;
|
||||
|
||||
@@ -12,11 +12,11 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const topicId = computed(() => route.params.topicId)
|
||||
const query = computed(() => (typeof route.query.q === 'string' ? route.query.q.trim() : ''))
|
||||
const topicName = ref('')
|
||||
const featuredTierLists = ref([])
|
||||
const tierLists = ref([])
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
const brokenThumbnailIds = ref({})
|
||||
const isTopicLoading = ref(false)
|
||||
const isListView = computed(() => route.query.view === 'list')
|
||||
@@ -84,12 +84,8 @@ function openTierList(id) {
|
||||
router.push(editorPath(topicId.value, id))
|
||||
}
|
||||
|
||||
function submitSearch() {
|
||||
loadTierLists()
|
||||
}
|
||||
|
||||
watch(
|
||||
topicId,
|
||||
[topicId, query],
|
||||
() => {
|
||||
topicName.value = ''
|
||||
error.value = ''
|
||||
@@ -107,10 +103,6 @@ watch(
|
||||
<h2 class="pageHead__title">{{ topicTitle }}</h2>
|
||||
<div class="pageHead__desc">이 주제의 공개 티어표를 같은 카드 레이아웃으로 살펴보고 이어서 새 티어표를 만들 수 있어요.</div>
|
||||
</div>
|
||||
<div class="pageHead__aside toolbar">
|
||||
<input v-model="query" class="input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="btn" @click="submitSearch">검색</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
@@ -245,28 +237,6 @@ watch(
|
||||
.sectionLabel {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.input {
|
||||
min-width: 240px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.btn {
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft-2);
|
||||
color: var(--theme-text);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
padding: 10px 12px;
|
||||
|
||||
Reference in New Issue
Block a user