Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 26d7e4c4a8 | |||
| ed68b609bc | |||
| 876c13d99b | |||
| 1a7ec50a93 | |||
| c1f0471f1f | |||
| 0812640ec1 | |||
| 285644bdde | |||
| ed4023d1bd | |||
| 14a6823c3e | |||
| adc697eb13 | |||
| 5d7797925e | |||
| 69a8cd3600 | |||
| 26a77bd3e1 | |||
| d36502fe51 | |||
| a6e78b29f1 | |||
| 2346b5fbe3 | |||
| d724a64451 | |||
| 781a131ade | |||
| 6fceeaf15b | |||
| 7886b98380 | |||
| b5d5f4b079 |
@@ -1,6 +1,99 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-30 v1.2.3
|
||||
## 2026-03-30 v1.2.24
|
||||
- `내 티어표` 헤더의 저장 개수 stat은 정보 가치보다 시각 잡음이 더 크다고 보고, 제목/설명 중심 헤더로 단순화하는 편이 낫다고 정리했다.
|
||||
- 게임 선택 카드는 티어표 카드와 달리 템플릿 선택 진입점이므로, 썸네일까지 반복하기보다 제목과 ID만 간결하게 보여주는 편이 더 적합하다고 판단했다.
|
||||
- 좌우 하단 액션 버튼은 푸터 블록 안에 있더라도 화면 바닥에 너무 붙으면 무거워 보이므로, 추가 하단 여백을 두어 숨을 쉬게 하는 편이 낫다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.23
|
||||
- 홈 화면의 게임 카드도 다른 목록 카드와 같은 밀도를 따라가야 하므로, 메인 라이브러리 역시 데스크톱 기본 4열을 기준으로 두는 편이 더 일관되다고 정리했다.
|
||||
- 게임 허브에서 새 티어표 만들기 버튼이 본문과 우측 패널에 동시에 있으면 역할이 겹치므로, 생성 CTA는 우측 사이드 하나만 남기는 편이 맞다고 판단했다.
|
||||
- 좌우 레일 액션 버튼은 스크롤되는 본문 안보다 독립된 하단 `56px` 푸터 영역에 놓는 편이 위치 인지가 더 안정적이라고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.22
|
||||
- 왼쪽 레일은 홈/목록/에디터 어디서든 “사라지는 패널”보다 “축소된 내비 레일”로 읽히는 편이 구조적으로 더 일관되므로, 완전 숨김 대신 아이콘 중심 축소 상태를 유지하기로 했다.
|
||||
- 좌우 패널 토글은 상태마다 다른 아이콘이 바뀌기보다 방향만 고정하는 편이 덜 혼란스러우므로, 우측은 `dock_to_left`, 좌측은 `dock_to_right` 하나로 통일하기로 정리했다.
|
||||
- 좌측 검색도 임시 선형 SVG보다 실제 에셋을 쓰는 편이 전체 레일 완성도가 높으므로, 사용자가 추가한 `search.svg`를 우선 적용하기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.21
|
||||
- 티어표 목록 카드는 페이지마다 다른 메타 구성을 두기보다, `제목+좋아요 / 작성자+최종 수정일` 두 줄 문법으로 통일하는 편이 시안과 사용성 모두에 더 맞다고 정리했다.
|
||||
- `내 즐겨찾기` 화면에서 “즐겨찾기한 날짜”는 컬렉션 내부 정보일 뿐 카드 핵심 정보는 아니므로, 정렬은 유지하되 카드에는 마지막 수정일만 보여주는 편이 더 읽기 쉽다고 판단했다.
|
||||
- 좌측 `Favorites`는 메인 내비보다 보조 영역이어야 하므로, 같은 공간 안에서도 더 작은 썸네일·더 작은 텍스트·더 약한 대비로 눌러두는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.20
|
||||
- 전역 검색 입력이 이미 좌측 레일에 고정되어 있으므로, 검색 결과 화면 안에 검색 폼을 또 두는 것은 중복이라고 판단해 `/search` 화면은 결과 표시 자체에만 집중시키기로 했다.
|
||||
- 중앙 워크스페이스는 셸 여백 위에 다시 큰 카드 테두리와 배경을 씌우면 시안보다 한 겹 더 두꺼워 보이므로, `workspaceBody`는 외곽 카드 없이 단일 여백 레이어만 유지하는 편이 더 맞다고 정리했다.
|
||||
- 우측 패널 토글은 같은 위치에 서로 다른 상태의 버튼이 번갈아 보여야 인지가 쉬우므로, 패널이 닫혀 있을 때는 중앙 헤더에 열기 버튼을 두고 열려 있을 때는 우측 헤더에 닫기 버튼을 두는 방식으로 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.19
|
||||
- 사용자 카드에서 프로필/로그아웃 팝업을 또 띄우는 구조는 좌측 `Settings` 메뉴와 역할이 겹치므로, 설정 진입은 메뉴 하나로만 통일하고 로그아웃은 설정 화면 안쪽에서 마무리하는 편이 더 명확하다고 정리했다.
|
||||
- 좌측 `Favorites`는 단순 링크보다 “내가 최근 좋아요한 실제 티어표 바로가기”를 보여주는 쪽이 시안과 사용성 모두에 더 가깝다고 판단해, 최근 10개만 노출하고 나머지는 `즐겨찾기 더 보기`로 보내기로 했다.
|
||||
- 좌측 검색은 페이지 내부 국소 검색보다 서비스 전체 공개 티어표 검색 진입점으로 쓰는 편이 더 자연스럽다고 판단해, 별도 `/search` 결과 화면을 두는 방향으로 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.18
|
||||
- 피그마 기준 상단 구조는 페이지마다 다르게 보이면 안 되므로, 좌/중앙/우 컬럼 모두 `56px` 헤더를 고정으로 두고 내용이 없을 때도 빈 헤더 공간을 유지하는 편이 맞다고 정리했다.
|
||||
- 사이트 브랜드는 좌측 레일 안쪽 카드가 아니라 중앙 워크스페이스 상단의 고정 헤더에 두는 쪽이 시안과 더 가깝고, 페이지 이동 시에도 더 일관되게 읽힌다고 판단했다.
|
||||
- 에디터 화면 안에서 `.layout`이 다시 좌우 컬럼을 만들면 공통 3단 셸과 충돌하므로, 에디터 본문은 셸이 제공한 중앙 컬럼 안에서만 레이아웃을 잡아야 한다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.17
|
||||
- 공통 오른쪽 레일을 쓰는 화면에서는 로컬 패널이 다시 외곽 래퍼 카드로 감싸지면 “오른쪽 레일 안의 또 다른 사이드”처럼 읽히므로, 에디터 우측 패널은 섹션들만 공통 레일 루트에 직접 배치하는 쪽이 더 일관적이라고 정리했다.
|
||||
- 에디터/관리자 공통 오른쪽 컬럼은 컨테이너를 화면별로 따로 꾸미기보다, 셸의 `localRightRailRoot`가 기본 스택 문법을 제공하고 각 화면은 내부 section만 채우는 방식으로 맞추기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.16
|
||||
- 홈 화면은 이동 경로가 이미 좌측/우측 사이드에 충분히 있으므로, 중앙 바디 상단에 상태 카드와 중복 버튼을 다시 두기보다 본문은 게임 카드에만 집중시키는 편이 더 낫다고 정리했다.
|
||||
- 오른쪽 사이드도 정보가 막막하다고 해서 임시 카드를 많이 넣기보다, 우선 핵심 CTA 하나만 남기고 나중에 필요한 항목만 추가하는 편이 시안과 운영 흐름 모두에 더 적합하다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.15
|
||||
- 리디자인 기준 구조는 화면마다 달라지면 안 되므로, 홈에서 보이는 `좌측 레일 / 중앙 / 우측 레일` 3단 셸을 일반 페이지 공통 뼈대로 고정하고 안쪽 콘텐츠만 바꾸는 방식으로 정리하기로 했다.
|
||||
- 에디터와 관리자의 우측 패널도 예외적인 바디 내부 aside가 아니라 공통 셸의 세 번째 컬럼을 공유해야 전체 제품 구조가 일관된다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.14
|
||||
- 에디터 우측 패널은 본문 내부 그리드의 일부가 아니라 공통 셸의 세 번째 컬럼이어야 메인 화면과 같은 구조로 읽히므로, Teleport로 셸 aside에 직접 붙이는 편이 맞다고 정리했다.
|
||||
- 로컬 우측 패널 화면에서 “메인 안쪽 2단 레이아웃”과 “셸 3단 레이아웃”을 섞으면 계속 혼선이 생기므로, 에디터는 셸 레벨 3단 구조를 우선 기준으로 삼기로 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.13
|
||||
- 공통 상태를 로컬 우측 패널에 연결할 때는 템플릿의 ref 자동 언래핑을 고려해야 하므로, 템플릿에서는 `.value` 없이 직접 참조하는 편이 안전하다고 다시 정리했다.
|
||||
- 이번 회귀처럼 편집 화면이 통째로 무너질 수 있는 연결점은 작은 레이아웃 수정이어도 바로 복구 릴리스로 끊는 편이 낫다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.12
|
||||
- 공통 상단의 패널 토글은 로컬 우측 패널 화면에서도 같은 의미로 동작해야 하므로, 에디터의 `editorSidebar`도 같은 상태를 공유해 접고 펴는 편이 일관된다고 판단했다.
|
||||
- 로컬 우측 패널 화면에 공통 `rightClosed` 그리드 계산이 다시 들어오면 컬럼 수가 꼬일 수 있으므로, 에디터/관리자 화면은 셸 차원에서 별도 예외 컬럼 규칙을 유지하기로 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.11
|
||||
- 에디터와 관리자처럼 자체 우측 패널이 있는 화면은 공통 `workspaceBody` 카드 배경 안에 다시 넣기보다, 셸 레벨에서 중앙 본문을 투명하게 풀어주는 편이 우측 사이드바 독립성이 더 잘 살아난다고 판단했다.
|
||||
- 로컬 우측 패널의 핵심은 “본문 안쪽 보조 박스”가 아니라 “진짜 오른쪽 컬럼”처럼 읽히는 것이므로, 에디터에서는 본문 카드보다 패널 분리감을 먼저 확보하기로 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.10
|
||||
- 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다.
|
||||
- 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다.
|
||||
- 카드 hover 반응은 화면마다 조금씩 다르게 두기보다, 모두 얕은 위로 이동과 배경 변화로 통일하는 편이 대시보드 감도를 유지하기 쉽다고 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.9
|
||||
- 관리자 화면은 기능보다 먼저 정보 계층이 읽혀야 하므로, 현재 탭에 맞는 요약 통계를 헤더에서 먼저 보여주는 편이 운영 판단에 더 유리하다고 정리했다.
|
||||
- 게임/아이템/티어표/회원 카드는 기능이 다른 대신 같은 제품 안에 있으므로, 배경층·반경·패딩은 하나의 대시보드 문법으로 맞춰 시안 톤을 더 강하게 유지하기로 결정했다.
|
||||
- 우측 운영 패널은 단순 필터 모음보다 “현재 상태를 짧게 읽고 바로 액션하는 패널”에 가까워야 하므로, 입력과 통계 카드를 더 단단한 카드형 레이어로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.8
|
||||
- 에디터는 “보드 편집”과 “옵션 편집”의 역할이 다르므로, 보드 옆에는 드래그용 아이템 풀을 두고 제목/설명/썸네일/저장 같은 설정은 최우측 사이드바에만 남기는 편이 맞다고 판단했다.
|
||||
- 커스텀 아이템 이름 정리는 배치 중에 계속 보는 정보보다 저장 전 정리용 정보에 가까우므로, 아이템 풀 아래보다 우측 편집 패널 내부가 더 적합하다고 정리했다.
|
||||
- 실제 SVG 에셋이 들어오기 시작한 만큼, 공통 셸은 새 아이콘을 우선 적용하고 나머지는 점진적으로 교체하는 방식이 안전하다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.7
|
||||
- 피그마 감도는 개별 화면보다 공통 셸의 밀도와 아이콘 체계가 먼저 맞아야 하므로, 좌측/우측 레일을 먼저 아이콘형 카드 문법으로 정리하기로 했다.
|
||||
- 실제 머티리얼 SVG 자산을 받기 전까지는 간단한 선형 SVG 아이콘으로 정보 구조를 먼저 맞추고, 이후 에셋 교체만으로 다듬을 수 있게 하는 편이 안전하다고 판단했다.
|
||||
- 에디터는 기능은 이미 많은 상태이므로 구조를 더 바꾸기보다 보드, 툴바, 우측 편집 패널의 카드 톤을 공통 셸과 맞추는 방식으로 단계적으로 다듬기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.6
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 중심 화면은 한 번에 같은 카드 문법으로 맞춰야 전체 앱이 하나의 제품처럼 보이므로, 목록 화면을 우선 통일하기로 했다.
|
||||
- 홈 화면은 단순 게임 버튼 모음보다 상태 카드와 CTA가 있는 라이브러리 대시보드 쪽이 피그마 톤에 더 가깝다고 판단했다.
|
||||
- 게임 허브와 개인 목록도 썸네일/작성자/메타의 비중이 비슷하므로, 화면마다 다른 카드 구조를 유지하기보다 동일한 정보 계층을 반복하는 편이 더 읽기 쉽다고 정리했다.
|
||||
|
||||
## 2026-03-30 v1.2.5
|
||||
- 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다.
|
||||
- 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다.
|
||||
- 새 셸 단계에서는 기능을 줄이기보다 위치를 재배치하는 것이 안전하므로, 기존 게임/아이템/티어표/회원 관리 로직은 유지한 채 정보 구조만 피그마 방향으로 옮기기로 했다.
|
||||
|
||||
## 2026-03-30 v1.2.4
|
||||
- 로그인 유도는 좌측 하단의 단일 버튼이면 충분하므로, 비로그인 상태에서 사이드 상단에 별도 안내 카드를 또 보여주는 구조는 제거하는 편이 더 깔끔하다고 판단했다.
|
||||
- 티어표 편집 화면은 공통 우측 패널의 generic 문맥 카드보다 실제 편집 필드가 우측에 있는 편이 훨씬 중요하므로, 이 화면은 전용 로컬 우측 패널을 두는 쪽으로 정리했다.
|
||||
- 좌측 내비가 이미 라우팅 역할을 하므로, 에디터 우측 패널에서는 “게임 목록으로” 같은 중복 이동 CTA보다 저장과 편집 자체에 집중하는 것이 맞다고 판단했다.
|
||||
|
||||
23
docs/map.md
@@ -2,17 +2,17 @@
|
||||
|
||||
## `/`
|
||||
- 화면 파일: `frontend/src/views/HomeView.vue`
|
||||
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 역할: 데스크톱 기본 4열 게임 카드 라이브러리 대시보드, 썸네일 없이 `게임명 + 작은 ID` 메타만 보이는 템플릿 선택 카드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
|
||||
- 연동 API: `GET /api/games`
|
||||
|
||||
## `/games/:gameId`
|
||||
- 화면 파일: `frontend/src/views/GameHubView.vue`
|
||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입
|
||||
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성은 우측 하단 CTA로 진입
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 우측 전용 편집 패널에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어, 즐겨찾기 토글, PNG 다운로드
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
||||
|
||||
## `/login`
|
||||
@@ -22,28 +22,33 @@
|
||||
|
||||
## `/me`
|
||||
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
|
||||
- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
|
||||
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
|
||||
|
||||
## `/favorites`
|
||||
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 편집 화면 이동, 즐겨찾기 해제
|
||||
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
|
||||
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
|
||||
|
||||
## `/search`
|
||||
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
|
||||
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
|
||||
- 연동 API: `GET /api/tierlists/public?q=...`
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장
|
||||
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
|
||||
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다. 좌우 레일의 주요 액션은 각각 하단 `56px` 푸터 영역 아래 추가 여백을 둔 상태로 배치된다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
|
||||
29
docs/spec.md
@@ -11,7 +11,10 @@
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
|
||||
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
|
||||
|
||||
## 데이터 저장 구조
|
||||
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
||||
@@ -24,16 +27,35 @@
|
||||
|
||||
## 화면 구조
|
||||
- 좌측 패널
|
||||
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다.
|
||||
- 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
|
||||
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
|
||||
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
|
||||
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
|
||||
- 중앙 워크스페이스
|
||||
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
|
||||
- 단, 홈 게임 선택 카드는 템플릿 선택용이므로 썸네일 없이 `게임명 + 작은 ID` 메타만 표시하는 간결한 카드형으로 유지한다.
|
||||
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
|
||||
- 우측 패널
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다.
|
||||
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
|
||||
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
|
||||
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
|
||||
- 하단 액션은 화면 바닥에 바로 붙지 않도록 추가 하단 여백을 둔다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
|
||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
## DB 스키마
|
||||
- `users`
|
||||
@@ -97,6 +119,7 @@
|
||||
- `GET /api/games/:gameId`
|
||||
- 티어표
|
||||
- `GET /api/tierlists/public`
|
||||
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
|
||||
- `GET /api/tierlists/me`
|
||||
- `GET /api/tierlists/favorites/me`
|
||||
- `GET /api/tierlists/:id`
|
||||
@@ -172,6 +195,8 @@
|
||||
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||
|
||||
20
docs/todo.md
@@ -2,8 +2,26 @@
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
||||
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
|
||||
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
|
||||
- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다.
|
||||
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
|
||||
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
|
||||
- 티어표 편집 화면은 기본 편집 필드를 우측 로컬 패널로 옮겼지만, 관리자 화면도 같은 방식으로 실제 운영 패널 중심 레이아웃으로 다시 정리할 필요가 있다.
|
||||
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
|
||||
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
|
||||
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
|
||||
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
|
||||
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
|
||||
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
|
||||
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
|
||||
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
|
||||
- 좌우 하단 액션 영역은 분리했으므로, 다음 단계는 축소된 왼쪽 레일에서도 관리자/로그인 버튼을 아이콘형으로 어떻게 유지할지 검토할 수 있다.
|
||||
- 홈 게임 카드 메타는 간소화했으므로, 이후 필요하면 게임 썸네일은 상세 허브나 우측 패널처럼 더 맥락이 분명한 위치에만 쓰는 방향을 검토할 수 있다.
|
||||
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
|
||||
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
|
||||
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
|
||||
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
|
||||
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
|
||||
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||
|
||||
@@ -1,6 +1,101 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-30 v1.2.3
|
||||
## 2026-03-30 v1.2.24
|
||||
- **내 티어표 상단 stat 제거**: `내 티어표` 화면 헤더 오른쪽의 저장 개수 stat 카드를 제거해 제목/설명만 남도록 단순화
|
||||
- **홈 게임 카드 메타 단순화**: 게임 선택 카드에서 썸네일과 점형 메타를 제거하고, 한글 게임 제목과 아래 작은 ID만 보이는 형태로 정리
|
||||
- **좌우 하단 액션 여백 보정**: 왼쪽 로그인/관리자 버튼과 오른쪽 빠른 액션 버튼은 바닥에 바로 붙지 않도록 하단에 추가 여백을 확보
|
||||
|
||||
## 2026-03-30 v1.2.23
|
||||
- **홈 게임 카드 4열 정리**: 메인 게임 목록 화면도 카드형 레이아웃에서 데스크톱 기준 기본 4열로 보이도록 그리드를 조정
|
||||
- **게임 허브 중복 생성 CTA 제거**: 게임 선택 화면 본문 상단의 `새로운 티어표 만들기` 버튼을 제거하고, 우측 사이드 하단 CTA만 유지하도록 정리
|
||||
- **좌우 하단 액션 영역 분리**: 왼쪽 `관리자 메뉴/로그인`과 오른쪽 빠른 액션 버튼을 각각 독립된 하단 `56px` 영역에 배치해, 본문/스크롤 영역과 분리된 고정 액션 위치로 통일
|
||||
|
||||
## 2026-03-30 v1.2.22
|
||||
- **왼쪽 사이드 축소/확대 추가**: 좌측 레일을 완전히 숨기지 않고 축소형 내비로 접었다 펼 수 있게 바꾸고, 접힌 상태에서는 아이콘 중심으로만 보이도록 레이아웃을 정리
|
||||
- **좌우 패널 토글 아이콘 통일**: 오른쪽 패널 열기/닫기는 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘만 사용하도록 통일
|
||||
- **전역 검색 아이콘 교체**: 좌측 전역 검색 입력에 사용자가 추가한 `search.svg`를 실제 아이콘으로 연결
|
||||
|
||||
## 2026-03-30 v1.2.21
|
||||
- **티어표 카드 문법 통일**: 게임 허브, 검색 결과, 내 티어표, 즐겨찾기 목록의 카드 레이아웃을 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 2줄 메타 구조로 통일하고, 데스크톱 기준 한 줄 4개 카드가 보이도록 재배치
|
||||
- **즐겨찾기 화면 날짜 기준 단순화**: `내 즐겨찾기` 화면은 더 이상 즐겨찾기한 시각을 표시하지 않고, 정렬 기준과 무관하게 덱의 마지막 수정일만 카드에 노출하도록 정리
|
||||
- **좌측 사용자 카드/즐겨찾기 밀도 보정**: 좌측 사용자 아바타를 원형 보더 스타일로 통일하고, `Favorites` 바로가기 섹션은 메인 메뉴보다 덜 강조되도록 썸네일·텍스트·간격을 한 단계 축소
|
||||
|
||||
## 2026-03-30 v1.2.20
|
||||
- **검색 결과 상단 툴바 제거**: `/search` 화면의 중복 검색 폼을 제거하고, 좌측 전역 검색 입력만 검색 진입점으로 사용하도록 단순화
|
||||
- **왼쪽 즐겨찾기 더보기 아이콘 교체**: 사용자가 추가한 `more.svg`를 좌측 `즐겨찾기 더 보기` 링크 아이콘에 연결
|
||||
- **중앙 본문 외곽 레이어 제거**: `workspaceBody`의 추가 패딩, 테두리, 둥근 카드 배경을 제거해 중앙 콘텐츠가 한 겹만 안쪽으로 들어온 것처럼 보이도록 셸 여백을 단순화
|
||||
- **게임 허브 상단 통계 제거**: 게임별 티어표 목록 화면의 `dashboardStat` 카드를 제거해 상단 헤더를 CTA 중심으로 정리
|
||||
- **우측 패널 토글 동작 정리**: 중앙 헤더에는 패널이 닫혀 있을 때만 열기 아이콘 버튼을, 우측 헤더에는 패널이 열려 있을 때만 닫기 아이콘 버튼을 표시하도록 토글 흐름을 재구성
|
||||
|
||||
## 2026-03-30 v1.2.19
|
||||
- **왼쪽 레일 설정 흐름 단순화**: 사용자 카드 클릭 팝업을 제거하고, 설정은 좌측 `Settings` 메뉴에서만 진입하도록 정리했으며 프로필 화면 하단에 로그아웃 버튼을 추가
|
||||
- **좌측 즐겨찾기 바로가기 추가**: 좌측 `Favorites` 영역에 최근 즐겨찾기 티어표 최대 10개를 바로가기 형태로 표시하고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결
|
||||
- **전역 공개 티어표 검색 추가**: 좌측 검색 입력은 이제 전체 공개 티어표를 대상으로 검색하며, 새 `/search` 결과 화면에서 제목/작성자 기준 검색 결과를 카드 목록으로 표시
|
||||
- **설정 아이콘 반영 및 중복 관리자 버튼 제거**: 사용자가 추가한 `settings.svg`를 좌측 `Settings` 메뉴에 연결하고, 상단 내비에 중복되던 관리자 메뉴 항목은 제거
|
||||
|
||||
## 2026-03-30 v1.2.18
|
||||
- **공통 56px 셸 헤더 도입**: 좌측 사이드, 중앙 워크스페이스, 우측 사이드 상단에 각각 높이 `56px`의 고정 헤더 블록을 두고, 사이트 타이틀 `Tier Maker by zenn`은 중앙 상단 헤더에만 표시되도록 셸 구조를 재정리
|
||||
- **에디터 메인 래퍼 단순화**: 티어표 편집 화면의 `.layout` 2열 그리드를 제거해 공통 3단 셸 바깥에 중복 컬럼이 생기지 않도록 정리
|
||||
- **아이템 라벨 overflow 수정**: 편집 화면 우측 아이템 풀에서 긴 아이템 이름이 화면 밖으로 밀려나지 않도록 `minmax(0, 1fr)`와 말줄임 처리 기준을 추가
|
||||
|
||||
## 2026-03-30 v1.2.17
|
||||
- **에디터 우측 패널 래퍼 제거**: 티어표 편집 화면의 `editorSidebar` 외곽 래퍼를 제거하고, 공통 오른쪽 레일 루트에 편집 섹션들이 직접 쌓이도록 구조를 단순화
|
||||
- **공통 우측 레일 정렬 통일**: `App.vue`의 `localRightRailRoot`에 섹션 스택 정렬을 부여해, 에디터/관리자 같은 로컬 패널 화면도 공통 레일 안에서 같은 방식으로 콘텐츠가 배치되도록 정리
|
||||
|
||||
## 2026-03-30 v1.2.16
|
||||
- **메인 오른쪽 사이드 단순화**: 홈 화면 기준 오른쪽 컬럼의 컨텍스트/계정/점프 카드 3종을 제거하고, 시안에 맞춰 핵심 CTA 버튼만 남기는 구조로 단순화
|
||||
- **홈 상단 중복 도구 제거**: 중앙 바디 상단에 추가돼 있던 `Visible Games`, `Account`, `즐겨찾기 보기`, `내 리스트 보기`, `커스텀 티어표 만들기` 도구 막대를 제거해, 왼쪽/오른쪽 사이드와 중복되는 이동 요소를 정리
|
||||
|
||||
## 2026-03-30 v1.2.15
|
||||
- **3단 셸 구조 고정**: 홈 화면처럼 `왼쪽 사이드 | 중앙 컨텐츠 | 오른쪽 사이드` 3단 레이아웃을 모든 일반 페이지의 공통 구조로 고정하고, 페이지 이동 시 오른쪽 컬럼이 사라졌다 나타나는 구조를 제거
|
||||
- **에디터/관리자 우측 패널 공통 컬럼 통합**: 티어표 편집과 관리자 화면의 로컬 우측 패널을 Teleport로 공통 오른쪽 컬럼에 배치해, 바디 내부 2단 레이아웃 대신 셸의 세 번째 컬럼을 공유하도록 재정리
|
||||
|
||||
## 2026-03-30 v1.2.14
|
||||
- **에디터 우측 패널 셸 컬럼 이관**: 티어표 편집 화면의 `editorSidebar`를 `workspaceBody` 내부 보조 칼럼이 아니라 공통 셸의 세 번째 컬럼으로 옮겨, 메인 화면과 같은 `왼쪽 사이드 | 메인 | 오른쪽 사이드` 구조를 사용하도록 재배치
|
||||
- **공통 토글과 실제 aside 연결**: 상단 패널 토글 버튼은 이제 Teleport로 이동한 에디터 우측 aside를 직접 접고 펴며, 본문 내부 2단 레이아웃처럼 보이던 구조를 제거
|
||||
|
||||
## 2026-03-30 v1.2.13
|
||||
- **에디터 우측 패널 회귀 수정**: 공통 패널 상태를 템플릿에서 잘못 참조해 `editorSidebar`가 항상 닫힌 상태로 계산되던 문제를 수정해, 제목/설명/썸네일/저장 패널이 다시 정상 표시되도록 복구
|
||||
|
||||
## 2026-03-30 v1.2.12
|
||||
- **에디터 우측 패널 토글 연결**: 공통 상단의 패널 토글 버튼이 이제 티어표 편집 화면의 `editorSidebar`에도 직접 연결되어, 숨기면 우측 패널이 접히고 중앙 보드 영역이 넓어지도록 수정
|
||||
- **로컬 우측 패널 컬럼 충돌 방지**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면에서는 공통 `rightClosed` 셸 컬럼 계산이 다시 끼어들지 않도록 예외 처리를 추가해 레이아웃이 다시 틀어지지 않게 보정
|
||||
|
||||
## 2026-03-30 v1.2.11
|
||||
- **에디터 로컬 우측 패널 분리 보정**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면은 공통 `workspaceBody` 카드 컨테이너를 벗기고, 로컬 패널이 중앙 본문 안쪽이 아니라 독립 컬럼처럼 보이도록 셸 구조를 조정
|
||||
- **에디터 우측 컬럼 간격 보정**: 티어표 편집 화면의 `editorSidebar`가 본문 내부 보조 박스처럼 눌리지 않도록 간격과 최소 폭을 정리해 우측 사이드바 역할이 더 분명하게 보이도록 수정
|
||||
|
||||
## 2026-03-30 v1.2.10
|
||||
- **목록 화면 상단 툴바 밀도 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 상단 영역의 통계 카드와 액션 버튼 높이/반경/배경을 맞춰 공통 셸과 같은 도구 막대 문법으로 정리
|
||||
- **홈 빠른 진입 흐름 보정**: 홈 화면 툴바에서 중복되던 버튼 흐름을 `즐겨찾기 / 내 리스트 / 커스텀 티어표 만들기` 중심으로 재구성해 실제 사용 동선에 맞게 정리
|
||||
- **목록 카드 인터랙션 보강**: 주요 카드 목록에 일관된 hover 이동과 배경 전환을 넣어, 대시보드 카드가 더 또렷하게 반응하도록 조정
|
||||
|
||||
## 2026-03-30 v1.2.9
|
||||
- **관리자 대시보드 헤더 보강**: 관리자 화면 상단에 현재 탭 기준 요약 통계 카드를 추가해, 게임/아이템/티어표/회원 상태를 즉시 읽을 수 있게 정리
|
||||
- **운영 패널 질감 정리**: 우측 `320px` 운영 패널의 탭, 입력, 통계 카드, 버튼 라운드/배경/호버 상태를 공통 셸 톤에 맞춰 더 두꺼운 대시보드 카드 문법으로 통일
|
||||
- **관리 카드 밀도 개선**: 게임 상세, 커스텀 아이템, 템플릿 요청, 전체 티어표, 회원 카드의 배경층·패딩·반경을 함께 다듬어 시안에 가까운 평평한 관리용 레이아웃으로 보정
|
||||
|
||||
## 2026-03-30 v1.2.8
|
||||
- **실제 SVG 아이콘 연결 시작**: 사용자가 추가한 `grid_view`, `lists`, `dock_to_left`, `dock_to_right` 아이콘을 공통 셸 내비와 우측 패널 토글에 연결해 문자 기반 아이콘을 일부 실제 에셋으로 교체
|
||||
- **에디터 3열 구조 복구**: 티어표 편집 화면을 `보드 / 아이템 풀 / 우측 편집 사이드바` 구조로 재배치해, 아이템 풀은 보드 옆에서 바로 드래그 가능하고 편집 옵션은 최우측 패널에만 남도록 수정
|
||||
- **커스텀 아이템 이름 정리 위치 조정**: 커스텀 아이템 이름 수정 목록은 드래그용 아이템 풀 아래가 아니라 우측 편집 사이드바 안으로 옮겨, 보드 배치 흐름과 옵션 정리 흐름을 분리
|
||||
|
||||
## 2026-03-30 v1.2.7
|
||||
- **공통 셸 아이콘형 정리**: 좌측 내비와 우측 보조 패널의 임시 문자 배지를 간단한 SVG 아이콘형으로 바꾸고, 버튼/카드 라운드와 밀도를 통일
|
||||
- **좌측 레일 정보 밀도 개선**: 사용자 카드, 빠른 검색, 내비 버튼, 하단 로그인/관리자 버튼을 더 두꺼운 카드 문법으로 맞춰 피그마 톤에 가까운 레일 형태로 재정리
|
||||
- **에디터 패널 감도 보정**: 티어표 편집 화면의 보드, 보드 툴바, 우측 편집 패널, 아이템 풀/드롭존 카드의 배경·경계·라운드를 함께 정리해 공통 셸과 시각 언어를 맞춤
|
||||
|
||||
## 2026-03-30 v1.2.6
|
||||
- **목록형 화면 카드 문법 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드형 목록을 동일한 썸네일/제목/작성자/메타 구조로 정리해 대시보드 톤을 맞춤
|
||||
- **홈 화면 대시보드 재정렬**: 메인 게임 라이브러리 화면에 상단 상태 카드와 CTA를 추가하고, 게임 카드는 `16:9` 썸네일 + ID 메타를 갖는 라이브러리 카드 형태로 재배치
|
||||
- **게임 허브 헤더/검색 정리**: 게임 허브는 상단 통계와 생성 버튼, 보조 설명을 포함한 헤더로 재구성하고, 공개 티어표 카드도 같은 카드 밀도로 재정리
|
||||
|
||||
## 2026-03-30 v1.2.5
|
||||
- **관리자 로컬 우측 패널 이관**: 관리자 화면도 공통 우측 패널 대신 화면 내부의 `320px` 전용 운영 패널을 사용하도록 정리하고, 탭·검색·필터·빠른 액션을 우측으로 이동
|
||||
- **관리 화면 본문 집중도 개선**: 중앙 영역은 상단 고정 게임 순서, 선택된 게임 상세, 커스텀 아이템 카드, 템플릿 요청/전체 티어표, 회원 카드 같은 실제 관리 대상만 남기고 빈 상태 안내도 별도 패널로 정리
|
||||
- **관리자 셸 예외 확장**: 공통 앱 셸에서 `/admin`도 전용 로컬 우측 패널을 사용하는 포커스 화면으로 분류해 generic 우측 문맥 카드가 중복 표시되지 않게 조정
|
||||
|
||||
## 2026-03-30 v1.2.4
|
||||
- **비로그인 중복 안내 제거**: 좌측 사이드 상단의 별도 로그인 안내 카드를 제거하고, 비로그인 상태에서는 좌측 하단 버튼만 `로그인` 진입점으로 사용하도록 단순화
|
||||
- **에디터 우측 편집 패널 이관**: 티어표 편집 화면의 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 중앙 상단이 아니라 독립 우측 편집 패널로 이동
|
||||
- **공통 우측 패널 예외 처리**: 티어표 편집 화면은 공통 우측 패널 대신 화면 내부 전용 편집 패널을 사용하도록 조정해, generic 안내 카드가 중복 표시되지 않게 정리
|
||||
|
||||
1
frontend/src/assets/icons/dock_to_left.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm440-80h120v-560H640v560Zm-80 0v-560H200v560h360Zm80 0h120-120Z"/></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
1
frontend/src/assets/icons/dock_to_right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm120-80v-560H200v560h120Zm80 0h360v-560H400v560Zm-80 0H200h120Z"/></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
1
frontend/src/assets/icons/grid_view.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-520v-320h320v320H120Zm0 400v-320h320v320H120Zm400-400v-320h320v320H520Zm0 400v-320h320v320H520ZM200-600h160v-160H200v160Zm400 0h160v-160H600v160Zm0 400h160v-160H600v160Zm-400 0h160v-160H200v160Zm400-400Zm0 240Zm-240 0Zm0-240Z"/></svg>
|
||||
|
After Width: | Height: | Size: 354 B |
1
frontend/src/assets/icons/lists.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M80-160v-160h160v160H80Zm240 0v-160h560v160H320ZM80-400v-160h160v160H80Zm240 0v-160h560v160H320ZM80-640v-160h160v160H80Zm240 0v-160h560v160H320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
1
frontend/src/assets/icons/more.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M360-160q-19 0-36-8.5T296-192L80-480l216-288q11-15 28-23.5t36-8.5h440q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H360ZM180-480l180 240h440v-480H360L180-480Zm248.5 28.5Q440-463 440-480t-11.5-28.5Q417-520 400-520t-28.5 11.5Q360-497 360-480t11.5 28.5Q383-440 400-440t28.5-11.5Zm140 0Q580-463 580-480t-11.5-28.5Q557-520 540-520t-28.5 11.5Q500-497 500-480t11.5 28.5Q523-440 540-440t28.5-11.5Zm140 0Q720-463 720-480t-11.5-28.5Q697-520 680-520t-28.5 11.5Q640-497 640-480t11.5 28.5Q663-440 680-440t28.5-11.5ZM580-480Z"/></svg>
|
||||
|
After Width: | Height: | Size: 639 B |
1
frontend/src/assets/icons/search.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>
|
||||
|
After Width: | Height: | Size: 375 B |
1
frontend/src/assets/icons/settings.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>
|
||||
|
After Width: | Height: | Size: 770 B |
@@ -62,6 +62,7 @@ export const api = {
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
|
||||
searchPublicTierLists: (gameId, q = '') =>
|
||||
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
|
||||
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
|
||||
listMyTierLists: () => request('/api/tierlists/me'),
|
||||
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
|
||||
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),
|
||||
|
||||
@@ -8,6 +8,7 @@ import MyTierListsView from '../views/MyTierListsView.vue'
|
||||
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
|
||||
import AdminView from '../views/AdminView.vue'
|
||||
import ProfileView from '../views/ProfileView.vue'
|
||||
import SearchResultsView from '../views/SearchResultsView.vue'
|
||||
|
||||
export function createRouter() {
|
||||
return _createRouter({
|
||||
@@ -20,6 +21,7 @@ export function createRouter() {
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
|
||||
{ path: '/search', name: 'search', component: SearchResultsView },
|
||||
{ path: '/admin', name: 'admin', component: AdminView },
|
||||
{ path: '/profile', name: 'profile', component: ProfileView },
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -8,6 +8,8 @@ import { useToast } from '../composables/useToast'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
|
||||
const activeTab = ref('games')
|
||||
@@ -73,6 +75,67 @@ const featuredGames = computed(() =>
|
||||
)
|
||||
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||
const activeTabTitle = computed(() => {
|
||||
if (activeTab.value === 'games') return '게임 관리'
|
||||
if (activeTab.value === 'items') return '아이템 관리'
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
||||
}
|
||||
return '회원 관리'
|
||||
})
|
||||
const activeTabDescription = computed(() => {
|
||||
if (activeTab.value === 'games') {
|
||||
return '홈 노출 순서, 게임 생성, 썸네일, 기본 아이템을 한 화면에서 정리합니다.'
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return '사용자 커스텀 이미지를 검색하고, 미사용 이미지를 정리하거나 템플릿으로 승격할 수 있어요.'
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests'
|
||||
? '사용자 요청 기반으로 새 템플릿 생성이나 템플릿 업데이트를 승인합니다.'
|
||||
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
|
||||
}
|
||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 함께 확인합니다.'
|
||||
})
|
||||
const adminOverviewStats = computed(() => {
|
||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
||||
const pendingRequests = templateRequests.value.length
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
|
||||
|
||||
if (activeTab.value === 'games') {
|
||||
return [
|
||||
{ label: '전체 게임', value: `${games.value.length}` },
|
||||
{ label: '상단 고정', value: `${featuredGameIds.value.length}/50` },
|
||||
{ label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return [
|
||||
{ label: '검색 결과', value: `${customItemTotal.value}` },
|
||||
{ label: '미사용', value: `${orphanItems}` },
|
||||
{ label: '대상 게임', value: customItemTargetGameId.value ? '선택됨' : '미선택' },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests'
|
||||
? [
|
||||
{ label: '대기 요청', value: `${pendingRequests}` },
|
||||
{ label: '생성 요청', value: `${templateRequests.value.filter((request) => request.type === 'create').length}` },
|
||||
{ label: '업데이트 요청', value: `${templateRequests.value.filter((request) => request.type === 'update').length}` },
|
||||
]
|
||||
: [
|
||||
{ label: '검색 결과', value: `${adminTierListTotal.value}` },
|
||||
{ label: '공개 티어표', value: `${publishedTierLists}` },
|
||||
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ label: '가입 회원', value: `${users.value.length}` },
|
||||
{ label: '관리자', value: `${adminCount}` },
|
||||
{ label: '활동 계정', value: `${users.value.filter((user) => user.tierListCount > 0).length}` },
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
@@ -869,14 +932,21 @@ async function saveFeaturedOrder() {
|
||||
<div v-else-if="!isAdmin" class="warn">이 계정은 관리자 권한이 없어요.</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||
</div>
|
||||
<div class="adminWorkspace">
|
||||
<div class="adminMain">
|
||||
<header class="adminHero">
|
||||
<div class="adminHero__eyebrow">Admin Workspace</div>
|
||||
<h2 class="adminHero__title">{{ activeTabTitle }}</h2>
|
||||
<p class="adminHero__desc">{{ activeTabDescription }}</p>
|
||||
<div class="adminHero__stats">
|
||||
<article v-for="stat in adminOverviewStats" :key="stat.label" class="adminHeroStat">
|
||||
<span class="adminHeroStat__label">{{ stat.label }}</span>
|
||||
<strong class="adminHeroStat__value">{{ stat.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="activeTab === 'games'">
|
||||
<template v-if="activeTab === 'games'">
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
@@ -927,33 +997,6 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modeTabs">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
|
||||
등록된 게임 선택
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'new' }" @click="setGameMode('new')">
|
||||
새 게임 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel panel--compact">
|
||||
<template v-if="gameMode === 'existing'">
|
||||
<!-- <div class="panel__title">등록된 게임 선택</div> -->
|
||||
<select v-model="selectedGameId" class="select" @change="loadGame">
|
||||
<option value="">게임을 선택해주세요</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
||||
</select>
|
||||
<!-- <div class="hint">이 영역은 게임 자체와 관리자 기본 아이템만 관리합니다. 여기서 아이템을 삭제해도 사용자 커스텀 이미지는 삭제되지 않아요.</div> -->
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel__title">새 게임 정보 입력</div>
|
||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
|
||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||
<button class="btn btn--primary" @click="createGame">게임 생성</button>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSelectedGame" class="panel">
|
||||
<div class="detailHead">
|
||||
<div>
|
||||
@@ -1039,41 +1082,18 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'items'">
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">사용자 커스텀 아이템 관리</div>
|
||||
<div class="hint hint--tight">사용자가 업로드한 이미지를 파일명/라벨 기준으로 검색하고, 한 번에 50개 또는 200개씩 페이지 형태로 확인할 수 있어요.</div>
|
||||
<div v-else class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">{{ gameMode === 'existing' ? '게임을 선택하면 상세 관리가 열려요.' : '새 게임 정보를 입력한 뒤 생성해 주세요.' }}</div>
|
||||
<div class="emptyState__desc">
|
||||
{{ gameMode === 'existing' ? '우측 패널에서 등록된 게임을 선택하면 썸네일과 기본 아이템 관리 영역이 활성화됩니다.' : '새 게임을 만들면 바로 선택 상태로 전환되어 썸네일과 기본 아이템 추가를 이어서 진행할 수 있어요.' }}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<input v-model="customItemQuery" class="input toolbar__search" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
<button class="btn btn--ghost toolbar__button" @click="submitCustomItemSearch">검색</button>
|
||||
<select :value="customItemLimit" class="select toolbar__select" @change="changeCustomItemLimit(Number($event.target.value))">
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<select v-model="customItemTargetGameId" class="select toolbar__select">
|
||||
<option value="">가져올 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
<label class="checkRow checkRow--toolbar">
|
||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||
<span>미사용 커스텀 이미지만 보기</span>
|
||||
</label>
|
||||
<button class="btn btn--danger toolbar__button" :disabled="!customItems.length" @click="removeUnusedCustomItems">
|
||||
미사용 이미지 일괄 삭제
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'items'">
|
||||
<div class="panel">
|
||||
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
|
||||
<div v-else class="customItemGrid">
|
||||
<article v-for="item in customItems" :key="item.id" class="customItemCard">
|
||||
@@ -1101,25 +1121,15 @@ async function saveFeaturedOrder() {
|
||||
<button class="btn btn--ghost" :disabled="customItemPage >= customItemPageCount" @click="moveCustomItemPage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'tierlists'">
|
||||
<div class="modeTabs modeTabs--admin">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'requests' }" @click="setTierlistsMode('requests')">
|
||||
템플릿 요청 관리
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'lists' }" @click="setTierlistsMode('lists')">
|
||||
전체 티어표 관리
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'tierlists'">
|
||||
<div v-if="tierlistsMode === 'requests'" class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">사용자 템플릿 요청</div>
|
||||
<div class="hint hint--tight">freeform 템플릿 등록 요청과 기존 게임 템플릿 업데이트 요청을 여기서 승인하거나 반려할 수 있어요. 반려한 요청은 대기 목록에서 바로 제외됩니다.</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost" @click="refreshTemplateRequests">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
|
||||
@@ -1166,21 +1176,6 @@ async function saveFeaturedOrder() {
|
||||
<div class="panel__title">전체 티어표 관리</div>
|
||||
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요. 여기는 요청 목록과 별개로 전체 저장 티어표를 보는 영역입니다.</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
v-model="adminTierListQuery"
|
||||
class="input toolbar__search"
|
||||
placeholder="제목, 작성자, 게임 이름 검색"
|
||||
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||
/>
|
||||
<button class="btn btn--ghost toolbar__button" @click="submitAdminTierListSearch">검색</button>
|
||||
<select :value="adminTierListLimit" class="select toolbar__select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
|
||||
@@ -1233,68 +1228,15 @@ async function saveFeaturedOrder() {
|
||||
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
|
||||
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">티어표 아이템 가져오기</div>
|
||||
<div class="modalCard__desc">
|
||||
"{{ importModalTierList?.title }}"의 아이템 {{ importModalItemCount }}개를 어디로 가져올지 선택해주세요.
|
||||
</div>
|
||||
|
||||
<div class="importModeTabs">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'existing' }" @click="importModalMode = 'existing'">
|
||||
기존 템플릿에 추가
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'new' }" @click="importModalMode = 'new'">
|
||||
새 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="importModalMode === 'existing'" class="modalCard__form">
|
||||
<select v-model="importModalTargetGameId" class="select">
|
||||
<option value="">기존 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-else class="modalCard__form">
|
||||
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
|
||||
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
|
||||
</div>
|
||||
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTierListImportModal">취소</button>
|
||||
<button class="btn btn--primary" @click="confirmTierListImport">
|
||||
{{ importModalMode === 'existing' ? '여기로 가져오기' : '새 템플릿 생성' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
|
||||
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__titleRow">
|
||||
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
||||
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="previewTierList"
|
||||
class="previewFrame"
|
||||
:src="previewTierListUrl(previewTierList)"
|
||||
title="티어표 미리보기"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<template v-else>
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">회원 관리</div>
|
||||
<div class="hint hint--tight">이메일, 닉네임, 관리자 권한을 수정하고 비밀번호도 직접 초기화할 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost" @click="refreshUsers">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
||||
@@ -1346,10 +1288,221 @@ async function saveFeaturedOrder() {
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
|
||||
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">티어표 아이템 가져오기</div>
|
||||
<div class="modalCard__desc">
|
||||
"{{ importModalTierList?.title }}"의 아이템 {{ importModalItemCount }}개를 어디로 가져올지 선택해주세요.
|
||||
</div>
|
||||
|
||||
<div class="importModeTabs">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'existing' }" @click="importModalMode = 'existing'">
|
||||
기존 템플릿에 추가
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'new' }" @click="importModalMode = 'new'">
|
||||
새 템플릿 만들기
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="importModalMode === 'existing'" class="modalCard__form">
|
||||
<select v-model="importModalTargetGameId" class="select">
|
||||
<option value="">기존 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-else class="modalCard__form">
|
||||
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
|
||||
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
|
||||
</div>
|
||||
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeTierListImportModal">취소</button>
|
||||
<button class="btn btn--primary" @click="confirmTierListImport">
|
||||
{{ importModalMode === 'existing' ? '여기로 가져오기' : '새 템플릿 생성' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
|
||||
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__titleRow">
|
||||
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
||||
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
||||
</div>
|
||||
<iframe
|
||||
v-if="previewTierList"
|
||||
class="previewFrame"
|
||||
:src="previewTierListUrl(previewTierList)"
|
||||
title="티어표 미리보기"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<aside v-show="globalRightRailOpen" class="adminSidebar">
|
||||
<section class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Mode</div>
|
||||
<div class="adminSidebar__tabs">
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === 'games'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Game Flow</div>
|
||||
<div class="modeTabs modeTabs--stack">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
|
||||
등록된 게임 선택
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'new' }" @click="setGameMode('new')">
|
||||
새 게임 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gameMode === 'existing'" class="adminSidebar__group">
|
||||
<div class="adminSidebar__groupTitle">선택할 게임</div>
|
||||
<select v-model="selectedGameId" class="select" @change="loadGame">
|
||||
<option value="">게임을 선택해주세요</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
||||
</select>
|
||||
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="adminSidebar__group">
|
||||
<div class="adminSidebar__groupTitle">새 게임 만들기</div>
|
||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
|
||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||
<button class="btn btn--primary" @click="createGame">게임 생성</button>
|
||||
</div>
|
||||
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">전체 게임</span>
|
||||
<strong class="sidebarStat__value">{{ games.length }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">상단 고정</span>
|
||||
<strong class="sidebarStat__value">{{ featuredGameIds.length }}/50</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Filters</div>
|
||||
<div class="adminSidebar__group">
|
||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
|
||||
</div>
|
||||
<div class="adminSidebar__group">
|
||||
<select :value="customItemLimit" class="select" @change="changeCustomItemLimit(Number($event.target.value))">
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
<select v-model="customItemTargetGameId" class="select">
|
||||
<option value="">가져올 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
<label class="checkRow checkRow--compact">
|
||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||
<span>미사용 커스텀 이미지만 보기</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||
<button class="btn btn--danger" :disabled="!customItems.length" @click="removeUnusedCustomItems">미사용 이미지 일괄 삭제</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">현재 페이지</span>
|
||||
<strong class="sidebarStat__value">{{ customItemPage }}/{{ customItemPageCount }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">검색 결과</span>
|
||||
<strong class="sidebarStat__value">{{ customItemTotal }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'tierlists'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Tierlists</div>
|
||||
<div class="modeTabs modeTabs--stack">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'requests' }" @click="setTierlistsMode('requests')">
|
||||
템플릿 요청 관리
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'lists' }" @click="setTierlistsMode('lists')">
|
||||
전체 티어표 관리
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="tierlistsMode === 'requests'">
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshTemplateRequests">요청 새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">대기 요청</span>
|
||||
<strong class="sidebarStat__value">{{ templateRequests.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="adminSidebar__group">
|
||||
<input
|
||||
v-model="adminTierListQuery"
|
||||
class="input"
|
||||
placeholder="제목, 작성자, 게임 이름 검색"
|
||||
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||
/>
|
||||
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
||||
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">현재 페이지</span>
|
||||
<strong class="sidebarStat__value">{{ adminTierListPage }}/{{ adminTierListPageCount }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">검색 결과</span>
|
||||
<strong class="sidebarStat__value">{{ adminTierListTotal }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-else class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Users</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshUsers">회원 새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">가입 회원</span>
|
||||
<strong class="sidebarStat__value">{{ users.length }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">관리자 수</span>
|
||||
<strong class="sidebarStat__value">{{ users.filter((user) => user.isAdmin).length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -1357,6 +1510,121 @@ async function saveFeaturedOrder() {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
.adminWorkspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.adminMain {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.adminHero {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.adminHero__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.adminHero__title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
line-height: 1.05;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.adminHero__desc {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.adminHero__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.adminHeroStat {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(7, 7, 7, 0.18);
|
||||
}
|
||||
.adminHeroStat__label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
.adminHeroStat__value {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.adminSidebar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.adminSidebar__panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015)),
|
||||
rgba(13, 13, 13, 0.94);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.adminSidebar__label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.adminSidebar__tabs,
|
||||
.adminSidebar__group,
|
||||
.adminSidebar__actions,
|
||||
.adminSidebar__stats {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminSidebar__groupTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
}
|
||||
.sidebarStat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.sidebarStat__label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
.sidebarStat__value {
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.card {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -1393,31 +1661,72 @@ async function saveFeaturedOrder() {
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.modeTabs--stack {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.tab,
|
||||
.modeTab {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
.tab:hover,
|
||||
.modeTab:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.tab--active,
|
||||
.modeTab--active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(96, 165, 250, 0.14);
|
||||
border-color: rgba(96, 165, 250, 0.28);
|
||||
color: rgba(239, 246, 255, 0.98);
|
||||
}
|
||||
.adminSidebar__tabs .tab,
|
||||
.modeTabs--stack .modeTab {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
}
|
||||
.panel {
|
||||
margin-top: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(48, 48, 48, 0.78);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.018)),
|
||||
rgba(34, 34, 34, 0.84);
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.panel--empty {
|
||||
min-height: 240px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.panel--compact {
|
||||
max-width: 520px;
|
||||
}
|
||||
.emptyState {
|
||||
max-width: 520px;
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
text-align: center;
|
||||
}
|
||||
.emptyState__title {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.emptyState__desc {
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.featuredOrderPanel {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
@@ -1427,9 +1736,9 @@ async function saveFeaturedOrder() {
|
||||
.featuredOrderPanel__list,
|
||||
.featuredOrderPanel__picker {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
.featuredList,
|
||||
.featuredPickerList {
|
||||
@@ -1444,10 +1753,10 @@ async function saveFeaturedOrder() {
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.featuredCard__meta {
|
||||
display: flex;
|
||||
@@ -1489,9 +1798,9 @@ async function saveFeaturedOrder() {
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
@@ -1521,9 +1830,9 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.adminCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sectionHeader {
|
||||
@@ -1566,8 +1875,8 @@ async function saveFeaturedOrder() {
|
||||
.input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -1609,8 +1918,8 @@ async function saveFeaturedOrder() {
|
||||
.btn {
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -1618,6 +1927,15 @@ async function saveFeaturedOrder() {
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.btn--small {
|
||||
margin-top: 0;
|
||||
@@ -1636,6 +1954,7 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.btn--primary {
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-color: rgba(96, 165, 250, 0.26);
|
||||
}
|
||||
.btn--danger {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
@@ -1721,8 +2040,8 @@ async function saveFeaturedOrder() {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.itemPreviewCard {
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
@@ -1760,9 +2079,9 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.thumbCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.thumb {
|
||||
@@ -1795,18 +2114,18 @@ async function saveFeaturedOrder() {
|
||||
.customItemGrid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.customItemCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
padding: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.customItemCard__image {
|
||||
@@ -1864,9 +2183,9 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.userCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.userCard__head {
|
||||
display: flex;
|
||||
@@ -1917,8 +2236,8 @@ async function saveFeaturedOrder() {
|
||||
.userStat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
@@ -1959,8 +2278,8 @@ async function saveFeaturedOrder() {
|
||||
.templateRequestCard {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
@@ -2025,9 +2344,9 @@ async function saveFeaturedOrder() {
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.tierAdminCard__preview {
|
||||
cursor: pointer;
|
||||
@@ -2087,8 +2406,8 @@ async function saveFeaturedOrder() {
|
||||
.tierAdminSection {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
@@ -2109,8 +2428,8 @@ async function saveFeaturedOrder() {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -2146,8 +2465,8 @@ async function saveFeaturedOrder() {
|
||||
width: min(560px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(11, 18, 32, 0.96);
|
||||
}
|
||||
@@ -2198,10 +2517,19 @@ async function saveFeaturedOrder() {
|
||||
align-items: center;
|
||||
opacity: 0.88;
|
||||
}
|
||||
.checkRow--compact {
|
||||
margin-top: 0;
|
||||
}
|
||||
.checkRow--toolbar {
|
||||
margin-top: 0;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.adminHero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminSidebar {
|
||||
display: none;
|
||||
}
|
||||
.featuredOrderPanel,
|
||||
.section--topGrid,
|
||||
.toolbar,
|
||||
@@ -2222,6 +2550,12 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.adminHero {
|
||||
padding: 16px;
|
||||
}
|
||||
.adminHero__title {
|
||||
font-size: 24px;
|
||||
}
|
||||
.thumbGrid,
|
||||
.customItemGrid,
|
||||
.userList {
|
||||
|
||||
@@ -11,17 +11,12 @@ const toast = useToast()
|
||||
const favorites = ref([])
|
||||
const query = ref('')
|
||||
const sort = ref('favorited')
|
||||
const sortLabel = computed(() =>
|
||||
sort.value === 'favorited' ? '즐겨찾기한 날짜' : sort.value === 'updated' ? '최종 업데이트' : '즐겨찾기 수'
|
||||
)
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,6 +57,7 @@ onMounted(loadFavorites)
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Collection</div>
|
||||
<h2 class="title">내 즐겨찾기</h2>
|
||||
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 수 있어요.</div>
|
||||
</div>
|
||||
@@ -78,28 +74,27 @@ onMounted(loadFavorites)
|
||||
|
||||
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="row">
|
||||
<button class="row__body" @click="openTierList(tierList)">
|
||||
<div class="row__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="row__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="row__thumbPlaceholder"></div>
|
||||
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="row__head">
|
||||
<div class="row__title">{{ tierList.title }}</div>
|
||||
<div class="row__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="row__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span>by {{ displayNameOf(tierList) }}</span>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat">♡ {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="row__foot">
|
||||
<div class="row__meta">
|
||||
<div>{{ tierList.gameName || tierList.gameId }}</div>
|
||||
<div>{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
<div class="favoriteStat">★ {{ tierList.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
@@ -116,10 +111,17 @@ onMounted(loadFavorites)
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
@@ -134,15 +136,15 @@ onMounted(loadFavorites)
|
||||
}
|
||||
.input,
|
||||
.select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -154,18 +156,25 @@ onMounted(loadFavorites)
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
border-radius: 14px;
|
||||
.boardCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.row__body {
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
@@ -173,83 +182,94 @@ onMounted(loadFavorites)
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__thumbWrap {
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #555;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.row__thumb,
|
||||
.row__thumbPlaceholder {
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__thumb {
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.row__thumbPlaceholder {
|
||||
.boardCard__thumbPlaceholder {
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.row__head {
|
||||
padding: 14px 14px 0;
|
||||
.boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__title {
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row__author {
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
.row__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row__avatar--fallback {
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.row__foot {
|
||||
padding: 0 14px 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.row__meta {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
}
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
font-weight: 800;
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1024px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -17,12 +17,10 @@ const error = ref('')
|
||||
const query = ref('')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -77,21 +75,21 @@ function submitSearch() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="head">
|
||||
<div class="head__left">
|
||||
<div class="kicker">Collection</div>
|
||||
<h2 class="title">{{ gameName || gameId }}</h2>
|
||||
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 카드형 목록으로 탐색할 수 있어요.</p>
|
||||
</div>
|
||||
<div class="head__right">
|
||||
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 후 새 티어표 만들기' }}</button>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__left">
|
||||
<div class="dashboardHero__eyebrow">Collection</div>
|
||||
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
|
||||
<p class="dashboardHero__desc">이 게임의 공개 티어표를 탐색하고, 바로 새 보드를 만들어 같은 흐름으로 이어갈 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="panel">
|
||||
<div class="panel__head">
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div>
|
||||
<div class="panel__title">공개 티어표</div>
|
||||
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 수 있어요.</div>
|
||||
</div>
|
||||
<div class="searchBar">
|
||||
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
|
||||
<button class="searchBar__button" @click="submitSearch">검색</button>
|
||||
@@ -99,71 +97,66 @@ function submitSearch() {
|
||||
</div>
|
||||
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in tierLists" :key="t.id" class="row">
|
||||
<button class="row__body" @click="openTierList(t.id)">
|
||||
<div class="row__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="row__thumbPlaceholder"></div>
|
||||
<article v-for="t in tierLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openTierList(t.id)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="row__head">
|
||||
<div class="row__title">{{ t.title }}</div>
|
||||
<div class="row__author">
|
||||
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span>by {{ displayNameOf(t) }}</span>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
|
||||
{{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="row__foot">
|
||||
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
|
||||
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
|
||||
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.head {
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
align-items: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 2px 18px;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.kicker {
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.title {
|
||||
.dashboardHero__title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 30px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.desc {
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.primary {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.primary:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
max-width: 720px;
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
/* border: 1px solid rgba(255, 255, 255, 0.08); */
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
@@ -177,6 +170,12 @@ function submitSearch() {
|
||||
}
|
||||
.panel__title {
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
}
|
||||
.panel__sub {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
.panel__head {
|
||||
display: flex;
|
||||
@@ -194,15 +193,15 @@ function submitSearch() {
|
||||
}
|
||||
.searchBar__input {
|
||||
min-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.searchBar__button {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -214,24 +213,26 @@ function submitSearch() {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
border-radius: 14px;
|
||||
.boardCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
min-height: 168px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.row:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
.boardCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.row__body {
|
||||
.boardCard__body {
|
||||
text-align: left;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
@@ -240,86 +241,96 @@ function submitSearch() {
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__thumbWrap {
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #555;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.row__thumb {
|
||||
.boardCard__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__thumbPlaceholder {
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__title {
|
||||
.boardCard__title {
|
||||
font-weight: 800;
|
||||
min-width: 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row__head {
|
||||
padding: 14px 14px 0;
|
||||
.boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
align-content: start;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__author {
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row__avatar--fallback {
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.row__meta {
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
}
|
||||
.row__foot {
|
||||
padding: 0 14px 14px;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: auto;
|
||||
}
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 999px;
|
||||
padding: 7px 10px;
|
||||
font-weight: 800;
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1280px) {
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1024px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -2,11 +2,8 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const items = ref([])
|
||||
const error = ref('')
|
||||
@@ -24,49 +21,30 @@ onMounted(async () => {
|
||||
function goGame(gameId) {
|
||||
router.push(`/games/${gameId}`)
|
||||
}
|
||||
|
||||
function goFreeform() {
|
||||
if (!auth.user) {
|
||||
router.push('/login?redirect=/editor/freeform/new')
|
||||
return
|
||||
}
|
||||
router.push('/editor/freeform/new')
|
||||
}
|
||||
|
||||
function thumbUrl(g) {
|
||||
if (!g.thumbnailSrc) return ''
|
||||
return toApiUrl(g.thumbnailSrc)
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="topBar">
|
||||
<div class="topBar__copy">
|
||||
<h1 class="topBar__title">Main Title</h1>
|
||||
<p class="topBar__desc">게임 선택과 커스텀 티어표 진입을 하나의 대시보드처럼 정리했습니다.</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="toolbar__ghost" @click="goFreeform">Toggle Filter</button>
|
||||
<button class="toolbar__select" @click="goFreeform">Select Filter</button>
|
||||
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '+ 커스텀 티어표 만들기' : '+ 로그인 후 커스텀 티어표 만들기' }}</button>
|
||||
<section class="dashboardHero">
|
||||
<div class="dashboardHero__copy">
|
||||
<div class="dashboardHero__eyebrow">Workspace</div>
|
||||
<h1 class="dashboardHero__title">Game Library</h1>
|
||||
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<section class="grid">
|
||||
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
|
||||
<div class="thumbWrap">
|
||||
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
|
||||
<div v-else class="thumbFallback">{{ g.name[0] }}</div>
|
||||
<section class="libraryGrid">
|
||||
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
|
||||
<div class="libraryCard__body">
|
||||
<div class="libraryCard__title">{{ g.name }}</div>
|
||||
<div class="libraryCard__meta">{{ g.id }}</div>
|
||||
</div>
|
||||
<div class="card__title">{{ g.name }}</div>
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.topBar {
|
||||
.dashboardHero {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
@@ -74,45 +52,34 @@ function thumbUrl(g) {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 18px;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.topBar__copy {
|
||||
.dashboardHero__copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.topBar__title {
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.dashboardHero__title {
|
||||
margin: 0;
|
||||
font-size: 32px;
|
||||
font-size: 34px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.topBar__desc {
|
||||
.dashboardHero__desc {
|
||||
margin: 0;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
line-height: 1.5;
|
||||
max-width: 720px;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar__ghost,
|
||||
.toolbar__select,
|
||||
.customTierBtn {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.customTierBtn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.grid {
|
||||
.libraryGrid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.error {
|
||||
@@ -123,69 +90,60 @@ function thumbUrl(g) {
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.card {
|
||||
.libraryCard {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
display: block;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.libraryCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.libraryCard__body {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
gap: 6px;
|
||||
}
|
||||
.card:hover {
|
||||
background: rgba(72, 72, 72, 0.92);
|
||||
}
|
||||
.thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: #555;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
}
|
||||
.thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumbFallback {
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.card__title {
|
||||
.libraryCard__title {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 15px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.libraryCard__meta {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid {
|
||||
.libraryGrid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
.libraryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.topBar {
|
||||
.dashboardHero {
|
||||
align-items: stretch;
|
||||
}
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
.toolbar__ghost,
|
||||
.toolbar__select,
|
||||
.customTierBtn {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.grid {
|
||||
.libraryGrid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,12 +17,10 @@ watch(error, (message) => {
|
||||
})
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleString(undefined, {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -72,25 +70,36 @@ async function removeList(t) {
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<h2 class="title">내 티어표</h2>
|
||||
<header class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Library</div>
|
||||
<h2 class="title">내 티어표</h2>
|
||||
<div class="desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 수 있어요.</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="card">
|
||||
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
|
||||
<div v-else class="list">
|
||||
<article v-for="t in myLists" :key="t.id" class="row">
|
||||
<button class="row__body" @click="openList(t)">
|
||||
<div class="row__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="row__thumbPlaceholder"></div>
|
||||
<article v-for="t in myLists" :key="t.id" class="boardCard">
|
||||
<button class="boardCard__body" @click="openList(t)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="row__head">
|
||||
<div class="row__title">{{ t.title }}</div>
|
||||
<div class="row__author">
|
||||
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span>by {{ displayNameOf(t) }}</span>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ t.title }}</div>
|
||||
<div class="favoriteStat">♡ {{ t.favoriteCount || 0 }}</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
|
||||
</button>
|
||||
<button class="link link--danger" @click="removeList(t)">삭제</button>
|
||||
</article>
|
||||
@@ -103,12 +112,30 @@ async function removeList(t) {
|
||||
.wrap {
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 18px;
|
||||
font-size: 30px;
|
||||
margin: 4px 0 6px;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.desc {
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.card {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
@@ -116,8 +143,8 @@ async function removeList(t) {
|
||||
padding: 0;
|
||||
}
|
||||
.link {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -129,19 +156,26 @@ async function removeList(t) {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
.boardCard {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.row__body {
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
@@ -151,76 +185,100 @@ async function removeList(t) {
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.row__thumbWrap {
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #555;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.row__thumb {
|
||||
.boardCard__thumb {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__thumbPlaceholder {
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.row__title {
|
||||
.boardCard__title {
|
||||
font-weight: 900;
|
||||
min-width: 0;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.row__head {
|
||||
padding: 0 14px;
|
||||
.boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.row__author {
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
font-size: 13px;
|
||||
opacity: 0.84;
|
||||
}
|
||||
.row__avatar {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.row__avatar--fallback {
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.row__meta {
|
||||
padding: 0 14px;
|
||||
margin-top: 6px;
|
||||
opacity: 0.76;
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.link--danger {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
border-color: rgba(239, 68, 68, 0.28);
|
||||
margin: 0 14px 14px;
|
||||
margin: 0 18px 18px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
@media (max-width: 1024px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
|
||||
@@ -70,6 +70,12 @@ async function saveProfile() {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
await auth.logout()
|
||||
toast.success('로그아웃했어요.')
|
||||
router.push('/')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -93,9 +99,12 @@ async function saveProfile() {
|
||||
<label class="label">아바타 업로드</label>
|
||||
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
|
||||
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 때 진행됩니다.</div>
|
||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
||||
</button>
|
||||
<div class="actions">
|
||||
<button class="saveBtn" :disabled="saving" @click="saveProfile">
|
||||
{{ saving ? '저장중...' : '프로필 저장' }}
|
||||
</button>
|
||||
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
@@ -188,7 +197,6 @@ async function saveProfile() {
|
||||
font-size: 13px;
|
||||
}
|
||||
.saveBtn {
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
@@ -197,4 +205,19 @@ async function saveProfile() {
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
.actions {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.logoutBtn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.24);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
}
|
||||
</style>
|
||||
|
||||
268
frontend/src/views/SearchResultsView.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const tierLists = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const query = ref('')
|
||||
|
||||
function fmt(ts) {
|
||||
return new Date(ts).toLocaleDateString(undefined, {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
function displayNameOf(tierList) {
|
||||
return tierList.authorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function avatarSrcOf(tierList) {
|
||||
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
|
||||
}
|
||||
|
||||
function avatarFallbackOf(tierList) {
|
||||
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
|
||||
}
|
||||
|
||||
function tierListThumbnailUrl(tierList) {
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function openTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
}
|
||||
|
||||
async function loadResults() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await api.searchAllPublicTierLists(query.value)
|
||||
tierLists.value = data.tierLists || []
|
||||
} catch (e) {
|
||||
error.value = '검색 결과를 불러오지 못했어요.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => route.query.q,
|
||||
async (nextQuery) => {
|
||||
query.value = typeof nextQuery === 'string' ? nextQuery : ''
|
||||
await loadResults()
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="wrap">
|
||||
<div class="head">
|
||||
<div>
|
||||
<div class="head__eyebrow">Search</div>
|
||||
<h2 class="title">전체 티어표 검색</h2>
|
||||
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 수 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-else-if="loading" 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" @click="openTierList(tierList)">
|
||||
<div class="boardCard__thumbWrap">
|
||||
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
|
||||
</div>
|
||||
<div class="boardCard__head">
|
||||
<div class="boardCard__titleRow">
|
||||
<div class="boardCard__title">{{ tierList.title }}</div>
|
||||
<div class="favoriteStat" :title="tierList.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
|
||||
{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="boardCard__metaRow">
|
||||
<div class="boardCard__author">
|
||||
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
|
||||
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
|
||||
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
|
||||
</div>
|
||||
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 0;
|
||||
font-size: 32px;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.error {
|
||||
margin: 0 0 8px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.empty {
|
||||
opacity: 0.76;
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition: transform 0.16s ease, background 0.16s ease;
|
||||
}
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: grid;
|
||||
}
|
||||
.boardCard__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
padding: 14px 14px 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.boardCard__thumb,
|
||||
.boardCard__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
border-radius: 18px;
|
||||
}
|
||||
.boardCard__thumb {
|
||||
object-fit: cover;
|
||||
}
|
||||
.boardCard__thumbPlaceholder {
|
||||
background: #555;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.boardCard__head {
|
||||
padding: 16px 18px 18px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.boardCard__titleRow,
|
||||
.boardCard__metaRow {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.boardCard__title {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
font-size: 18px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__author {
|
||||
min-width: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
opacity: 0.86;
|
||||
}
|
||||
.boardCard__authorName {
|
||||
min-width: 0;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.boardCard__avatar {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
object-fit: cover;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.boardCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.boardCard__date,
|
||||
.favoriteStat {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: rgba(255, 255, 255, 0.64);
|
||||
white-space: nowrap;
|
||||
}
|
||||
@media (max-width: 1400px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
@@ -12,6 +12,8 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const previewMode = computed(() => route.query.preview === '1')
|
||||
@@ -685,118 +687,105 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div ref="boardEl" class="board">
|
||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||
<div class="boardTools__left">
|
||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||
</div>
|
||||
<div class="boardTools__right">
|
||||
<span class="boardTools__label">아이콘 크기</span>
|
||||
<div class="sizePicker">
|
||||
<button
|
||||
v-for="size in iconSizeOptions"
|
||||
:key="size"
|
||||
class="sizePicker__button"
|
||||
:class="{ 'sizePicker__button--active': iconSize === size }"
|
||||
@click="setIconSize(size)"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||
<div ref="groupListEl" class="rows">
|
||||
<div v-for="g in groups" :key="g.id" class="row">
|
||||
<div class="row__label">
|
||||
<template v-if="isExporting">
|
||||
<div class="row__exportName">{{ g.name }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
||||
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
||||
</template>
|
||||
<div class="editorCanvas">
|
||||
<div ref="boardEl" class="board">
|
||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||
<div class="boardTools__left">
|
||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||
</div>
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||
>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="boardTools__right">
|
||||
<span class="boardTools__label">아이콘 크기</span>
|
||||
<div class="sizePicker">
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
class="cellRemoveBtn"
|
||||
type="button"
|
||||
title="아이템 빼내기"
|
||||
@pointerdown.stop
|
||||
@click.stop="removeItemFromGroup(g.id, id)"
|
||||
v-for="size in iconSizeOptions"
|
||||
:key="size"
|
||||
class="sizePicker__button"
|
||||
:class="{ 'sizePicker__button--active': iconSize === size }"
|
||||
@click="setIconSize(size)"
|
||||
>
|
||||
×
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||
<div ref="groupListEl" class="rows">
|
||||
<div v-for="g in groups" :key="g.id" class="row">
|
||||
<div class="row__label">
|
||||
<template v-if="isExporting">
|
||||
<div class="row__exportName">{{ g.name }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
||||
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||
>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
class="cellRemoveBtn"
|
||||
type="button"
|
||||
title="아이템 빼내기"
|
||||
@pointerdown.stop
|
||||
@click.stop="removeItemFromGroup(g.id, id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isExporting" class="exportBoard__footer">
|
||||
<span>{{ effectiveAuthorName }}</span>
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isExporting" class="exportBoard__footer">
|
||||
<span>{{ effectiveAuthorName }}</span>
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '보드 바로 옆에서 드래그해 넣을 수 있도록 아이템 풀을 고정합니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canEdit && customItems.length" class="customItemEditor">
|
||||
<div class="customItemEditor__title">추가한 커스텀 아이템 이름 정리</div>
|
||||
<div class="customItemEditor__desc">
|
||||
템플릿 요청 전에 이름을 정리해두면 관리자가 그대로 기본 템플릿으로 반영할 수 있어요.
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customItemEditor__list">
|
||||
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
|
||||
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
|
||||
<input
|
||||
class="customItemEditor__input"
|
||||
:value="item.label"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름"
|
||||
@input="updateCustomItemLabel(item.id, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="dropzone"
|
||||
:class="{ 'dropzone--active': isDragActive }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||||
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
||||
</div>
|
||||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="dropzone"
|
||||
:class="{ 'dropzone--active': isDragActive }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||||
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
||||
</div>
|
||||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="editorSidebar">
|
||||
</section>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<template v-if="globalRightRailOpen">
|
||||
<div class="editorSidebar__section">
|
||||
<div class="editorSidebar__label">Title</div>
|
||||
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
|
||||
@@ -831,6 +820,27 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
|
||||
<div class="editorSidebar__label">커스텀 이름 정리</div>
|
||||
<div class="customItemEditor customItemEditor--sidebar">
|
||||
<div class="customItemEditor__desc">
|
||||
아래에서 이름만 정리해두면 관리자 요청 시 그대로 전달됩니다.
|
||||
</div>
|
||||
<div class="customItemEditor__list">
|
||||
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
|
||||
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
|
||||
<input
|
||||
class="customItemEditor__input"
|
||||
:value="item.label"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름"
|
||||
@input="updateCustomItemLabel(item.id, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editorSidebar__section editorSidebar__section--footer">
|
||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
||||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||||
@@ -858,8 +868,8 @@ onUnmounted(() => {
|
||||
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -874,6 +884,12 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.editorCanvas {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.editorMain__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
@@ -1024,10 +1040,8 @@ onUnmounted(() => {
|
||||
margin-top: 0;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
@@ -1038,10 +1052,11 @@ onUnmounted(() => {
|
||||
}
|
||||
.board {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(48, 48, 48, 0.78);
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
background: linear-gradient(180deg, rgba(55, 55, 55, 0.86), rgba(42, 42, 42, 0.82));
|
||||
border-radius: 22px;
|
||||
padding: 20px;
|
||||
align-self: start;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
@@ -1124,8 +1139,12 @@ onUnmounted(() => {
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 14px;
|
||||
margin-bottom: 16px;
|
||||
flex-wrap: wrap;
|
||||
padding: 10px 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.boardTools__left,
|
||||
.boardTools__right {
|
||||
@@ -1150,8 +1169,8 @@ onUnmounted(() => {
|
||||
.sizePicker__button {
|
||||
margin: 0;
|
||||
min-width: 48px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 9px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -1208,8 +1227,8 @@ onUnmounted(() => {
|
||||
align-items: stretch;
|
||||
}
|
||||
.row__label {
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
@@ -1234,7 +1253,7 @@ onUnmounted(() => {
|
||||
.groupName {
|
||||
width: 100%;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(0, 0, 0, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 10px;
|
||||
padding: 6px 8px;
|
||||
@@ -1264,7 +1283,7 @@ onUnmounted(() => {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.row__drop {
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
min-height: calc(var(--thumb-size, 80px) + 24px);
|
||||
@@ -1322,36 +1341,34 @@ onUnmounted(() => {
|
||||
}
|
||||
.sidebar {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(48, 48, 48, 0.78);
|
||||
border-radius: 18px;
|
||||
padding: 12px;
|
||||
}
|
||||
.editorSidebar {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
padding: 14px 12px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(18, 18, 18, 0.96);
|
||||
background: linear-gradient(180deg, rgba(52, 52, 52, 0.84), rgba(36, 36, 36, 0.8));
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
}
|
||||
.editorSidebar__section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.editorSidebar__label {
|
||||
font-size: 13px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
color: rgba(255, 255, 255, 0.52);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.12em;
|
||||
}
|
||||
.editorSidebar__input,
|
||||
.editorSidebar__textarea {
|
||||
width: 100%;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
padding: 11px 12px;
|
||||
outline: none;
|
||||
@@ -1371,7 +1388,7 @@ onUnmounted(() => {
|
||||
.editorSidebar__thumbFrame {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #4c4c4c;
|
||||
@@ -1404,17 +1421,16 @@ onUnmounted(() => {
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
padding: 12px 0 0;
|
||||
border: 0;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
padding: 11px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
.editorSidebar__section--footer {
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.editorSidebar__actionGrid {
|
||||
display: grid;
|
||||
@@ -1423,19 +1439,26 @@ onUnmounted(() => {
|
||||
}
|
||||
.sidebar__title {
|
||||
font-weight: 900;
|
||||
margin-bottom: 6px;
|
||||
margin-bottom: 8px;
|
||||
font-size: 18px;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.sidebar__hint {
|
||||
opacity: 0.78;
|
||||
font-size: 13px;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.customItemEditor {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.customItemEditor--sidebar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.customItemEditor__title {
|
||||
font-weight: 900;
|
||||
@@ -1480,7 +1503,7 @@ onUnmounted(() => {
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px dashed rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
text-align: center;
|
||||
}
|
||||
.dropzone--active {
|
||||
@@ -1502,17 +1525,21 @@ onUnmounted(() => {
|
||||
}
|
||||
.poolItem {
|
||||
display: grid;
|
||||
grid-template-columns: var(--thumb-size, 80px) 1fr;
|
||||
grid-template-columns: var(--thumb-size, 80px) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.poolItem__label {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
opacity: 0.9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
@@ -1531,13 +1558,13 @@ onUnmounted(() => {
|
||||
.heroCard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.layout {
|
||||
.editorCanvas {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 150px 1fr;
|
||||
}
|
||||
.editorSidebar {
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
.editorSidebar__actionGrid {
|
||||
|
||||