Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 14607fbbbb | |||
| 28e23d6c26 | |||
| b15398761b | |||
| 2bee78ba5e | |||
| 7b4a80f47d | |||
| 9644eabf00 | |||
| 676b952982 |
@@ -920,6 +920,20 @@ function uniqueTierListItems(poolItems) {
|
|||||||
return Array.from(map.values())
|
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 = '' } = {}) {
|
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
|
||||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
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 }) {
|
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
|
||||||
const existing = id ? await findTierListById(id, authorId) : null
|
const existing = id ? await findTierListById(id, authorId) : null
|
||||||
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
|
||||||
|
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
await query(
|
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 = ?
|
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
|
||||||
WHERE id = ?
|
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)
|
return findTierListById(existing.id, authorId)
|
||||||
}
|
}
|
||||||
@@ -1224,7 +1239,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
|
|||||||
)
|
)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
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)
|
return findTierListById(id, authorId)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,6 @@ function normalizeTierList(tierList) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTierListBoardEmpty(tierList) {
|
|
||||||
return !(tierList?.groups || []).some((group) => Array.isArray(group?.itemIds) && group.itemIds.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCustomTemplateItems(tierList) {
|
function getCustomTemplateItems(tierList) {
|
||||||
const seen = new Set()
|
const seen = new Set()
|
||||||
return (tierList?.pool || []).filter((item) => {
|
return (tierList?.pool || []).filter((item) => {
|
||||||
@@ -200,7 +196,6 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
|
|||||||
|
|
||||||
if (parsed.data.type === 'create') {
|
if (parsed.data.type === 'create') {
|
||||||
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
if (tierList.gameId !== FREEFORM_GAME_ID) return res.status(400).json({ error: 'freeform_required' })
|
||||||
if (!isTierListBoardEmpty(tierList)) return res.status(400).json({ error: 'board_must_be_empty' })
|
|
||||||
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
|
if (!(tierList.title || '').trim() || (tierList.title || '').trim() === FREEFORM_DEFAULT_TITLE) {
|
||||||
return res.status(400).json({ error: 'title_required' })
|
return res.status(400).json({ error: 'title_required' })
|
||||||
}
|
}
|
||||||
|
|||||||
177
docs/history.md
177
docs/history.md
@@ -1,5 +1,36 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.2
|
||||||
|
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
|
||||||
|
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
|
||||||
|
- 우측 패널 토글은 라우트별 개별 구현보다 공통 셸의 상단 컨트롤로 두는 편이 모든 화면에서 일관된 사용성을 제공한다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.1
|
||||||
|
- 공통 셸을 먼저 올린 직후에는 에디터와 관리자처럼 자체 패널이 많은 화면이 가장 크게 깨지므로, 이 화면들은 우선 공통 우측 패널을 숨기고 중앙 폭을 회복시키는 편이 안정적이라고 판단했다.
|
||||||
|
- 목록형 카드 화면은 셸 안쪽 폭이 줄어든 상태에서 이전보다 더 많은 컬럼을 유지하면 즉시 사용성이 무너지므로, 기본 컬럼 수를 줄여 먼저 읽히는 상태를 만드는 쪽을 우선하기로 했다.
|
||||||
|
- 리디자인 초기 단계에서는 “완벽한 시안 재현”보다 먼저 실제 조작 가능한 상태를 되찾는 것이 중요하므로, 이번 단계는 안정화 릴리스로 짧게 끊어 가기로 정리했다.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
- 관리자 확인은 편집 화면으로 이동하는 것보다 관리 페이지 안에서 닫고 돌아올 수 있는 미리보기 모달이 더 적합하다고 판단했다.
|
||||||
|
- 템플릿 등록 요청은 실제로는 배치 상태보다 제목 식별성이 더 중요하므로, `보드 비움` 조건은 제거하고 제목 직접 입력 중심으로 단순화하기로 결정했다.
|
||||||
|
|
||||||
|
## 2026-03-27 v0.1.50
|
||||||
|
- 신규 티어표 저장 직후 요청 실패는 별도 요청용 티어표를 또 만드는 것보다, 방금 저장된 실제 티어표 ID를 그대로 이어받아 요청하는 편이 구조가 단순하고 안전하다고 판단했다.
|
||||||
|
|
||||||
|
## 2026-03-27 v0.1.49
|
||||||
|
- 템플릿 등록 요청 모달은 체크리스트 설명이 먼저 읽히고 상태가 우측에서 한눈에 보여야 하므로, 라벨 좌측·상태 우측 구조로 정리하기로 했다.
|
||||||
|
- 관리자 입장에서는 `요청 목록`과 `저장된 전체 티어표 목록`이 서로 다른 성격이므로, 같은 화면 안에서도 서브 탭으로 분리해 맥락을 명확히 하는 편이 더 적합하다고 판단했다.
|
||||||
|
|
||||||
## 2026-03-27 v0.1.48
|
## 2026-03-27 v0.1.48
|
||||||
- 템플릿 등록 요청은 실패 원인이 불명확하면 혼란이 크므로, 요청 전에 체크리스트 모달로 조건을 먼저 확인시키고 조건이 맞을 때만 전송하게 하는 편이 낫다고 정리했다.
|
- 템플릿 등록 요청은 실패 원인이 불명확하면 혼란이 크므로, 요청 전에 체크리스트 모달로 조건을 먼저 확인시키고 조건이 맞을 때만 전송하게 하는 편이 낫다고 정리했다.
|
||||||
- freeform 템플릿 등록 요청은 제목이 곧 게임 이름 후보가 되므로, 기본값이 아닌 사용자가 직접 입력한 제목을 요구하기로 했다.
|
- freeform 템플릿 등록 요청은 제목이 곧 게임 이름 후보가 되므로, 기본값이 아닌 사용자가 직접 입력한 제목을 요구하기로 했다.
|
||||||
@@ -48,79 +79,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
|
## 2026-03-26 v0.1.21
|
||||||
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
|
||||||
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
|
||||||
@@ -185,3 +143,76 @@
|
|||||||
## 2026-03-26 v0.1.37
|
## 2026-03-26 v0.1.37
|
||||||
- 운영 포트 충돌을 피하기 위해 프로덕션 외부 포트는 `frontend=18080`, `phpMyAdmin=18081`로 고정하고, 리버스 프록시 문서도 그 기준으로 맞추기로 했다.
|
- 운영 포트 충돌을 피하기 위해 프로덕션 외부 포트는 `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,8 @@
|
|||||||
|
|
||||||
## 공통 레이아웃
|
## 공통 레이아웃
|
||||||
- 앱 셸 파일: `frontend/src/App.vue`
|
- 앱 셸 파일: `frontend/src/App.vue`
|
||||||
- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
|
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 사용자 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
|
||||||
|
- 세부: 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 상단 토글 버튼으로 우측 패널을 접고 펼칠 수 있다.
|
||||||
|
|
||||||
## 백엔드 진입점
|
## 백엔드 진입점
|
||||||
- 서버 엔트리: `backend/index.js`
|
- 서버 엔트리: `backend/index.js`
|
||||||
|
|||||||
21
docs/spec.md
21
docs/spec.md
@@ -9,6 +9,8 @@
|
|||||||
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
|
||||||
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
|
||||||
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
|
||||||
|
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
|
||||||
|
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
|
||||||
|
|
||||||
## 데이터 저장 구조
|
## 데이터 저장 구조
|
||||||
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
|
||||||
@@ -19,6 +21,16 @@
|
|||||||
- 커스텀 아이템: `backend/uploads/custom/`
|
- 커스텀 아이템: `backend/uploads/custom/`
|
||||||
- 시드 이미지: `backend/uploads/seeds/`
|
- 시드 이미지: `backend/uploads/seeds/`
|
||||||
|
|
||||||
|
## 화면 구조
|
||||||
|
- 좌측 패널
|
||||||
|
- 사용자 요약, 빠른 검색 버튼, 주요 라우트 내비게이션, 즐겨찾기 성격의 빠른 링크, 관리자 진입 버튼을 배치한다.
|
||||||
|
- 중앙 워크스페이스
|
||||||
|
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
|
||||||
|
- 우측 패널
|
||||||
|
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
|
||||||
|
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
|
||||||
|
- 공통 토글 버튼으로 패널을 접으면 중앙 워크스페이스가 남는 공간을 확장 사용한다.
|
||||||
|
|
||||||
## DB 스키마
|
## DB 스키마
|
||||||
- `users`
|
- `users`
|
||||||
- `id`: string
|
- `id`: string
|
||||||
@@ -52,6 +64,7 @@
|
|||||||
- `gameId`: string
|
- `gameId`: string
|
||||||
- `title`: string
|
- `title`: string
|
||||||
- `thumbnailSrc`: string
|
- `thumbnailSrc`: string
|
||||||
|
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
|
||||||
- `description`: string
|
- `description`: string
|
||||||
- `isPublic`: boolean
|
- `isPublic`: boolean
|
||||||
- `groups`: `{ id, name, itemIds[] }[]`
|
- `groups`: `{ id, name, itemIds[] }[]`
|
||||||
@@ -124,7 +137,7 @@
|
|||||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 최근 티어표 전체를 제목/게임/작성자 기준으로 검색하고 공개 여부를 함께 확인할 수 있다.
|
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
|
||||||
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
|
||||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||||
@@ -148,10 +161,12 @@
|
|||||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||||
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
|
||||||
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
|
||||||
- `freeform` 티어표는 보드가 비어 있고 커스텀 아이템이 준비된 상태에서만 `템플릿 등록 요청`을 보낼 수 있다.
|
- `freeform` 티어표는 커스텀 아이템이 준비된 상태에서 `템플릿 등록 요청`을 보낼 수 있다.
|
||||||
- `템플릿 등록 요청` 전에는 체크리스트 모달로 `제목 직접 입력`, `보드 비움 상태`를 확인하고 두 조건이 충족될 때만 전송할 수 있다.
|
- `템플릿 등록 요청` 전에는 체크리스트 모달로 `제목 직접 입력` 여부를 확인하고, 관리자가 식별하기 쉬운 게임 이름을 입력하도록 안내한다.
|
||||||
|
- 신규 티어표를 막 저장한 직후에도, 템플릿 요청은 새로 발급된 실제 티어표 ID를 기준으로 이어서 처리한다.
|
||||||
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
|
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
|
||||||
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
|
||||||
|
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
|
||||||
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
|
||||||
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
|
||||||
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 즉시 확인 필요
|
## 즉시 확인 필요
|
||||||
|
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
|
||||||
|
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
|
||||||
|
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
|
||||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||||
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
|
||||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||||
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
|
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
|
||||||
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
|
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
|
||||||
|
|||||||
@@ -1,5 +1,38 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.2
|
||||||
|
- **사이드 패널 폭 고정**: 공통 앱 셸의 좌측 패널 폭을 `248px`, 우측 패널 폭을 `320px` 기준으로 재정의해 피그마 시안과 더 가깝게 맞춤
|
||||||
|
- **우측 패널 토글 추가**: 상단 우측 토글 버튼으로 우측 패널을 접고 펼칠 수 있게 하고, 접힐 때는 중앙 작업 영역이 자연스럽게 확장되도록 전환 애니메이션을 추가
|
||||||
|
- **우측 패널 독립성 강화**: 우측 패널은 본문과 별도 컬럼으로 유지하고, 닫힐 때도 본문 레이아웃과 분리된 독립 패널처럼 동작하도록 셸 구조를 조정
|
||||||
|
|
||||||
|
## 2026-03-30 v1.2.1
|
||||||
|
- **포커스 화면 폭 복구**: 에디터·관리자·프로필·로그인 화면은 공통 우측 패널을 잠시 숨기고 중앙 작업 폭을 넓혀, 기존 기능 UI가 3단 셸과 충돌하며 깨지던 문제를 완화
|
||||||
|
- **목록 카드 밀도 재조정**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 기본 컬럼 수를 줄여 현재 셸 폭 안에서도 카드가 과도하게 눌리지 않도록 정리
|
||||||
|
- **에디터/관리자 패널 안정화**: 내부 작업 패널 색상과 폭을 새 셸 톤에 맞춰 다시 정리해, 중첩 패널 때문에 사용성이 무너지던 부분을 우선 복구
|
||||||
|
|
||||||
|
## 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 템플릿 등록 요청은 더 이상 `보드 비움`을 요구하지 않고, `제목 직접 입력 + 커스텀 아이템 존재` 조건 중심으로 단순화
|
||||||
|
- **등록 요청 안내 문구 조정**: 요청 모달 안내를 “게임 이름을 구체적으로 적어 달라”는 방향으로 정리해, 관리자 식별성을 높이는 쪽으로 보강
|
||||||
|
|
||||||
|
## 2026-03-27 v0.1.50
|
||||||
|
- **신규 티어표 등록 요청 타이밍 수정**: 막 저장한 티어표에서 곧바로 템플릿 등록 요청을 보낼 때도 `new`가 아닌 실제 저장된 티어표 ID로 이어서 요청하도록 수정해, 신규 작성 직후 요청 실패 문제를 해결
|
||||||
|
|
||||||
|
## 2026-03-27 v0.1.49
|
||||||
|
- **템플릿 등록 요청 모달 레이아웃 보정**: 체크리스트 문구 줄바꿈과 버튼 겹침 문제를 수정하고, 설명은 좌측·상태 배지는 우측에 배치되도록 요청 모달 레이아웃을 다시 정리
|
||||||
|
- **관리자 티어표 화면 분리**: `티어표 관리` 탭 안에서 `템플릿 요청 관리 / 전체 티어표 관리`를 서브 탭으로 분리해, 요청 목록과 저장된 전체 티어표 목록이 섞여 보이지 않도록 개선
|
||||||
|
- **관리자 안내 문구 보강**: 전체 티어표 목록은 요청과 별개로 저장된 티어표 전체를 보는 영역이라는 설명을 추가해 혼선을 줄이도록 보강
|
||||||
|
|
||||||
## 2026-03-27 v0.1.48
|
## 2026-03-27 v0.1.48
|
||||||
- **템플릿 등록 요청 체크리스트 모달 추가**: freeform 템플릿 등록 요청 전 `제목 직접 입력 여부`, `보드 비움 상태`를 확인하는 모달과 안내 문구를 추가하고, 조건이 맞을 때만 요청 버튼이 활성화되도록 조정
|
- **템플릿 등록 요청 체크리스트 모달 추가**: freeform 템플릿 등록 요청 전 `제목 직접 입력 여부`, `보드 비움 상태`를 확인하는 모달과 안내 문구를 추가하고, 조건이 맞을 때만 요청 버튼이 활성화되도록 조정
|
||||||
- **등록 요청 실패 원인 구체화**: 템플릿 등록 요청 실패 시 제목 미입력, 보드 비우지 않음, 커스텀 아이템 없음, 중복 대기 요청 같은 주요 원인을 토스트로 구체적으로 안내하도록 보강
|
- **등록 요청 실패 원인 구체화**: 템플릿 등록 요청 실패 시 제목 미입력, 보드 비우지 않음, 커스텀 아이템 없음, 중복 대기 요청 같은 주요 원인을 토스트로 구체적으로 안내하도록 보강
|
||||||
|
|||||||
@@ -9,16 +9,130 @@ const route = useRoute()
|
|||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const { toasts, dismissToast } = useToast()
|
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 menuOpen = ref(false)
|
||||||
|
const rightRailOpen = ref(true)
|
||||||
|
|
||||||
|
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 () => {
|
onMounted(async () => {
|
||||||
await auth.refresh()
|
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)
|
document.addEventListener('click', onDocumentClick)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,13 +147,25 @@ watch(
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
function onDocumentClick(event) {
|
||||||
|
if (!event.target.closest('.appUserCard')) {
|
||||||
|
menuOpen.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRouteActive(path) {
|
||||||
|
if (path === '/') return route.path === '/'
|
||||||
|
return route.path.startsWith(path)
|
||||||
|
}
|
||||||
|
|
||||||
function toggleMenu() {
|
function toggleMenu() {
|
||||||
menuOpen.value = !menuOpen.value
|
menuOpen.value = !menuOpen.value
|
||||||
}
|
}
|
||||||
|
|
||||||
function onDocumentClick(event) {
|
function toggleRightRail() {
|
||||||
if (!event.target.closest('.user')) {
|
rightRailOpen.value = !rightRailOpen.value
|
||||||
menuOpen.value = false
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.setItem('tier-maker:right-rail-open', rightRailOpen.value ? '1' : '0')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,169 +182,541 @@ async function logout() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="app-shell">
|
<div class="appShell" :class="{ 'appShell--preview': isPreviewMode, 'appShell--rightClosed': !rightRailOpen }">
|
||||||
<header class="app-header">
|
<template v-if="isPreviewMode">
|
||||||
<div class="brand" @click="$router.push('/')">
|
<main class="appMain appMain--preview">
|
||||||
<span class="brand__title">Tier Maker</span>
|
<RouterView />
|
||||||
<span class="brand__sub">by zenn</span>
|
</main>
|
||||||
</div>
|
</template>
|
||||||
<nav class="nav">
|
<template v-else>
|
||||||
<RouterLink to="/" class="nav__link">게임</RouterLink>
|
<aside class="leftRail">
|
||||||
<RouterLink to="/me" class="nav__link">내 티어표</RouterLink>
|
<div class="leftRail__top">
|
||||||
<RouterLink v-if="auth.user" to="/favorites" class="nav__link">즐겨찾기</RouterLink>
|
<button class="ghostIcon" type="button" aria-label="메뉴">▥</button>
|
||||||
<RouterLink v-if="isAdmin" to="/admin" class="nav__link">관리자</RouterLink>
|
<div class="brandBlock" @click="$router.push('/')">
|
||||||
|
<div class="brandBlock__title">Tier Maker</div>
|
||||||
<RouterLink v-if="!auth.user" to="/login" class="nav__link">로그인</RouterLink>
|
<div class="brandBlock__sub">by zenn</div>
|
||||||
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
|
||||||
</header>
|
<div class="appUserCard">
|
||||||
<main class="app-main">
|
<button v-if="auth.user" class="appUserCard__button" type="button" @click.stop="toggleMenu">
|
||||||
<RouterView />
|
<img v-if="avatarUrl" :src="avatarUrl" class="appUserCard__avatar" alt="avatar" />
|
||||||
</main>
|
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
|
||||||
<div class="toastStack" aria-live="polite" aria-atomic="true">
|
<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>
|
||||||
|
<div class="workspaceHead__actions">
|
||||||
|
<button class="ghostIcon ghostIcon--workspace" type="button" :aria-pressed="rightRailOpen" @click="toggleRightRail">
|
||||||
|
{{ rightRailOpen ? '우측 패널 숨기기' : '우측 패널 보기' }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div class="workspaceBody">
|
||||||
|
<RouterView />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<aside class="rightRail" :class="{ 'rightRail--closed': !rightRailOpen }" :aria-hidden="!rightRailOpen">
|
||||||
|
<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 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__body">
|
||||||
<div class="toast__message">{{ item.message }}</div>
|
<div class="toast__message">{{ item.message }}</div>
|
||||||
<div v-if="item.count > 1" class="toast__count">x{{ item.count }}</div>
|
<div v-if="item.count > 1" class="toast__count">x{{ item.count }}</div>
|
||||||
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.app-shell {
|
.appShell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: radial-gradient(1200px 800px at 20% 10%, rgba(110, 231, 183, 0.18), transparent 55%),
|
display: grid;
|
||||||
radial-gradient(1000px 700px at 80% 20%, rgba(96, 165, 250, 0.18), transparent 55%),
|
grid-template-columns: 248px minmax(0, 1fr) 320px;
|
||||||
#0b1220;
|
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);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
|
transition: grid-template-columns 220ms ease;
|
||||||
}
|
}
|
||||||
.app-header {
|
|
||||||
position: sticky;
|
.appShell--preview {
|
||||||
top: 0;
|
display: block;
|
||||||
z-index: 10;
|
}
|
||||||
|
|
||||||
|
.leftRail,
|
||||||
|
.rightRail {
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 14px 12px;
|
||||||
|
border-right: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
background: rgba(14, 14, 14, 0.92);
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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,
|
||||||
|
.rightRail__top {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: 12px;
|
||||||
padding: 14px 18px;
|
margin-bottom: 18px;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
background: rgba(11, 18, 32, 0.72);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
.brand {
|
|
||||||
display: flex;
|
.ghostIcon {
|
||||||
gap: 10px;
|
min-width: 28px;
|
||||||
align-items: baseline;
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
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;
|
cursor: pointer;
|
||||||
user-select: none;
|
|
||||||
}
|
}
|
||||||
.brand__title {
|
|
||||||
font-weight: 800;
|
.ghostIcon--workspace {
|
||||||
letter-spacing: -0.02em;
|
min-width: 118px;
|
||||||
}
|
height: 36px;
|
||||||
.brand__sub {
|
padding: 0 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
padding: 2px 8px;
|
font-weight: 800;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
||||||
border-radius: 999px;
|
|
||||||
opacity: 0.9;
|
|
||||||
}
|
}
|
||||||
.nav {
|
|
||||||
|
.brandBlock {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandBlock__title {
|
||||||
|
font-size: 21px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brandBlock__sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: rgba(255, 255, 255, 0.56);
|
||||||
|
}
|
||||||
|
|
||||||
|
.appUserCard {
|
||||||
|
position: relative;
|
||||||
|
margin-bottom: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.appUserCard__button,
|
||||||
|
.appUserCard__guest {
|
||||||
|
width: 100%;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-wrap: wrap;
|
padding: 10px;
|
||||||
justify-content: flex-end;
|
border-radius: 14px;
|
||||||
}
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
.nav__link {
|
background: rgba(255, 255, 255, 0.03);
|
||||||
text-decoration: none;
|
color: inherit;
|
||||||
color: rgba(255, 255, 255, 0.86);
|
text-align: left;
|
||||||
padding: 8px 10px;
|
cursor: pointer;
|
||||||
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%;
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user {
|
.appUserCard__avatar {
|
||||||
position: relative;
|
width: 38px;
|
||||||
}
|
height: 38px;
|
||||||
.avatarBtn {
|
|
||||||
display: inline-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);
|
|
||||||
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;
|
|
||||||
display: grid;
|
|
||||||
gap: 6px;
|
|
||||||
}
|
|
||||||
.menuItem {
|
|
||||||
text-align: left;
|
|
||||||
padding: 10px 10px;
|
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid transparent;
|
object-fit: cover;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
flex: 0 0 auto;
|
||||||
color: rgba(255, 255, 255, 0.92);
|
}
|
||||||
cursor: pointer;
|
|
||||||
|
.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;
|
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: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
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: 24px;
|
||||||
|
height: 24px;
|
||||||
|
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: 14px 18px 22px;
|
||||||
|
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__actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceHead__title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 900;
|
||||||
|
letter-spacing: -0.04em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceHead__subtitle {
|
||||||
|
margin-top: 6px;
|
||||||
|
color: rgba(255, 255, 255, 0.58);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.workspaceBody {
|
||||||
|
min-height: calc(100vh - 110px);
|
||||||
|
padding: 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 {
|
.toastStack {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
top: 78px;
|
top: 18px;
|
||||||
right: 18px;
|
right: 20px;
|
||||||
z-index: 30;
|
z-index: 40;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
width: min(360px, calc(100vw - 24px));
|
width: min(360px, calc(100vw - 24px));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toastStack--preview {
|
||||||
|
top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
@@ -234,50 +732,71 @@ async function logout() {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
transition: opacity 220ms ease, transform 220ms ease;
|
transition: opacity 220ms ease, transform 220ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast--closing {
|
.toast--closing {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transform: translateY(-6px);
|
transform: translateY(-6px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast--success {
|
.toast--success {
|
||||||
border-color: rgba(52, 211, 153, 0.38);
|
border-color: rgba(52, 211, 153, 0.38);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast--error {
|
.toast--error {
|
||||||
border-color: rgba(239, 68, 68, 0.34);
|
border-color: rgba(239, 68, 68, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast--info {
|
.toast--info {
|
||||||
border-color: rgba(96, 165, 250, 0.34);
|
border-color: rgba(96, 165, 250, 0.34);
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast__message {
|
.toast__message {
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.toast__body {
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.toast__count {
|
.toast__count {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
width: fit-content;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgba(255, 255, 255, 0.08);
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 800;
|
color: rgba(255, 255, 255, 0.56);
|
||||||
opacity: 0.84;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.toast__close {
|
.toast__close {
|
||||||
flex: 0 0 auto;
|
|
||||||
border: 0;
|
border: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: rgba(255, 255, 255, 0.76);
|
color: rgba(255, 255, 255, 0.68);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 800;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
@media (max-width: 640px) {
|
|
||||||
.toastStack {
|
@media (max-width: 1280px) {
|
||||||
top: 70px;
|
.appShell {
|
||||||
right: 12px;
|
grid-template-columns: 220px minmax(0, 1fr);
|
||||||
left: 12px;
|
}
|
||||||
width: auto;
|
|
||||||
|
.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>
|
</style>
|
||||||
|
|||||||
@@ -1,57 +1,49 @@
|
|||||||
:root {
|
:root {
|
||||||
--text: #6b6375;
|
font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif;
|
||||||
--text-h: #08060d;
|
line-height: 1.5;
|
||||||
--bg: #fff;
|
font-weight: 400;
|
||||||
--border: #e5e4e7;
|
color: rgba(255, 255, 255, 0.92);
|
||||||
--code-bg: #f4f3ec;
|
background: #121212;
|
||||||
--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-synthesis: none;
|
font-synthesis: none;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
|
||||||
@media (max-width: 1024px) {
|
|
||||||
font-size: 16px;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
* {
|
||||||
:root {
|
box-sizing: border-box;
|
||||||
--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;
|
|
||||||
}
|
|
||||||
|
|
||||||
#social .button-icon {
|
html,
|
||||||
filter: invert(1) brightness(2);
|
body,
|
||||||
}
|
#app {
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
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 {
|
select {
|
||||||
@@ -59,253 +51,24 @@ select {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.78) 50%),
|
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%),
|
||||||
linear-gradient(135deg, rgba(255, 255, 255, 0.78) 50%, transparent 50%);
|
linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%);
|
||||||
background-position:
|
background-position:
|
||||||
calc(100% - 18px) calc(50% - 2px),
|
calc(100% - 20px) calc(50% - 2px),
|
||||||
calc(100% - 12px) calc(50% - 2px);
|
calc(100% - 14px) calc(50% - 2px);
|
||||||
background-size: 6px 6px, 6px 6px;
|
background-size: 6px 6px, 6px 6px;
|
||||||
background-repeat: no-repeat;
|
background-repeat: no-repeat;
|
||||||
padding-right: 36px;
|
padding-right: 40px;
|
||||||
}
|
}
|
||||||
|
|
||||||
h1,
|
h1,
|
||||||
h2 {
|
h2,
|
||||||
font-family: var(--heading);
|
h3,
|
||||||
font-weight: 500;
|
h4,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
p {
|
p {
|
||||||
margin: 0;
|
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 {
|
#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%;
|
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
|
||||||
import Sortable from 'sortablejs'
|
import Sortable from 'sortablejs'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { toApiUrl } from '../lib/runtime'
|
import { toApiUrl } from '../lib/runtime'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToast } from '../composables/useToast'
|
import { useToast } from '../composables/useToast'
|
||||||
|
|
||||||
const router = useRouter()
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||||
|
|
||||||
const activeTab = ref('games')
|
const activeTab = ref('games')
|
||||||
|
const tierlistsMode = ref('requests')
|
||||||
const gameMode = ref('existing')
|
const gameMode = ref('existing')
|
||||||
|
|
||||||
const games = ref([])
|
const games = ref([])
|
||||||
@@ -41,6 +40,8 @@ const importModalItems = ref([])
|
|||||||
const importModalTargetGameId = ref('')
|
const importModalTargetGameId = ref('')
|
||||||
const importModalNewGameId = ref('')
|
const importModalNewGameId = ref('')
|
||||||
const importModalNewGameName = ref('')
|
const importModalNewGameName = ref('')
|
||||||
|
const previewModalOpen = ref(false)
|
||||||
|
const previewTierList = ref(null)
|
||||||
|
|
||||||
const users = ref([])
|
const users = ref([])
|
||||||
|
|
||||||
@@ -105,11 +106,19 @@ function resetMessages() {
|
|||||||
function setTab(tab) {
|
function setTab(tab) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
activeTab.value = tab
|
activeTab.value = tab
|
||||||
|
if (tab === 'tierlists') {
|
||||||
|
tierlistsMode.value = 'requests'
|
||||||
|
}
|
||||||
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
||||||
customItemTargetGameId.value = games.value[0].id
|
customItemTargetGameId.value = games.value[0].id
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setTierlistsMode(mode) {
|
||||||
|
resetMessages()
|
||||||
|
tierlistsMode.value = mode
|
||||||
|
}
|
||||||
|
|
||||||
async function refreshGames() {
|
async function refreshGames() {
|
||||||
try {
|
try {
|
||||||
const data = await api.listGames()
|
const data = await api.listGames()
|
||||||
@@ -642,7 +651,18 @@ function tierListVisibilityLabel(tierList) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openAdminTierList(tierList) {
|
function openAdminTierList(tierList) {
|
||||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
previewTierList.value = tierList
|
||||||
|
previewModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closePreviewModal() {
|
||||||
|
previewModalOpen.value = false
|
||||||
|
previewTierList.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewTierListUrl(tierList) {
|
||||||
|
if (!tierList?.gameId || !tierList?.id) return ''
|
||||||
|
return `/editor/${tierList.gameId}/${tierList.id}?preview=1`
|
||||||
}
|
}
|
||||||
|
|
||||||
function openTierListImportModal(tierList, items) {
|
function openTierListImportModal(tierList, items) {
|
||||||
@@ -1084,7 +1104,16 @@ async function saveFeaturedOrder() {
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="activeTab === 'tierlists'">
|
<template v-else-if="activeTab === 'tierlists'">
|
||||||
<div class="panel">
|
<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>
|
||||||
|
|
||||||
|
<div v-if="tierlistsMode === 'requests'" class="panel">
|
||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel__title">사용자 템플릿 요청</div>
|
<div class="panel__title">사용자 템플릿 요청</div>
|
||||||
@@ -1131,11 +1160,11 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div v-else class="panel">
|
||||||
<div class="sectionHeader">
|
<div class="sectionHeader">
|
||||||
<div>
|
<div>
|
||||||
<div class="panel__title">전체 티어표 관리</div>
|
<div class="panel__title">전체 티어표 관리</div>
|
||||||
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요.</div>
|
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요. 여기는 요청 목록과 별개로 전체 저장 티어표를 보는 영역입니다.</div>
|
||||||
</div>
|
</div>
|
||||||
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -1241,6 +1270,21 @@ async function saveFeaturedOrder() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
@@ -1310,18 +1354,14 @@ async function saveFeaturedOrder() {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
.wrap {
|
||||||
padding: 10px 2px;
|
display: grid;
|
||||||
}
|
gap: 16px;
|
||||||
.title {
|
|
||||||
margin: 0 0 10px;
|
|
||||||
font-size: 26px;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 0;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: transparent;
|
||||||
border-radius: 16px;
|
border-radius: 0;
|
||||||
padding: 14px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.desc {
|
.desc {
|
||||||
opacity: 0.82;
|
opacity: 0.82;
|
||||||
@@ -1348,7 +1388,7 @@ async function saveFeaturedOrder() {
|
|||||||
}
|
}
|
||||||
.tabs,
|
.tabs,
|
||||||
.modeTabs {
|
.modeTabs {
|
||||||
margin-top: 14px;
|
margin-top: 0;
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -1356,26 +1396,27 @@ async function saveFeaturedOrder() {
|
|||||||
.tab,
|
.tab,
|
||||||
.modeTab {
|
.modeTab {
|
||||||
padding: 10px 14px;
|
padding: 10px 14px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
.tab--active,
|
.tab--active,
|
||||||
.modeTab--active {
|
.modeTab--active {
|
||||||
background: rgba(96, 165, 250, 0.2);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.18);
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(0, 0, 0, 0.12);
|
background: rgba(48, 48, 48, 0.78);
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
padding: 14px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.panel--compact {
|
.panel--compact {
|
||||||
max-width: 480px;
|
max-width: 520px;
|
||||||
}
|
}
|
||||||
.featuredOrderPanel {
|
.featuredOrderPanel {
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
@@ -2110,6 +2151,16 @@ async function saveFeaturedOrder() {
|
|||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
background: rgba(11, 18, 32, 0.96);
|
background: rgba(11, 18, 32, 0.96);
|
||||||
}
|
}
|
||||||
|
.modalCard--preview {
|
||||||
|
width: min(1200px, 100%);
|
||||||
|
}
|
||||||
|
.modalCard__titleRow {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
.modalCard__title {
|
.modalCard__title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -2128,6 +2179,13 @@ async function saveFeaturedOrder() {
|
|||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
}
|
}
|
||||||
|
.previewFrame {
|
||||||
|
width: 100%;
|
||||||
|
min-height: min(80vh, 820px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgba(255, 255, 255, 0.02);
|
||||||
|
}
|
||||||
.importModeTabs {
|
.importModeTabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -108,7 +108,7 @@ onMounted(loadFavorites)
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
.wrap {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.head {
|
.head {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -119,11 +119,13 @@ onMounted(loadFavorites)
|
|||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 28px;
|
font-size: 30px;
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
|
letter-spacing: -0.04em;
|
||||||
}
|
}
|
||||||
.desc {
|
.desc {
|
||||||
margin-top: 6px;
|
margin-top: 6px;
|
||||||
opacity: 0.78;
|
color: rgba(255, 255, 255, 0.58);
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -133,15 +135,15 @@ onMounted(loadFavorites)
|
|||||||
.input,
|
.input,
|
||||||
.select {
|
.select {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
.btn {
|
.btn {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -153,12 +155,12 @@ onMounted(loadFavorites)
|
|||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(62, 62, 62, 0.82);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -176,7 +178,7 @@ onMounted(loadFavorites)
|
|||||||
.row__thumbWrap {
|
.row__thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: #555;
|
||||||
}
|
}
|
||||||
.row__thumb,
|
.row__thumb,
|
||||||
.row__thumbPlaceholder {
|
.row__thumbPlaceholder {
|
||||||
@@ -188,7 +190,7 @@ onMounted(loadFavorites)
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.row__thumbPlaceholder {
|
.row__thumbPlaceholder {
|
||||||
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
background: #555;
|
||||||
}
|
}
|
||||||
.row__head {
|
.row__head {
|
||||||
padding: 14px 14px 0;
|
padding: 14px 14px 0;
|
||||||
@@ -245,6 +247,11 @@ onMounted(loadFavorites)
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -79,9 +79,9 @@ function submitSearch() {
|
|||||||
<template>
|
<template>
|
||||||
<section class="head">
|
<section class="head">
|
||||||
<div class="head__left">
|
<div class="head__left">
|
||||||
<div class="kicker">게임</div>
|
<div class="kicker">Collection</div>
|
||||||
<h2 class="title">{{ gameName || gameId }}</h2>
|
<h2 class="title">{{ gameName || gameId }}</h2>
|
||||||
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세요.</p>
|
<p class="desc">새 티어표를 만들거나, 다른 사람들이 올린 티어표를 카드형 목록으로 탐색할 수 있어요.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="head__right">
|
<div class="head__right">
|
||||||
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 후 새 티어표 만들기' }}</button>
|
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 후 새 티어표 만들기' }}</button>
|
||||||
@@ -128,42 +128,45 @@ function submitSearch() {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.head {
|
.head {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 14px;
|
gap: 18px;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
padding: 6px 2px 14px;
|
padding: 4px 2px 18px;
|
||||||
}
|
}
|
||||||
.kicker {
|
.kicker {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
opacity: 0.7;
|
color: rgba(255, 255, 255, 0.42);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
margin: 4px 0 6px;
|
margin: 4px 0 6px;
|
||||||
font-size: 26px;
|
font-size: 30px;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.04em;
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
}
|
}
|
||||||
.desc {
|
.desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.84;
|
color: rgba(255, 255, 255, 0.58);
|
||||||
}
|
}
|
||||||
.primary {
|
.primary {
|
||||||
padding: 10px 12px;
|
padding: 10px 14px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(96, 165, 250, 0.2);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.primary:hover {
|
.primary:hover {
|
||||||
background: rgba(96, 165, 250, 0.26);
|
background: rgba(255, 255, 255, 0.12);
|
||||||
}
|
}
|
||||||
.panel {
|
.panel {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: transparent;
|
||||||
border-radius: 16px;
|
border-radius: 0;
|
||||||
padding: 14px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
margin: 10px 0 14px;
|
margin: 10px 0 14px;
|
||||||
@@ -181,7 +184,7 @@ function submitSearch() {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
.searchBar {
|
.searchBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -192,15 +195,15 @@ function submitSearch() {
|
|||||||
.searchBar__input {
|
.searchBar__input {
|
||||||
min-width: 240px;
|
min-width: 240px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
.searchBar__button {
|
.searchBar__button {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -212,12 +215,12 @@ function submitSearch() {
|
|||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(62, 62, 62, 0.82);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
@@ -242,7 +245,7 @@ function submitSearch() {
|
|||||||
.row__thumbWrap {
|
.row__thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: #555;
|
||||||
}
|
}
|
||||||
.row__thumb {
|
.row__thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -253,8 +256,7 @@ function submitSearch() {
|
|||||||
.row__thumbPlaceholder {
|
.row__thumbPlaceholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
background: #555;
|
||||||
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
|
||||||
}
|
}
|
||||||
.row__title {
|
.row__title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
@@ -310,11 +312,16 @@ function submitSearch() {
|
|||||||
padding: 7px 10px;
|
padding: 7px 10px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
}
|
}
|
||||||
@media (max-width: 1100px) {
|
@media (max-width: 1280px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 1100px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -43,10 +43,14 @@ function thumbUrl(g) {
|
|||||||
<template>
|
<template>
|
||||||
<section class="topBar">
|
<section class="topBar">
|
||||||
<div class="topBar__copy">
|
<div class="topBar__copy">
|
||||||
<h1 class="topBar__title">게임 선택</h1>
|
<h1 class="topBar__title">Main Title</h1>
|
||||||
<p class="topBar__desc">관리자 고정 순서가 있으면 먼저 보여주고, 그 외 게임은 최근 생성순으로 정렬됩니다.</p>
|
<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>
|
</div>
|
||||||
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '커스텀 티어표 만들기' : '로그인 후 커스텀 티어표 만들기' }}</button>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<div v-if="error" class="error">{{ error }}</div>
|
<div v-if="error" class="error">{{ error }}</div>
|
||||||
@@ -64,70 +68,82 @@ function thumbUrl(g) {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.topBar {
|
.topBar {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 18px;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
margin-top: 4px;
|
margin-top: 2px;
|
||||||
|
margin-bottom: 18px;
|
||||||
}
|
}
|
||||||
.topBar__copy {
|
.topBar__copy {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 6px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.topBar__title {
|
.topBar__title {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 30px;
|
font-size: 32px;
|
||||||
letter-spacing: -0.03em;
|
letter-spacing: -0.04em;
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
}
|
}
|
||||||
.topBar__desc {
|
.topBar__desc {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
opacity: 0.78;
|
color: rgba(255, 255, 255, 0.58);
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.toolbar__ghost,
|
||||||
|
.toolbar__select,
|
||||||
.customTierBtn {
|
.customTierBtn {
|
||||||
padding: 12px 16px;
|
padding: 10px 14px;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(96, 165, 250, 0.28);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(16, 185, 129, 0.16));
|
background: rgba(255, 255, 255, 0.06);
|
||||||
color: rgba(255, 255, 255, 0.96);
|
color: rgba(255, 255, 255, 0.84);
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
.customTierBtn {
|
||||||
|
background: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
.grid {
|
.grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 18px;
|
||||||
margin-top: 14px;
|
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
margin-top: 12px;
|
margin: 0 0 16px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(239, 68, 68, 0.3);
|
border: 1px solid rgba(239, 68, 68, 0.18);
|
||||||
background: rgba(239, 68, 68, 0.12);
|
background: rgba(239, 68, 68, 0.1);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
padding: 14px;
|
padding: 12px;
|
||||||
border-radius: 16px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(62, 62, 62, 0.82);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 12px;
|
||||||
|
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04);
|
||||||
}
|
}
|
||||||
.card:hover {
|
.card:hover {
|
||||||
background: rgba(255, 255, 255, 0.07);
|
background: rgba(72, 72, 72, 0.92);
|
||||||
border-color: rgba(255, 255, 255, 0.18);
|
|
||||||
}
|
}
|
||||||
.thumbWrap {
|
.thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
border-radius: 14px;
|
border-radius: 10px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||||
background: rgba(0, 0, 0, 0.18);
|
background: #555;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
@@ -138,28 +154,39 @@ function thumbUrl(g) {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.thumbFallback {
|
.thumbFallback {
|
||||||
font-weight: 900;
|
font-weight: 700;
|
||||||
font-size: 28px;
|
font-size: 14px;
|
||||||
opacity: 0.85;
|
color: rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
.card__title {
|
.card__title {
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.topBar {
|
.topBar {
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.customTierBtn {
|
.toolbar {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
.toolbar__ghost,
|
||||||
|
.toolbar__select,
|
||||||
|
.customTierBtn {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
}
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (min-width: 721px) and (max-width: 1100px) {
|
|
||||||
.grid {
|
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -101,18 +101,19 @@ async function removeList(t) {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.wrap {
|
.wrap {
|
||||||
padding: 10px 2px;
|
padding: 4px 2px;
|
||||||
}
|
}
|
||||||
.title {
|
.title {
|
||||||
margin: 0 0 10px;
|
margin: 0 0 18px;
|
||||||
font-size: 26px;
|
font-size: 30px;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.04em;
|
||||||
|
color: rgba(255, 255, 255, 0.96);
|
||||||
}
|
}
|
||||||
.card {
|
.card {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 0;
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: transparent;
|
||||||
border-radius: 16px;
|
border-radius: 0;
|
||||||
padding: 14px;
|
padding: 0;
|
||||||
}
|
}
|
||||||
.link {
|
.link {
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
@@ -129,14 +130,14 @@ async function removeList(t) {
|
|||||||
.list {
|
.list {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 14px;
|
gap: 18px;
|
||||||
}
|
}
|
||||||
.row {
|
.row {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.10);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
background: rgba(0, 0, 0, 0.16);
|
background: rgba(62, 62, 62, 0.82);
|
||||||
color: rgba(255, 255, 255, 0.92);
|
color: rgba(255, 255, 255, 0.92);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -155,7 +156,7 @@ async function removeList(t) {
|
|||||||
.row__thumbWrap {
|
.row__thumbWrap {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
aspect-ratio: 16 / 9;
|
aspect-ratio: 16 / 9;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: #555;
|
||||||
}
|
}
|
||||||
.row__thumb {
|
.row__thumb {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -166,8 +167,7 @@ async function removeList(t) {
|
|||||||
.row__thumbPlaceholder {
|
.row__thumbPlaceholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
background: #555;
|
||||||
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
|
|
||||||
}
|
}
|
||||||
.row__title {
|
.row__title {
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -218,6 +218,11 @@ async function removeList(t) {
|
|||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@media (max-width: 960px) {
|
||||||
|
.list {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.list {
|
.list {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const auth = useAuthStore()
|
|||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
const gameId = computed(() => route.params.gameId)
|
const gameId = computed(() => route.params.gameId)
|
||||||
const tierListId = computed(() => route.params.tierListId)
|
const tierListId = computed(() => route.params.tierListId)
|
||||||
|
const previewMode = computed(() => route.query.preview === '1')
|
||||||
const gameName = ref('')
|
const gameName = ref('')
|
||||||
|
|
||||||
const groups = ref([
|
const groups = ref([
|
||||||
@@ -91,9 +92,8 @@ const customItems = computed(() =>
|
|||||||
.filter((item) => item?.origin === 'custom')
|
.filter((item) => item?.origin === 'custom')
|
||||||
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
|
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
|
||||||
)
|
)
|
||||||
const hasPlacedItems = computed(() => groups.value.some((group) => (group.itemIds || []).length > 0))
|
|
||||||
const canRequestTemplateCreate = computed(
|
const canRequestTemplateCreate = computed(
|
||||||
() => canEdit.value && !isNewTierList.value && gameId.value === 'freeform' && !hasPlacedItems.value && customItems.value.length > 0
|
() => canEdit.value && !isNewTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
|
||||||
)
|
)
|
||||||
const canRequestTemplateUpdate = computed(
|
const canRequestTemplateUpdate = computed(
|
||||||
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
|
||||||
@@ -104,11 +104,6 @@ const templateRequestChecks = computed(() => [
|
|||||||
label: '티어표 이름(게임 이름)을 직접 입력했는지',
|
label: '티어표 이름(게임 이름)을 직접 입력했는지',
|
||||||
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
passed: !!(title.value || '').trim() && (title.value || '').trim() !== (gameName.value || '').trim(),
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: 'empty-board',
|
|
||||||
label: '등록한 이미지를 티어에 배치하지 않은 원본 상태인지',
|
|
||||||
passed: !hasPlacedItems.value,
|
|
||||||
},
|
|
||||||
])
|
])
|
||||||
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
|
const canSubmitTemplateCreateRequest = computed(() => templateRequestChecks.value.every((item) => item.passed))
|
||||||
|
|
||||||
@@ -443,14 +438,17 @@ async function persistTierList({ showModal = false } = {}) {
|
|||||||
await uploadPendingThumbnail()
|
await uploadPendingThumbnail()
|
||||||
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
||||||
const res = await api.saveTierList(payload)
|
const res = await api.saveTierList(payload)
|
||||||
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.value}/${res.tierList.id}`)
|
const savedTierListId = res.tierList?.id || tierListId.value
|
||||||
|
if (tierListId.value === 'new' && res.tierList?.id) {
|
||||||
|
await router.replace(`/editor/${gameId.value}/${res.tierList.id}`)
|
||||||
|
}
|
||||||
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
||||||
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
||||||
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
|
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
|
||||||
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
|
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
|
||||||
isFavorited.value = !!res.tierList?.isFavorited
|
isFavorited.value = !!res.tierList?.isFavorited
|
||||||
if (showModal) isSaveModalOpen.value = true
|
if (showModal) isSaveModalOpen.value = true
|
||||||
return res
|
return { ...res, savedTierListId }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function save() {
|
async function save() {
|
||||||
@@ -514,8 +512,8 @@ async function requestTemplate(type) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
isRequestingTemplate.value = true
|
isRequestingTemplate.value = true
|
||||||
await persistTierList({ showModal: false })
|
const persisted = await persistTierList({ showModal: false })
|
||||||
await api.requestTierListTemplate(tierListId.value, { type })
|
await api.requestTierListTemplate(persisted.savedTierListId, { type })
|
||||||
if (type === 'create') closeTemplateRequestModal()
|
if (type === 'create') closeTemplateRequestModal()
|
||||||
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -527,10 +525,6 @@ async function requestTemplate(type) {
|
|||||||
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
|
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (e?.status === 400 && e?.data?.error === 'board_must_be_empty') {
|
|
||||||
toast.error('템플릿 등록 요청은 보드를 비운 상태에서만 보낼 수 있어요.')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (e?.status === 400 && e?.data?.error === 'custom_items_required') {
|
if (e?.status === 400 && e?.data?.error === 'custom_items_required') {
|
||||||
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
|
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
|
||||||
return
|
return
|
||||||
@@ -609,6 +603,32 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<section class="head">
|
||||||
<div class="heroCard">
|
<div class="heroCard">
|
||||||
<div class="heroCard__main">
|
<div class="heroCard__main">
|
||||||
@@ -704,12 +724,12 @@ onUnmounted(() => {
|
|||||||
class="requestChecklist__item"
|
class="requestChecklist__item"
|
||||||
:class="{ 'requestChecklist__item--passed': check.passed }"
|
:class="{ 'requestChecklist__item--passed': check.passed }"
|
||||||
>
|
>
|
||||||
|
<span class="requestChecklist__label">{{ check.label }}</span>
|
||||||
<span class="requestChecklist__icon">{{ check.passed ? '완료' : '확인 필요' }}</span>
|
<span class="requestChecklist__icon">{{ check.passed ? '완료' : '확인 필요' }}</span>
|
||||||
<span>{{ check.label }}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="requestChecklist__hint">
|
<div class="requestChecklist__hint">
|
||||||
제목이 명확하고, 보드는 비워둔 채 원본 아이템만 정리되어 있을수록 관리자가 새 게임 템플릿으로 빠르게 등록하기 쉬워져요.
|
제목만 명확하게 적어두면 관리자가 어떤 게임 템플릿 요청인지 빠르게 파악할 수 있어요. 여러 사용자가 비슷한 주제로 요청할 수 있으니 게임 이름을 구체적으로 적어주세요.
|
||||||
</div>
|
</div>
|
||||||
<div class="modalCard__actions">
|
<div class="modalCard__actions">
|
||||||
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
||||||
@@ -831,18 +851,95 @@ onUnmounted(() => {
|
|||||||
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
</template>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.head {
|
.head {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
padding: 6px 2px 14px;
|
padding: 2px 2px 16px;
|
||||||
|
}
|
||||||
|
.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 {
|
.heroCard {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
|
grid-template-columns: minmax(0, 1.65fr) minmax(260px, 320px);
|
||||||
gap: 18px;
|
gap: 16px;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
.heroCard__main,
|
.heroCard__main,
|
||||||
@@ -1026,8 +1123,8 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 320px;
|
grid-template-columns: minmax(0, 1fr) 284px;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
align-items: start;
|
align-items: start;
|
||||||
}
|
}
|
||||||
.error {
|
.error {
|
||||||
@@ -1038,10 +1135,10 @@ onUnmounted(() => {
|
|||||||
background: rgba(239, 68, 68, 0.12);
|
background: rgba(239, 68, 68, 0.12);
|
||||||
}
|
}
|
||||||
.board {
|
.board {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(48, 48, 48, 0.78);
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
padding: 20px;
|
padding: 18px;
|
||||||
align-self: start;
|
align-self: start;
|
||||||
}
|
}
|
||||||
.modalOverlay {
|
.modalOverlay {
|
||||||
@@ -1077,13 +1174,19 @@ onUnmounted(() => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.modalCard__actions .btn {
|
||||||
|
width: auto;
|
||||||
|
min-width: 120px;
|
||||||
}
|
}
|
||||||
.requestChecklist {
|
.requestChecklist {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
}
|
}
|
||||||
.requestChecklist__item {
|
.requestChecklist__item {
|
||||||
display: flex;
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
@@ -1096,12 +1199,18 @@ onUnmounted(() => {
|
|||||||
border-color: rgba(52, 211, 153, 0.24);
|
border-color: rgba(52, 211, 153, 0.24);
|
||||||
background: rgba(52, 211, 153, 0.1);
|
background: rgba(52, 211, 153, 0.1);
|
||||||
}
|
}
|
||||||
|
.requestChecklist__label {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
.requestChecklist__icon {
|
.requestChecklist__icon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
min-width: 56px;
|
min-width: 68px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.requestChecklist__hint {
|
.requestChecklist__hint {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
@@ -1310,9 +1419,9 @@ onUnmounted(() => {
|
|||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.sidebar {
|
.sidebar {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: rgba(48, 48, 48, 0.78);
|
||||||
border-radius: 16px;
|
border-radius: 18px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
}
|
}
|
||||||
.sidebar__title {
|
.sidebar__title {
|
||||||
@@ -1419,6 +1528,9 @@ onUnmounted(() => {
|
|||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
}
|
}
|
||||||
@media (max-width: 980px) {
|
@media (max-width: 980px) {
|
||||||
|
.previewOnly__row {
|
||||||
|
grid-template-columns: 140px 1fr;
|
||||||
|
}
|
||||||
.heroCard {
|
.heroCard {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
@@ -1446,5 +1558,22 @@ onUnmounted(() => {
|
|||||||
.descInput {
|
.descInput {
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
}
|
}
|
||||||
|
.requestChecklist__item {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.requestChecklist__icon {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.modalCard__actions .btn {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.previewOnly {
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.previewOnly__row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user