Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b15398761b | |||
| 2bee78ba5e |
@@ -920,6 +920,20 @@ function uniqueTierListItems(poolItems) {
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
function getAutoThumbnailSrc(groups = [], pool = []) {
|
||||
const itemMap = new Map((pool || []).filter((item) => item?.id && item?.src).map((item) => [item.id, item]))
|
||||
|
||||
for (const group of groups || []) {
|
||||
for (const itemId of group?.itemIds || []) {
|
||||
const item = itemMap.get(itemId)
|
||||
if (item?.src) return item.src
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackItem = (pool || []).find((item) => item?.src)
|
||||
return fallbackItem?.src || ''
|
||||
}
|
||||
|
||||
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
@@ -1203,6 +1217,7 @@ async function deleteCustomItems(ids) {
|
||||
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
|
||||
const existing = id ? await findTierListById(id, authorId) : null
|
||||
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
||||
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
|
||||
|
||||
if (existing) {
|
||||
await query(
|
||||
@@ -1211,7 +1226,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
||||
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`,
|
||||
[title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
|
||||
)
|
||||
return findTierListById(existing.id, authorId)
|
||||
}
|
||||
@@ -1224,7 +1239,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`,
|
||||
[id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
|
||||
)
|
||||
return findTierListById(id, authorId)
|
||||
}
|
||||
|
||||
156
docs/history.md
156
docs/history.md
@@ -1,5 +1,15 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-30 v1.2.0
|
||||
- 피그마 시안은 단순 컴포넌트 교체보다 앱 전체의 정보 구조를 바꾸는 성격이 강하므로, 우선 공통 앱 셸부터 `좌측 내비 / 중앙 워크스페이스 / 우측 컨텍스트 패널`로 올리는 단계적 리디자인이 더 안전하다고 판단했다.
|
||||
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 목록 중심 화면은 시안 톤을 먼저 맞추고, 에디터와 관리자처럼 상호작용이 무거운 화면은 같은 셸 안에서 후속 이관하는 방식이 리스크가 적다고 정리했다.
|
||||
- 이번 리디자인은 사용자 체감 변화가 큰 편이므로, 버전도 기존 `0.1.x`가 아니라 `v1.2.0`으로 점프해 기록하는 편이 더 자연스럽다고 판단했다.
|
||||
|
||||
## 2026-03-27 v0.1.52
|
||||
- 관리자 확인용 완성본은 사이트 전체가 아니라 보드만 보여주는 preview 전용 모드가 더 적합하다고 판단했다.
|
||||
- 티어표 썸네일은 비어 있는 것보다 자동 기본값이 있는 편이 낫다고 보고, 사용자가 직접 지정하지 않으면 티어표 아이템 중 대표 이미지를 자동 썸네일로 채우기로 결정했다.
|
||||
- 이력 문서는 날짜 역순이 깨지면 추적이 어렵기 때문에, 오래된 2026-03-19 항목을 최신 2026-03-26/27 항목 뒤로 다시 정렬해 흐름을 복구했다.
|
||||
|
||||
## 2026-03-27 v0.1.51
|
||||
- 관리자 확인은 편집 화면으로 이동하는 것보다 관리 페이지 안에서 닫고 돌아올 수 있는 미리보기 모달이 더 적합하다고 판단했다.
|
||||
- 템플릿 등록 요청은 실제로는 배치 상태보다 제목 식별성이 더 중요하므로, `보드 비움` 조건은 제거하고 제목 직접 입력 중심으로 단순화하기로 결정했다.
|
||||
@@ -59,79 +69,6 @@
|
||||
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
|
||||
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
|
||||
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
|
||||
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
|
||||
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
|
||||
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
|
||||
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
|
||||
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
|
||||
- 로컬 실행 편의를 위해 `docker-compose.yml`에 `mariadb`와 `phpMyAdmin` 서비스를 추가했다.
|
||||
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
|
||||
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
|
||||
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
|
||||
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
|
||||
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
|
||||
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
|
||||
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
|
||||
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
|
||||
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
|
||||
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
|
||||
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
|
||||
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
|
||||
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
|
||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
|
||||
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.19
|
||||
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
|
||||
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
||||
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
||||
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
||||
|
||||
## 2026-03-26 v0.1.21
|
||||
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
||||
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
||||
@@ -196,3 +133,76 @@
|
||||
## 2026-03-26 v0.1.37
|
||||
- 운영 포트 충돌을 피하기 위해 프로덕션 외부 포트는 `frontend=18080`, `phpMyAdmin=18081`로 고정하고, 리버스 프록시 문서도 그 기준으로 맞추기로 했다.
|
||||
- 인증 장애 원인을 찾기 위한 디버그 로그는 문제 해결 후 제거하고, 실제 운영에는 세션 저장 보강과 프록시 헤더 설정만 유지하는 편이 낫다고 판단했다.
|
||||
|
||||
## 2026-03-19 v0.1.19
|
||||
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
|
||||
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제`와 `공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
|
||||
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
|
||||
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.17
|
||||
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
|
||||
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.13
|
||||
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
|
||||
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
|
||||
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
|
||||
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.12
|
||||
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
|
||||
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
|
||||
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
|
||||
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.11
|
||||
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
|
||||
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
|
||||
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
|
||||
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
|
||||
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.9
|
||||
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
|
||||
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.8
|
||||
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
|
||||
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
|
||||
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
|
||||
|
||||
## 2026-03-19 v0.1.7
|
||||
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
|
||||
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
|
||||
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.6
|
||||
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.5
|
||||
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
|
||||
- 로컬 실행 편의를 위해 `docker-compose.yml`에 `mariadb`와 `phpMyAdmin` 서비스를 추가했다.
|
||||
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
|
||||
|
||||
## 2026-03-19 v0.1.4
|
||||
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
|
||||
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
|
||||
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
|
||||
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
|
||||
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.3
|
||||
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
|
||||
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
|
||||
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
|
||||
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
|
||||
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
|
||||
|
||||
## 2026-03-19
|
||||
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
|
||||
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
|
||||
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
## 공통 레이아웃
|
||||
- 앱 셸 파일: `frontend/src/App.vue`
|
||||
- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
|
||||
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
|
||||
|
||||
## 백엔드 진입점
|
||||
- 서버 엔트리: `backend/index.js`
|
||||
|
||||
14
docs/spec.md
14
docs/spec.md
@@ -9,6 +9,7 @@
|
||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||
|
||||
## 데이터 저장 구조
|
||||
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
||||
@@ -19,6 +20,15 @@
|
||||
- 커스텀 아이템: `backend/uploads/custom/`
|
||||
- 시드 이미지: `backend/uploads/seeds/`
|
||||
|
||||
## 화면 구조
|
||||
- 좌측 패널
|
||||
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다.
|
||||
- 중앙 워크스페이스
|
||||
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||
- 우측 패널
|
||||
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||
|
||||
## DB 스키마
|
||||
- `users`
|
||||
- `id`: string
|
||||
@@ -52,6 +62,7 @@
|
||||
- `gameId`: string
|
||||
- `title`: string
|
||||
- `thumbnailSrc`: string
|
||||
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
|
||||
- `description`: string
|
||||
- `isPublic`: boolean
|
||||
- `groups`: `{ id, name, itemIds[] }[]`
|
||||
@@ -124,7 +135,7 @@
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 모달 미리보기로 연다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||
@@ -153,6 +164,7 @@
|
||||
- 신규 티어표를 막 저장한 직후에도, 템플릿 요청은 새로 발급된 실제 티어표 ID를 기준으로 이어서 처리한다.
|
||||
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
|
||||
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면까지만 1차 적용된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
||||
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
|
||||
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-30 v1.2.0
|
||||
- **피그마 기반 공통 앱 셸 1차 적용**: 상단 헤더 중심 구조를 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 앱 셸로 재구성하고, 데스크톱 기준의 어두운 대시보드형 톤으로 전환
|
||||
- **홈/목록 화면 카드 UI 리디자인**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드 그리드와 툴바를 시안에 맞춰 더 조밀한 대시보드 형태로 재배치
|
||||
- **전역 스타일 리셋 정리**: 기존 Vite 기본 스타일 흔적을 제거하고, 서비스 전용 다크 테마와 입력/셀렉트/버튼 기본값을 새 레이아웃 기준으로 통일
|
||||
|
||||
## 2026-03-27 v0.1.52
|
||||
- **관리자 완성본 프리뷰 전용화**: 관리자 모달의 완성본 확인은 이제 전용 preview 모드로 열려 전역 헤더와 편집/탐색 UI 없이 보드만 깔끔하게 확인할 수 있도록 정리
|
||||
- **티어표 기본 썸네일 자동 생성**: 사용자가 별도 썸네일을 지정하지 않아도 저장 시 티어표에 포함된 아이템 중 대표 이미지를 골라 기본 썸네일을 자동으로 채우도록 보강
|
||||
- **이력 문서 날짜순 재정리**: `docs/history.md`를 날짜 역순 기준으로 다시 정렬해 오래된 2026-03-19 항목이 중간에 끼어 보이던 흐름을 바로잡음
|
||||
|
||||
## 2026-03-27 v0.1.51
|
||||
- **관리자 티어표 미리보기 모달 추가**: 템플릿 요청 관리와 전체 티어표 관리에서 `원본 보기 / 완성본 보기`를 눌러도 관리자 화면을 벗어나지 않도록, 확인용 미리보기를 모달 iframe으로 열도록 변경
|
||||
- **템플릿 등록 요청 조건 단순화**: freeform 템플릿 등록 요청은 더 이상 `보드 비움`을 요구하지 않고, `제목 직접 입력 + 커스텀 아이템 존재` 조건 중심으로 단순화
|
||||
|
||||
@@ -9,14 +9,123 @@ const route = useRoute()
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const { toasts, dismissToast } = useToast()
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
const avatarUrl = computed(() => {
|
||||
if (!auth.user?.avatarSrc) return ''
|
||||
return toApiUrl(auth.user.avatarSrc)
|
||||
})
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
const isPreviewMode = computed(() => route.query.preview === '1')
|
||||
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
|
||||
const accountName = computed(() => {
|
||||
const nickname = (auth.user?.nickname || '').trim()
|
||||
if (nickname) return nickname
|
||||
const email = (auth.user?.email || '').trim()
|
||||
if (email) return email.split('@')[0] || email
|
||||
return 'Guest'
|
||||
})
|
||||
const accountEmail = computed(() => (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.')
|
||||
const leftNavItems = computed(() => {
|
||||
const items = [
|
||||
{ key: 'home', label: 'Games', path: '/', initials: 'GM' },
|
||||
{ key: 'me', label: '내 리스트', path: '/me', initials: 'ME', requiresAuth: true },
|
||||
{ key: 'favorites', label: '즐겨찾기', path: '/favorites', initials: 'FV', requiresAuth: true },
|
||||
{ key: 'profile', label: 'Settings', path: '/profile', initials: 'ST', requiresAuth: true },
|
||||
]
|
||||
if (isAdmin.value) {
|
||||
items.push({ key: 'admin', label: 'Admin', path: '/admin', initials: 'AD' })
|
||||
}
|
||||
return items.filter((item) => !item.requiresAuth || auth.user)
|
||||
})
|
||||
const routeMeta = computed(() => {
|
||||
if (route.name === 'home') {
|
||||
return {
|
||||
title: 'Main Title',
|
||||
subtitle: '게임 선택 및 커스텀 티어표 진입',
|
||||
contextTitle: '빠른 시작',
|
||||
contextText: auth.user ? '커스텀 티어표를 만들거나 원하는 게임을 바로 선택할 수 있어요.' : '로그인하면 커스텀 티어표 생성과 개인 목록 관리가 열립니다.',
|
||||
actionLabel: auth.user ? '커스텀 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
router.push(auth.user ? '/editor/freeform/new' : '/login')
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'gameHub') {
|
||||
return {
|
||||
title: 'Tier Lists',
|
||||
subtitle: '게임별 공개 티어표 목록',
|
||||
contextTitle: '작성 작업',
|
||||
contextText: auth.user ? '이 게임의 새 티어표를 만들거나 기존 공개 티어표를 확인할 수 있어요.' : '로그인 후 새 티어표를 만들 수 있어요.',
|
||||
actionLabel: auth.user ? '새 티어표 만들기' : '로그인하러 가기',
|
||||
action: () => {
|
||||
const target = `/editor/${route.params.gameId}/new`
|
||||
router.push(auth.user ? target : `/login?redirect=${target}`)
|
||||
},
|
||||
}
|
||||
}
|
||||
if (route.name === 'editEditor' || route.name === 'newEditor') {
|
||||
return {
|
||||
title: 'Deck Builder',
|
||||
subtitle: '티어표 편집 및 공유',
|
||||
contextTitle: '편집 패널',
|
||||
contextText: '현재 편집 옵션은 중앙 화면 안에 유지되어 있습니다. 다음 단계에서 우측 패널로 정리해갈게요.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'admin') {
|
||||
return {
|
||||
title: 'Admin Workspace',
|
||||
subtitle: '게임·아이템·회원 관리',
|
||||
contextTitle: '운영 노트',
|
||||
contextText: '관리자 화면은 기능이 많아 우선 공통 셸 톤을 맞췄고, 세부 패널은 다음 단계에서 시안 방식으로 더 세밀하게 나눌 예정입니다.',
|
||||
actionLabel: '게임 목록으로',
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'me') {
|
||||
return {
|
||||
title: 'My Lists',
|
||||
subtitle: '내가 저장한 티어표',
|
||||
contextTitle: '작성 이력',
|
||||
contextText: '최근 저장한 티어표를 열람하고 정리할 수 있어요.',
|
||||
actionLabel: '즐겨찾기 보기',
|
||||
action: () => router.push('/favorites'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'favorites') {
|
||||
return {
|
||||
title: 'Favorites',
|
||||
subtitle: '마음에 드는 티어표 모음',
|
||||
contextTitle: '정리 도구',
|
||||
contextText: '정렬과 검색으로 원하는 티어표를 빠르게 다시 찾을 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
}
|
||||
}
|
||||
if (route.name === 'profile') {
|
||||
return {
|
||||
title: 'Profile',
|
||||
subtitle: '프로필 및 계정 설정',
|
||||
contextTitle: '계정 관리',
|
||||
contextText: '아바타와 닉네임을 관리하고 표시 정보를 확인할 수 있어요.',
|
||||
actionLabel: '내 티어표 보기',
|
||||
action: () => router.push('/me'),
|
||||
}
|
||||
}
|
||||
return {
|
||||
title: 'Tier Maker',
|
||||
subtitle: 'by zenn',
|
||||
contextTitle: 'Workspace',
|
||||
contextText: '현재 화면에 맞는 도구와 안내를 여기에 배치할 수 있습니다.',
|
||||
actionLabel: '홈으로',
|
||||
action: () => router.push('/'),
|
||||
}
|
||||
})
|
||||
const favoriteLinks = computed(() => [
|
||||
{ label: 'Games', path: '/' },
|
||||
...(auth.user ? [{ label: 'Favorites', path: '/favorites' }] : []),
|
||||
...(auth.user ? [{ label: 'My Lists', path: '/me' }] : []),
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
document.addEventListener('click', onDocumentClick)
|
||||
@@ -33,16 +142,21 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value
|
||||
}
|
||||
|
||||
function onDocumentClick(event) {
|
||||
if (!event.target.closest('.user')) {
|
||||
if (!event.target.closest('.appUserCard')) {
|
||||
menuOpen.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function isRouteActive(path) {
|
||||
if (path === '/') return route.path === '/'
|
||||
return route.path.startsWith(path)
|
||||
}
|
||||
|
||||
function toggleMenu() {
|
||||
menuOpen.value = !menuOpen.value
|
||||
}
|
||||
|
||||
function goProfile() {
|
||||
menuOpen.value = false
|
||||
router.push('/profile')
|
||||
@@ -56,169 +170,496 @@ async function logout() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="app-shell">
|
||||
<header class="app-header">
|
||||
<div class="brand" @click="$router.push('/')">
|
||||
<span class="brand__title">Tier Maker</span>
|
||||
<span class="brand__sub">by zenn</span>
|
||||
</div>
|
||||
<nav class="nav">
|
||||
<RouterLink to="/" class="nav__link">게임</RouterLink>
|
||||
<RouterLink to="/me" class="nav__link">내 티어표</RouterLink>
|
||||
<RouterLink v-if="auth.user" to="/favorites" class="nav__link">즐겨찾기</RouterLink>
|
||||
<RouterLink v-if="isAdmin" to="/admin" class="nav__link">관리자</RouterLink>
|
||||
|
||||
<RouterLink v-if="!auth.user" to="/login" class="nav__link">로그인</RouterLink>
|
||||
<div v-else class="user">
|
||||
<button class="avatarBtn" @click.stop="toggleMenu" :title="auth.user.email">
|
||||
<img v-if="avatarUrl" :src="avatarUrl" class="avatarImg" alt="avatar" />
|
||||
<div v-else class="avatarFallback">{{ (auth.user.email || 'U')[0].toUpperCase() }}</div>
|
||||
</button>
|
||||
<div v-if="menuOpen" class="menu">
|
||||
<button class="menuItem" @click="goProfile">프로필</button>
|
||||
<button class="menuItem" @click="logout">로그아웃</button>
|
||||
<div class="appShell" :class="{ 'appShell--preview': isPreviewMode }">
|
||||
<template v-if="isPreviewMode">
|
||||
<main class="appMain appMain--preview">
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
<template v-else>
|
||||
<aside class="leftRail">
|
||||
<div class="leftRail__top">
|
||||
<button class="ghostIcon" type="button" aria-label="메뉴">▥</button>
|
||||
<div class="brandBlock" @click="$router.push('/')">
|
||||
<div class="brandBlock__title">Tier Maker</div>
|
||||
<div class="brandBlock__sub">by zenn</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
</header>
|
||||
<main class="app-main">
|
||||
<RouterView />
|
||||
</main>
|
||||
<div class="toastStack" aria-live="polite" aria-atomic="true">
|
||||
|
||||
<div 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>
|
||||
<div class="appUserCard__meta">
|
||||
<div class="appUserCard__name">{{ accountName }}</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="searchStub" type="button" @click="$router.push('/favorites')">
|
||||
<span class="searchStub__icon">⌕</span>
|
||||
<span>Search</span>
|
||||
</button>
|
||||
|
||||
<nav class="leftNav">
|
||||
<RouterLink
|
||||
v-for="item in leftNavItems"
|
||||
:key="item.key"
|
||||
:to="item.path"
|
||||
class="leftNav__item"
|
||||
:class="{ 'leftNav__item--active': isRouteActive(item.path) }"
|
||||
>
|
||||
<span class="leftNav__glyph">{{ item.initials }}</span>
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</nav>
|
||||
|
||||
<div class="leftRail__section">
|
||||
<div class="leftRail__sectionTitle">Favorites</div>
|
||||
<RouterLink v-for="item in favoriteLinks" :key="item.path" :to="item.path" class="favoriteLink">
|
||||
<span class="favoriteLink__dot"></span>
|
||||
<span>{{ item.label }}</span>
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
<div class="leftRail__bottom">
|
||||
<RouterLink v-if="isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
|
||||
<RouterLink v-else-if="!auth.user" to="/login" class="adminButton">로그인</RouterLink>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="appMain">
|
||||
<section class="workspace">
|
||||
<header class="workspaceHead">
|
||||
<div>
|
||||
<div class="workspaceHead__title">{{ routeMeta.title }}</div>
|
||||
<div class="workspaceHead__subtitle">{{ routeMeta.subtitle }}</div>
|
||||
</div>
|
||||
</header>
|
||||
<div class="workspaceBody">
|
||||
<RouterView />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<aside class="rightRail">
|
||||
<div class="rightRail__top">
|
||||
<button class="ghostIcon" type="button" aria-label="상태">⌗</button>
|
||||
</div>
|
||||
<section class="contextCard">
|
||||
<div class="contextCard__label">Context</div>
|
||||
<h2 class="contextCard__title">{{ routeMeta.contextTitle }}</h2>
|
||||
<p class="contextCard__text">{{ routeMeta.contextText }}</p>
|
||||
<button class="contextCard__action" type="button" @click="routeMeta.action">
|
||||
{{ routeMeta.actionLabel }}
|
||||
</button>
|
||||
</section>
|
||||
<section class="contextCard">
|
||||
<div class="contextCard__label">Account</div>
|
||||
<div class="contextStat">
|
||||
<span class="contextStat__name">현재 사용자</span>
|
||||
<span class="contextStat__value">{{ accountName }}</span>
|
||||
</div>
|
||||
<div class="contextStat">
|
||||
<span class="contextStat__name">권한</span>
|
||||
<span class="contextStat__value">{{ isAdmin ? 'Admin' : auth.user ? 'Member' : 'Guest' }}</span>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<div class="toastStack" :class="{ 'toastStack--preview': isPreviewMode }" aria-live="polite" aria-atomic="true">
|
||||
<div v-for="item in toasts" :key="item.id" class="toast" :class="[`toast--${item.type}`, { 'toast--closing': item.isClosing }]">
|
||||
<div class="toast__body">
|
||||
<div class="toast__message">{{ item.message }}</div>
|
||||
<div v-if="item.count > 1" class="toast__count">x{{ item.count }}</div>
|
||||
</div>
|
||||
<button class="toast__close" @click="dismissToast(item.id)">닫기</button>
|
||||
<button class="toast__close" type="button" @click="dismissToast(item.id)">닫기</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-shell {
|
||||
.appShell {
|
||||
min-height: 100vh;
|
||||
background: radial-gradient(1200px 800px at 20% 10%, rgba(110, 231, 183, 0.18), transparent 55%),
|
||||
radial-gradient(1000px 700px at 80% 20%, rgba(96, 165, 250, 0.18), transparent 55%),
|
||||
#0b1220;
|
||||
display: grid;
|
||||
grid-template-columns: 260px minmax(0, 1fr) 280px;
|
||||
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);
|
||||
}
|
||||
.app-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 14px 18px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(11, 18, 32, 0.72);
|
||||
backdrop-filter: blur(10px);
|
||||
|
||||
.appShell--preview {
|
||||
display: block;
|
||||
}
|
||||
.brand {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: baseline;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.brand__title {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
.brand__sub {
|
||||
font-size: 12px;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 999px;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.nav__link {
|
||||
text-decoration: none;
|
||||
color: rgba(255, 255, 255, 0.86);
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
.nav__link.router-link-active {
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.app-main {
|
||||
padding: 20px 18px 60px;
|
||||
width: 100%;
|
||||
|
||||
.leftRail,
|
||||
.rightRail {
|
||||
min-height: 100vh;
|
||||
padding: 14px 10px;
|
||||
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(14, 14, 14, 0.92);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user {
|
||||
position: relative;
|
||||
.rightRail {
|
||||
border-right: 0;
|
||||
border-left: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.avatarBtn {
|
||||
display: inline-flex;
|
||||
|
||||
.leftRail__top,
|
||||
.rightRail__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.ghostIcon {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.avatarImg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
object-fit: cover;
|
||||
}
|
||||
.avatarFallback {
|
||||
font-weight: 900;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: calc(100% + 8px);
|
||||
width: 160px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(11, 18, 32, 0.92);
|
||||
backdrop-filter: blur(10px);
|
||||
padding: 6px;
|
||||
|
||||
.brandBlock {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.menuItem {
|
||||
text-align: left;
|
||||
padding: 10px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid transparent;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
gap: 2px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.brandBlock__title {
|
||||
font-size: 22px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.brandBlock__sub {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
|
||||
.appUserCard {
|
||||
position: relative;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.appUserCard__button,
|
||||
.appUserCard__guest {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.appUserCard__avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 12px;
|
||||
object-fit: cover;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.appUserCard__avatar--fallback {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.appUserCard__meta {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.appUserCard__name {
|
||||
font-size: 14px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.menuItem:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
|
||||
.appUserCard__email {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.appUserMenu {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
left: 0;
|
||||
right: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(10, 10, 10, 0.98);
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.appUserMenu__item {
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
border: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.searchStub {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
cursor: pointer;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.searchStub__icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.leftNav {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.leftNav__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.leftNav__item--active,
|
||||
.leftNav__item.router-link-active {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
|
||||
.leftNav__glyph {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
font-size: 10px;
|
||||
font-weight: 900;
|
||||
letter-spacing: 0.06em;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.leftRail__section {
|
||||
margin-top: 24px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.leftRail__sectionTitle {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.favoriteLink {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.favoriteLink__dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
|
||||
.leftRail__bottom {
|
||||
margin-top: auto;
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.adminButton {
|
||||
width: 100%;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
text-decoration: none;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.appMain {
|
||||
min-width: 0;
|
||||
padding: 18px 18px 28px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.appMain--preview {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.workspace {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.workspaceHead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.workspaceHead__title {
|
||||
font-size: 34px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.workspaceHead__subtitle {
|
||||
margin-top: 6px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.workspaceBody {
|
||||
min-height: calc(100vh - 110px);
|
||||
padding: 18px;
|
||||
border-radius: 26px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: #2b2b2b;
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.rightRail {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.contextCard {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 18px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
|
||||
.contextCard__label {
|
||||
font-size: 12px;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.contextCard__title {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.contextCard__text {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: rgba(255, 255, 255, 0.66);
|
||||
}
|
||||
|
||||
.contextCard__action {
|
||||
width: 100%;
|
||||
padding: 12px 14px;
|
||||
border-radius: 12px;
|
||||
border: 0;
|
||||
background: #4b7fe9;
|
||||
color: #fff;
|
||||
font-weight: 800;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.contextStat {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contextStat__name {
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contextStat__value {
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toastStack {
|
||||
position: fixed;
|
||||
top: 78px;
|
||||
right: 18px;
|
||||
z-index: 30;
|
||||
top: 18px;
|
||||
right: 20px;
|
||||
z-index: 40;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
width: min(360px, calc(100vw - 24px));
|
||||
}
|
||||
|
||||
.toastStack--preview {
|
||||
top: 12px;
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -234,50 +675,71 @@ async function logout() {
|
||||
transform: translateY(0);
|
||||
transition: opacity 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.toast--closing {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
|
||||
.toast--success {
|
||||
border-color: rgba(52, 211, 153, 0.38);
|
||||
}
|
||||
|
||||
.toast--error {
|
||||
border-color: rgba(239, 68, 68, 0.34);
|
||||
}
|
||||
|
||||
.toast--info {
|
||||
border-color: rgba(96, 165, 250, 0.34);
|
||||
}
|
||||
|
||||
.toast__message {
|
||||
line-height: 1.5;
|
||||
font-size: 14px;
|
||||
}
|
||||
.toast__body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.toast__count {
|
||||
margin-top: 6px;
|
||||
width: fit-content;
|
||||
padding: 2px 8px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
opacity: 0.84;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
|
||||
.toast__close {
|
||||
flex: 0 0 auto;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.76);
|
||||
color: rgba(255, 255, 255, 0.68);
|
||||
cursor: pointer;
|
||||
font-weight: 800;
|
||||
font-size: 12px;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.toastStack {
|
||||
top: 70px;
|
||||
right: 12px;
|
||||
left: 12px;
|
||||
width: auto;
|
||||
|
||||
@media (max-width: 1280px) {
|
||||
.appShell {
|
||||
grid-template-columns: 220px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.rightRail {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.appShell {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.leftRail {
|
||||
min-height: auto;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.workspaceBody {
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.workspaceHead__title {
|
||||
font-size: 26px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,57 +1,49 @@
|
||||
:root {
|
||||
--text: #6b6375;
|
||||
--text-h: #08060d;
|
||||
--bg: #fff;
|
||||
--border: #e5e4e7;
|
||||
--code-bg: #f4f3ec;
|
||||
--accent: #aa3bff;
|
||||
--accent-bg: rgba(170, 59, 255, 0.1);
|
||||
--accent-border: rgba(170, 59, 255, 0.5);
|
||||
--social-bg: rgba(244, 243, 236, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
|
||||
|
||||
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
|
||||
--mono: ui-monospace, Consolas, monospace;
|
||||
|
||||
font: 18px/145% var(--sans);
|
||||
letter-spacing: 0.18px;
|
||||
color-scheme: light dark;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif;
|
||||
line-height: 1.5;
|
||||
font-weight: 400;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
background: #121212;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--text: #9ca3af;
|
||||
--text-h: #f3f4f6;
|
||||
--bg: #16171d;
|
||||
--border: #2e303a;
|
||||
--code-bg: #1f2028;
|
||||
--accent: #c084fc;
|
||||
--accent-bg: rgba(192, 132, 252, 0.15);
|
||||
--accent-border: rgba(192, 132, 252, 0.5);
|
||||
--social-bg: rgba(47, 48, 58, 0.5);
|
||||
--shadow:
|
||||
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
|
||||
}
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#social .button-icon {
|
||||
filter: invert(1) brightness(2);
|
||||
}
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #121212;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
button {
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
|
||||
select {
|
||||
@@ -59,253 +51,24 @@ select {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
background-image:
|
||||
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.78) 50%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.78) 50%, transparent 50%);
|
||||
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%),
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%);
|
||||
background-position:
|
||||
calc(100% - 18px) calc(50% - 2px),
|
||||
calc(100% - 12px) calc(50% - 2px);
|
||||
calc(100% - 20px) calc(50% - 2px),
|
||||
calc(100% - 14px) calc(50% - 2px);
|
||||
background-size: 6px 6px, 6px 6px;
|
||||
background-repeat: no-repeat;
|
||||
padding-right: 36px;
|
||||
padding-right: 40px;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
font-family: var(--heading);
|
||||
font-weight: 500;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 56px;
|
||||
letter-spacing: -1.68px;
|
||||
margin: 32px 0;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
}
|
||||
h2 {
|
||||
font-size: 24px;
|
||||
line-height: 118%;
|
||||
letter-spacing: -0.24px;
|
||||
margin: 0 0 8px;
|
||||
@media (max-width: 1024px) {
|
||||
font-size: 20px;
|
||||
}
|
||||
}
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
code,
|
||||
.counter {
|
||||
font-family: var(--mono);
|
||||
display: inline-flex;
|
||||
border-radius: 4px;
|
||||
color: var(--text-h);
|
||||
}
|
||||
|
||||
code {
|
||||
font-size: 15px;
|
||||
line-height: 135%;
|
||||
padding: 4px 8px;
|
||||
background: var(--code-bg);
|
||||
}
|
||||
|
||||
.counter {
|
||||
font-size: 16px;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
color: var(--accent);
|
||||
background: var(--accent-bg);
|
||||
border: 2px solid transparent;
|
||||
transition: border-color 0.3s;
|
||||
margin-bottom: 24px;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.hero {
|
||||
position: relative;
|
||||
|
||||
.base,
|
||||
.framework,
|
||||
.vite {
|
||||
inset-inline: 0;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.base {
|
||||
width: 170px;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.framework,
|
||||
.vite {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.framework {
|
||||
z-index: 1;
|
||||
top: 34px;
|
||||
height: 28px;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
|
||||
scale(1.4);
|
||||
}
|
||||
|
||||
.vite {
|
||||
z-index: 0;
|
||||
top: 107px;
|
||||
height: 26px;
|
||||
width: auto;
|
||||
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
|
||||
scale(0.8);
|
||||
}
|
||||
}
|
||||
|
||||
#app {
|
||||
/* width: 1126px; */
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
text-align: center;
|
||||
border-inline: 1px solid var(--border);
|
||||
min-height: 100svh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
#center {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 25px;
|
||||
place-content: center;
|
||||
place-items: center;
|
||||
flex-grow: 1;
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
padding: 32px 20px 24px;
|
||||
gap: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps {
|
||||
display: flex;
|
||||
border-top: 1px solid var(--border);
|
||||
text-align: left;
|
||||
|
||||
& > div {
|
||||
flex: 1 1 0;
|
||||
padding: 32px;
|
||||
@media (max-width: 1024px) {
|
||||
padding: 24px 20px;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
margin-bottom: 16px;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
#docs {
|
||||
border-right: 1px solid var(--border);
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
#next-steps ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin: 32px 0 0;
|
||||
|
||||
.logo {
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--text-h);
|
||||
font-size: 16px;
|
||||
border-radius: 6px;
|
||||
background: var(--social-bg);
|
||||
display: flex;
|
||||
padding: 6px 12px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
text-decoration: none;
|
||||
transition: box-shadow 0.3s;
|
||||
|
||||
&:hover {
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.button-icon {
|
||||
height: 18px;
|
||||
width: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
margin-top: 20px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
|
||||
li {
|
||||
flex: 1 1 calc(50% - 8px);
|
||||
}
|
||||
|
||||
a {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#spacer {
|
||||
height: 88px;
|
||||
border-top: 1px solid var(--border);
|
||||
@media (max-width: 1024px) {
|
||||
height: 48px;
|
||||
}
|
||||
}
|
||||
|
||||
.ticks {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -4.5px;
|
||||
border: 5px solid transparent;
|
||||
}
|
||||
|
||||
&::before {
|
||||
left: 0;
|
||||
border-left-color: var(--border);
|
||||
}
|
||||
&::after {
|
||||
right: 0;
|
||||
border-right-color: var(--border);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +662,7 @@ function closePreviewModal() {
|
||||
|
||||
function previewTierListUrl(tierList) {
|
||||
if (!tierList?.gameId || !tierList?.id) return ''
|
||||
return `/editor/${tierList.gameId}/${tierList.id}`
|
||||
return `/editor/${tierList.gameId}/${tierList.id}?preview=1`
|
||||
}
|
||||
|
||||
function openTierListImportModal(tierList, items) {
|
||||
@@ -1274,10 +1274,7 @@ async function saveFeaturedOrder() {
|
||||
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
|
||||
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__titleRow">
|
||||
<div>
|
||||
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
||||
<div class="modalCard__desc">관리 화면을 벗어나지 않고 완성본만 확인할 수 있어요.</div>
|
||||
</div>
|
||||
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
||||
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
||||
</div>
|
||||
<iframe
|
||||
|
||||
@@ -108,7 +108,7 @@ onMounted(loadFavorites)
|
||||
<style scoped>
|
||||
.wrap {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
gap: 18px;
|
||||
}
|
||||
.head {
|
||||
display: flex;
|
||||
@@ -119,11 +119,13 @@ onMounted(loadFavorites)
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 28px;
|
||||
font-size: 30px;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
.desc {
|
||||
margin-top: 6px;
|
||||
opacity: 0.78;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
@@ -133,15 +135,15 @@ onMounted(loadFavorites)
|
||||
.input,
|
||||
.select {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 10px;
|
||||
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: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-weight: 800;
|
||||
@@ -152,13 +154,13 @@ onMounted(loadFavorites)
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -176,7 +178,7 @@ onMounted(loadFavorites)
|
||||
.row__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: #555;
|
||||
}
|
||||
.row__thumb,
|
||||
.row__thumbPlaceholder {
|
||||
@@ -188,7 +190,7 @@ onMounted(loadFavorites)
|
||||
object-fit: cover;
|
||||
}
|
||||
.row__thumbPlaceholder {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
background: #555;
|
||||
}
|
||||
.row__head {
|
||||
padding: 14px 14px 0;
|
||||
@@ -241,6 +243,11 @@ onMounted(loadFavorites)
|
||||
font-weight: 800;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -79,9 +79,9 @@ function submitSearch() {
|
||||
<template>
|
||||
<section class="head">
|
||||
<div class="head__left">
|
||||
<div class="kicker">게임</div>
|
||||
<div class="kicker">Collection</div>
|
||||
<h2 class="title">{{ gameName || gameId }}</h2>
|
||||
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세요.</p>
|
||||
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 카드형 목록으로 탐색할 수 있어요.</p>
|
||||
</div>
|
||||
<div class="head__right">
|
||||
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 후 새 티어표 만들기' }}</button>
|
||||
@@ -128,42 +128,45 @@ function submitSearch() {
|
||||
<style scoped>
|
||||
.head {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
gap: 18px;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
padding: 6px 2px 14px;
|
||||
padding: 4px 2px 18px;
|
||||
}
|
||||
.kicker {
|
||||
font-size: 12px;
|
||||
opacity: 0.7;
|
||||
color: rgba(255, 255, 255, 0.42);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
.title {
|
||||
margin: 4px 0 6px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.desc {
|
||||
margin: 0;
|
||||
opacity: 0.84;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
.primary {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.2);
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
font-weight: 700;
|
||||
}
|
||||
.primary:hover {
|
||||
background: rgba(96, 165, 250, 0.26);
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.panel {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.error {
|
||||
margin: 10px 0 14px;
|
||||
@@ -181,7 +184,7 @@ function submitSearch() {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 10px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.searchBar {
|
||||
display: flex;
|
||||
@@ -192,15 +195,15 @@ function submitSearch() {
|
||||
.searchBar__input {
|
||||
min-width: 240px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.searchBar__button {
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
font-weight: 800;
|
||||
@@ -211,13 +214,13 @@ function submitSearch() {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||
background: rgba(62, 62, 62, 0.82);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
@@ -242,7 +245,7 @@ function submitSearch() {
|
||||
.row__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: #555;
|
||||
}
|
||||
.row__thumb {
|
||||
width: 100%;
|
||||
@@ -253,8 +256,7 @@ function submitSearch() {
|
||||
.row__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
background: #555;
|
||||
}
|
||||
.row__title {
|
||||
font-weight: 800;
|
||||
@@ -310,6 +312,11 @@ function submitSearch() {
|
||||
padding: 7px 10px;
|
||||
font-weight: 800;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
|
||||
@@ -43,10 +43,14 @@ function thumbUrl(g) {
|
||||
<template>
|
||||
<section class="topBar">
|
||||
<div class="topBar__copy">
|
||||
<h1 class="topBar__title">게임 선택</h1>
|
||||
<p class="topBar__desc">관리자 고정 순서가 있으면 먼저 보여주고, 그 외 게임은 최근 생성순으로 정렬됩니다.</p>
|
||||
<h1 class="topBar__title">Main Title</h1>
|
||||
<p class="topBar__desc">게임 선택과 커스텀 티어표 진입을 하나의 대시보드처럼 정리했습니다.</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<button class="toolbar__ghost" @click="goFreeform">Toggle Filter</button>
|
||||
<button class="toolbar__select" @click="goFreeform">Select Filter</button>
|
||||
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '+ 커스텀 티어표 만들기' : '+ 로그인 후 커스텀 티어표 만들기' }}</button>
|
||||
</div>
|
||||
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '커스텀 티어표 만들기' : '로그인 후 커스텀 티어표 만들기' }}</button>
|
||||
</section>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
@@ -64,70 +68,82 @@ function thumbUrl(g) {
|
||||
<style scoped>
|
||||
.topBar {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
gap: 18px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
margin-top: 2px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
.topBar__copy {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
gap: 8px;
|
||||
}
|
||||
.topBar__title {
|
||||
margin: 0;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.03em;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.topBar__desc {
|
||||
margin: 0;
|
||||
opacity: 0.78;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.toolbar {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.toolbar__ghost,
|
||||
.toolbar__select,
|
||||
.customTierBtn {
|
||||
padding: 12px 16px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(16, 185, 129, 0.16));
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
font-weight: 900;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: rgba(255, 255, 255, 0.84);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
.customTierBtn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
margin-top: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
margin: 0 0 16px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||||
background: rgba(239, 68, 68, 0.12);
|
||||
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
}
|
||||
.card {
|
||||
text-align: left;
|
||||
padding: 14px;
|
||||
border-radius: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
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: 10px;
|
||||
gap: 12px;
|
||||
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.card:hover {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
border-color: rgba(255, 255, 255, 0.18);
|
||||
background: rgba(72, 72, 72, 0.92);
|
||||
}
|
||||
.thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border-radius: 10px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: #555;
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -138,28 +154,39 @@ function thumbUrl(g) {
|
||||
object-fit: cover;
|
||||
}
|
||||
.thumbFallback {
|
||||
font-weight: 900;
|
||||
font-size: 28px;
|
||||
opacity: 0.85;
|
||||
font-weight: 700;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
.card__title {
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.02em;
|
||||
font-size: 15px;
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 900px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.topBar {
|
||||
align-items: stretch;
|
||||
}
|
||||
.customTierBtn {
|
||||
.toolbar {
|
||||
width: 100%;
|
||||
}
|
||||
.toolbar__ghost,
|
||||
.toolbar__select,
|
||||
.customTierBtn {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
.grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@media (min-width: 721px) and (max-width: 1100px) {
|
||||
.grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -101,18 +101,19 @@ async function removeList(t) {
|
||||
|
||||
<style scoped>
|
||||
.wrap {
|
||||
padding: 10px 2px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
.title {
|
||||
margin: 0 0 10px;
|
||||
font-size: 26px;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0 0 18px;
|
||||
font-size: 30px;
|
||||
letter-spacing: -0.04em;
|
||||
color: rgba(255, 255, 255, 0.96);
|
||||
}
|
||||
.card {
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
border-radius: 16px;
|
||||
padding: 14px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.link {
|
||||
padding: 8px 10px;
|
||||
@@ -128,15 +129,15 @@ async function removeList(t) {
|
||||
}
|
||||
.list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 14px;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 18px;
|
||||
}
|
||||
.row {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||||
background: rgba(0, 0, 0, 0.16);
|
||||
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;
|
||||
}
|
||||
@@ -155,7 +156,7 @@ async function removeList(t) {
|
||||
.row__thumbWrap {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
background: #555;
|
||||
}
|
||||
.row__thumb {
|
||||
width: 100%;
|
||||
@@ -166,8 +167,7 @@ async function removeList(t) {
|
||||
.row__thumbPlaceholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
||||
background: #555;
|
||||
}
|
||||
.row__title {
|
||||
font-weight: 900;
|
||||
@@ -214,6 +214,11 @@ async function removeList(t) {
|
||||
margin: 0 14px 14px;
|
||||
}
|
||||
@media (max-width: 1100px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.list {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const gameId = computed(() => route.params.gameId)
|
||||
const tierListId = computed(() => route.params.tierListId)
|
||||
const previewMode = computed(() => route.query.preview === '1')
|
||||
const gameName = ref('')
|
||||
|
||||
const groups = ref([
|
||||
@@ -602,6 +603,32 @@ onUnmounted(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section v-if="previewMode" class="previewOnly">
|
||||
<div class="previewOnly__sheet">
|
||||
<div class="previewOnly__title">{{ effectiveTitle }}</div>
|
||||
<div v-if="description" class="previewOnly__description">{{ description }}</div>
|
||||
<div class="previewOnly__rows">
|
||||
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
|
||||
<div class="previewOnly__label">{{ g.name }}</div>
|
||||
<div class="previewOnly__drop">
|
||||
<div v-for="id in g.itemIds" :key="id" class="previewOnly__cell">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="pool.length" class="previewOnly__pool">
|
||||
<div class="previewOnly__poolTitle">남은 아이템</div>
|
||||
<div class="previewOnly__poolGrid">
|
||||
<div v-for="id in pool" :key="id" class="previewOnly__poolItem">
|
||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-else>
|
||||
<section class="head">
|
||||
<div class="heroCard">
|
||||
<div class="heroCard__main">
|
||||
@@ -824,6 +851,7 @@ onUnmounted(() => {
|
||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -832,6 +860,82 @@ onUnmounted(() => {
|
||||
gap: 14px;
|
||||
padding: 6px 2px 14px;
|
||||
}
|
||||
.previewOnly {
|
||||
min-height: 100vh;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||||
rgba(11, 18, 32, 0.98);
|
||||
}
|
||||
.previewOnly__sheet {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
max-width: 1280px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.previewOnly__title {
|
||||
font-size: 28px;
|
||||
font-weight: 900;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
.previewOnly__description {
|
||||
margin-top: -8px;
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
opacity: 0.76;
|
||||
}
|
||||
.previewOnly__rows {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.previewOnly__row {
|
||||
display: grid;
|
||||
grid-template-columns: 180px 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
.previewOnly__label {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 10px 8px;
|
||||
text-align: center;
|
||||
font-weight: 900;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.previewOnly__drop {
|
||||
border-radius: 14px;
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
min-height: calc(var(--thumb-size, 80px) + 24px);
|
||||
padding: 10px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
.previewOnly__cell {
|
||||
display: inline-flex;
|
||||
}
|
||||
.previewOnly__pool {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 8px;
|
||||
}
|
||||
.previewOnly__poolTitle {
|
||||
font-weight: 900;
|
||||
opacity: 0.82;
|
||||
}
|
||||
.previewOnly__poolGrid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.previewOnly__poolItem {
|
||||
display: inline-flex;
|
||||
}
|
||||
.heroCard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
|
||||
@@ -1424,6 +1528,9 @@ onUnmounted(() => {
|
||||
border-radius: 14px;
|
||||
}
|
||||
@media (max-width: 980px) {
|
||||
.previewOnly__row {
|
||||
grid-template-columns: 140px 1fr;
|
||||
}
|
||||
.heroCard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -1461,4 +1568,12 @@ onUnmounted(() => {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.previewOnly {
|
||||
padding: 14px;
|
||||
}
|
||||
.previewOnly__row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user