Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a6e78b29f1 | |||
| 2346b5fbe3 | |||
| d724a64451 |
@@ -1,5 +1,20 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-30 v1.2.10
|
||||
- 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다.
|
||||
- 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다.
|
||||
- 카드 hover 반응은 화면마다 조금씩 다르게 두기보다, 모두 얕은 위로 이동과 배경 변화로 통일하는 편이 대시보드 감도를 유지하기 쉽다고 결정했다.
|
||||
|
||||
## 2026-03-30 v1.2.9
|
||||
- 관리자 화면은 기능보다 먼저 정보 계층이 읽혀야 하므로, 현재 탭에 맞는 요약 통계를 헤더에서 먼저 보여주는 편이 운영 판단에 더 유리하다고 정리했다.
|
||||
- 게임/아이템/티어표/회원 카드는 기능이 다른 대신 같은 제품 안에 있으므로, 배경층·반경·패딩은 하나의 대시보드 문법으로 맞춰 시안 톤을 더 강하게 유지하기로 결정했다.
|
||||
- 우측 운영 패널은 단순 필터 모음보다 “현재 상태를 짧게 읽고 바로 액션하는 패널”에 가까워야 하므로, 입력과 통계 카드를 더 단단한 카드형 레이어로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.8
|
||||
- 에디터는 “보드 편집”과 “옵션 편집”의 역할이 다르므로, 보드 옆에는 드래그용 아이템 풀을 두고 제목/설명/썸네일/저장 같은 설정은 최우측 사이드바에만 남기는 편이 맞다고 판단했다.
|
||||
- 커스텀 아이템 이름 정리는 배치 중에 계속 보는 정보보다 저장 전 정리용 정보에 가까우므로, 아이템 풀 아래보다 우측 편집 패널 내부가 더 적합하다고 정리했다.
|
||||
- 실제 SVG 에셋이 들어오기 시작한 만큼, 공통 셸은 새 아이콘을 우선 적용하고 나머지는 점진적으로 교체하는 방식이 안전하다고 판단했다.
|
||||
|
||||
## 2026-03-30 v1.2.7
|
||||
- 피그마 감도는 개별 화면보다 공통 셸의 밀도와 아이콘 체계가 먼저 맞아야 하므로, 좌측/우측 레일을 먼저 아이콘형 카드 문법으로 정리하기로 했다.
|
||||
- 실제 머티리얼 SVG 자산을 받기 전까지는 간단한 선형 SVG 아이콘으로 정보 구조를 먼저 맞추고, 이후 에셋 교체만으로 다듬을 수 있게 하는 편이 안전하다고 판단했다.
|
||||
|
||||
@@ -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`
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 선형 SVG 아이콘 기반 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 일부 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
|
||||
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
|
||||
|
||||
## 백엔드 진입점
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
- 중앙 워크스페이스
|
||||
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기 화면은 같은 카드 문법(상단 16:9 썸네일, 제목, 작성자/보조 메타, 하단 상태 영역)을 공유하도록 정리한다.
|
||||
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
|
||||
- 우측 패널
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
@@ -36,9 +37,11 @@
|
||||
- 티어표 편집 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
|
||||
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
|
||||
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
|
||||
- 관리자 화면
|
||||
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
|
||||
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
|
||||
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
|
||||
|
||||
## DB 스키마
|
||||
- `users`
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
## 즉시 확인 필요
|
||||
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
||||
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
|
||||
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
|
||||
- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다.
|
||||
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
|
||||
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
|
||||
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
|
||||
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
|
||||
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-30 v1.2.10
|
||||
- **목록 화면 상단 툴바 밀도 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 상단 영역의 통계 카드와 액션 버튼 높이/반경/배경을 맞춰 공통 셸과 같은 도구 막대 문법으로 정리
|
||||
- **홈 빠른 진입 흐름 보정**: 홈 화면 툴바에서 중복되던 버튼 흐름을 `즐겨찾기 / 내 리스트 / 커스텀 티어표 만들기` 중심으로 재구성해 실제 사용 동선에 맞게 정리
|
||||
- **목록 카드 인터랙션 보강**: 주요 카드 목록에 일관된 hover 이동과 배경 전환을 넣어, 대시보드 카드가 더 또렷하게 반응하도록 조정
|
||||
|
||||
## 2026-03-30 v1.2.9
|
||||
- **관리자 대시보드 헤더 보강**: 관리자 화면 상단에 현재 탭 기준 요약 통계 카드를 추가해, 게임/아이템/티어표/회원 상태를 즉시 읽을 수 있게 정리
|
||||
- **운영 패널 질감 정리**: 우측 `320px` 운영 패널의 탭, 입력, 통계 카드, 버튼 라운드/배경/호버 상태를 공통 셸 톤에 맞춰 더 두꺼운 대시보드 카드 문법으로 통일
|
||||
- **관리 카드 밀도 개선**: 게임 상세, 커스텀 아이템, 템플릿 요청, 전체 티어표, 회원 카드의 배경층·패딩·반경을 함께 다듬어 시안에 가까운 평평한 관리용 레이아웃으로 보정
|
||||
|
||||
## 2026-03-30 v1.2.8
|
||||
- **실제 SVG 아이콘 연결 시작**: 사용자가 추가한 `grid_view`, `lists`, `dock_to_left`, `dock_to_right` 아이콘을 공통 셸 내비와 우측 패널 토글에 연결해 문자 기반 아이콘을 일부 실제 에셋으로 교체
|
||||
- **에디터 3열 구조 복구**: 티어표 편집 화면을 `보드 / 아이템 풀 / 우측 편집 사이드바` 구조로 재배치해, 아이템 풀은 보드 옆에서 바로 드래그 가능하고 편집 옵션은 최우측 패널에만 남도록 수정
|
||||
- **커스텀 아이템 이름 정리 위치 조정**: 커스텀 아이템 이름 수정 목록은 드래그용 아이템 풀 아래가 아니라 우측 편집 사이드바 안으로 옮겨, 보드 배치 흐름과 옵션 정리 흐름을 분리
|
||||
|
||||
## 2026-03-30 v1.2.7
|
||||
- **공통 셸 아이콘형 정리**: 좌측 내비와 우측 보조 패널의 임시 문자 배지를 간단한 SVG 아이콘형으로 바꾸고, 버튼/카드 라운드와 밀도를 통일
|
||||
- **좌측 레일 정보 밀도 개선**: 사용자 카드, 빠른 검색, 내비 버튼, 하단 로그인/관리자 버튼을 더 두꺼운 카드 문법으로 맞춰 피그마 톤에 가까운 레일 형태로 재정리
|
||||
|
||||
@@ -4,6 +4,10 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from './stores/auth'
|
||||
import { toApiUrl } from './lib/runtime'
|
||||
import { useToast } from './composables/useToast'
|
||||
import iconDockToLeft from './assets/icons/dock_to_left.svg'
|
||||
import iconDockToRight from './assets/icons/dock_to_right.svg'
|
||||
import iconGridView from './assets/icons/grid_view.svg'
|
||||
import iconLists from './assets/icons/lists.svg'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -27,13 +31,13 @@ const accountName = computed(() => {
|
||||
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
||||
const leftNavItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: 'Games', path: '/', icon: 'M4 6.5h16M4 12h16M4 17.5h16' },
|
||||
{ key: 'me', label: '내 리스트', path: '/me', icon: 'M6 5.5h12v12H6z M8.75 8.5h6.5 M8.75 11.75h6.5 M8.75 15h4.5', requiresAuth: true },
|
||||
{ key: 'home', label: 'Games', path: '/', iconSrc: iconGridView },
|
||||
{ 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: '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 },
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ key: 'admin', label: 'Admin', path: '/admin', icon: 'M5.5 6.25h13v13h-13z M9 9.25h6 M9 12h6 M9 14.75h4.5' })
|
||||
items.push({ key: 'admin', label: 'Admin', path: '/admin', iconSrc: iconLists })
|
||||
}
|
||||
return items.filter((item) => !item.requiresAuth || auth.user)
|
||||
})
|
||||
@@ -131,7 +135,6 @@ const favoriteLinks = computed(() => [
|
||||
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 === 'panel') return 'M5.5 6.5h5v5h-5z M13.5 6.5h5v5h-5z M5.5 13.5h5v5h-5z M13.5 13.5h5v5h-5z'
|
||||
if (type === 'link') return 'M8 12h8 M12 8l4 4-4 4'
|
||||
return 'M4 12h16'
|
||||
}
|
||||
@@ -240,7 +243,8 @@ async function logout() {
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
>
|
||||
<span class="leftNav__glyph">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="item.icon" /></svg>
|
||||
<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>
|
||||
</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
@@ -269,7 +273,7 @@ async function logout() {
|
||||
</div>
|
||||
<div class="workspaceHead__actions">
|
||||
<button class="ghostIcon ghostIcon--workspace" type="button" :aria-pressed="rightRailOpen" @click="toggleRightRail">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('panel')" /></svg>
|
||||
<img :src="rightRailOpen ? iconDockToRight : iconDockToLeft" alt="" />
|
||||
<span>{{ rightRailOpen ? '패널 숨기기' : '패널 보기' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -288,7 +292,7 @@ async function logout() {
|
||||
>
|
||||
<div class="rightRail__top">
|
||||
<button class="ghostIcon ghostIcon--iconOnly" type="button" aria-label="상태">
|
||||
<svg viewBox="0 0 24 24" aria-hidden="true"><path :d="railGlyph('panel')" /></svg>
|
||||
<img :src="iconGridView" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<section class="contextCard">
|
||||
@@ -422,6 +426,15 @@ async function logout() {
|
||||
stroke-linejoin: round;
|
||||
}
|
||||
|
||||
.ghostIcon img,
|
||||
.leftNav__glyph img,
|
||||
.ghostIcon--workspace img {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
filter: brightness(0) saturate(100%) invert(94%) sepia(6%) saturate(207%) hue-rotate(186deg) brightness(96%) contrast(92%);
|
||||
}
|
||||
|
||||
.ghostIcon--iconOnly {
|
||||
min-width: 32px;
|
||||
width: 32px;
|
||||
|
||||
1
frontend/src/assets/icons/dock_to_left.svg
Normal file
1
frontend/src/assets/icons/dock_to_left.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm440-80h120v-560H640v560Zm-80 0v-560H200v560h360Zm80 0h120-120Z"/></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
1
frontend/src/assets/icons/dock_to_right.svg
Normal file
1
frontend/src/assets/icons/dock_to_right.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm120-80v-560H200v560h120Zm80 0h360v-560H400v560Zm-80 0H200h120Z"/></svg>
|
||||
|
After Width: | Height: | Size: 306 B |
1
frontend/src/assets/icons/grid_view.svg
Normal file
1
frontend/src/assets/icons/grid_view.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-520v-320h320v320H120Zm0 400v-320h320v320H120Zm400-400v-320h320v320H520Zm0 400v-320h320v320H520ZM200-600h160v-160H200v160Zm400 0h160v-160H600v160Zm0 400h160v-160H600v160Zm-400 0h160v-160H200v160Zm400-400Zm0 240Zm-240 0Zm0-240Z"/></svg>
|
||||
|
After Width: | Height: | Size: 354 B |
1
frontend/src/assets/icons/lists.svg
Normal file
1
frontend/src/assets/icons/lists.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M80-160v-160h160v160H80Zm240 0v-160h560v160H320ZM80-400v-160h160v160H80Zm240 0v-160h560v160H320ZM80-640v-160h160v160H80Zm240 0v-160h560v160H320Z"/></svg>
|
||||
|
After Width: | Height: | Size: 268 B |
@@ -95,6 +95,45 @@ const activeTabDescription = computed(() => {
|
||||
}
|
||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 함께 확인합니다.'
|
||||
})
|
||||
const adminOverviewStats = computed(() => {
|
||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
||||
const pendingRequests = templateRequests.value.length
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
|
||||
|
||||
if (activeTab.value === 'games') {
|
||||
return [
|
||||
{ label: '전체 게임', value: `${games.value.length}` },
|
||||
{ label: '상단 고정', value: `${featuredGameIds.value.length}/50` },
|
||||
{ label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return [
|
||||
{ label: '검색 결과', value: `${customItemTotal.value}` },
|
||||
{ label: '미사용', value: `${orphanItems}` },
|
||||
{ label: '대상 게임', value: customItemTargetGameId.value ? '선택됨' : '미선택' },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests'
|
||||
? [
|
||||
{ label: '대기 요청', value: `${pendingRequests}` },
|
||||
{ label: '생성 요청', value: `${templateRequests.value.filter((request) => request.type === 'create').length}` },
|
||||
{ label: '업데이트 요청', value: `${templateRequests.value.filter((request) => request.type === 'update').length}` },
|
||||
]
|
||||
: [
|
||||
{ label: '검색 결과', value: `${adminTierListTotal.value}` },
|
||||
{ label: '공개 티어표', value: `${publishedTierLists}` },
|
||||
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ label: '가입 회원', value: `${users.value.length}` },
|
||||
{ label: '관리자', value: `${adminCount}` },
|
||||
{ label: '활동 계정', value: `${users.value.filter((user) => user.tierListCount > 0).length}` },
|
||||
]
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
@@ -897,6 +936,12 @@ async function saveFeaturedOrder() {
|
||||
<div class="adminHero__eyebrow">Admin Workspace</div>
|
||||
<h2 class="adminHero__title">{{ activeTabTitle }}</h2>
|
||||
<p class="adminHero__desc">{{ activeTabDescription }}</p>
|
||||
<div class="adminHero__stats">
|
||||
<article v-for="stat in adminOverviewStats" :key="stat.label" class="adminHeroStat">
|
||||
<span class="adminHeroStat__label">{{ stat.label }}</span>
|
||||
<strong class="adminHeroStat__value">{{ stat.value }}</strong>
|
||||
</article>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="activeTab === 'games'">
|
||||
@@ -1474,11 +1519,14 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.adminHero {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 18px 20px;
|
||||
border-radius: 18px;
|
||||
gap: 10px;
|
||||
padding: 22px 24px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.02)),
|
||||
rgba(255, 255, 255, 0.02);
|
||||
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.adminHero__eyebrow {
|
||||
font-size: 11px;
|
||||
@@ -1498,6 +1546,32 @@ async function saveFeaturedOrder() {
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
line-height: 1.6;
|
||||
}
|
||||
.adminHero__stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-top: 6px;
|
||||
}
|
||||
.adminHeroStat {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(7, 7, 7, 0.18);
|
||||
}
|
||||
.adminHeroStat__label {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
}
|
||||
.adminHeroStat__value {
|
||||
font-size: 22px;
|
||||
line-height: 1;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.adminSidebar {
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
@@ -1508,10 +1582,13 @@ async function saveFeaturedOrder() {
|
||||
.adminSidebar__panel {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(17, 17, 17, 0.9);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.04), rgba(255, 255, 255, 0.015)),
|
||||
rgba(13, 13, 13, 0.94);
|
||||
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.adminSidebar__label {
|
||||
font-size: 11px;
|
||||
@@ -1534,8 +1611,8 @@ async function saveFeaturedOrder() {
|
||||
.sidebarStat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
@@ -1589,18 +1666,29 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.tab,
|
||||
.modeTab {
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
.tab:hover,
|
||||
.modeTab:hover {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.tab--active,
|
||||
.modeTab--active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(96, 165, 250, 0.14);
|
||||
border-color: rgba(96, 165, 250, 0.28);
|
||||
color: rgba(239, 246, 255, 0.98);
|
||||
}
|
||||
.adminSidebar__tabs .tab,
|
||||
.modeTabs--stack .modeTab {
|
||||
@@ -1609,9 +1697,12 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(48, 48, 48, 0.78);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 255, 255, 0.035), rgba(255, 255, 255, 0.018)),
|
||||
rgba(34, 34, 34, 0.84);
|
||||
border-radius: 24px;
|
||||
padding: 18px;
|
||||
box-shadow: 0 18px 44px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.panel--empty {
|
||||
min-height: 240px;
|
||||
@@ -1644,9 +1735,9 @@ async function saveFeaturedOrder() {
|
||||
.featuredOrderPanel__list,
|
||||
.featuredOrderPanel__picker {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
}
|
||||
.featuredList,
|
||||
.featuredPickerList {
|
||||
@@ -1661,10 +1752,10 @@ async function saveFeaturedOrder() {
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
.featuredCard__meta {
|
||||
display: flex;
|
||||
@@ -1706,9 +1797,9 @@ async function saveFeaturedOrder() {
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
@@ -1738,9 +1829,9 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.adminCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.sectionHeader {
|
||||
@@ -1783,8 +1874,8 @@ async function saveFeaturedOrder() {
|
||||
.input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -1826,8 +1917,8 @@ async function saveFeaturedOrder() {
|
||||
.btn {
|
||||
font-size: 12px;
|
||||
margin-top: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -1835,6 +1926,15 @@ async function saveFeaturedOrder() {
|
||||
font-weight: 800;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
transition:
|
||||
background 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
transform 0.16s ease;
|
||||
}
|
||||
.btn:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgba(255, 255, 255, 0.22);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.btn--small {
|
||||
margin-top: 0;
|
||||
@@ -1853,6 +1953,7 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.btn--primary {
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
border-color: rgba(96, 165, 250, 0.26);
|
||||
}
|
||||
.btn--danger {
|
||||
background: rgba(239, 68, 68, 0.14);
|
||||
@@ -1938,8 +2039,8 @@ async function saveFeaturedOrder() {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.itemPreviewCard {
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
@@ -1977,9 +2078,9 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.thumbCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 14px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 10px;
|
||||
padding: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
.thumb {
|
||||
@@ -2012,18 +2113,18 @@ async function saveFeaturedOrder() {
|
||||
.customItemGrid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.customItemCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: flex-start;
|
||||
padding: 12px;
|
||||
padding: 14px;
|
||||
min-width: 0;
|
||||
}
|
||||
.customItemCard__image {
|
||||
@@ -2081,9 +2182,9 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.userCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 16px;
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.userCard__head {
|
||||
display: flex;
|
||||
@@ -2134,8 +2235,8 @@ async function saveFeaturedOrder() {
|
||||
.userStat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
@@ -2176,8 +2277,8 @@ async function saveFeaturedOrder() {
|
||||
.templateRequestCard {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 16px;
|
||||
border-radius: 18px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
@@ -2242,9 +2343,9 @@ async function saveFeaturedOrder() {
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px;
|
||||
padding: 16px;
|
||||
}
|
||||
.tierAdminCard__preview {
|
||||
cursor: pointer;
|
||||
@@ -2304,8 +2405,8 @@ async function saveFeaturedOrder() {
|
||||
.tierAdminSection {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
@@ -2326,8 +2427,8 @@ async function saveFeaturedOrder() {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -2363,8 +2464,8 @@ async function saveFeaturedOrder() {
|
||||
width: min(560px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
padding: 20px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(11, 18, 32, 0.96);
|
||||
}
|
||||
@@ -2425,6 +2526,9 @@ async function saveFeaturedOrder() {
|
||||
.adminWorkspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminHero__stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.adminSidebar {
|
||||
position: static;
|
||||
order: -1;
|
||||
|
||||
@@ -117,6 +117,7 @@ onMounted(loadFavorites)
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
@@ -141,15 +142,15 @@ onMounted(loadFavorites)
|
||||
}
|
||||
.input,
|
||||
.select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.btn {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -165,13 +166,20 @@ onMounted(loadFavorites)
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
border-radius: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.boardCard:hover {
|
||||
transform: translateY(-2px);
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
}
|
||||
.boardCard__body {
|
||||
border: 0;
|
||||
|
||||
@@ -139,7 +139,7 @@ function submitSearch() {
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 4px 2px 18px;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__left {
|
||||
display: grid;
|
||||
@@ -172,10 +172,10 @@ function submitSearch() {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 112px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
.dashboardStat__label {
|
||||
font-size: 11px;
|
||||
@@ -189,12 +189,19 @@ function submitSearch() {
|
||||
}
|
||||
.primary {
|
||||
padding: 12px 16px;
|
||||
border-radius: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(77, 127, 233, 0.96);
|
||||
background: rgba(77, 127, 233, 0.88);
|
||||
color: #fff;
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease,
|
||||
border-color 0.16s ease;
|
||||
}
|
||||
.primary:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
.panel {
|
||||
/* border: 1px solid rgba(255, 255, 255, 0.08); */
|
||||
@@ -234,15 +241,15 @@ function submitSearch() {
|
||||
}
|
||||
.searchBar__input {
|
||||
min-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.searchBar__button {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
padding: 11px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -258,7 +265,7 @@ function submitSearch() {
|
||||
gap: 18px;
|
||||
}
|
||||
.boardCard {
|
||||
border-radius: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -268,9 +275,13 @@ function submitSearch() {
|
||||
min-height: 168px;
|
||||
overflow: hidden;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.boardCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.boardCard__body {
|
||||
text-align: left;
|
||||
|
||||
@@ -33,6 +33,22 @@ 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)
|
||||
@@ -52,8 +68,12 @@ function thumbUrl(g) {
|
||||
<span class="dashboardToolbar__label">Visible Games</span>
|
||||
<strong class="dashboardToolbar__value">{{ games.length }}</strong>
|
||||
</div>
|
||||
<button class="dashboardToolbar__ghost" @click="goFreeform">Quick Start</button>
|
||||
<button class="dashboardToolbar__ghost" @click="goFreeform">Browse All</button>
|
||||
<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>
|
||||
@@ -85,10 +105,12 @@ function thumbUrl(g) {
|
||||
flex-wrap: wrap;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 18px;
|
||||
padding: 6px 2px 18px;
|
||||
}
|
||||
.dashboardHero__copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
max-width: 720px;
|
||||
}
|
||||
.dashboardHero__eyebrow {
|
||||
font-size: 11px;
|
||||
@@ -118,10 +140,10 @@ function thumbUrl(g) {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 112px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
.dashboardToolbar__label {
|
||||
font-size: 11px;
|
||||
@@ -135,13 +157,21 @@ function thumbUrl(g) {
|
||||
}
|
||||
.dashboardToolbar__ghost,
|
||||
.customTierBtn {
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
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);
|
||||
@@ -163,8 +193,8 @@ function thumbUrl(g) {
|
||||
}
|
||||
.libraryCard {
|
||||
text-align: left;
|
||||
padding: 12px;
|
||||
border-radius: 18px;
|
||||
padding: 14px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -172,9 +202,13 @@ function thumbUrl(g) {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
background 0.16s ease;
|
||||
}
|
||||
.libraryCard:hover {
|
||||
background: rgba(70, 70, 70, 0.96);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.libraryCard__thumbWrap {
|
||||
width: 100%;
|
||||
|
||||
@@ -120,6 +120,7 @@ async function removeList(t) {
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 18px;
|
||||
padding: 6px 2px 8px;
|
||||
}
|
||||
.head__eyebrow {
|
||||
font-size: 11px;
|
||||
@@ -140,10 +141,10 @@ async function removeList(t) {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
min-width: 112px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
background: rgba(255, 255, 255, 0.045);
|
||||
}
|
||||
.head__statLabel {
|
||||
font-size: 11px;
|
||||
@@ -162,8 +163,8 @@ async function removeList(t) {
|
||||
padding: 0;
|
||||
}
|
||||
.link {
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
@@ -181,12 +182,19 @@ async function removeList(t) {
|
||||
.boardCard {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-radius: 18px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
overflow: hidden;
|
||||
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 {
|
||||
flex: 1 1 auto;
|
||||
|
||||
@@ -685,114 +685,98 @@ onUnmounted(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div ref="boardEl" class="board">
|
||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||
<div class="boardTools__left">
|
||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||
</div>
|
||||
<div class="boardTools__right">
|
||||
<span class="boardTools__label">아이콘 크기</span>
|
||||
<div class="sizePicker">
|
||||
<button
|
||||
v-for="size in iconSizeOptions"
|
||||
:key="size"
|
||||
class="sizePicker__button"
|
||||
:class="{ 'sizePicker__button--active': iconSize === size }"
|
||||
@click="setIconSize(size)"
|
||||
>
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||
<div ref="groupListEl" class="rows">
|
||||
<div v-for="g in groups" :key="g.id" class="row">
|
||||
<div class="row__label">
|
||||
<template v-if="isExporting">
|
||||
<div class="row__exportName">{{ g.name }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
||||
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
||||
</template>
|
||||
<div class="editorCanvas">
|
||||
<div ref="boardEl" class="board">
|
||||
<div v-if="canEdit && !isExporting" class="boardTools">
|
||||
<div class="boardTools__left">
|
||||
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
||||
</div>
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||
>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="boardTools__right">
|
||||
<span class="boardTools__label">아이콘 크기</span>
|
||||
<div class="sizePicker">
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
class="cellRemoveBtn"
|
||||
type="button"
|
||||
title="아이템 빼내기"
|
||||
@pointerdown.stop
|
||||
@click.stop="removeItemFromGroup(g.id, id)"
|
||||
v-for="size in iconSizeOptions"
|
||||
:key="size"
|
||||
class="sizePicker__button"
|
||||
:class="{ 'sizePicker__button--active': iconSize === size }"
|
||||
@click="setIconSize(size)"
|
||||
>
|
||||
×
|
||||
{{ size }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||||
<div ref="groupListEl" class="rows">
|
||||
<div v-for="g in groups" :key="g.id" class="row">
|
||||
<div class="row__label">
|
||||
<template v-if="isExporting">
|
||||
<div class="row__exportName">{{ g.name }}</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||||
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
|
||||
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
||||
</template>
|
||||
</div>
|
||||
<div
|
||||
class="row__drop"
|
||||
:data-list-type="'group'"
|
||||
:data-group-id="g.id"
|
||||
:ref="(el) => setGroupDropEl(g.id, el)"
|
||||
>
|
||||
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
||||
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<button
|
||||
v-if="canEdit && !isExporting"
|
||||
class="cellRemoveBtn"
|
||||
type="button"
|
||||
title="아이템 빼내기"
|
||||
@pointerdown.stop
|
||||
@click.stop="removeItemFromGroup(g.id, id)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isExporting" class="exportBoard__footer">
|
||||
<span>{{ effectiveAuthorName }}</span>
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="isExporting" class="exportBoard__footer">
|
||||
<span>{{ effectiveAuthorName }}</span>
|
||||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
<div class="sidebar">
|
||||
<div class="sidebar__title">아이템</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '보드 바로 옆에서 드래그해 넣을 수 있도록 아이템 풀을 고정합니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="canEdit && customItems.length" class="customItemEditor">
|
||||
<div class="customItemEditor__title">추가한 커스텀 아이템 이름 정리</div>
|
||||
<div class="customItemEditor__desc">
|
||||
템플릿 요청 전에 이름을 정리해두면 관리자가 그대로 기본 템플릿으로 반영할 수 있어요.
|
||||
<div ref="poolEl" class="pool" data-list-type="pool">
|
||||
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customItemEditor__list">
|
||||
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
|
||||
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
|
||||
<input
|
||||
class="customItemEditor__input"
|
||||
:value="item.label"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름"
|
||||
@input="updateCustomItemLabel(item.id, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="dropzone"
|
||||
:class="{ 'dropzone--active': isDragActive }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||||
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
||||
</div>
|
||||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="canEdit"
|
||||
class="dropzone"
|
||||
:class="{ 'dropzone--active': isDragActive }"
|
||||
@dragenter.prevent="onDragEnter"
|
||||
@dragover.prevent="onDragEnter"
|
||||
@dragleave="onDragLeave"
|
||||
@drop.prevent="onDropFiles"
|
||||
>
|
||||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||||
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
||||
</div>
|
||||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -831,6 +815,27 @@ onUnmounted(() => {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
|
||||
<div class="editorSidebar__label">커스텀 이름 정리</div>
|
||||
<div class="customItemEditor customItemEditor--sidebar">
|
||||
<div class="customItemEditor__desc">
|
||||
아래에서 이름만 정리해두면 관리자 요청 시 그대로 전달됩니다.
|
||||
</div>
|
||||
<div class="customItemEditor__list">
|
||||
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
|
||||
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
|
||||
<input
|
||||
class="customItemEditor__input"
|
||||
:value="item.label"
|
||||
maxlength="60"
|
||||
placeholder="아이템 이름"
|
||||
@input="updateCustomItemLabel(item.id, $event.target.value)"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editorSidebar__section editorSidebar__section--footer">
|
||||
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
||||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||||
@@ -874,6 +879,12 @@ onUnmounted(() => {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.editorCanvas {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 280px;
|
||||
gap: 16px;
|
||||
align-items: start;
|
||||
}
|
||||
.editorMain__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
@@ -1331,6 +1342,8 @@ onUnmounted(() => {
|
||||
border-radius: 22px;
|
||||
padding: 14px;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
position: sticky;
|
||||
top: 14px;
|
||||
}
|
||||
.editorSidebar {
|
||||
display: grid;
|
||||
@@ -1446,11 +1459,15 @@ onUnmounted(() => {
|
||||
line-height: 1.5;
|
||||
}
|
||||
.customItemEditor {
|
||||
margin-top: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.customItemEditor--sidebar {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.customItemEditor__title {
|
||||
font-weight: 900;
|
||||
@@ -1549,9 +1566,13 @@ onUnmounted(() => {
|
||||
.layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.editorCanvas {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.row {
|
||||
grid-template-columns: 150px 1fr;
|
||||
}
|
||||
.sidebar,
|
||||
.editorSidebar {
|
||||
position: static;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user