Compare commits

...

12 Commits

19 changed files with 1171 additions and 729 deletions

View File

@@ -1,5 +1,58 @@
# 의사결정 이력 # 의사결정 이력
## 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 ## 2026-03-30 v1.2.10
- 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다. - 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다.
- 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다. - 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다.

View File

@@ -7,12 +7,12 @@
## `/games/:gameId` ## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue` - 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 상단 통계/생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 상태 표시, 새 티어표 작성 진입 - 역할: 선택한 게임 정보 표시, 상단 생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite` - 연동 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` ## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue` - 화면 파일: `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` - 연동 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` ## `/login`
@@ -22,7 +22,7 @@
## `/me` ## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue` - 화면 파일: `frontend/src/views/MyTierListsView.vue`
- 역할: 내 티어표 목록 조회, 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제 - 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id` - 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
## `/favorites` ## `/favorites`
@@ -30,6 +30,11 @@
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인 - 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite` - 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/search`
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
- 연동 API: `GET /api/tierlists/public?q=...`
## `/admin` ## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue` - 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 - 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
@@ -37,13 +42,13 @@
## `/profile` ## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue` - 화면 파일: `frontend/src/views/ProfileView.vue`
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장 - 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile` - 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
## 공통 레이아웃 ## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue` - 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 일부 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링 - 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다. - 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다.
## 백엔드 진입점 ## 백엔드 진입점
- 서버 엔트리: `backend/index.js` - 서버 엔트리: `backend/index.js`

View File

@@ -11,6 +11,8 @@
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다. - 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다. - 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다. - 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다. - 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다. - 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
@@ -25,21 +27,30 @@
## 화면 구조 ## 화면 구조
- 좌측 패널 - 좌측 패널
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다. - 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
- 중앙 워크스페이스 - 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다. - 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기 화면은 같은 카드 문법(상단 16:9 썸네일, 제목, 작성자/보조 메타, 하단 상태 영역)을 공유하도록 정리한다. - 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다. - 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
- 우측 패널 - 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다. - 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다. - 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다. - 공통 토글 버튼 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 티어표 편집 화면 - 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다. - 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. - 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다. - 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면 - 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다. - 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. - 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다. - 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
@@ -105,6 +116,7 @@
- `GET /api/games/:gameId` - `GET /api/games/:gameId`
- 티어표 - 티어표
- `GET /api/tierlists/public` - `GET /api/tierlists/public`
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me` - `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me` - `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id` - `GET /api/tierlists/:id`
@@ -180,6 +192,8 @@
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다. - 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다. - 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다. - 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집 화면의 우측 패널은 공통 `rightRail``localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다. - 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다. - 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다. - 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.

View File

@@ -8,6 +8,17 @@
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다. - 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다. - 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다. - 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다. - 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다. - 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다. - 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.

View File

@@ -1,5 +1,60 @@
# 업데이트 로그 # 업데이트 로그
## 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 ## 2026-03-30 v1.2.10
- **목록 화면 상단 툴바 밀도 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 상단 영역의 통계 카드와 액션 버튼 높이/반경/배경을 맞춰 공통 셸과 같은 도구 막대 문법으로 정리 - **목록 화면 상단 툴바 밀도 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 상단 영역의 통계 카드와 액션 버튼 높이/반경/배경을 맞춰 공통 셸과 같은 도구 막대 문법으로 정리
- **홈 빠른 진입 흐름 보정**: 홈 화면 툴바에서 중복되던 버튼 흐름을 `즐겨찾기 / 내 리스트 / 커스텀 티어표 만들기` 중심으로 재구성해 실제 사용 동선에 맞게 정리 - **홈 빠른 진입 흐름 보정**: 홈 화면 툴바에서 중복되던 버튼 흐름을 `즐겨찾기 / 내 리스트 / 커스텀 티어표 만들기` 중심으로 재구성해 실제 사용 동선에 맞게 정리

View File

@@ -1,21 +1,29 @@
<script setup> <script setup>
import { computed, onMounted, onUnmounted, ref, watch } from 'vue' import { computed, onMounted, provide, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { useAuthStore } from './stores/auth' import { useAuthStore } from './stores/auth'
import { toApiUrl } from './lib/runtime' import { toApiUrl } from './lib/runtime'
import { api } from './lib/api'
import { useToast } from './composables/useToast' import { useToast } from './composables/useToast'
import iconDockToLeft from './assets/icons/dock_to_left.svg' import iconDockToLeft from './assets/icons/dock_to_left.svg'
import iconDockToRight from './assets/icons/dock_to_right.svg' import iconDockToRight from './assets/icons/dock_to_right.svg'
import iconGridView from './assets/icons/grid_view.svg' import iconGridView from './assets/icons/grid_view.svg'
import iconLists from './assets/icons/lists.svg' import iconLists from './assets/icons/lists.svg'
import iconMore from './assets/icons/more.svg'
import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const { toasts, dismissToast } = useToast() const { toasts, dismissToast } = useToast()
const menuOpen = ref(false) const leftRailCollapsed = ref(false)
const rightRailOpen = ref(true) const rightRailOpen = ref(true)
const searchQuery = ref('')
const favoriteShortcuts = ref([])
provide('rightRailOpen', rightRailOpen)
provide('localRightRailTarget', '#local-right-rail-root')
const isAdmin = computed(() => !!auth.user?.isAdmin) const isAdmin = computed(() => !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1') const isPreviewMode = computed(() => route.query.preview === '1')
@@ -29,16 +37,17 @@ const accountName = computed(() => {
return 'Guest' return 'Guest'
}) })
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.') const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
const shellStyle = computed(() => ({
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
'--right-rail-width': rightRailOpen.value ? '320px' : '0px',
}))
const leftNavItems = computed(() => { const leftNavItems = computed(() => {
const items = [ const items = [
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView }, { key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
{ key: 'me', label: '내 리스트', path: '/me', iconSrc: iconLists, requiresAuth: true }, { key: 'me', label: '내 리스트', path: '/me', iconSrc: iconLists, requiresAuth: true },
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', icon: 'M12 4.75l2.18 4.42 4.88.71-3.53 3.44.83 4.86L12 15.9 7.64 18.18l.83-4.86-3.53-3.44 4.88-.71z', requiresAuth: true }, { key: 'favorites', label: '즐겨찾기', path: '/favorites', icon: 'M12 4.75l2.18 4.42 4.88.71-3.53 3.44.83 4.86L12 15.9 7.64 18.18l.83-4.86-3.53-3.44 4.88-.71z', requiresAuth: true },
{ key: 'profile', label: 'Settings', path: '/profile', icon: 'M12 4.75a2.2 2.2 0 0 1 2.08 1.5l.18.56.58.13a2.2 2.2 0 0 1 1.52 2.76l-.17.56.39.46a2.2 2.2 0 0 1 0 2.86l-.39.46.17.56a2.2 2.2 0 0 1-1.52 2.76l-.58.13-.18.56a2.2 2.2 0 0 1-4.16 0l-.18-.56-.58-.13a2.2 2.2 0 0 1-1.52-2.76l.17-.56-.39-.46a2.2 2.2 0 0 1 0-2.86l.39-.46-.17-.56a2.2 2.2 0 0 1 1.52-2.76l.58-.13.18-.56A2.2 2.2 0 0 1 12 4.75z M12 9.35a2.65 2.65 0 1 0 0 5.3 2.65 2.65 0 0 0 0-5.3z', requiresAuth: true }, { key: 'profile', label: 'Settings', path: '/profile', iconSrc: iconSettings, requiresAuth: true },
] ]
if (isAdmin.value) {
items.push({ key: 'admin', label: 'Admin', path: '/admin', iconSrc: iconLists })
}
return items.filter((item) => !item.requiresAuth || auth.user) return items.filter((item) => !item.requiresAuth || auth.user)
}) })
const routeMeta = computed(() => { const routeMeta = computed(() => {
@@ -117,6 +126,16 @@ const routeMeta = computed(() => {
action: () => router.push('/me'), action: () => router.push('/me'),
} }
} }
if (route.name === 'search') {
return {
title: 'Search',
subtitle: '전체 공개 티어표 검색 결과',
contextTitle: '검색',
contextText: '제목, 작성자 기준으로 공개 티어표를 전체 검색할 수 있어요.',
actionLabel: '홈으로',
action: () => router.push('/'),
}
}
return { return {
title: 'Tier Maker', title: 'Tier Maker',
subtitle: 'by zenn', subtitle: 'by zenn',
@@ -126,52 +145,36 @@ const routeMeta = computed(() => {
action: () => router.push('/'), action: () => router.push('/'),
} }
}) })
const favoriteLinks = computed(() => [
{ label: 'Games', path: '/' },
...(auth.user ? [{ label: 'Favorites', path: '/favorites' }] : []),
...(auth.user ? [{ label: 'My Lists', path: '/me' }] : []),
])
function railGlyph(type) {
if (type === 'menu') return 'M4 6.5h16M4 12h16M4 17.5h16'
if (type === 'search') return 'M10.2 6.2a4 4 0 1 1 0 8 4 4 0 0 1 0-8z M13.6 13.6l3.2 3.2'
if (type === 'link') return 'M8 12h8 M12 8l4 4-4 4'
return 'M4 12h16'
}
onMounted(async () => { onMounted(async () => {
await auth.refresh() await auth.refresh()
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
if (leftSaved === '1') leftRailCollapsed.value = true
const saved = window.localStorage.getItem('tier-maker:right-rail-open') const saved = window.localStorage.getItem('tier-maker:right-rail-open')
if (saved === '0') rightRailOpen.value = false if (saved === '0') rightRailOpen.value = false
} }
document.addEventListener('click', onDocumentClick) searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
}) await loadFavoriteShortcuts()
onUnmounted(() => {
document.removeEventListener('click', onDocumentClick)
}) })
watch( watch(
() => route.fullPath, () => route.fullPath,
() => { () => {
menuOpen.value = false searchQuery.value = typeof route.query.q === 'string' ? route.query.q : ''
} }
) )
function onDocumentClick(event) {
if (!event.target.closest('.appUserCard')) {
menuOpen.value = false
}
}
function isRouteActive(path) { function isRouteActive(path) {
if (path === '/') return route.path === '/' if (path === '/') return route.path === '/'
return route.path.startsWith(path) return route.path.startsWith(path)
} }
function toggleMenu() { function toggleLeftRail() {
menuOpen.value = !menuOpen.value leftRailCollapsed.value = !leftRailCollapsed.value
if (typeof window !== 'undefined') {
window.localStorage.setItem('tier-maker:left-rail-collapsed', leftRailCollapsed.value ? '1' : '0')
}
} }
function toggleRightRail() { function toggleRightRail() {
@@ -181,20 +184,54 @@ function toggleRightRail() {
} }
} }
function goProfile() { function avatarFallbackOfFavorite(tierList) {
menuOpen.value = false return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
router.push('/profile')
} }
async function logout() { function favoriteThumbnailUrl(tierList) {
menuOpen.value = false return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
await auth.logout()
router.push('/')
} }
async function loadFavoriteShortcuts() {
if (!auth.user) {
favoriteShortcuts.value = []
return
}
try {
const data = await api.listMyFavoriteTierLists({ sort: 'favorited' })
favoriteShortcuts.value = (data.tierLists || []).slice(0, 10)
} catch (e) {
favoriteShortcuts.value = []
}
}
function openFavoriteShortcut(item) {
router.push(`/editor/${item.gameId}/${item.id}`)
}
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
}
watch(
() => auth.user?.id,
async () => {
await loadFavoriteShortcuts()
}
)
</script> </script>
<template> <template>
<div class="appShell" :class="{ 'appShell--preview': isPreviewMode, 'appShell--rightClosed': !rightRailOpen }"> <div
class="appShell"
:class="{
'appShell--preview': isPreviewMode,
'appShell--leftCollapsed': leftRailCollapsed,
'appShell--rightClosed': !rightRailOpen,
}"
:style="shellStyle"
>
<template v-if="isPreviewMode"> <template v-if="isPreviewMode">
<main class="appMain appMain--preview"> <main class="appMain appMain--preview">
<RouterView /> <RouterView />
@@ -202,37 +239,30 @@ async function logout() {
</template> </template>
<template v-else> <template v-else>
<aside class="leftRail"> <aside class="leftRail">
<div class="leftRail__top"> <div class="leftRail__top railHeader">
<button class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="메뉴"> <button class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="왼쪽 패널 토글" @click="toggleLeftRail">
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('menu')" /></svg> <img :src="iconDockToRight" alt="" />
</button> </button>
<div class="brandBlock" @click="$router.push('/')">
<div class="brandBlock__title">Tier Maker</div>
<div class="brandBlock__sub">by zenn</div>
</div>
</div> </div>
<div class="leftRail__body">
<div v-if="auth.user" class="appUserCard"> <div v-if="auth.user" class="appUserCard">
<button v-if="auth.user" class="appUserCard__button" type="button" @click.stop="toggleMenu"> <div class="appUserCard__button">
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" /> <img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div> <div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
<div class="appUserCard__meta"> <div class="appUserCard__meta">
<div class="appUserCard__name">{{ accountName }}</div> <div class="appUserCard__name">{{ accountName }}</div>
<div class="appUserCard__email">{{ accountEmail }}</div> <div class="appUserCard__email">{{ accountEmail }}</div>
</div> </div>
</button>
<div v-if="menuOpen" class="appUserMenu">
<button class="appUserMenu__item" type="button" @click="goProfile">프로필</button>
<button class="appUserMenu__item" type="button" @click="logout">로그아웃</button>
</div> </div>
</div> </div>
<button class="searchStub" type="button" @click="$router.push('/favorites')"> <form class="searchStub" @submit.prevent="submitGlobalSearch">
<span class="searchStub__icon"> <span class="searchStub__icon">
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('search')" /></svg> <img :src="iconSearch" alt="" />
</span> </span>
<span>Quick Search</span> <input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : '전체 티어표 검색'" />
</button> </form>
<nav class="leftNav"> <nav class="leftNav">
<RouterLink <RouterLink
@@ -246,85 +276,77 @@ async function logout() {
<img v-if="item.iconSrc" :src="item.iconSrc" alt="" /> <img v-if="item.iconSrc" :src="item.iconSrc" alt="" />
<svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg> <svg v-else viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
</span> </span>
<span>{{ item.label }}</span> <span class="leftNav__label">{{ item.label }}</span>
</RouterLink> </RouterLink>
</nav> </nav>
<div class="leftRail__section"> <div class="leftRail__section">
<div class="leftRail__sectionTitle">Favorites</div> <div class="leftRail__sectionTitle">Favorites</div>
<RouterLink v-for="item in favoriteLinks" :key="item.path" :to="item.path" class="favoriteLink"> <template v-if="favoriteShortcuts.length">
<span class="favoriteLink__dot"></span> <button
<span>{{ item.label }}</span> v-for="item in favoriteShortcuts"
</RouterLink> :key="item.id"
type="button"
class="favoriteShortcut"
@click="openFavoriteShortcut(item)"
>
<img v-if="favoriteThumbnailUrl(item)" :src="favoriteThumbnailUrl(item)" alt="" class="favoriteShortcut__thumb" />
<div v-else class="favoriteShortcut__thumb favoriteShortcut__thumb--fallback">{{ avatarFallbackOfFavorite(item) }}</div>
<span class="favoriteShortcut__label">{{ item.title }}</span>
</button>
<RouterLink to="/favorites" class="favoriteMoreLink">
<span class="favoriteMoreLink__icon">
<img :src="iconMore" alt="" />
</span>
<span>즐겨찾기 보기</span>
<span class="favoriteMoreLink__arrow"></span>
</RouterLink>
</template>
<div v-else class="favoriteEmpty">아직 즐겨찾기한 티어표가 없어요.</div>
</div> </div>
<div class="leftRail__bottom"> <div class="leftRail__bottom">
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink> <RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink> <RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
</div> </div>
</div>
</aside> </aside>
<main class="appMain"> <main class="appMain">
<section class="workspace"> <section class="workspace" :class="{ 'workspace--localRail': usesLocalRightRail }">
<header class="workspaceHead"> <header class="workspaceHead railHeader">
<div> <div class="workspaceHead__brand" @click="$router.push('/')">
<div class="workspaceHead__title">{{ routeMeta.title }}</div> <span class="workspaceHead__brandTitle">Tier Maker</span>
<div class="workspaceHead__subtitle">{{ routeMeta.subtitle }}</div> <span class="workspaceHead__brandSub">by zenn</span>
</div> </div>
<div class="workspaceHead__actions"> <div class="workspaceHead__actions">
<button class="ghostIcon ghostIcon--workspace" type="button" :aria-pressed="rightRailOpen" @click="toggleRightRail"> <button v-if="!rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 열기" @click="toggleRightRail">
<img :src="rightRailOpen ? iconDockToRight : iconDockToLeft" alt="" /> <img :src="iconDockToLeft" alt="" />
<span>{{ rightRailOpen ? '패널 숨기기' : '패널 보기' }}</span>
</button> </button>
</div> </div>
</header> </header>
<div class="workspaceBody"> <div class="workspaceBody" :class="{ 'workspaceBody--localRail': usesLocalRightRail }">
<RouterView /> <RouterView />
</div> </div>
</section> </section>
</main> </main>
<aside <aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen }" :aria-hidden="!rightRailOpen">
v-if="!usesLocalRightRail" <div class="rightRail__top railHeader">
class="rightRail" <button v-if="rightRailOpen" class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="패널 닫기" @click="toggleRightRail">
:class="{ 'rightRail--closed': !rightRailOpen }" <img :src="iconDockToLeft" alt="" />
:aria-hidden="!rightRailOpen"
>
<div class="rightRail__top">
<button class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="상태">
<img :src="iconGridView" alt="" />
</button> </button>
</div> </div>
<section class="contextCard"> <div class="rightRail__body">
<div class="contextCard__label">Context</div> <template v-if="!usesLocalRightRail">
<h2 class="contextCard__title">{{ routeMeta.contextTitle }}</h2> <section class="rightRailAction">
<p class="contextCard__text">{{ routeMeta.contextText }}</p> <button class="rightRailAction__button" type="button" @click="routeMeta.action">
<button class="contextCard__action" type="button" @click="routeMeta.action"> {{ routeMeta.actionLabel }}
{{ routeMeta.actionLabel }} </button>
</button> </section>
</section> </template>
<section class="contextCard"> <div id="local-right-rail-root" class="localRightRailRoot"></div>
<div class="contextCard__label">Account</div> </div>
<div class="contextStat">
<span class="contextStat__name">현재 사용자</span>
<span class="contextStat__value">{{ accountName }}</span>
</div>
<div class="contextStat">
<span class="contextStat__name">권한</span>
<span class="contextStat__value">{{ isAdmin ? 'Admin' : auth.user ? 'Member' : 'Guest' }}</span>
</div>
</section>
<section class="contextCard contextCard--links">
<div class="contextCard__label">Jump</div>
<button class="contextLink" type="button" @click="$router.push('/')">
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('link')" /></svg>
<span>게임 목록으로</span>
</button>
<button v-if="auth.user" class="contextLink" type="button" @click="$router.push('/me')">
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('link')" /></svg>
<span> 티어표 열기</span>
</button>
</section>
</aside> </aside>
</template> </template>
@@ -344,7 +366,7 @@ async function logout() {
.appShell { .appShell {
min-height: 100vh; min-height: 100vh;
display: grid; display: grid;
grid-template-columns: 248px minmax(0, 1fr) 320px; grid-template-columns: var(--left-rail-width, 248px) minmax(0, 1fr) var(--right-rail-width, 320px);
background: background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 28%), radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 28%),
linear-gradient(180deg, #1a1a1a 0%, #121212 100%); linear-gradient(180deg, #1a1a1a 0%, #121212 100%);
@@ -359,11 +381,13 @@ async function logout() {
.leftRail, .leftRail,
.rightRail { .rightRail {
min-height: 100vh; min-height: 100vh;
padding: 14px 12px;
border-right: 1px solid rgba(255, 255, 255, 0.08); border-right: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(14, 14, 14, 0.92); background: rgba(14, 14, 14, 0.92);
box-sizing: border-box; box-sizing: border-box;
min-width: 0; min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
} }
.rightRail { .rightRail {
@@ -376,10 +400,6 @@ async function logout() {
border-color 220ms ease; border-color 220ms ease;
} }
.appShell--rightClosed {
grid-template-columns: 248px minmax(0, 1fr) 0px;
}
.appShell--rightClosed .rightRail { .appShell--rightClosed .rightRail {
opacity: 0; opacity: 0;
transform: translateX(18px); transform: translateX(18px);
@@ -390,12 +410,35 @@ async function logout() {
border-left-color: transparent; border-left-color: transparent;
} }
.leftRail__top, .railHeader {
.rightRail__top { height: 56px;
min-height: 56px;
display: flex; display: flex;
align-items: center; align-items: center;
padding: 0 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
box-sizing: border-box;
}
.leftRail__top,
.rightRail__top {
gap: 12px; gap: 12px;
margin-bottom: 18px; }
.leftRail__top {
justify-content: flex-start;
}
.rightRail__top {
justify-content: flex-end;
}
.leftRail__body,
.rightRail__body {
flex: 1;
min-height: 0;
padding: 14px 12px;
box-sizing: border-box;
} }
.ghostIcon { .ghostIcon {
@@ -428,7 +471,8 @@ async function logout() {
.ghostIcon img, .ghostIcon img,
.leftNav__glyph img, .leftNav__glyph img,
.ghostIcon--workspace img { .searchStub__icon img,
.favoriteMoreLink__icon img {
width: 16px; width: 16px;
height: 16px; height: 16px;
display: block; display: block;
@@ -441,38 +485,11 @@ async function logout() {
padding: 0; padding: 0;
} }
.ghostIcon--workspace {
min-width: 132px;
height: 38px;
padding: 0 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
font-weight: 800;
}
.brandBlock {
display: grid;
gap: 4px;
cursor: pointer;
}
.brandBlock__title {
font-size: 21px;
font-weight: 900;
letter-spacing: -0.04em;
}
.brandBlock__sub {
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
}
.appUserCard { .appUserCard {
position: relative; position: relative;
margin-bottom: 14px; margin-bottom: 14px;
min-height: 58px; min-height: 58px;
transition: margin 220ms ease;
} }
.appUserCard__button, .appUserCard__button,
@@ -487,16 +504,19 @@ async function logout() {
background: rgba(255, 255, 255, 0.04); background: rgba(255, 255, 255, 0.04);
color: inherit; color: inherit;
text-align: left; text-align: left;
cursor: pointer; cursor: default;
box-sizing: border-box; box-sizing: border-box;
transition: padding 220ms ease, justify-content 220ms ease;
} }
.appUserCard__avatar { .appUserCard__avatar {
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 12px; border-radius: 999px;
object-fit: cover; object-fit: cover;
flex: 0 0 auto; flex: 0 0 auto;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.08);
} }
.appUserCard__avatar--fallback { .appUserCard__avatar--fallback {
@@ -510,6 +530,7 @@ async function logout() {
min-width: 0; min-width: 0;
display: grid; display: grid;
gap: 4px; gap: 4px;
transition: opacity 180ms ease;
} }
.appUserCard__name { .appUserCard__name {
@@ -525,30 +546,6 @@ async function logout() {
text-overflow: ellipsis; text-overflow: ellipsis;
} }
.appUserMenu {
position: absolute;
top: calc(100% + 8px);
left: 0;
right: 0;
display: grid;
gap: 6px;
padding: 8px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(10, 10, 10, 0.98);
z-index: 20;
}
.appUserMenu__item {
padding: 10px 12px;
border-radius: 10px;
border: 0;
background: rgba(255, 255, 255, 0.04);
color: inherit;
cursor: pointer;
text-align: left;
}
.searchStub { .searchStub {
width: 100%; width: 100%;
display: flex; display: flex;
@@ -559,8 +556,24 @@ async function logout() {
border: 1px solid rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03); background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.62); color: rgba(255, 255, 255, 0.62);
cursor: pointer;
margin-bottom: 14px; margin-bottom: 14px;
box-sizing: border-box;
transition: padding 220ms ease, justify-content 220ms ease;
}
.searchStub__input {
min-width: 0;
flex: 1;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.92);
outline: none;
font: inherit;
transition: opacity 180ms ease, width 180ms ease;
}
.searchStub__input::placeholder {
color: rgba(255, 255, 255, 0.42);
} }
.searchStub__icon { .searchStub__icon {
@@ -587,6 +600,11 @@ async function logout() {
transition: background 180ms ease, color 180ms ease, transform 180ms ease; transition: background 180ms ease, color 180ms ease, transform 180ms ease;
} }
.leftNav__label {
min-width: 0;
white-space: nowrap;
}
.leftNav__item--active, .leftNav__item--active,
.leftNav__item.router-link-active { .leftNav__item.router-link-active {
background: rgba(255, 255, 255, 0.1); background: rgba(255, 255, 255, 0.1);
@@ -604,33 +622,154 @@ async function logout() {
} }
.leftRail__section { .leftRail__section {
margin-top: 24px; margin-top: 22px;
display: grid; display: grid;
gap: 10px; gap: 8px;
transition: margin 220ms ease;
} }
.leftRail__sectionTitle { .leftRail__sectionTitle {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.favoriteLink {
display: flex; display: flex;
gap: 10px;
align-items: center; align-items: center;
color: rgba(255, 255, 255, 0.7); gap: 8px;
text-decoration: none; font-size: 11px;
font-size: 14px; color: rgba(255, 255, 255, 0.38);
padding: 4px 0; font-weight: 600;
} }
.favoriteLink__dot { .favoriteEmpty {
width: 10px; font-size: 13px;
height: 10px; color: rgba(255, 255, 255, 0.46);
border-radius: 3px; line-height: 1.5;
background: rgba(255, 255, 255, 0.86); }
.favoriteShortcut {
display: grid;
grid-template-columns: 24px minmax(0, 1fr);
gap: 8px;
align-items: center;
padding: 3px 0;
border: 0;
background: transparent;
color: rgba(255, 255, 255, 0.72);
text-align: left;
cursor: pointer;
}
.favoriteShortcut__thumb {
width: 24px;
height: 24px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.08);
}
.favoriteShortcut__thumb--fallback {
display: grid;
place-items: center;
font-size: 14px;
font-weight: 800;
}
.favoriteShortcut__label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.favoriteMoreLink {
display: flex;
align-items: center;
gap: 8px;
margin-top: 4px;
color: rgba(255, 255, 255, 0.62);
text-decoration: none;
font-size: 12px;
}
.favoriteMoreLink__icon {
width: 22px;
height: 22px;
border-radius: 8px;
display: grid;
place-items: center;
background: rgba(255, 255, 255, 0.06);
flex: 0 0 auto;
}
.favoriteMoreLink__icon img {
width: 12px;
height: 12px;
display: block;
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
}
.favoriteMoreLink__arrow {
margin-left: auto;
opacity: 0.56;
}
.appShell--leftCollapsed .leftRail__body {
padding-left: 10px;
padding-right: 10px;
}
.appShell--leftCollapsed .appUserCard {
margin-bottom: 10px;
}
.appShell--leftCollapsed .appUserCard__button,
.appShell--leftCollapsed .appUserCard__guest {
justify-content: center;
padding-left: 8px;
padding-right: 8px;
}
.appShell--leftCollapsed .appUserCard__meta,
.appShell--leftCollapsed .searchStub__input,
.appShell--leftCollapsed .leftNav__label,
.appShell--leftCollapsed .leftRail__sectionTitle,
.appShell--leftCollapsed .favoriteShortcut__label,
.appShell--leftCollapsed .favoriteMoreLink span:not(.favoriteMoreLink__icon),
.appShell--leftCollapsed .favoriteEmpty {
display: none;
}
.appShell--leftCollapsed .searchStub {
justify-content: center;
padding-left: 8px;
padding-right: 8px;
}
.appShell--leftCollapsed .leftNav {
gap: 10px;
}
.appShell--leftCollapsed .leftNav__item {
justify-content: center;
padding-left: 8px;
padding-right: 8px;
}
.appShell--leftCollapsed .leftRail__section {
margin-top: 18px;
}
.appShell--leftCollapsed .favoriteShortcut {
grid-template-columns: 1fr;
justify-items: center;
padding: 2px 0;
}
.appShell--leftCollapsed .favoriteMoreLink {
justify-content: center;
}
.appShell--leftCollapsed .leftRail__bottom {
display: none;
} }
.leftRail__bottom { .leftRail__bottom {
@@ -653,14 +792,8 @@ async function logout() {
font-weight: 800; font-weight: 800;
} }
.leftRail {
display: flex;
flex-direction: column;
}
.appMain { .appMain {
min-width: 0; min-width: 0;
padding: 14px 18px 22px;
box-sizing: border-box; box-sizing: border-box;
} }
@@ -670,16 +803,40 @@ async function logout() {
.workspace { .workspace {
display: grid; display: grid;
gap: 16px; gap: 0;
min-height: 100vh;
}
.workspace--localRail {
gap: 0;
} }
.workspaceHead { .workspaceHead {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 16px; gap: 16px;
} }
.workspaceHead__brand {
display: inline-flex;
align-items: baseline;
gap: 8px;
cursor: pointer;
}
.workspaceHead__brandTitle {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.05em;
}
.workspaceHead__brandSub {
font-size: 13px;
font-weight: 700;
color: rgba(255, 255, 255, 0.58);
}
.workspaceHead__actions { .workspaceHead__actions {
display: flex; display: flex;
gap: 10px; gap: 10px;
@@ -687,106 +844,50 @@ async function logout() {
flex-wrap: wrap; flex-wrap: wrap;
} }
.workspaceHead__title {
font-size: 30px;
font-weight: 900;
letter-spacing: -0.04em;
}
.workspaceHead__subtitle {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
font-size: 13px;
}
.workspaceBody { .workspaceBody {
min-height: calc(100vh - 110px); min-height: calc(100vh - 56px);
padding: 20px; padding: 0;
border-radius: 26px; border: 0;
border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 0;
background: linear-gradient(180deg, #2d2d2d 0%, #2a2a2a 100%); background: transparent;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: none;
margin: 18px 18px 0;
}
.workspaceBody--localRail {
min-height: calc(100vh - 56px);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
margin: 18px 18px 0;
} }
.rightRail { .rightRail {
gap: 0;
}
.rightRailAction {
display: grid; display: grid;
align-content: start;
gap: 18px;
} }
.contextCard { .rightRailAction__button {
display: grid;
gap: 12px;
padding: 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.contextCard__label {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.contextCard__title {
margin: 0;
font-size: 22px;
line-height: 1.2;
}
.contextCard__text {
margin: 0;
font-size: 14px;
line-height: 1.6;
color: rgba(255, 255, 255, 0.66);
}
.contextCard__action {
width: 100%; width: 100%;
padding: 12px 14px; padding: 12px 14px;
border-radius: 12px; border-radius: 14px;
border: 0; border: 1px solid rgba(77, 127, 233, 0.96);
background: #4b7fe9; background: rgba(77, 127, 233, 0.88);
color: #fff; color: #fff;
font-weight: 800; font-weight: 800;
cursor: pointer; cursor: pointer;
} }
.contextCard--links { .localRightRailRoot {
gap: 10px; min-height: calc(100vh - 84px);
} display: grid;
align-content: start;
.contextLink { gap: 14px;
width: 100%;
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 11px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.86);
cursor: pointer;
}
.contextStat {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: center;
}
.contextStat__name {
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
}
.contextStat__value {
font-size: 14px;
font-weight: 700;
} }
.toastStack { .toastStack {
@@ -877,12 +978,15 @@ async function logout() {
} }
.workspaceBody { .workspaceBody {
padding: 14px; padding: 0;
border-radius: 20px; border-radius: 0;
margin: 14px 14px 0;
} }
.workspaceHead__title { .workspaceBody--localRail {
font-size: 26px; padding: 0;
border-radius: 0;
margin: 14px 14px 0;
} }
} }
</style> </style>

View 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

View 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

View 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

View File

@@ -62,6 +62,7 @@ export const api = {
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`), request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') => searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(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'), listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) => listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`), request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),

View File

@@ -8,6 +8,7 @@ import MyTierListsView from '../views/MyTierListsView.vue'
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue' import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
import AdminView from '../views/AdminView.vue' import AdminView from '../views/AdminView.vue'
import ProfileView from '../views/ProfileView.vue' import ProfileView from '../views/ProfileView.vue'
import SearchResultsView from '../views/SearchResultsView.vue'
export function createRouter() { export function createRouter() {
return _createRouter({ return _createRouter({
@@ -20,6 +21,7 @@ export function createRouter() {
{ path: '/login', name: 'login', component: LoginView }, { path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView }, { path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView }, { path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', name: 'admin', component: AdminView }, { path: '/admin', name: 'admin', component: AdminView },
{ path: '/profile', name: 'profile', component: ProfileView }, { path: '/profile', name: 'profile', component: ProfileView },
], ],

View File

@@ -1,5 +1,5 @@
<script setup> <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 Sortable from 'sortablejs'
import { api } from '../lib/api' import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime' import { toApiUrl } from '../lib/runtime'
@@ -8,6 +8,8 @@ import { useToast } from '../composables/useToast'
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const isAdmin = computed(() => !!auth.user?.isAdmin) const isAdmin = computed(() => !!auth.user?.isAdmin)
const activeTab = ref('games') const activeTab = ref('games')
@@ -1340,165 +1342,167 @@ async function saveFeaturedOrder() {
</div> </div>
</div> </div>
</div> </div>
<aside 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>
</div> </div>
</template> </template>
</div> </div>
</section> </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> </template>
<style scoped> <style scoped>
@@ -1508,7 +1512,7 @@ async function saveFeaturedOrder() {
} }
.adminWorkspace { .adminWorkspace {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) 320px; grid-template-columns: minmax(0, 1fr);
gap: 16px; gap: 16px;
align-items: start; align-items: start;
} }
@@ -1573,9 +1577,6 @@ async function saveFeaturedOrder() {
letter-spacing: -0.04em; letter-spacing: -0.04em;
} }
.adminSidebar { .adminSidebar {
position: sticky;
top: 14px;
align-self: start;
display: grid; display: grid;
gap: 12px; gap: 12px;
} }
@@ -2523,15 +2524,11 @@ async function saveFeaturedOrder() {
margin-top: 0; margin-top: 0;
} }
@media (max-width: 980px) { @media (max-width: 980px) {
.adminWorkspace {
grid-template-columns: 1fr;
}
.adminHero__stats { .adminHero__stats {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.adminSidebar { .adminSidebar {
position: static; display: none;
order: -1;
} }
.featuredOrderPanel, .featuredOrderPanel,
.section--topGrid, .section--topGrid,

View File

@@ -11,17 +11,12 @@ const toast = useToast()
const favorites = ref([]) const favorites = ref([])
const query = ref('') const query = ref('')
const sort = ref('favorited') const sort = ref('favorited')
const sortLabel = computed(() =>
sort.value === 'favorited' ? '즐겨찾기한 날짜' : sort.value === 'updated' ? '최종 업데이트' : '즐겨찾기 수'
)
function fmt(ts) { function fmt(ts) {
return new Date(ts).toLocaleString(undefined, { return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}) })
} }
@@ -86,21 +81,20 @@ onMounted(loadFavorites)
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div> <div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div> </div>
<div class="boardCard__head"> <div class="boardCard__head">
<div class="boardCard__title">{{ tierList.title }}</div> <div class="boardCard__titleRow">
<div class="boardCard__author"> <div class="boardCard__title">{{ tierList.title }}</div>
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" /> <div class="favoriteStat"> {{ tierList.favoriteCount || 0 }}</div>
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div> </div>
<span>by {{ displayNameOf(tierList) }}</span> <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>
</div> </div>
</button> </button>
<div class="boardCard__foot">
<div class="boardCard__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> </article>
</div> </div>
</section> </section>
@@ -162,7 +156,7 @@ onMounted(loadFavorites)
} }
.list { .list {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px; gap: 18px;
} }
.boardCard { .boardCard {
@@ -171,7 +165,6 @@ onMounted(loadFavorites)
background: rgba(62, 62, 62, 0.82); background: rgba(62, 62, 62, 0.82);
overflow: hidden; overflow: hidden;
display: grid; display: grid;
gap: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transition:
transform 0.16s ease, transform 0.16s ease,
@@ -189,20 +182,19 @@ onMounted(loadFavorites)
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
display: grid; display: grid;
gap: 10px;
} }
.boardCard__thumbWrap { .boardCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background: #555; padding: 14px 14px 0;
display: grid; box-sizing: border-box;
place-items: center;
} }
.boardCard__thumb, .boardCard__thumb,
.boardCard__thumbPlaceholder { .boardCard__thumbPlaceholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
display: block; display: block;
border-radius: 18px;
} }
.boardCard__thumb { .boardCard__thumb {
object-fit: cover; object-fit: cover;
@@ -216,63 +208,68 @@ onMounted(loadFavorites)
font-weight: 700; font-weight: 700;
} }
.boardCard__head { .boardCard__head {
padding: 14px 14px 0; padding: 16px 18px 18px;
display: grid; display: grid;
gap: 10px; gap: 10px;
} }
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__title { .boardCard__title {
min-width: 0;
font-weight: 800; font-weight: 800;
font-size: 18px; font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.boardCard__author { .boardCard__author {
min-width: 0;
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
font-size: 13px; font-size: 13px;
opacity: 0.86; opacity: 0.86;
} }
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar { .boardCard__avatar {
width: 28px; width: 22px;
height: 28px; height: 22px;
border-radius: 999px; border-radius: 6px;
object-fit: cover; object-fit: cover;
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
} }
.boardCard__avatar--fallback { .boardCard__avatar--fallback {
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 12px; font-size: 11px;
font-weight: 900; font-weight: 900;
} }
.boardCard__foot { .boardCard__date,
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__meta {
display: grid;
gap: 4px;
opacity: 0.78;
font-size: 13px;
}
.favoriteStat { .favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12); flex: 0 0 auto;
background: rgba(255, 255, 255, 0.05); font-size: 13px;
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.64);
border-radius: 999px; white-space: nowrap;
padding: 7px 10px;
font-weight: 800;
} }
@media (max-width: 1100px) { @media (max-width: 1400px) {
.list { .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 { .list {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {

View File

@@ -17,12 +17,10 @@ const error = ref('')
const query = ref('') const query = ref('')
function fmt(ts) { function fmt(ts) {
return new Date(ts).toLocaleString(undefined, { return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}) })
} }
@@ -84,10 +82,6 @@ function submitSearch() {
<p class="dashboardHero__desc"> 게임의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p> <p class="dashboardHero__desc"> 게임의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p>
</div> </div>
<div class="dashboardHero__right"> <div class="dashboardHero__right">
<div class="dashboardStat">
<span class="dashboardStat__label">Visible Lists</span>
<strong class="dashboardStat__value">{{ tierLists.length }}</strong>
</div>
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 티어표 만들기' }}</button> <button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 티어표 만들기' }}</button>
</div> </div>
</section> </section>
@@ -113,20 +107,22 @@ function submitSearch() {
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div> <div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div> </div>
<div class="boardCard__head"> <div class="boardCard__head">
<div class="boardCard__title">{{ t.title }}</div> <div class="boardCard__titleRow">
<div class="boardCard__author"> <div class="boardCard__title">{{ t.title }}</div>
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" /> <div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div> {{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
<span>by {{ displayNameOf(t) }}</span> </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> </div>
</button> </button>
<div class="boardCard__foot">
<div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
</div>
</div>
</article> </article>
</div> </div>
</section> </section>
@@ -168,25 +164,6 @@ function submitSearch() {
color: rgba(255, 255, 255, 0.58); color: rgba(255, 255, 255, 0.58);
max-width: 720px; max-width: 720px;
} }
.dashboardStat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.045);
}
.dashboardStat__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardStat__value {
font-size: 18px;
font-weight: 900;
}
.primary { .primary {
padding: 12px 16px; padding: 12px 16px;
border-radius: 14px; border-radius: 14px;
@@ -261,7 +238,7 @@ function submitSearch() {
} }
.list { .list {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px; gap: 18px;
} }
.boardCard { .boardCard {
@@ -270,9 +247,6 @@ function submitSearch() {
background: rgba(62, 62, 62, 0.82); background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.92);
display: grid; display: grid;
gap: 10px;
align-content: start;
min-height: 168px;
overflow: hidden; overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transition:
@@ -292,20 +266,19 @@ function submitSearch() {
cursor: pointer; cursor: pointer;
width: 100%; width: 100%;
display: grid; display: grid;
gap: 10px;
} }
.boardCard__thumbWrap { .boardCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background: #555; padding: 14px 14px 0;
display: grid; box-sizing: border-box;
place-items: center;
} }
.boardCard__thumb { .boardCard__thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
border-radius: 18px;
} }
.boardCard__thumbPlaceholder { .boardCard__thumbPlaceholder {
width: 100%; width: 100%;
@@ -316,76 +289,79 @@ function submitSearch() {
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
border-radius: 18px;
} }
.boardCard__title { .boardCard__title {
font-weight: 800; font-weight: 800;
min-width: 0; min-width: 0;
font-size: 18px; font-size: 18px;
line-height: 1.35; line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.boardCard__head { .boardCard__head {
padding: 14px 14px 0; padding: 16px 18px 18px;
display: grid; display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px; gap: 12px;
align-content: start; align-items: center;
justify-content: space-between;
} }
.boardCard__author { .boardCard__author {
min-width: 0;
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
opacity: 0.86; opacity: 0.86;
flex: 0 0 auto; }
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.boardCard__avatar { .boardCard__avatar {
width: 28px; width: 22px;
height: 28px; height: 22px;
border-radius: 999px; border-radius: 6px;
object-fit: cover; object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
} }
.boardCard__avatar--fallback { .boardCard__avatar--fallback {
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 12px; font-size: 11px;
font-weight: 900; font-weight: 900;
} }
.boardCard__meta { .boardCard__date,
opacity: 0.78;
font-size: 13px;
}
.boardCard__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
.favoriteStat { .favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12); flex: 0 0 auto;
background: rgba(255, 255, 255, 0.05); font-size: 13px;
color: rgba(255, 255, 255, 0.92); color: rgba(255, 255, 255, 0.64);
border-radius: 999px; white-space: nowrap;
padding: 7px 10px;
font-weight: 800;
} }
@media (max-width: 1280px) { @media (max-width: 1400px) {
.list { .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 { .list {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {
.dashboardHero__right { .dashboardHero__right {
width: 100%; width: 100%;
} }
.dashboardStat,
.primary { .primary {
width: 100%; width: 100%;
} }

View File

@@ -33,22 +33,6 @@ function goFreeform() {
router.push('/editor/freeform/new') router.push('/editor/freeform/new')
} }
function goFavorites() {
if (!auth.user) {
router.push('/login?redirect=/favorites')
return
}
router.push('/favorites')
}
function goMyLists() {
if (!auth.user) {
router.push('/login?redirect=/me')
return
}
router.push('/me')
}
function thumbUrl(g) { function thumbUrl(g) {
if (!g.thumbnailSrc) return '' if (!g.thumbnailSrc) return ''
return toApiUrl(g.thumbnailSrc) return toApiUrl(g.thumbnailSrc)
@@ -63,19 +47,6 @@ function thumbUrl(g) {
<h1 class="dashboardHero__title">Game Library</h1> <h1 class="dashboardHero__title">Game Library</h1>
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p> <p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
</div> </div>
<div class="dashboardToolbar">
<div class="dashboardToolbar__stat">
<span class="dashboardToolbar__label">Visible Games</span>
<strong class="dashboardToolbar__value">{{ games.length }}</strong>
</div>
<div class="dashboardToolbar__stat">
<span class="dashboardToolbar__label">Account</span>
<strong class="dashboardToolbar__value">{{ auth.user ? 'Ready' : 'Guest' }}</strong>
</div>
<button class="dashboardToolbar__ghost" @click="goFavorites">{{ auth.user ? '즐겨찾기 보기' : '로그인 즐겨찾기' }}</button>
<button class="dashboardToolbar__ghost" @click="goMyLists">{{ auth.user ? ' 리스트 보기' : '로그인 리스트' }}</button>
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '+ 커스텀 티어표 만들기' : '+ 로그인 커스텀 티어표 만들기' }}</button>
</div>
</section> </section>
<div v-if="error" class="error">{{ error }}</div> <div v-if="error" class="error">{{ error }}</div>
@@ -130,54 +101,6 @@ function thumbUrl(g) {
line-height: 1.5; line-height: 1.5;
max-width: 720px; max-width: 720px;
} }
.dashboardToolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.dashboardToolbar__stat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.045);
}
.dashboardToolbar__label {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.dashboardToolbar__value {
font-size: 18px;
font-weight: 900;
}
.dashboardToolbar__ghost,
.customTierBtn {
padding: 12px 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.84);
font-weight: 700;
cursor: pointer;
transition:
transform 0.16s ease,
border-color 0.16s ease,
background 0.16s ease;
}
.dashboardToolbar__ghost:hover,
.customTierBtn:hover {
transform: translateY(-1px);
}
.customTierBtn {
background: rgba(77, 127, 233, 0.88);
border-color: rgba(77, 127, 233, 0.96);
color: #fff;
}
.libraryGrid { .libraryGrid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));

View File

@@ -17,12 +17,10 @@ watch(error, (message) => {
}) })
function fmt(ts) { function fmt(ts) {
return new Date(ts).toLocaleString(undefined, { return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric', year: 'numeric',
month: '2-digit', month: '2-digit',
day: '2-digit', day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}) })
} }
@@ -93,14 +91,19 @@ async function removeList(t) {
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div> <div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div> </div>
<div class="boardCard__head"> <div class="boardCard__head">
<div class="boardCard__title">{{ t.title }}</div> <div class="boardCard__titleRow">
<div class="boardCard__author"> <div class="boardCard__title">{{ t.title }}</div>
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" /> <div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div> </div>
<span>by {{ displayNameOf(t) }}</span> <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> </div>
<div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
</button> </button>
<button class="link link--danger" @click="removeList(t)">삭제</button> <button class="link link--danger" @click="removeList(t)">삭제</button>
</article> </article>
@@ -176,12 +179,11 @@ async function removeList(t) {
} }
.list { .list {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px; gap: 18px;
} }
.boardCard { .boardCard {
display: grid; display: grid;
gap: 10px;
border-radius: 22px; border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16); border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82); background: rgba(62, 62, 62, 0.82);
@@ -206,20 +208,19 @@ async function removeList(t) {
color: inherit; color: inherit;
padding: 0; padding: 0;
display: grid; display: grid;
gap: 10px;
} }
.boardCard__thumbWrap { .boardCard__thumbWrap {
width: 100%; width: 100%;
aspect-ratio: 16 / 9; aspect-ratio: 16 / 9;
background: #555; padding: 14px 14px 0;
display: grid; box-sizing: border-box;
place-items: center;
} }
.boardCard__thumb { .boardCard__thumb {
width: 100%; width: 100%;
height: 100%; height: 100%;
object-fit: cover; object-fit: cover;
display: block; display: block;
border-radius: 18px;
} }
.boardCard__thumbPlaceholder { .boardCard__thumbPlaceholder {
width: 100%; width: 100%;
@@ -230,59 +231,77 @@ async function removeList(t) {
color: rgba(255, 255, 255, 0.4); color: rgba(255, 255, 255, 0.4);
font-size: 13px; font-size: 13px;
font-weight: 700; font-weight: 700;
border-radius: 18px;
} }
.boardCard__title { .boardCard__title {
font-weight: 900; font-weight: 900;
min-width: 0; min-width: 0;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
.boardCard__head { .boardCard__head {
padding: 0 14px; padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex; display: flex;
gap: 12px; gap: 12px;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
flex-wrap: wrap;
} }
.boardCard__author { .boardCard__author {
min-width: 0;
display: inline-flex; display: inline-flex;
gap: 8px; gap: 8px;
align-items: center; align-items: center;
font-size: 13px; font-size: 13px;
opacity: 0.84; opacity: 0.84;
} }
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar { .boardCard__avatar {
width: 28px; width: 22px;
height: 28px; height: 22px;
border-radius: 999px; border-radius: 6px;
object-fit: cover; object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12); border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08); background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
} }
.boardCard__avatar--fallback { .boardCard__avatar--fallback {
display: grid; display: grid;
place-items: center; place-items: center;
font-size: 12px; font-size: 11px;
font-weight: 900; font-weight: 900;
} }
.boardCard__meta { .boardCard__date,
padding: 0 14px; .favoriteStat {
margin-top: 6px; flex: 0 0 auto;
opacity: 0.76;
font-size: 13px; font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
} }
.link--danger { .link--danger {
background: rgba(239, 68, 68, 0.14); background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28); 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 { .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 { .list {
grid-template-columns: 1fr; grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 720px) { @media (max-width: 720px) {

View File

@@ -70,6 +70,12 @@ async function saveProfile() {
saving.value = false saving.value = false
} }
} }
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
router.push('/')
}
</script> </script>
<template> <template>
@@ -93,9 +99,12 @@ async function saveProfile() {
<label class="label">아바타 업로드</label> <label class="label">아바타 업로드</label>
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" /> <input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 진행됩니다.</div> <div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 진행됩니다.</div>
<button class="saveBtn" :disabled="saving" @click="saveProfile"> <div class="actions">
{{ saving ? '저장중...' : '프로필 저장' }} <button class="saveBtn" :disabled="saving" @click="saveProfile">
</button> {{ saving ? '저장중...' : '프로필 저장' }}
</button>
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
</div>
</div> </div>
</div> </div>
</section> </section>
@@ -188,7 +197,6 @@ async function saveProfile() {
font-size: 13px; font-size: 13px;
} }
.saveBtn { .saveBtn {
margin-top: 12px;
padding: 10px 12px; padding: 10px 12px;
border-radius: 12px; border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14); border: 1px solid rgba(255, 255, 255, 0.14);
@@ -197,4 +205,19 @@ async function saveProfile() {
cursor: pointer; cursor: pointer;
font-weight: 800; 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> </style>

View 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>

View File

@@ -1,5 +1,5 @@
<script setup> <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 { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs' import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image' import * as htmlToImage from 'html-to-image'
@@ -12,6 +12,8 @@ const route = useRoute()
const router = useRouter() const router = useRouter()
const auth = useAuthStore() const auth = useAuthStore()
const toast = useToast() const toast = useToast()
const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const gameId = computed(() => route.params.gameId) const gameId = computed(() => route.params.gameId)
const tierListId = computed(() => route.params.tierListId) const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1') const previewMode = computed(() => route.query.preview === '1')
@@ -780,7 +782,10 @@ onUnmounted(() => {
</div> </div>
</div> </div>
<aside class="editorSidebar"> </section>
<Teleport :to="localRightRailTarget">
<template v-if="globalRightRailOpen">
<div class="editorSidebar__section"> <div class="editorSidebar__section">
<div class="editorSidebar__label">Title</div> <div class="editorSidebar__label">Title</div>
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" /> <input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
@@ -863,8 +868,8 @@ onUnmounted(() => {
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }} {{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button> </button>
</div> </div>
</aside> </template>
</section> </Teleport>
</template> </template>
</template> </template>
@@ -1035,10 +1040,8 @@ onUnmounted(() => {
margin-top: 0; margin-top: 0;
} }
.layout { .layout {
display: grid; display: block;
grid-template-columns: minmax(0, 1fr) 320px; min-width: 0;
gap: 16px;
align-items: start;
} }
.error { .error {
margin: 10px 0 14px; margin: 10px 0 14px;
@@ -1345,18 +1348,6 @@ onUnmounted(() => {
position: sticky; position: sticky;
top: 14px; top: 14px;
} }
.editorSidebar {
display: grid;
align-content: start;
gap: 14px;
padding: 14px 12px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: linear-gradient(180deg, rgba(17, 17, 17, 0.96), rgba(12, 12, 12, 0.96));
position: sticky;
top: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.editorSidebar__section { .editorSidebar__section {
display: grid; display: grid;
gap: 10px; gap: 10px;
@@ -1534,7 +1525,7 @@ onUnmounted(() => {
} }
.poolItem { .poolItem {
display: grid; display: grid;
grid-template-columns: var(--thumb-size, 80px) 1fr; grid-template-columns: var(--thumb-size, 80px) minmax(0, 1fr);
gap: 10px; gap: 10px;
align-items: center; align-items: center;
padding: 10px; padding: 10px;
@@ -1543,8 +1534,12 @@ onUnmounted(() => {
background: rgba(0, 0, 0, 0.18); background: rgba(0, 0, 0, 0.18);
} }
.poolItem__label { .poolItem__label {
min-width: 0;
font-weight: 800; font-weight: 800;
opacity: 0.9; opacity: 0.9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.hidden { .hidden {
display: none; display: none;
@@ -1563,17 +1558,13 @@ onUnmounted(() => {
.heroCard { .heroCard {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.layout {
grid-template-columns: 1fr;
}
.editorCanvas { .editorCanvas {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.row { .row {
grid-template-columns: 150px 1fr; grid-template-columns: 150px 1fr;
} }
.sidebar, .sidebar {
.editorSidebar {
position: static; position: static;
} }
.editorSidebar__actionGrid { .editorSidebar__actionGrid {