Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 285644bdde | |||
| ed4023d1bd | |||
| 14a6823c3e | |||
| adc697eb13 | |||
| 5d7797925e |
@@ -1,5 +1,26 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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` 없이 직접 참조하는 편이 안전하다고 다시 정리했다.
|
||||
- 이번 회귀처럼 편집 화면이 통째로 무너질 수 있는 연결점은 작은 레이아웃 수정이어도 바로 복구 릴리스로 끊는 편이 낫다고 판단했다.
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
|
||||
- 화면 파일: `frontend/src/views/TierEditorView.vue`
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 우측 전용 편집 패널에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
|
||||
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
|
||||
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
|
||||
|
||||
## `/login`
|
||||
@@ -43,7 +43,7 @@
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 일부 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
|
||||
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
|
||||
11
docs/spec.md
11
docs/spec.md
@@ -11,6 +11,7 @@
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
|
||||
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
|
||||
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
|
||||
|
||||
@@ -34,16 +35,16 @@
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다.
|
||||
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역과 우측 `320px` 편집 패널이 별도 컬럼처럼 보이도록 배치한다.
|
||||
- 공통 상단 토글 버튼은 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||
- 템플릿에서는 공통 패널 상태를 ref 자동 언래핑 기준으로 직접 참조해, 우측 편집 패널이 의도치 않게 항상 닫히는 회귀를 방지한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
|
||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록과 우측 운영 패널이 독립 컬럼처럼 보이도록 배치한다.
|
||||
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
@@ -184,6 +185,8 @@
|
||||
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집 화면의 우측 패널은 공통 `rightRail`의 `localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
|
||||
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.
|
||||
|
||||
@@ -10,7 +10,11 @@
|
||||
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
|
||||
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
|
||||
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
|
||||
- 에디터 우측 패널 회귀는 복구했지만, 로컬 패널과 공통 셸 상태를 함께 쓰는 화면은 토글/초기 상태를 더 엄격히 점검할 필요가 있다.
|
||||
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
|
||||
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
|
||||
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
|
||||
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
|
||||
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
|
||||
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
|
||||
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||
|
||||
@@ -1,5 +1,26 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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`가 항상 닫힌 상태로 계산되던 문제를 수정해, 제목/설명/썸네일/저장 패널이 다시 정상 표시되도록 복구
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ const { toasts, dismissToast } = useToast()
|
||||
const menuOpen = ref(false)
|
||||
const rightRailOpen = ref(true)
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||
@@ -200,7 +201,6 @@ async function logout() {
|
||||
:class="{
|
||||
'appShell--preview': isPreviewMode,
|
||||
'appShell--rightClosed': !rightRailOpen,
|
||||
'appShell--localRail': usesLocalRightRail,
|
||||
}"
|
||||
>
|
||||
<template v-if="isPreviewMode">
|
||||
@@ -210,16 +210,13 @@ async function logout() {
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top">
|
||||
<div class="leftRail__top railHeader">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="메뉴">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('menu')" /></svg>
|
||||
</button>
|
||||
<div class="brandBlock" @click="$router.push('/')">
|
||||
<div class="brandBlock__title">Tier Maker</div>
|
||||
<div class="brandBlock__sub">by zenn</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="leftRail__body">
|
||||
<div v-if="auth.user" class="appUserCard">
|
||||
<button v-if="auth.user" class="appUserCard__button" type="button" @click.stop="toggleMenu">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||
@@ -270,14 +267,15 @@ async function logout() {
|
||||
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
|
||||
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="appMain">
|
||||
<section class="workspace" :class="{ 'workspace--localRail': usesLocalRightRail }">
|
||||
<header class="workspaceHead">
|
||||
<div>
|
||||
<div class="workspaceHead__title">{{ routeMeta.title }}</div>
|
||||
<div class="workspaceHead__subtitle">{{ routeMeta.subtitle }}</div>
|
||||
<header class="workspaceHead railHeader">
|
||||
<div class="workspaceHead__brand" @click="$router.push('/')">
|
||||
<span class="workspaceHead__brandTitle">Tier Maker</span>
|
||||
<span class="workspaceHead__brandSub">by zenn</span>
|
||||
</div>
|
||||
<div class="workspaceHead__actions">
|
||||
<button class="ghostIcon ghostIcon--workspace" type="button" :aria-pressed="rightRailOpen" @click="toggleRightRail">
|
||||
@@ -292,47 +290,18 @@ async function logout() {
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside
|
||||
v-if="!usesLocalRightRail"
|
||||
class="rightRail"
|
||||
:class="{ 'rightRail--closed': !rightRailOpen }"
|
||||
:aria-hidden="!rightRailOpen"
|
||||
>
|
||||
<div class="rightRail__top">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="상태">
|
||||
<img :src="iconGridView" alt="" />
|
||||
</button>
|
||||
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen }" :aria-hidden="!rightRailOpen">
|
||||
<div class="rightRail__top railHeader"></div>
|
||||
<div class="rightRail__body">
|
||||
<template v-if="!usesLocalRightRail">
|
||||
<section class="rightRailAction">
|
||||
<button class="rightRailAction__button" type="button" @click="routeMeta.action">
|
||||
{{ routeMeta.actionLabel }}
|
||||
</button>
|
||||
</section>
|
||||
</template>
|
||||
<div id="local-right-rail-root" class="localRightRailRoot"></div>
|
||||
</div>
|
||||
<section class="contextCard">
|
||||
<div class="contextCard__label">Context</div>
|
||||
<h2 class="contextCard__title">{{ routeMeta.contextTitle }}</h2>
|
||||
<p class="contextCard__text">{{ routeMeta.contextText }}</p>
|
||||
<button class="contextCard__action" type="button" @click="routeMeta.action">
|
||||
{{ routeMeta.actionLabel }}
|
||||
</button>
|
||||
</section>
|
||||
<section class="contextCard">
|
||||
<div class="contextCard__label">Account</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>
|
||||
</template>
|
||||
|
||||
@@ -364,22 +333,15 @@ async function logout() {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.appShell--localRail {
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.appShell--localRail.appShell--rightClosed {
|
||||
grid-template-columns: 248px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.leftRail,
|
||||
.rightRail {
|
||||
min-height: 100vh;
|
||||
padding: 14px 12px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(14, 14, 14, 0.92);
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rightRail {
|
||||
@@ -406,12 +368,27 @@ async function logout() {
|
||||
border-left-color: transparent;
|
||||
}
|
||||
|
||||
.leftRail__top,
|
||||
.rightRail__top {
|
||||
.railHeader {
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
display: flex;
|
||||
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;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.leftRail__body,
|
||||
.rightRail__body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 14px 12px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.ghostIcon {
|
||||
@@ -468,23 +445,6 @@ async function logout() {
|
||||
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 {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
@@ -669,14 +629,8 @@ async function logout() {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-width: 0;
|
||||
padding: 14px 18px 22px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
@@ -686,20 +640,40 @@ async function logout() {
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
gap: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.workspace--localRail {
|
||||
gap: 12px;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.workspaceHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
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 {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
@@ -707,115 +681,50 @@ async function logout() {
|
||||
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 {
|
||||
min-height: calc(100vh - 110px);
|
||||
padding: 20px;
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 18px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: linear-gradient(180deg, #2d2d2d 0%, #2a2a2a 100%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
margin: 18px;
|
||||
}
|
||||
|
||||
.workspaceBody--localRail {
|
||||
min-height: calc(100vh - 92px);
|
||||
min-height: calc(100vh - 56px);
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
margin: 18px;
|
||||
}
|
||||
|
||||
.rightRail {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.rightRailAction {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.contextCard {
|
||||
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 {
|
||||
.rightRailAction__button {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
background: #4b7fe9;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(77, 127, 233, 0.96);
|
||||
background: rgba(77, 127, 233, 0.88);
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contextCard--links {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.contextLink {
|
||||
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;
|
||||
.localRightRailRoot {
|
||||
min-height: calc(100vh - 84px);
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.toastStack {
|
||||
@@ -889,14 +798,6 @@ async function logout() {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.appShell--localRail {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.appShell--localRail.appShell--rightClosed {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rightRail {
|
||||
display: none;
|
||||
}
|
||||
@@ -916,15 +817,13 @@ async function logout() {
|
||||
.workspaceBody {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
margin: 14px;
|
||||
}
|
||||
|
||||
.workspaceBody--localRail {
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.workspaceHead__title {
|
||||
font-size: 26px;
|
||||
margin: 14px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import Sortable from 'sortablejs'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
@@ -8,6 +8,8 @@ import { useToast } from '../composables/useToast'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
|
||||
const activeTab = ref('games')
|
||||
@@ -1340,165 +1342,167 @@ async function saveFeaturedOrder() {
|
||||
</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>
|
||||
</template>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<aside v-show="globalRightRailOpen" class="adminSidebar">
|
||||
<section class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Mode</div>
|
||||
<div class="adminSidebar__tabs">
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === 'games'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Game Flow</div>
|
||||
<div class="modeTabs modeTabs--stack">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
|
||||
등록된 게임 선택
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'new' }" @click="setGameMode('new')">
|
||||
새 게임 추가
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="gameMode === 'existing'" class="adminSidebar__group">
|
||||
<div class="adminSidebar__groupTitle">선택할 게임</div>
|
||||
<select v-model="selectedGameId" class="select" @change="loadGame">
|
||||
<option value="">게임을 선택해주세요</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
||||
</select>
|
||||
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="adminSidebar__group">
|
||||
<div class="adminSidebar__groupTitle">새 게임 만들기</div>
|
||||
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
|
||||
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
||||
<button class="btn btn--primary" @click="createGame">게임 생성</button>
|
||||
</div>
|
||||
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">전체 게임</span>
|
||||
<strong class="sidebarStat__value">{{ games.length }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">상단 고정</span>
|
||||
<strong class="sidebarStat__value">{{ featuredGameIds.length }}/50</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Filters</div>
|
||||
<div class="adminSidebar__group">
|
||||
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
||||
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
|
||||
</div>
|
||||
<div class="adminSidebar__group">
|
||||
<select :value="customItemLimit" class="select" @change="changeCustomItemLimit(Number($event.target.value))">
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
<select v-model="customItemTargetGameId" class="select">
|
||||
<option value="">가져올 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
<label class="checkRow checkRow--compact">
|
||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||
<span>미사용 커스텀 이미지만 보기</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||
<button class="btn btn--danger" :disabled="!customItems.length" @click="removeUnusedCustomItems">미사용 이미지 일괄 삭제</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">현재 페이지</span>
|
||||
<strong class="sidebarStat__value">{{ customItemPage }}/{{ customItemPageCount }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">검색 결과</span>
|
||||
<strong class="sidebarStat__value">{{ customItemTotal }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'tierlists'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Tierlists</div>
|
||||
<div class="modeTabs modeTabs--stack">
|
||||
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'requests' }" @click="setTierlistsMode('requests')">
|
||||
템플릿 요청 관리
|
||||
</button>
|
||||
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'lists' }" @click="setTierlistsMode('lists')">
|
||||
전체 티어표 관리
|
||||
</button>
|
||||
</div>
|
||||
<template v-if="tierlistsMode === 'requests'">
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshTemplateRequests">요청 새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">대기 요청</span>
|
||||
<strong class="sidebarStat__value">{{ templateRequests.length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="adminSidebar__group">
|
||||
<input
|
||||
v-model="adminTierListQuery"
|
||||
class="input"
|
||||
placeholder="제목, 작성자, 게임 이름 검색"
|
||||
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||
/>
|
||||
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
||||
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">현재 페이지</span>
|
||||
<strong class="sidebarStat__value">{{ adminTierListPage }}/{{ adminTierListPageCount }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">검색 결과</span>
|
||||
<strong class="sidebarStat__value">{{ adminTierListTotal }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-else class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Users</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshUsers">회원 새로고침</button>
|
||||
</div>
|
||||
<div class="adminSidebar__stats">
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">가입 회원</span>
|
||||
<strong class="sidebarStat__value">{{ users.length }}</strong>
|
||||
</div>
|
||||
<div class="sidebarStat">
|
||||
<span class="sidebarStat__label">관리자 수</span>
|
||||
<strong class="sidebarStat__value">{{ users.filter((user) => user.isAdmin).length }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -1508,7 +1512,7 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.adminWorkspace {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
@@ -1573,9 +1577,6 @@ async function saveFeaturedOrder() {
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.adminSidebar {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
align-self: start;
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
@@ -2523,15 +2524,11 @@ async function saveFeaturedOrder() {
|
||||
margin-top: 0;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.adminWorkspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminHero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminSidebar {
|
||||
position: static;
|
||||
order: -1;
|
||||
display: none;
|
||||
}
|
||||
.featuredOrderPanel,
|
||||
.section--topGrid,
|
||||
|
||||
@@ -33,22 +33,6 @@ function goFreeform() {
|
||||
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) {
|
||||
if (!g.thumbnailSrc) return ''
|
||||
return toApiUrl(g.thumbnailSrc)
|
||||
@@ -63,19 +47,6 @@ function thumbUrl(g) {
|
||||
<h1 class="dashboardHero__title">Game Library</h1>
|
||||
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 수 있어요.</p>
|
||||
</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>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
@@ -130,54 +101,6 @@ function thumbUrl(g) {
|
||||
line-height: 1.5;
|
||||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import * as htmlToImage from 'html-to-image'
|
||||
@@ -13,6 +13,7 @@ const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const previewMode = computed(() => route.query.preview === '1')
|
||||
@@ -670,11 +671,7 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="layout"
|
||||
:class="{ 'layout--railClosed': !globalRightRailOpen }"
|
||||
:style="{ '--thumb-size': `${iconSize}px` }"
|
||||
>
|
||||
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||||
<div class="editorMain">
|
||||
<section class="head">
|
||||
<div class="editorMain__headCopy">
|
||||
@@ -785,7 +782,10 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside class="editorSidebar">
|
||||
</section>
|
||||
|
||||
<Teleport :to="localRightRailTarget">
|
||||
<template v-if="globalRightRailOpen">
|
||||
<div class="editorSidebar__section">
|
||||
<div class="editorSidebar__label">Title</div>
|
||||
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
|
||||
@@ -868,8 +868,8 @@ onUnmounted(() => {
|
||||
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</template>
|
||||
</Teleport>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1040,14 +1040,8 @@ onUnmounted(() => {
|
||||
margin-top: 0;
|
||||
}
|
||||
.layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 320px;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
transition: grid-template-columns 220ms ease;
|
||||
}
|
||||
.layout--railClosed {
|
||||
grid-template-columns: minmax(0, 1fr) 0;
|
||||
display: block;
|
||||
min-width: 0;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
@@ -1354,34 +1348,6 @@ onUnmounted(() => {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
}
|
||||
.editorSidebar {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 14px;
|
||||
min-width: 0;
|
||||
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);
|
||||
transition:
|
||||
opacity 220ms ease,
|
||||
transform 220ms ease,
|
||||
padding 220ms ease,
|
||||
border-color 220ms ease,
|
||||
background 220ms ease;
|
||||
}
|
||||
.layout--railClosed .editorSidebar {
|
||||
opacity: 0;
|
||||
transform: translateX(18px);
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
.editorSidebar__section {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -1559,7 +1525,7 @@ onUnmounted(() => {
|
||||
}
|
||||
.poolItem {
|
||||
display: grid;
|
||||
grid-template-columns: var(--thumb-size, 80px) 1fr;
|
||||
grid-template-columns: var(--thumb-size, 80px) minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
@@ -1568,8 +1534,12 @@ onUnmounted(() => {
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.poolItem__label {
|
||||
min-width: 0;
|
||||
font-weight: 800;
|
||||
opacity: 0.9;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
@@ -1588,17 +1558,13 @@ onUnmounted(() => {
|
||||
.heroCard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editorCanvas {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 150px 1fr;
|
||||
}
|
||||
.sidebar,
|
||||
.editorSidebar {
|
||||
.sidebar {
|
||||
position: static;
|
||||
}
|
||||
.editorSidebar__actionGrid {
|
||||
|
||||
Reference in New Issue
Block a user