Compare commits

...

5 Commits

12 changed files with 1117 additions and 622 deletions

View File

@@ -1,5 +1,25 @@
# 의사결정 이력
## 2026-03-30 v1.2.6
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 중심 화면은 한 번에 같은 카드 문법으로 맞춰야 전체 앱이 하나의 제품처럼 보이므로, 목록 화면을 우선 통일하기로 했다.
- 홈 화면은 단순 게임 버튼 모음보다 상태 카드와 CTA가 있는 라이브러리 대시보드 쪽이 피그마 톤에 더 가깝다고 판단했다.
- 게임 허브와 개인 목록도 썸네일/작성자/메타의 비중이 비슷하므로, 화면마다 다른 카드 구조를 유지하기보다 동일한 정보 계층을 반복하는 편이 더 읽기 쉽다고 정리했다.
## 2026-03-30 v1.2.5
- 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다.
- 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다.
- 새 셸 단계에서는 기능을 줄이기보다 위치를 재배치하는 것이 안전하므로, 기존 게임/아이템/티어표/회원 관리 로직은 유지한 채 정보 구조만 피그마 방향으로 옮기기로 했다.
## 2026-03-30 v1.2.4
- 로그인 유도는 좌측 하단의 단일 버튼이면 충분하므로, 비로그인 상태에서 사이드 상단에 별도 안내 카드를 또 보여주는 구조는 제거하는 편이 더 깔끔하다고 판단했다.
- 티어표 편집 화면은 공통 우측 패널의 generic 문맥 카드보다 실제 편집 필드가 우측에 있는 편이 훨씬 중요하므로, 이 화면은 전용 로컬 우측 패널을 두는 쪽으로 정리했다.
- 좌측 내비가 이미 라우팅 역할을 하므로, 에디터 우측 패널에서는 “게임 목록으로” 같은 중복 이동 CTA보다 저장과 편집 자체에 집중하는 것이 맞다고 판단했다.
## 2026-03-30 v1.2.2
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
- 우측 패널 토글은 라우트별 개별 구현보다 공통 셸의 상단 컨트롤로 두는 편이 모든 화면에서 일관된 사용성을 제공한다고 판단했다.
## 2026-03-30 v1.2.1
- 공통 셸을 먼저 올린 직후에는 에디터와 관리자처럼 자체 패널이 많은 화면이 가장 크게 깨지므로, 이 화면들은 우선 공통 우측 패널을 숨기고 중앙 폭을 회복시키는 편이 안정적이라고 판단했다.
- 목록형 카드 화면은 셸 안쪽 폭이 줄어든 상태에서 이전보다 더 많은 컬럼을 유지하면 즉시 사용성이 무너지므로, 기본 컬럼 수를 줄여 먼저 읽히는 상태를 만드는 쪽을 우선하기로 했다.

View File

@@ -2,17 +2,17 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 역할: 상단 상태/CTA가 있는 라이브러리 대시보드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games`
## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입
- 역할: 선택한 게임 정보 표시, 상단 통계/생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 상태 표시, 새 티어표 작성 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, 즐겨찾기 토글, PNG 다운로드
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 우측 전용 편집 패널에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어, 즐겨찾기 토글, PNG 다운로드
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
## `/login`
@@ -22,17 +22,17 @@
## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 역할: 내 티어표 목록 조회, 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
## `/favorites`
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 편집 화면 이동, 즐겨찾기 해제
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리`과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`
@@ -43,7 +43,7 @@
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
- 예외: `/admin`, `/editor/*`, `/profile`, `/login`처럼 작업 밀도가 높은 포커스 화면은 공통 우측 패널을 숨기고 중앙 작업 폭을 우선 확보한다.
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`

View File

@@ -10,7 +10,8 @@
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
- 단, 에디터·관리자·프로필·로그인처럼 자체 패널이 많은 포커스 화면은 현재 안정화를 위해 공통 우측 패널을 숨기고 중앙 작업 폭을 우선 확보한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
## 데이터 저장 구조
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
@@ -26,10 +27,17 @@
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다.
- 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기 화면은 같은 카드 문법(상단 16:9 썸네일, 제목, 작성자/보조 메타, 하단 상태 영역)을 공유하도록 정리한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
- 이관 전까지는 해당 포커스 화면에서 공통 우측 패널을 접고 화면 내부 패널을 그대로 사용한다.
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
## DB 스키마
- `users`

View File

@@ -2,6 +2,9 @@
## 즉시 확인 필요
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.

View File

@@ -1,5 +1,25 @@
# 업데이트 로그
## 2026-03-30 v1.2.6
- **목록형 화면 카드 문법 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드형 목록을 동일한 썸네일/제목/작성자/메타 구조로 정리해 대시보드 톤을 맞춤
- **홈 화면 대시보드 재정렬**: 메인 게임 라이브러리 화면에 상단 상태 카드와 CTA를 추가하고, 게임 카드는 `16:9` 썸네일 + ID 메타를 갖는 라이브러리 카드 형태로 재배치
- **게임 허브 헤더/검색 정리**: 게임 허브는 상단 통계와 생성 버튼, 보조 설명을 포함한 헤더로 재구성하고, 공개 티어표 카드도 같은 카드 밀도로 재정리
## 2026-03-30 v1.2.5
- **관리자 로컬 우측 패널 이관**: 관리자 화면도 공통 우측 패널 대신 화면 내부의 `320px` 전용 운영 패널을 사용하도록 정리하고, 탭·검색·필터·빠른 액션을 우측으로 이동
- **관리 화면 본문 집중도 개선**: 중앙 영역은 상단 고정 게임 순서, 선택된 게임 상세, 커스텀 아이템 카드, 템플릿 요청/전체 티어표, 회원 카드 같은 실제 관리 대상만 남기고 빈 상태 안내도 별도 패널로 정리
- **관리자 셸 예외 확장**: 공통 앱 셸에서 `/admin`도 전용 로컬 우측 패널을 사용하는 포커스 화면으로 분류해 generic 우측 문맥 카드가 중복 표시되지 않게 조정
## 2026-03-30 v1.2.4
- **비로그인 중복 안내 제거**: 좌측 사이드 상단의 별도 로그인 안내 카드를 제거하고, 비로그인 상태에서는 좌측 하단 버튼만 `로그인` 진입점으로 사용하도록 단순화
- **에디터 우측 편집 패널 이관**: 티어표 편집 화면의 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 중앙 상단이 아니라 독립 우측 편집 패널로 이동
- **공통 우측 패널 예외 처리**: 티어표 편집 화면은 공통 우측 패널 대신 화면 내부 전용 편집 패널을 사용하도록 조정해, generic 안내 카드가 중복 표시되지 않게 정리
## 2026-03-30 v1.2.2
- **사이드 패널 폭 고정**: 공통 앱 셸의 좌측 패널 폭을 `248px`, 우측 패널 폭을 `320px` 기준으로 재정의해 피그마 시안과 더 가깝게 맞춤
- **우측 패널 토글 추가**: 상단 우측 토글 버튼으로 우측 패널을 접고 펼칠 수 있게 하고, 접힐 때는 중앙 작업 영역이 자연스럽게 확장되도록 전환 애니메이션을 추가
- **우측 패널 독립성 강화**: 우측 패널은 본문과 별도 컬럼으로 유지하고, 닫힐 때도 본문 레이아웃과 분리된 독립 패널처럼 동작하도록 셸 구조를 조정
## 2026-03-30 v1.2.1
- **포커스 화면 폭 복구**: 에디터·관리자·프로필·로그인 화면은 공통 우측 패널을 잠시 숨기고 중앙 작업 폭을 넓혀, 기존 기능 UI가 3단 셸과 충돌하며 깨지던 문제를 완화
- **목록 카드 밀도 재조정**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 기본 컬럼 수를 줄여 현재 셸 폭 안에서도 카드가 과도하게 눌리지 않도록 정리

View File

@@ -11,10 +11,11 @@ const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const menuOpen = ref(false)
const rightRailOpen = ref(true)
const isAdmin = computed(() => !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const isFocusWorkspace = computed(() => ['admin', 'newEditor', 'editEditor', 'profile', 'login'].includes(String(route.name || '')))
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
const accountName = computed(() => {
const nickname = (auth.user?.nickname || '').trim()
@@ -129,6 +130,10 @@ const favoriteLinks = computed(() => [
onMounted(async () => {
await auth.refresh()
if (typeof window !== 'undefined') {
const saved = window.localStorage.getItem('tier-maker:right-rail-open')
if (saved === '0') rightRailOpen.value = false
}
document.addEventListener('click', onDocumentClick)
})
@@ -158,6 +163,13 @@ function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function toggleRightRail() {
rightRailOpen.value = !rightRailOpen.value
if (typeof window !== 'undefined') {
window.localStorage.setItem('tier-maker:right-rail-open', rightRailOpen.value ? '1' : '0')
}
}
function goProfile() {
menuOpen.value = false
router.push('/profile')
@@ -171,7 +183,7 @@ async function logout() {
</script>
<template>
<div class="appShell" :class="{ 'appShell--preview': isPreviewMode, 'appShell--focus': isFocusWorkspace && !isPreviewMode }">
<div class="appShell" :class="{ 'appShell--preview': isPreviewMode, 'appShell--rightClosed': !rightRailOpen }">
<template v-if="isPreviewMode">
<main class="appMain appMain--preview">
<RouterView />
@@ -187,7 +199,7 @@ async function logout() {
</div>
</div>
<div class="appUserCard">
<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" />
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
@@ -196,13 +208,6 @@ async function logout() {
<div class="appUserCard__email">{{ accountEmail }}</div>
</div>
</button>
<div v-else class="appUserCard__guest" @click="$router.push('/login')">
<div class="appUserCard__avatar appUserCard__avatar--fallback">G</div>
<div class="appUserCard__meta">
<div class="appUserCard__name">로그인 필요</div>
<div class="appUserCard__email">개인 메뉴를 사용하려면 로그인하세요.</div>
</div>
</div>
<div v-if="menuOpen" class="appUserMenu">
<button class="appUserMenu__item" type="button" @click="goProfile">프로필</button>
<button class="appUserMenu__item" type="button" @click="logout">로그아웃</button>
@@ -243,11 +248,16 @@ async function logout() {
<main class="appMain">
<section class="workspace">
<header v-if="!isFocusWorkspace" class="workspaceHead">
<header class="workspaceHead">
<div>
<div class="workspaceHead__title">{{ routeMeta.title }}</div>
<div class="workspaceHead__subtitle">{{ routeMeta.subtitle }}</div>
</div>
<div class="workspaceHead__actions">
<button class="ghostIcon ghostIcon--workspace" type="button" :aria-pressed="rightRailOpen" @click="toggleRightRail">
{{ rightRailOpen ? '우측 패널 숨기기' : '우측 패널 보기' }}
</button>
</div>
</header>
<div class="workspaceBody">
<RouterView />
@@ -255,7 +265,12 @@ async function logout() {
</section>
</main>
<aside v-if="!isFocusWorkspace" class="rightRail">
<aside
v-if="!usesLocalRightRail"
class="rightRail"
:class="{ 'rightRail--closed': !rightRailOpen }"
:aria-hidden="!rightRailOpen"
>
<div class="rightRail__top">
<button class="ghostIcon" type="button" aria-label="상태"></button>
</div>
@@ -297,11 +312,12 @@ async function logout() {
.appShell {
min-height: 100vh;
display: grid;
grid-template-columns: 176px minmax(0, 1fr) 228px;
grid-template-columns: 248px minmax(0, 1fr) 320px;
background:
radial-gradient(circle at top left, rgba(255, 255, 255, 0.04), transparent 28%),
linear-gradient(180deg, #1a1a1a 0%, #121212 100%);
color: rgba(255, 255, 255, 0.92);
transition: grid-template-columns 220ms ease;
}
.appShell--preview {
@@ -311,15 +327,35 @@ async function logout() {
.leftRail,
.rightRail {
min-height: 100vh;
padding: 12px 10px;
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;
}
.rightRail {
border-right: 0;
border-left: 1px solid rgba(255, 255, 255, 0.08);
transition:
opacity 220ms ease,
transform 220ms ease,
padding 220ms ease,
border-color 220ms ease;
}
.appShell--rightClosed {
grid-template-columns: 248px minmax(0, 1fr) 0px;
}
.appShell--rightClosed .rightRail {
opacity: 0;
transform: translateX(18px);
pointer-events: none;
overflow: hidden;
padding-left: 0;
padding-right: 0;
border-left-color: transparent;
}
.leftRail__top,
@@ -331,8 +367,9 @@ async function logout() {
}
.ghostIcon {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0 10px;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
@@ -340,6 +377,17 @@ async function logout() {
cursor: pointer;
}
.ghostIcon--workspace {
min-width: 118px;
height: 36px;
padding: 0 14px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.88);
font-size: 12px;
font-weight: 800;
}
.brandBlock {
display: grid;
gap: 2px;
@@ -347,7 +395,7 @@ async function logout() {
}
.brandBlock__title {
font-size: 18px;
font-size: 21px;
font-weight: 900;
letter-spacing: -0.04em;
}
@@ -360,6 +408,7 @@ async function logout() {
.appUserCard {
position: relative;
margin-bottom: 14px;
min-height: 58px;
}
.appUserCard__button,
@@ -543,7 +592,7 @@ async function logout() {
.appMain {
min-width: 0;
padding: 14px 14px 22px;
padding: 14px 18px 22px;
box-sizing: border-box;
}
@@ -563,6 +612,13 @@ async function logout() {
gap: 16px;
}
.workspaceHead__actions {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.workspaceHead__title {
font-size: 28px;
font-weight: 900;
@@ -584,19 +640,6 @@ async function logout() {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.appShell--focus {
grid-template-columns: 176px minmax(0, 1fr);
}
.appShell--focus .workspaceBody {
min-height: calc(100vh - 92px);
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
.rightRail {
display: grid;
align-content: start;
@@ -728,7 +771,7 @@ async function logout() {
@media (max-width: 1280px) {
.appShell {
grid-template-columns: 160px minmax(0, 1fr);
grid-template-columns: 220px minmax(0, 1fr);
}
.rightRail {

View File

@@ -73,6 +73,28 @@ const featuredGames = computed(() =>
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'games') return '게임 관리'
if (activeTab.value === 'items') return '아이템 관리'
if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
}
return '회원 관리'
})
const activeTabDescription = computed(() => {
if (activeTab.value === 'games') {
return '홈 노출 순서, 게임 생성, 썸네일, 기본 아이템을 한 화면에서 정리합니다.'
}
if (activeTab.value === 'items') {
return '사용자 커스텀 이미지를 검색하고, 미사용 이미지를 정리하거나 템플릿으로 승격할 수 있어요.'
}
if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests'
? '사용자 요청 기반으로 새 템플릿 생성이나 템플릿 업데이트를 승인합니다.'
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
}
return '계정 정보, 권한, 비밀번호와 최근 활동을 함께 확인합니다.'
})
onMounted(async () => {
await auth.refresh()
@@ -869,14 +891,15 @@ async function saveFeaturedOrder() {
<div v-else-if="!isAdmin" class="warn"> 계정은 관리자 권한이 없어요.</div>
<template v-else>
<div class="tabs">
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
</div>
<div class="adminWorkspace">
<div class="adminMain">
<header class="adminHero">
<div class="adminHero__eyebrow">Admin Workspace</div>
<h2 class="adminHero__title">{{ activeTabTitle }}</h2>
<p class="adminHero__desc">{{ activeTabDescription }}</p>
</header>
<template v-if="activeTab === 'games'">
<template v-if="activeTab === 'games'">
<div class="panel">
<div class="sectionHeader">
<div>
@@ -927,33 +950,6 @@ async function saveFeaturedOrder() {
</div>
</div>
<div class="modeTabs">
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
등록된 게임 선택
</button>
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'new' }" @click="setGameMode('new')">
게임 추가
</button>
</div>
<div class="panel panel--compact">
<template v-if="gameMode === 'existing'">
<!-- <div class="panel__title">등록된 게임 선택</div> -->
<select v-model="selectedGameId" class="select" @change="loadGame">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
</select>
<!-- <div class="hint"> 영역은 게임 자체와 관리자 기본 아이템만 관리합니다. 여기서 아이템을 삭제해도 사용자 커스텀 이미지는 삭제되지 않아요.</div> -->
</template>
<template v-else>
<div class="panel__title"> 게임 정보 입력</div>
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
<input v-model="newGameName" class="input" placeholder="게임 이름" />
<button class="btn btn--primary" @click="createGame">게임 생성</button>
</template>
</div>
<div v-if="hasSelectedGame" class="panel">
<div class="detailHead">
<div>
@@ -1039,41 +1035,18 @@ async function saveFeaturedOrder() {
</div>
</div>
</div>
</template>
<template v-else-if="activeTab === 'items'">
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">사용자 커스텀 아이템 관리</div>
<div class="hint hint--tight">사용자가 업로드한 이미지를 파일명/라벨 기준으로 검색하고, 번에 50 또는 200개씩 페이지 형태로 확인할 있어요.</div>
<div v-else class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">{{ gameMode === 'existing' ? '게임을 선택하면 상세 관리가 열려요.' : '새 게임 정보를 입력한 뒤 생성해 주세요.' }}</div>
<div class="emptyState__desc">
{{ gameMode === 'existing' ? '우측 패널에서 등록된 게임을 선택하면 썸네일과 기본 아이템 관리 영역이 활성화됩니다.' : '새 게임을 만들면 바로 선택 상태로 전환되어 썸네일과 기본 아이템 추가를 이어서 진행할 수 있어요.' }}
</div>
</div>
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
</div>
<div class="toolbar">
<input v-model="customItemQuery" class="input toolbar__search" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
<button class="btn btn--ghost toolbar__button" @click="submitCustomItemSearch">검색</button>
<select :value="customItemLimit" class="select toolbar__select" @change="changeCustomItemLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option>
</select>
</div>
<div class="toolbar toolbar--secondary">
<select v-model="customItemTargetGameId" class="select toolbar__select">
<option value="">가져올 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
<label class="checkRow checkRow--toolbar">
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
<span>미사용 커스텀 이미지만 보기</span>
</label>
<button class="btn btn--danger toolbar__button" :disabled="!customItems.length" @click="removeUnusedCustomItems">
미사용 이미지 일괄 삭제
</button>
</div>
</template>
<template v-else-if="activeTab === 'items'">
<div class="panel">
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<article v-for="item in customItems" :key="item.id" class="customItemCard">
@@ -1101,25 +1074,15 @@ async function saveFeaturedOrder() {
<button class="btn btn--ghost" :disabled="customItemPage >= customItemPageCount" @click="moveCustomItemPage(1)">다음</button>
</div>
</div>
</template>
<template v-else-if="activeTab === 'tierlists'">
<div class="modeTabs modeTabs--admin">
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'requests' }" @click="setTierlistsMode('requests')">
템플릿 요청 관리
</button>
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'lists' }" @click="setTierlistsMode('lists')">
전체 티어표 관리
</button>
</div>
</template>
<template v-else-if="activeTab === 'tierlists'">
<div v-if="tierlistsMode === 'requests'" class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">사용자 템플릿 요청</div>
<div class="hint hint--tight">freeform 템플릿 등록 요청과 기존 게임 템플릿 업데이트 요청을 여기서 승인하거나 반려할 있어요. 반려한 요청은 대기 목록에서 바로 제외됩니다.</div>
</div>
<button class="btn btn--ghost" @click="refreshTemplateRequests">새로고침</button>
</div>
<div v-if="!templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
@@ -1166,21 +1129,6 @@ async function saveFeaturedOrder() {
<div class="panel__title">전체 티어표 관리</div>
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 게임 템플릿으로 만들 있어요. 여기는 요청 목록과 별개로 전체 저장 티어표를 보는 영역입니다.</div>
</div>
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
</div>
<div class="toolbar">
<input
v-model="adminTierListQuery"
class="input toolbar__search"
placeholder="제목, 작성자, 게임 이름 검색"
@keydown.enter.prevent="submitAdminTierListSearch"
/>
<button class="btn btn--ghost toolbar__button" @click="submitAdminTierListSearch">검색</button>
<select :value="adminTierListLimit" class="select toolbar__select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option>
</select>
</div>
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
@@ -1233,68 +1181,15 @@ async function saveFeaturedOrder() {
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
</div>
</div>
</template>
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 아이템 가져오기</div>
<div class="modalCard__desc">
"{{ importModalTierList?.title }}" 아이템 {{ importModalItemCount }}개를 어디로 가져올지 선택해주세요.
</div>
<div class="importModeTabs">
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'existing' }" @click="importModalMode = 'existing'">
기존 템플릿에 추가
</button>
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'new' }" @click="importModalMode = 'new'">
템플릿 만들기
</button>
</div>
<div v-if="importModalMode === 'existing'" class="modalCard__form">
<select v-model="importModalTargetGameId" class="select">
<option value="">기존 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div v-else class="modalCard__form">
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTierListImportModal">취소</button>
<button class="btn btn--primary" @click="confirmTierListImport">
{{ importModalMode === 'existing' ? '여기로 가져오기' : ' 템플릿 생성' }}
</button>
</div>
</div>
</div>
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div>
<iframe
v-if="previewTierList"
class="previewFrame"
:src="previewTierListUrl(previewTierList)"
title="티어표 미리보기"
/>
</div>
</div>
</template>
<template v-else>
<template v-else>
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">회원 관리</div>
<div class="hint hint--tight">이메일, 닉네임, 관리자 권한을 수정하고 비밀번호도 직접 초기화할 있어요.</div>
</div>
<button class="btn btn--ghost" @click="refreshUsers">새로고침</button>
</div>
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
@@ -1346,7 +1241,216 @@ async function saveFeaturedOrder() {
</article>
</div>
</div>
</template>
</template>
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 아이템 가져오기</div>
<div class="modalCard__desc">
"{{ importModalTierList?.title }}" 아이템 {{ importModalItemCount }}개를 어디로 가져올지 선택해주세요.
</div>
<div class="importModeTabs">
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'existing' }" @click="importModalMode = 'existing'">
기존 템플릿에 추가
</button>
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'new' }" @click="importModalMode = 'new'">
템플릿 만들기
</button>
</div>
<div v-if="importModalMode === 'existing'" class="modalCard__form">
<select v-model="importModalTargetGameId" class="select">
<option value="">기존 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
</div>
<div v-else class="modalCard__form">
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTierListImportModal">취소</button>
<button class="btn btn--primary" @click="confirmTierListImport">
{{ importModalMode === 'existing' ? '여기로 가져오기' : ' 템플릿 생성' }}
</button>
</div>
</div>
</div>
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
</div>
<iframe
v-if="previewTierList"
class="previewFrame"
:src="previewTierListUrl(previewTierList)"
title="티어표 미리보기"
/>
</div>
</div>
</div>
<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>
@@ -1357,6 +1461,92 @@ async function saveFeaturedOrder() {
display: grid;
gap: 16px;
}
.adminWorkspace {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
.adminMain {
min-width: 0;
display: grid;
gap: 14px;
}
.adminHero {
display: grid;
gap: 8px;
padding: 18px 20px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.adminHero__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.adminHero__title {
margin: 0;
font-size: 28px;
line-height: 1.05;
font-weight: 900;
letter-spacing: -0.04em;
}
.adminHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.66);
line-height: 1.6;
}
.adminSidebar {
position: sticky;
top: 14px;
align-self: start;
display: grid;
gap: 12px;
}
.adminSidebar__panel {
display: grid;
gap: 12px;
padding: 14px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(17, 17, 17, 0.9);
}
.adminSidebar__label {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.adminSidebar__tabs,
.adminSidebar__group,
.adminSidebar__actions,
.adminSidebar__stats {
display: grid;
gap: 10px;
}
.adminSidebar__groupTitle {
font-size: 13px;
font-weight: 800;
color: rgba(255, 255, 255, 0.84);
}
.sidebarStat {
display: grid;
gap: 4px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.sidebarStat__label {
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
}
.sidebarStat__value {
font-size: 14px;
font-weight: 900;
}
.card {
border: 0;
background: transparent;
@@ -1393,6 +1583,10 @@ async function saveFeaturedOrder() {
gap: 10px;
flex-wrap: wrap;
}
.modeTabs--stack {
display: grid;
gap: 8px;
}
.tab,
.modeTab {
padding: 10px 14px;
@@ -1408,16 +1602,39 @@ async function saveFeaturedOrder() {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.18);
}
.adminSidebar__tabs .tab,
.modeTabs--stack .modeTab {
width: 100%;
text-align: left;
}
.panel {
margin-top: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(48, 48, 48, 0.78);
border-radius: 18px;
padding: 16px;
}
.panel--empty {
min-height: 240px;
display: grid;
place-items: center;
}
.panel--compact {
max-width: 520px;
}
.emptyState {
max-width: 520px;
display: grid;
gap: 8px;
text-align: center;
}
.emptyState__title {
font-size: 18px;
font-weight: 900;
}
.emptyState__desc {
color: rgba(255, 255, 255, 0.66);
line-height: 1.6;
}
.featuredOrderPanel {
margin-top: 14px;
display: grid;
@@ -2198,10 +2415,20 @@ async function saveFeaturedOrder() {
align-items: center;
opacity: 0.88;
}
.checkRow--compact {
margin-top: 0;
}
.checkRow--toolbar {
margin-top: 0;
}
@media (max-width: 980px) {
.adminWorkspace {
grid-template-columns: 1fr;
}
.adminSidebar {
position: static;
order: -1;
}
.featuredOrderPanel,
.section--topGrid,
.toolbar,
@@ -2222,6 +2449,12 @@ async function saveFeaturedOrder() {
}
}
@media (max-width: 640px) {
.adminHero {
padding: 16px;
}
.adminHero__title {
font-size: 24px;
}
.thumbGrid,
.customItemGrid,
.userList {

View File

@@ -62,6 +62,7 @@ onMounted(loadFavorites)
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">Collection</div>
<h2 class="title"> 즐겨찾기</h2>
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div>
@@ -78,23 +79,23 @@ onMounted(loadFavorites)
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="tierList in favorites" :key="tierList.id" class="row">
<button class="row__body" @click="openTierList(tierList)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="row__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="row__thumbPlaceholder"></div>
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ tierList.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(tierList)" class="row__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<div class="boardCard__head">
<div class="boardCard__title">{{ tierList.title }}</div>
<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>by {{ displayNameOf(tierList) }}</span>
</div>
</div>
</button>
<div class="row__foot">
<div class="row__meta">
<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>
@@ -117,9 +118,15 @@ onMounted(loadFavorites)
align-items: flex-end;
flex-wrap: wrap;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 0;
font-size: 30px;
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
letter-spacing: -0.04em;
}
@@ -157,15 +164,16 @@ onMounted(loadFavorites)
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.row {
border-radius: 14px;
.boardCard {
border-radius: 18px;
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);
}
.row__body {
.boardCard__body {
border: 0;
background: transparent;
color: inherit;
@@ -175,60 +183,67 @@ onMounted(loadFavorites)
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
display: grid;
place-items: center;
}
.row__thumb,
.row__thumbPlaceholder {
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
}
.row__thumb {
.boardCard__thumb {
object-fit: cover;
}
.row__thumbPlaceholder {
.boardCard__thumbPlaceholder {
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
}
.row__head {
.boardCard__head {
padding: 14px 14px 0;
display: grid;
gap: 10px;
}
.row__title {
.boardCard__title {
font-weight: 800;
font-size: 18px;
}
.row__author {
.boardCard__author {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
}
.row__avatar {
.boardCard__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-weight: 900;
}
.row__foot {
.boardCard__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.row__meta {
.boardCard__meta {
display: grid;
gap: 4px;
opacity: 0.78;

View File

@@ -77,13 +77,17 @@ function submitSearch() {
</script>
<template>
<section class="head">
<div class="head__left">
<div class="kicker">Collection</div>
<h2 class="title">{{ gameName || gameId }}</h2>
<p class="desc"> 티어표를 만들거나, 다른 사람들이 올린 티어표를 카드형 목록으로 탐색할 있어요.</p>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Collection</div>
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
<p class="dashboardHero__desc"> 게임의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어요.</p>
</div>
<div class="head__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>
</div>
</section>
@@ -91,7 +95,10 @@ function submitSearch() {
<div v-if="error" class="error">{{ error }}</div>
<section class="panel">
<div class="panel__head">
<div class="panel__title">공개 티어표</div>
<div>
<div class="panel__title">공개 티어표</div>
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 있어요.</div>
</div>
<div class="searchBar">
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="searchBar__button" @click="submitSearch">검색</button>
@@ -99,23 +106,23 @@ function submitSearch() {
</div>
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in tierLists" :key="t.id" class="row">
<button class="row__body" @click="openTierList(t.id)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
<article v-for="t in tierLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<div class="boardCard__head">
<div class="boardCard__title">{{ t.title }}</div>
<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>by {{ displayNameOf(t) }}</span>
</div>
</div>
</button>
<div class="row__foot">
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
<div class="boardCard__foot">
<div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
</div>
@@ -126,44 +133,71 @@ function submitSearch() {
</template>
<style scoped>
.head {
.dashboardHero {
display: flex;
gap: 18px;
align-items: flex-end;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 4px 2px 18px;
}
.kicker {
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__right {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.dashboardHero__eyebrow {
font-size: 12px;
color: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.title {
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 30px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.desc {
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
max-width: 720px;
}
.dashboardStat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.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 {
padding: 10px 14px;
border-radius: 10px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.08);
color: rgba(255, 255, 255, 0.92);
padding: 12px 16px;
border-radius: 12px;
border: 1px solid rgba(77, 127, 233, 0.96);
background: rgba(77, 127, 233, 0.88);
color: #fff;
cursor: pointer;
font-weight: 700;
}
.primary:hover {
background: rgba(255, 255, 255, 0.12);
font-weight: 800;
}
.panel {
border: 1px solid rgba(255, 255, 255, 0.08);
/* border: 1px solid rgba(255, 255, 255, 0.08); */
background: transparent;
border-radius: 0;
padding: 0;
@@ -177,6 +211,12 @@ function submitSearch() {
}
.panel__title {
font-weight: 800;
font-size: 18px;
}
.panel__sub {
margin-top: 6px;
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
}
.panel__head {
display: flex;
@@ -201,8 +241,8 @@ function submitSearch() {
color: rgba(255, 255, 255, 0.92);
}
.searchBar__button {
padding: 10px 12px;
border-radius: 10px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
@@ -217,8 +257,8 @@ function submitSearch() {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.row {
border-radius: 14px;
.boardCard {
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
@@ -227,11 +267,12 @@ function submitSearch() {
align-content: start;
min-height: 168px;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.row:hover {
background: rgba(255, 255, 255, 0.05);
.boardCard:hover {
background: rgba(70, 70, 70, 0.96);
}
.row__body {
.boardCard__body {
text-align: left;
padding: 0;
border: 0;
@@ -242,35 +283,42 @@ function submitSearch() {
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
display: grid;
place-items: center;
}
.row__thumb {
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.row__thumbPlaceholder {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
}
.row__title {
.boardCard__title {
font-weight: 800;
min-width: 0;
font-size: 18px;
line-height: 1.35;
}
.row__head {
.boardCard__head {
padding: 14px 14px 0;
display: grid;
gap: 12px;
align-content: start;
}
.row__author {
.boardCard__author {
display: inline-flex;
gap: 8px;
align-items: center;
@@ -278,7 +326,7 @@ function submitSearch() {
opacity: 0.86;
flex: 0 0 auto;
}
.row__avatar {
.boardCard__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
@@ -286,17 +334,17 @@ function submitSearch() {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-weight: 900;
}
.row__meta {
.boardCard__meta {
opacity: 0.78;
font-size: 13px;
}
.row__foot {
.boardCard__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
@@ -323,6 +371,13 @@ function submitSearch() {
}
}
@media (max-width: 720px) {
.dashboardHero__right {
width: 100%;
}
.dashboardStat,
.primary {
width: 100%;
}
.list {
grid-template-columns: 1fr;
}

View File

@@ -41,32 +41,43 @@ function thumbUrl(g) {
</script>
<template>
<section class="topBar">
<div class="topBar__copy">
<h1 class="topBar__title">Main Title</h1>
<p class="topBar__desc">게임 선택과 커스텀 티어표 진입을 하나의 대시보드처럼 정리했습니다.</p>
<section class="dashboardHero">
<div class="dashboardHero__copy">
<div class="dashboardHero__eyebrow">Workspace</div>
<h1 class="dashboardHero__title">Game Library</h1>
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
</div>
<div class="toolbar">
<button class="toolbar__ghost" @click="goFreeform">Toggle Filter</button>
<button class="toolbar__select" @click="goFreeform">Select Filter</button>
<div class="dashboardToolbar">
<div class="dashboardToolbar__stat">
<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>
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '+ 커스텀 티어표 만들기' : '+ 로그인 커스텀 티어표 만들기' }}</button>
</div>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="grid">
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
<div class="thumbWrap">
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="thumbFallback">{{ g.name[0] }}</div>
<section class="libraryGrid">
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
<div class="libraryCard__thumbWrap">
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ g.name }}</div>
<div class="libraryCard__meta">
<span class="libraryCard__metaDot"></span>
<span>{{ g.id }}</span>
</div>
</div>
<div class="card__title">{{ g.name }}</div>
</button>
</section>
</template>
<style scoped>
.topBar {
.dashboardHero {
display: flex;
gap: 18px;
justify-content: space-between;
@@ -75,32 +86,57 @@ function thumbUrl(g) {
margin-top: 2px;
margin-bottom: 18px;
}
.topBar__copy {
.dashboardHero__copy {
display: grid;
gap: 8px;
}
.topBar__title {
.dashboardHero__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.dashboardHero__title {
margin: 0;
font-size: 32px;
font-size: 34px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.topBar__desc {
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
line-height: 1.5;
max-width: 720px;
}
.toolbar {
.dashboardToolbar {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.toolbar__ghost,
.toolbar__select,
.dashboardToolbar__stat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.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: 10px 14px;
border-radius: 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.84);
@@ -108,9 +144,11 @@ function thumbUrl(g) {
cursor: pointer;
}
.customTierBtn {
background: rgba(255, 255, 255, 0.08);
background: rgba(77, 127, 233, 0.88);
border-color: rgba(77, 127, 233, 0.96);
color: #fff;
}
.grid {
.libraryGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
@@ -123,69 +161,87 @@ function thumbUrl(g) {
background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92);
}
.card {
.libraryCard {
text-align: left;
padding: 12px;
border-radius: 14px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
display: grid;
gap: 12px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.card:hover {
background: rgba(72, 72, 72, 0.92);
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
}
.thumbWrap {
.libraryCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: #555;
overflow: hidden;
display: grid;
place-items: center;
}
.thumb {
.libraryCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbFallback {
.libraryCard__thumbFallback {
font-size: 13px;
font-weight: 700;
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
}
.card__title {
.libraryCard__body {
display: grid;
gap: 8px;
}
.libraryCard__title {
font-weight: 800;
letter-spacing: -0.02em;
font-size: 15px;
font-size: 16px;
}
.libraryCard__meta {
display: inline-flex;
gap: 8px;
align-items: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
.libraryCard__metaDot {
width: 10px;
height: 10px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.9);
}
@media (max-width: 1200px) {
.grid {
.libraryGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.grid {
.libraryGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.topBar {
.dashboardHero {
align-items: stretch;
}
.toolbar {
.dashboardToolbar {
width: 100%;
}
.toolbar__ghost,
.toolbar__select,
.dashboardToolbar__ghost,
.dashboardToolbar__stat,
.customTierBtn {
flex: 1 1 100%;
}
.grid {
.libraryGrid {
grid-template-columns: 1fr;
}
}

View File

@@ -72,25 +72,35 @@ async function removeList(t) {
<template>
<section class="wrap">
<h2 class="title"> 티어표</h2>
<header class="head">
<div>
<div class="head__eyebrow">Library</div>
<h2 class="title"> 티어표</h2>
<div class="desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
<div class="head__stat">
<span class="head__statLabel">Saved Lists</span>
<strong class="head__statValue">{{ myLists.length }}</strong>
</div>
</header>
<div class="card">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="row">
<button class="row__body" @click="openList(t)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<div class="boardCard__head">
<div class="boardCard__title">{{ t.title }}</div>
<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>by {{ displayNameOf(t) }}</span>
</div>
</div>
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
<div class="boardCard__meta">{{ fmt(t.updatedAt) }}</div>
</button>
<button class="link link--danger" @click="removeList(t)">삭제</button>
</article>
@@ -103,12 +113,48 @@ async function removeList(t) {
.wrap {
padding: 4px 2px;
}
.head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 18px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 0 0 18px;
font-size: 30px;
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.desc {
color: rgba(255, 255, 255, 0.58);
}
.head__stat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 10px 14px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
}
.head__statLabel {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.head__statValue {
font-size: 18px;
font-weight: 900;
}
.card {
border: 0;
background: transparent;
@@ -132,16 +178,17 @@ async function removeList(t) {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 18px;
}
.row {
.boardCard {
display: grid;
gap: 10px;
border-radius: 14px;
border-radius: 18px;
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);
}
.row__body {
.boardCard__body {
flex: 1 1 auto;
min-width: 0;
text-align: left;
@@ -153,27 +200,34 @@ async function removeList(t) {
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: #555;
display: grid;
place-items: center;
}
.row__thumb {
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.row__thumbPlaceholder {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
}
.row__title {
.boardCard__title {
font-weight: 900;
min-width: 0;
}
.row__head {
.boardCard__head {
padding: 0 14px;
display: flex;
gap: 12px;
@@ -181,14 +235,14 @@ async function removeList(t) {
justify-content: space-between;
flex-wrap: wrap;
}
.row__author {
.boardCard__author {
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
opacity: 0.84;
}
.row__avatar {
.boardCard__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
@@ -196,13 +250,13 @@ async function removeList(t) {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-weight: 900;
}
.row__meta {
.boardCard__meta {
padding: 0 14px;
margin-top: 6px;
opacity: 0.76;
@@ -224,6 +278,9 @@ async function removeList(t) {
}
}
@media (max-width: 720px) {
.head__stat {
width: 100%;
}
.list {
grid-template-columns: 1fr;
}

View File

@@ -629,77 +629,6 @@ onUnmounted(() => {
</section>
<template v-else>
<section class="head">
<div class="heroCard">
<div class="heroCard__main">
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
<input
v-model="description"
class="descInput"
placeholder="설명(선택): 이 티어표의 기준/룰"
:readonly="!canEdit"
/>
<div class="hint">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b> 누르세요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
</div>
</div>
<div class="heroCard__side">
<div class="thumbComposer">
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="thumbComposer__header">
<div class="thumbComposer__eyebrow">대표 썸네일</div>
<div class="thumbComposer__caption">목록 카드 상단에 표시됩니다.</div>
</div>
<div class="thumbComposer__preview">
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="thumbComposer__empty">썸네일 없음</div>
</div>
<div v-if="canEdit" class="thumbComposer__actions">
<button class="btn btn--ghost thumbComposer__button" @click="openThumbnailFile">썸네일 선택</button>
<button class="btn btn--danger thumbComposer__button" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">제거</button>
</div>
</div>
</div>
</div>
<div class="actions">
<div class="actions__left">
<button class="btn btn--download" @click="downloadImage">이미지 다운로드</button>
<button v-if="canEdit && !isNewTierList" class="btn btn--danger" @click="removeTierList">삭제</button>
</div>
<div class="actions__right">
<button v-if="canFavorite" class="btn btn--ghost" :disabled="isFavoriteBusy" @click="toggleFavorite">
{{ isFavorited ? ' 즐겨찾기' : ' 즐겨찾기' }} {{ favoriteCount }}
</button>
<button
v-if="canRequestTemplateCreate"
class="btn btn--ghost"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="btn btn--ghost"
:disabled="isRequestingTemplate"
@click="requestTemplate('update')"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button>
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>{{ isPublic ? '공개 ON' : '공개 OFF' }}</span>
</label>
<button v-if="canEdit" class="btn btn--save" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
</div>
</section>
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
@@ -741,7 +670,22 @@ onUnmounted(() => {
</div>
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
<div ref="boardEl" class="board">
<div class="editorMain">
<section class="head">
<div class="editorMain__headCopy">
<div class="editorMain__title">{{ gameName || gameId }}</div>
<div class="editorMain__subtitle">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
</div>
</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>
@@ -765,91 +709,156 @@ onUnmounted(() => {
<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 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>
<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>
</div>
<div v-if="canEdit && customItems.length" class="customItemEditor">
<div class="customItemEditor__title">추가한 커스텀 아이템 이름 정리</div>
<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
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>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
<aside class="editorSidebar">
<div class="editorSidebar__section">
<div class="editorSidebar__label">Title</div>
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
</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="editorSidebar__section">
<div class="editorSidebar__label">Desc</div>
<textarea
v-model="description"
class="editorSidebar__textarea"
placeholder="Description Text"
:readonly="!canEdit"
></textarea>
</div>
<div class="editorSidebar__section">
<div class="editorSidebar__label">대표 썸네일</div>
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="editorSidebar__thumbFrame">
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
</div>
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
</div>
<div v-if="canEdit && customItems.length" class="customItemEditor">
<div class="customItemEditor__title">추가한 커스텀 아이템 이름 정리</div>
<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 class="editorSidebar__section">
<button v-if="canFavorite" class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
<span> 즐겨찾기</span>
<span>{{ favoriteCount }}</span>
</button>
</div>
<div class="editorSidebar__section editorSidebar__section--footer">
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>공개</span>
</label>
<div class="editorSidebar__actionGrid">
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<button v-if="canEdit && !isNewTierList" class="btn btn--danger editorSidebar__button" @click="removeTierList">삭제</button>
<button
v-if="canRequestTemplateCreate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="requestTemplate('update')"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</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>
</aside>
</section>
</template>
</template>
@@ -857,8 +866,23 @@ onUnmounted(() => {
<style scoped>
.head {
display: grid;
gap: 16px;
padding: 2px 2px 16px;
gap: 8px;
padding: 2px 2px 8px;
}
.editorMain {
min-width: 0;
display: grid;
gap: 14px;
}
.editorMain__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.04em;
}
.editorMain__subtitle {
color: rgba(255, 255, 255, 0.58);
font-size: 13px;
line-height: 1.5;
}
.previewOnly {
min-height: 100vh;
@@ -936,128 +960,6 @@ onUnmounted(() => {
.previewOnly__poolItem {
display: inline-flex;
}
.heroCard {
display: grid;
grid-template-columns: minmax(0, 1.65fr) minmax(260px, 320px);
gap: 16px;
align-items: stretch;
}
.heroCard__main,
.heroCard__side {
min-width: 0;
}
.heroCard__main {
display: grid;
gap: 12px;
}
.heroCard__side {
display: flex;
}
.titleInput {
width: 100%;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04));
color: rgba(255, 255, 255, 0.92);
outline: none;
box-sizing: border-box;
}
.descInput {
width: 100%;
min-height: 92px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
outline: none;
box-sizing: border-box;
}
.hint {
opacity: 0.78;
font-size: 13px;
line-height: 1.6;
padding: 0 2px;
}
.titleNotice {
font-size: 13px;
line-height: 1.5;
color: rgba(251, 191, 36, 0.94);
padding: 0 2px;
}
.thumbComposer {
display: grid;
gap: 10px;
width: 100%;
padding: 16px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.12);
background:
radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 46%),
rgba(255, 255, 255, 0.04);
box-sizing: border-box;
}
.thumbComposer__header {
display: grid;
gap: 4px;
}
.thumbComposer__eyebrow {
font-size: 13px;
font-weight: 900;
letter-spacing: 0.02em;
}
.thumbComposer__caption {
font-size: 12px;
opacity: 0.68;
}
.thumbComposer__preview {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(11, 18, 32, 0.78);
}
.thumbComposer__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbComposer__empty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.62);
font-size: 13px;
}
.thumbComposer__actions {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.thumbComposer__button {
width: 100%;
margin-top: 0;
}
.actions {
display: flex;
gap: 14px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.actions__left,
.actions__right {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.toggle {
display: inline-flex;
gap: 8px;
@@ -1123,7 +1025,7 @@ onUnmounted(() => {
}
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 284px;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 16px;
align-items: start;
}
@@ -1424,6 +1326,101 @@ onUnmounted(() => {
border-radius: 18px;
padding: 12px;
}
.editorSidebar {
display: grid;
align-content: start;
gap: 14px;
padding: 14px 12px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(18, 18, 18, 0.96);
position: sticky;
top: 14px;
}
.editorSidebar__section {
display: grid;
gap: 10px;
}
.editorSidebar__label {
font-size: 13px;
font-weight: 800;
color: rgba(255, 255, 255, 0.82);
}
.editorSidebar__input,
.editorSidebar__textarea {
width: 100%;
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.92);
padding: 11px 12px;
outline: none;
resize: vertical;
}
.editorSidebar__textarea {
min-height: 92px;
}
.editorSidebar__hint {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.56);
}
.editorSidebar__hint--warn {
color: rgba(251, 191, 36, 0.92);
}
.editorSidebar__thumbFrame {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #4c4c4c;
}
.editorSidebar__thumbImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.editorSidebar__thumbEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.36);
font-size: 13px;
}
.editorSidebar__button {
width: 100%;
margin-top: 0;
}
.editorSidebar__fileName {
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
word-break: break-word;
}
.editorSidebar__favorite {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 12px 0 0;
border: 0;
border-top: 1px solid rgba(255, 255, 255, 0.08);
background: transparent;
color: rgba(255, 255, 255, 0.9);
font-weight: 800;
cursor: pointer;
}
.editorSidebar__section--footer {
padding-top: 12px;
border-top: 1px solid rgba(255, 255, 255, 0.08);
}
.editorSidebar__actionGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.sidebar__title {
font-weight: 900;
margin-bottom: 6px;
@@ -1537,26 +1534,14 @@ onUnmounted(() => {
.layout {
grid-template-columns: 1fr;
}
.actions {
justify-content: stretch;
}
.actions__left,
.actions__right {
width: 100%;
}
.actions__right {
justify-content: flex-end;
}
.row {
grid-template-columns: 150px 1fr;
}
.thumbComposer {
padding: 14px;
border-radius: 18px;
.editorSidebar {
position: static;
}
.titleInput,
.descInput {
border-radius: 16px;
.editorSidebar__actionGrid {
grid-template-columns: 1fr;
}
.requestChecklist__item {
grid-template-columns: 1fr;