Compare commits

..

26 Commits

Author SHA1 Message Date
876c13d99b 릴리스: v1.2.22 좌측 레일 축소형 토글 추가 2026-03-30 18:27:52 +09:00
1a7ec50a93 릴리스: v1.2.21 티어표 카드와 좌측 레일 밀도 조정 2026-03-30 17:58:51 +09:00
c1f0471f1f 릴리스: v1.2.20 패널 토글과 검색 결과 화면 정리 2026-03-30 17:46:38 +09:00
0812640ec1 릴리스: v1.2.19 왼쪽 레일 검색과 즐겨찾기 정리 2026-03-30 17:34:49 +09:00
285644bdde 릴리스: v1.2.18 공통 56px 셸 헤더 정리 2026-03-30 17:24:21 +09:00
ed4023d1bd 릴리스: v1.2.17 에디터 우측 패널 래퍼 제거 2026-03-30 17:15:50 +09:00
14a6823c3e 릴리스: v1.2.16 메인 화면 사이드와 헤더 단순화 2026-03-30 17:11:10 +09:00
adc697eb13 릴리스: v1.2.15 공통 3단 셸 구조 고정 2026-03-30 17:05:29 +09:00
5d7797925e 릴리스: v1.2.14 에디터 우측 패널 셸 컬럼 이관 2026-03-30 16:58:38 +09:00
69a8cd3600 릴리스: v1.2.13 에디터 우측 패널 회귀 수정 2026-03-30 16:55:10 +09:00
26a77bd3e1 릴리스: v1.2.12 에디터 우측 패널 토글 연결 2026-03-30 16:49:37 +09:00
d36502fe51 릴리스: v1.2.11 에디터 우측 패널 분리 보정 2026-03-30 16:46:57 +09:00
a6e78b29f1 릴리스: v1.2.10 목록 화면 툴바와 카드 반응 정리 2026-03-30 16:43:15 +09:00
2346b5fbe3 릴리스: v1.2.9 관리자 대시보드 디테일 정리 2026-03-30 16:40:09 +09:00
d724a64451 릴리스: v1.2.8 에디터 3열 구조와 SVG 아이콘 연결 2026-03-30 16:33:02 +09:00
781a131ade 릴리스: v1.2.7 공통 셸과 에디터 패널 감도 보정 2026-03-30 16:22:30 +09:00
6fceeaf15b 릴리스: v1.2.6 목록 화면 카드 레이아웃 정리 2026-03-30 16:08:00 +09:00
7886b98380 릴리스: v1.2.5 관리자 로컬 우측 패널 정리 2026-03-30 15:44:23 +09:00
b5d5f4b079 릴리스: v1.2.4 문서 버전 정리 2026-03-30 15:18:39 +09:00
fbd596bdd0 릴리스: v1.2.3 에디터 우측 편집 패널 정리 2026-03-30 15:11:50 +09:00
14607fbbbb 릴리스: v1.2.2 사이드 패널 폭과 토글 정리 2026-03-30 14:50:06 +09:00
28e23d6c26 릴리스: v1.2.1 리디자인 포커스 화면 안정화 2026-03-30 14:43:34 +09:00
b15398761b 릴리스: v1.2.0 피그마 기반 앱 셸 1차 리디자인 2026-03-30 14:37:30 +09:00
2bee78ba5e 릴리스: v0.1.52 프리뷰 전용화와 썸네일 자동 생성 2026-03-27 12:15:16 +09:00
7b4a80f47d 릴리스: v0.1.51 관리자 미리보기와 요청 조건 정리 2026-03-27 11:59:17 +09:00
9644eabf00 릴리스: v0.1.50 신규 티어표 요청 타이밍 수정 2026-03-27 11:43:09 +09:00
26 changed files with 3155 additions and 1374 deletions

View File

@@ -920,6 +920,20 @@ function uniqueTierListItems(poolItems) {
return Array.from(map.values())
}
function getAutoThumbnailSrc(groups = [], pool = []) {
const itemMap = new Map((pool || []).filter((item) => item?.id && item?.src).map((item) => [item.id, item]))
for (const group of groups || []) {
for (const itemId of group?.itemIds || []) {
const item = itemMap.get(itemId)
if (item?.src) return item.src
}
}
const fallbackItem = (pool || []).find((item) => item?.src)
return fallbackItem?.src || ''
}
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
@@ -1203,6 +1217,7 @@ async function deleteCustomItems(ids) {
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
const existing = id ? await findTierListById(id, authorId) : null
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
if (existing) {
await query(
@@ -1211,7 +1226,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ?
`,
[title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id, authorId)
}
@@ -1224,7 +1239,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
}

View File

@@ -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) {
const seen = new Set()
return (tierList?.pool || []).filter((item) => {
@@ -200,7 +196,6 @@ router.post('/:id/template-request', requireAuth, async (req, res) => {
if (parsed.data.type === 'create') {
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) {
return res.status(400).json({ error: 'title_required' })
}

View File

@@ -1,5 +1,120 @@
# 의사결정 이력
## 2026-03-30 v1.2.22
- 왼쪽 레일은 홈/목록/에디터 어디서든 “사라지는 패널”보다 “축소된 내비 레일”로 읽히는 편이 구조적으로 더 일관되므로, 완전 숨김 대신 아이콘 중심 축소 상태를 유지하기로 했다.
- 좌우 패널 토글은 상태마다 다른 아이콘이 바뀌기보다 방향만 고정하는 편이 덜 혼란스러우므로, 우측은 `dock_to_left`, 좌측은 `dock_to_right` 하나로 통일하기로 정리했다.
- 좌측 검색도 임시 선형 SVG보다 실제 에셋을 쓰는 편이 전체 레일 완성도가 높으므로, 사용자가 추가한 `search.svg`를 우선 적용하기로 했다.
## 2026-03-30 v1.2.21
- 티어표 목록 카드는 페이지마다 다른 메타 구성을 두기보다, `제목+좋아요 / 작성자+최종 수정일` 두 줄 문법으로 통일하는 편이 시안과 사용성 모두에 더 맞다고 정리했다.
- `내 즐겨찾기` 화면에서 “즐겨찾기한 날짜”는 컬렉션 내부 정보일 뿐 카드 핵심 정보는 아니므로, 정렬은 유지하되 카드에는 마지막 수정일만 보여주는 편이 더 읽기 쉽다고 판단했다.
- 좌측 `Favorites`는 메인 내비보다 보조 영역이어야 하므로, 같은 공간 안에서도 더 작은 썸네일·더 작은 텍스트·더 약한 대비로 눌러두는 편이 맞다고 정리했다.
## 2026-03-30 v1.2.20
- 전역 검색 입력이 이미 좌측 레일에 고정되어 있으므로, 검색 결과 화면 안에 검색 폼을 또 두는 것은 중복이라고 판단해 `/search` 화면은 결과 표시 자체에만 집중시키기로 했다.
- 중앙 워크스페이스는 셸 여백 위에 다시 큰 카드 테두리와 배경을 씌우면 시안보다 한 겹 더 두꺼워 보이므로, `workspaceBody`는 외곽 카드 없이 단일 여백 레이어만 유지하는 편이 더 맞다고 정리했다.
- 우측 패널 토글은 같은 위치에 서로 다른 상태의 버튼이 번갈아 보여야 인지가 쉬우므로, 패널이 닫혀 있을 때는 중앙 헤더에 열기 버튼을 두고 열려 있을 때는 우측 헤더에 닫기 버튼을 두는 방식으로 정리했다.
## 2026-03-30 v1.2.19
- 사용자 카드에서 프로필/로그아웃 팝업을 또 띄우는 구조는 좌측 `Settings` 메뉴와 역할이 겹치므로, 설정 진입은 메뉴 하나로만 통일하고 로그아웃은 설정 화면 안쪽에서 마무리하는 편이 더 명확하다고 정리했다.
- 좌측 `Favorites`는 단순 링크보다 “내가 최근 좋아요한 실제 티어표 바로가기”를 보여주는 쪽이 시안과 사용성 모두에 더 가깝다고 판단해, 최근 10개만 노출하고 나머지는 `즐겨찾기 더 보기`로 보내기로 했다.
- 좌측 검색은 페이지 내부 국소 검색보다 서비스 전체 공개 티어표 검색 진입점으로 쓰는 편이 더 자연스럽다고 판단해, 별도 `/search` 결과 화면을 두는 방향으로 정리했다.
## 2026-03-30 v1.2.18
- 피그마 기준 상단 구조는 페이지마다 다르게 보이면 안 되므로, 좌/중앙/우 컬럼 모두 `56px` 헤더를 고정으로 두고 내용이 없을 때도 빈 헤더 공간을 유지하는 편이 맞다고 정리했다.
- 사이트 브랜드는 좌측 레일 안쪽 카드가 아니라 중앙 워크스페이스 상단의 고정 헤더에 두는 쪽이 시안과 더 가깝고, 페이지 이동 시에도 더 일관되게 읽힌다고 판단했다.
- 에디터 화면 안에서 `.layout`이 다시 좌우 컬럼을 만들면 공통 3단 셸과 충돌하므로, 에디터 본문은 셸이 제공한 중앙 컬럼 안에서만 레이아웃을 잡아야 한다고 정리했다.
## 2026-03-30 v1.2.17
- 공통 오른쪽 레일을 쓰는 화면에서는 로컬 패널이 다시 외곽 래퍼 카드로 감싸지면 “오른쪽 레일 안의 또 다른 사이드”처럼 읽히므로, 에디터 우측 패널은 섹션들만 공통 레일 루트에 직접 배치하는 쪽이 더 일관적이라고 정리했다.
- 에디터/관리자 공통 오른쪽 컬럼은 컨테이너를 화면별로 따로 꾸미기보다, 셸의 `localRightRailRoot`가 기본 스택 문법을 제공하고 각 화면은 내부 section만 채우는 방식으로 맞추기로 했다.
## 2026-03-30 v1.2.16
- 홈 화면은 이동 경로가 이미 좌측/우측 사이드에 충분히 있으므로, 중앙 바디 상단에 상태 카드와 중복 버튼을 다시 두기보다 본문은 게임 카드에만 집중시키는 편이 더 낫다고 정리했다.
- 오른쪽 사이드도 정보가 막막하다고 해서 임시 카드를 많이 넣기보다, 우선 핵심 CTA 하나만 남기고 나중에 필요한 항목만 추가하는 편이 시안과 운영 흐름 모두에 더 적합하다고 판단했다.
## 2026-03-30 v1.2.15
- 리디자인 기준 구조는 화면마다 달라지면 안 되므로, 홈에서 보이는 `좌측 레일 / 중앙 / 우측 레일` 3단 셸을 일반 페이지 공통 뼈대로 고정하고 안쪽 콘텐츠만 바꾸는 방식으로 정리하기로 했다.
- 에디터와 관리자의 우측 패널도 예외적인 바디 내부 aside가 아니라 공통 셸의 세 번째 컬럼을 공유해야 전체 제품 구조가 일관된다고 판단했다.
## 2026-03-30 v1.2.14
- 에디터 우측 패널은 본문 내부 그리드의 일부가 아니라 공통 셸의 세 번째 컬럼이어야 메인 화면과 같은 구조로 읽히므로, Teleport로 셸 aside에 직접 붙이는 편이 맞다고 정리했다.
- 로컬 우측 패널 화면에서 “메인 안쪽 2단 레이아웃”과 “셸 3단 레이아웃”을 섞으면 계속 혼선이 생기므로, 에디터는 셸 레벨 3단 구조를 우선 기준으로 삼기로 결정했다.
## 2026-03-30 v1.2.13
- 공통 상태를 로컬 우측 패널에 연결할 때는 템플릿의 ref 자동 언래핑을 고려해야 하므로, 템플릿에서는 `.value` 없이 직접 참조하는 편이 안전하다고 다시 정리했다.
- 이번 회귀처럼 편집 화면이 통째로 무너질 수 있는 연결점은 작은 레이아웃 수정이어도 바로 복구 릴리스로 끊는 편이 낫다고 판단했다.
## 2026-03-30 v1.2.12
- 공통 상단의 패널 토글은 로컬 우측 패널 화면에서도 같은 의미로 동작해야 하므로, 에디터의 `editorSidebar`도 같은 상태를 공유해 접고 펴는 편이 일관된다고 판단했다.
- 로컬 우측 패널 화면에 공통 `rightClosed` 그리드 계산이 다시 들어오면 컬럼 수가 꼬일 수 있으므로, 에디터/관리자 화면은 셸 차원에서 별도 예외 컬럼 규칙을 유지하기로 결정했다.
## 2026-03-30 v1.2.11
- 에디터와 관리자처럼 자체 우측 패널이 있는 화면은 공통 `workspaceBody` 카드 배경 안에 다시 넣기보다, 셸 레벨에서 중앙 본문을 투명하게 풀어주는 편이 우측 사이드바 독립성이 더 잘 살아난다고 판단했다.
- 로컬 우측 패널의 핵심은 “본문 안쪽 보조 박스”가 아니라 “진짜 오른쪽 컬럼”처럼 읽히는 것이므로, 에디터에서는 본문 카드보다 패널 분리감을 먼저 확보하기로 결정했다.
## 2026-03-30 v1.2.10
- 목록 화면도 결국 같은 제품의 라이브러리 레이어이므로, 상단 통계 카드와 버튼의 높이·반경·배경을 공통 셸과 같은 문법으로 맞추는 편이 일관성이 높다고 정리했다.
- 홈 화면의 빠른 액션은 중복 의미 버튼보다 `즐겨찾기 / 내 리스트 / 커스텀 시작`처럼 실제 이동 동선이 분명한 버튼 구성이 더 적합하다고 판단했다.
- 카드 hover 반응은 화면마다 조금씩 다르게 두기보다, 모두 얕은 위로 이동과 배경 변화로 통일하는 편이 대시보드 감도를 유지하기 쉽다고 결정했다.
## 2026-03-30 v1.2.9
- 관리자 화면은 기능보다 먼저 정보 계층이 읽혀야 하므로, 현재 탭에 맞는 요약 통계를 헤더에서 먼저 보여주는 편이 운영 판단에 더 유리하다고 정리했다.
- 게임/아이템/티어표/회원 카드는 기능이 다른 대신 같은 제품 안에 있으므로, 배경층·반경·패딩은 하나의 대시보드 문법으로 맞춰 시안 톤을 더 강하게 유지하기로 결정했다.
- 우측 운영 패널은 단순 필터 모음보다 “현재 상태를 짧게 읽고 바로 액션하는 패널”에 가까워야 하므로, 입력과 통계 카드를 더 단단한 카드형 레이어로 정리하는 편이 맞다고 판단했다.
## 2026-03-30 v1.2.8
- 에디터는 “보드 편집”과 “옵션 편집”의 역할이 다르므로, 보드 옆에는 드래그용 아이템 풀을 두고 제목/설명/썸네일/저장 같은 설정은 최우측 사이드바에만 남기는 편이 맞다고 판단했다.
- 커스텀 아이템 이름 정리는 배치 중에 계속 보는 정보보다 저장 전 정리용 정보에 가까우므로, 아이템 풀 아래보다 우측 편집 패널 내부가 더 적합하다고 정리했다.
- 실제 SVG 에셋이 들어오기 시작한 만큼, 공통 셸은 새 아이콘을 우선 적용하고 나머지는 점진적으로 교체하는 방식이 안전하다고 판단했다.
## 2026-03-30 v1.2.7
- 피그마 감도는 개별 화면보다 공통 셸의 밀도와 아이콘 체계가 먼저 맞아야 하므로, 좌측/우측 레일을 먼저 아이콘형 카드 문법으로 정리하기로 했다.
- 실제 머티리얼 SVG 자산을 받기 전까지는 간단한 선형 SVG 아이콘으로 정보 구조를 먼저 맞추고, 이후 에셋 교체만으로 다듬을 수 있게 하는 편이 안전하다고 판단했다.
- 에디터는 기능은 이미 많은 상태이므로 구조를 더 바꾸기보다 보드, 툴바, 우측 편집 패널의 카드 톤을 공통 셸과 맞추는 방식으로 단계적으로 다듬기로 했다.
## 2026-03-30 v1.2.6
- 홈, 게임 허브, 내 티어표, 즐겨찾기처럼 카드 중심 화면은 한 번에 같은 카드 문법으로 맞춰야 전체 앱이 하나의 제품처럼 보이므로, 목록 화면을 우선 통일하기로 했다.
- 홈 화면은 단순 게임 버튼 모음보다 상태 카드와 CTA가 있는 라이브러리 대시보드 쪽이 피그마 톤에 더 가깝다고 판단했다.
- 게임 허브와 개인 목록도 썸네일/작성자/메타의 비중이 비슷하므로, 화면마다 다른 카드 구조를 유지하기보다 동일한 정보 계층을 반복하는 편이 더 읽기 쉽다고 정리했다.
## 2026-03-30 v1.2.5
- 관리자 화면도 에디터와 마찬가지로 공통 우측 패널보다 전용 로컬 운영 패널이 더 중요하므로, `/admin` 역시 화면 내부 `320px` 패널을 사용하는 포커스 화면으로 정리하기로 결정했다.
- 관리자 기능은 탭, 검색, 필터, 빠른 액션이 본문에 섞이면 밀도가 너무 높아지므로, 우측 패널에는 제어 요소를 모으고 중앙에는 실제 관리 대상 목록과 상세만 남기는 편이 낫다고 판단했다.
- 새 셸 단계에서는 기능을 줄이기보다 위치를 재배치하는 것이 안전하므로, 기존 게임/아이템/티어표/회원 관리 로직은 유지한 채 정보 구조만 피그마 방향으로 옮기기로 했다.
## 2026-03-30 v1.2.4
- 로그인 유도는 좌측 하단의 단일 버튼이면 충분하므로, 비로그인 상태에서 사이드 상단에 별도 안내 카드를 또 보여주는 구조는 제거하는 편이 더 깔끔하다고 판단했다.
- 티어표 편집 화면은 공통 우측 패널의 generic 문맥 카드보다 실제 편집 필드가 우측에 있는 편이 훨씬 중요하므로, 이 화면은 전용 로컬 우측 패널을 두는 쪽으로 정리했다.
- 좌측 내비가 이미 라우팅 역할을 하므로, 에디터 우측 패널에서는 “게임 목록으로” 같은 중복 이동 CTA보다 저장과 편집 자체에 집중하는 것이 맞다고 판단했다.
## 2026-03-30 v1.2.2
- 우측 패널은 본문 내부 보조 박스가 아니라 별도 컬럼으로 보이는 것이 핵심이므로, 폭을 `320px`로 고정하고 접힘/펼침도 레이아웃 레벨에서 처리하는 편이 맞다고 판단했다.
- 좌측 패널도 시안 기준 인지 폭이 중요하므로 `248px`로 고정하고, 중앙 콘텐츠는 나머지 공간을 유동적으로 쓰게 하는 구조로 정리했다.
- 우측 패널 토글은 라우트별 개별 구현보다 공통 셸의 상단 컨트롤로 두는 편이 모든 화면에서 일관된 사용성을 제공한다고 판단했다.
## 2026-03-30 v1.2.1
- 공통 셸을 먼저 올린 직후에는 에디터와 관리자처럼 자체 패널이 많은 화면이 가장 크게 깨지므로, 이 화면들은 우선 공통 우측 패널을 숨기고 중앙 폭을 회복시키는 편이 안정적이라고 판단했다.
- 목록형 카드 화면은 셸 안쪽 폭이 줄어든 상태에서 이전보다 더 많은 컬럼을 유지하면 즉시 사용성이 무너지므로, 기본 컬럼 수를 줄여 먼저 읽히는 상태를 만드는 쪽을 우선하기로 했다.
- 리디자인 초기 단계에서는 “완벽한 시안 재현”보다 먼저 실제 조작 가능한 상태를 되찾는 것이 중요하므로, 이번 단계는 안정화 릴리스로 짧게 끊어 가기로 정리했다.
## 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
- 템플릿 등록 요청 모달은 체크리스트 설명이 먼저 읽히고 상태가 우측에서 한눈에 보여야 하므로, 라벨 좌측·상태 우측 구조로 정리하기로 했다.
- 관리자 입장에서는 `요청 목록``저장된 전체 티어표 목록`이 서로 다른 성격이므로, 같은 화면 안에서도 서브 탭으로 분리해 맥락을 명확히 하는 편이 더 적합하다고 판단했다.
@@ -52,79 +167,6 @@
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.
## 2026-03-19
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.
## 2026-03-19 v0.1.3
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-03-19 v0.1.4
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
## 2026-03-19 v0.1.5
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
- 로컬 실행 편의를 위해 `docker-compose.yml``mariadb``phpMyAdmin` 서비스를 추가했다.
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
## 2026-03-19 v0.1.6
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
## 2026-03-19 v0.1.7
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
## 2026-03-19 v0.1.8
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
## 2026-03-19 v0.1.9
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
## 2026-03-19 v0.1.10
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
## 2026-03-19 v0.1.11
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
## 2026-03-19 v0.1.12
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
## 2026-03-19 v0.1.13
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
## 2026-03-19 v0.1.17
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
## 2026-03-19 v0.1.19
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제``공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
## 2026-03-26 v0.1.21
- 목록 썸네일 fallback 문자는 닉네임보다 계정 기준이 더 일관되므로, 아바타 이미지가 없을 때는 계정명 첫 글자를 사용하기로 결정했다.
- 저장 성공은 화면 이동보다 현 위치 유지가 더 중요하므로, 편집을 계속할 수 있는 확인형 모달로 피드백을 제공하기로 결정했다.
@@ -189,3 +231,76 @@
## 2026-03-26 v0.1.37
- 운영 포트 충돌을 피하기 위해 프로덕션 외부 포트는 `frontend=18080`, `phpMyAdmin=18081`로 고정하고, 리버스 프록시 문서도 그 기준으로 맞추기로 했다.
- 인증 장애 원인을 찾기 위한 디버그 로그는 문제 해결 후 제거하고, 실제 운영에는 세션 저장 보강과 프록시 헤더 설정만 유지하는 편이 낫다고 판단했다.
## 2026-03-19 v0.1.19
- 티어표 공개 여부는 운영 기준상 대부분 공개 공유가 목적이므로, 신규 작성 시 기본값을 `공개 ON`으로 두기로 결정했다.
- 에디터에서 저장 관련 행동은 좌우로 역할을 나눠 `다운로드/삭제``공개/저장`으로 묶는 편이 더 빠르게 인지된다고 판단했다.
- 공개 목록과 내 목록에서 제목만으로는 식별이 어려우므로, 제목 옆에 작성자 아바타와 표시명을 함께 노출하기로 결정했다.
- 티어표 카드 메타 정보는 저장 시각과 업데이트 시각을 동시에 노출하는 대신, 최종 업데이트 시각만 남겨 단순화하기로 결정했다.
## 2026-03-19 v0.1.17
- 작성자가 자기 티어표를 직접 삭제할 수 있어야 관리 흐름이 완결되므로, `내 티어표` 화면에 삭제 기능을 추가하기로 결정했다.
- 사용자 커스텀 이미지는 서버 용량을 계속 차지하므로, 참조가 하나도 남지 않은 이미지(미사용 항목)를 식별하고 관리자 화면에서 개별/일괄 정리할 수 있도록 하기로 결정했다.
## 2026-03-19 v0.1.13
- 관리자 페이지는 기능 수가 늘어난 만큼 게임, 아이템, 회원 관리 탭으로 나누는 편이 더 안전하다고 판단했다.
- 사용자 커스텀 아이템은 수량이 빠르게 커질 수 있으므로, 검색과 페이지네이션을 기본 제공하는 관리 화면으로 보는 방향을 채택했다.
- 메일 기반 복구가 없으므로, 관리자가 회원 비밀번호를 직접 초기화할 수 있는 기능을 제공하기로 결정했다.
- 티어표 구조는 게임마다 다르므로, 기본 5단은 시작 템플릿일 뿐 사용자가 행을 직접 추가/삭제할 수 있어야 한다고 결정했다.
## 2026-03-19 v0.1.12
- 앱 전체 배경은 화면 폭 전체를 사용하고, 개별 콘텐츠만 필요한 만큼 정렬하는 방향이 더 자연스럽다고 판단해 전역 최대 폭 제한을 제거했다.
- 비로그인 사용자가 새 티어표를 편집하다 저장 단계에서 막히는 경험은 손실 위험이 크므로, 작성 시작 자체를 로그인 사용자로 제한하고 공개 티어표는 읽기 전용으로 보여주기로 결정했다.
- 커스텀 이미지 업로드는 단일 파일 선택만으로는 불편하므로, 다중 선택과 드래그 앤 드롭을 기본 흐름으로 보강했다.
- 관리자에게는 게임 관리뿐 아니라 회원 관리 책임도 필요하므로, 회원 목록 조회/수정/삭제 기능을 추가하기로 결정했다.
## 2026-03-19 v0.1.11
- 관리자 화면은 좌우 여백이 크게 남는 구조보다, 상단 2열 작업 카드와 하단 목록 영역으로 나누는 편이 더 안정적이라고 판단해 레이아웃을 재정리했다.
- 게임 목록에 없는 주제로도 바로 작업할 수 있도록, 시스템 전용 `freeform` 게임을 내부적으로 유지하고 홈 화면에서는 `직접 티어표 만들기` 카드로 노출하기로 결정했다.
- 게임 제안은 현재 운영 흐름과 맞지 않아 사용자 진입점과 프런트 API에서 제거하고, 대신 관리자에게는 사용자 커스텀 아이템 검토 기능을 제공하기로 했다.
## 2026-03-19 v0.1.10
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
## 2026-03-19 v0.1.9
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
## 2026-03-19 v0.1.8
- 관리자 업로드 작업은 선택 즉시 결과를 예측할 수 있어야 하므로, 썸네일과 아이템 모두 “파일 선택 → 미리보기 → 실제 업로드” 흐름으로 보강했다.
- 게임 썸네일은 대표 이미지 성격이 강하므로 16:9 비율로, 아이템은 캐릭터/오브젝트 단위 식별이 중요하므로 1:1 비율로 보는 방향을 채택했다.
- 현재 `db.json`과 lowdb 관련 코드는 기본 운영 런타임이 아니라 마이그레이션/예외 fallback 성격임을 분명히 정리했다.
## 2026-03-19 v0.1.7
- 관리자 페이지는 여러 작업을 동시에 나열하는 구조보다 “하나의 작업 모드를 선택하고 그 작업에 집중하는 구조”가 더 적합하다고 판단해 단계형 UI로 전환했다.
- 관리자에게는 생성뿐 아니라 삭제 책임도 필요하므로 게임 삭제와 아이템 삭제 기능을 추가하기로 결정했다.
- 아이템 삭제는 단순 파일/레코드 삭제만으로 끝내면 안 되고, 기존 티어표 데이터의 참조까지 함께 정리해야 한다고 결정했다.
## 2026-03-19 v0.1.6
- 저장소 운영 규칙을 정리하면서 Git 작성자 정보는 프로젝트 기준 계정으로 통일하고, 커밋 메시지는 한국어로 남기기로 결정했다.
## 2026-03-19 v0.1.5
- 로컬 개발과 운영 환경의 차이를 줄이기 위해 기본 로컬 개발 DB도 MariaDB로 고정했다.
- 로컬 실행 편의를 위해 `docker-compose.yml``mariadb``phpMyAdmin` 서비스를 추가했다.
- 백엔드 기본 `dev/start/migrate` 스크립트는 로컬 MariaDB 기준 값으로 정리하고, lowdb는 예외용 fallback 스크립트로만 남겼다.
## 2026-03-19 v0.1.4
- 운영 편의성과 NAS 환경에서의 데이터 조회 필요성 때문에 저장소를 MariaDB(MySQL 호환) 기준으로 전환했다.
- 관리자 지정 아이템과 사용자 커스텀 이미지는 책임과 수명 주기가 다르므로 별도 테이블(`game_items`, `custom_items`)로 분리했다.
- 작성자 식별성을 위해 공개 티어표에 닉네임을 표시하고, 프로필에서 닉네임을 수정할 수 있게 했다.
- 아바타 업로드는 즉시 반영보다 “선택 후 저장” 흐름이 맞다고 판단해 미리보기와 실제 저장을 분리했다.
- 관리자 페이지는 게임 선택 후 상세 관리가 열리는 단계형 흐름으로 바꾸는 것이 실사용에 더 안전하다고 결정했다.
## 2026-03-19 v0.1.3
- 배포 환경 호환성을 위해 프런트엔드의 API 기준 주소를 환경변수(`VITE_API_ORIGIN`)로 통합했다.
- NAS/리버스 프록시 환경을 고려해 CORS 및 세션 쿠키 옵션을 환경변수 기반으로 전환했다.
- 파일명 깨짐과 URL 이식성 문제를 줄이기 위해 업로드 파일명을 ASCII 기반으로 생성하도록 변경했다.
- 게임 이미지 경로는 저장 시 상대 경로(`/uploads/...`)를 유지하는 방향으로 정리했다.
- 현재 단계에서는 구조 변경 비용을 고려해 DB를 유지하되, 운영/확장성 요구가 커지기 전 RDB 이관 판단이 필요하다고 기록했다.
## 2026-03-19
- 초기 저장소는 빠른 구현을 위해 `lowdb(JSON 파일)`를 채택했다.
- 인증은 JWT 대신 서버 세션(`express-session` + `session-file-store`)을 사용했다.
- 업로드 파일은 외부 스토리지 없이 로컬 디스크(`backend/uploads/`)에 저장하기로 했다.

View File

@@ -2,17 +2,17 @@
## `/`
- 화면 파일: `frontend/src/views/HomeView.vue`
- 역할: 게임 목록 표시, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 역할: 상단 상태/CTA가 있는 라이브러리 대시보드, 게임 카드 클릭 이동, `직접 티어표 만들기` 진입
- 연동 API: `GET /api/games`
## `/games/:gameId`
- 화면 파일: `frontend/src/views/GameHubView.vue`
- 역할: 선택한 게임 정보 표시, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 상단 썸네일/작성자 표시, 즐겨찾기 토글, 새 티어표 작성 진입
- 역할: 선택한 게임 정보 표시, 상단 생성 CTA, 공개 티어표 목록 표시, 제목/작성자 검색, 티어표별 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 표시, 새 티어표 작성 진입
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/public`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 티어표 썸네일 선택, 작성 권한 제어, 저장, 공개 여부 설정, 즐겨찾기 토글, PNG 다운로드
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
## `/login`
@@ -22,27 +22,33 @@
## `/me`
- 화면 파일: `frontend/src/views/MyTierListsView.vue`
- 역할: 내 티어표 목록 조회, 상단 썸네일 카드 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 역할: 내 티어표 목록 조회, 4열 라이브러리 카드형 썸네일 표시, 편집 화면으로 이동, 작성자 본인 티어표 삭제
- 연동 API: `GET /api/tierlists/me`, `DELETE /api/tierlists/:id`
## `/favorites`
- 화면 파일: `frontend/src/views/FavoriteTierListsView.vue`
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 편집 화면 이동, 즐겨찾기 해제
- 역할: 즐겨찾기한 티어표 목록 조회, 검색/정렬, 라이브러리 카드형 표시, 편집 화면 이동, 즐겨찾기 상태 확인
- 연동 API: `GET /api/tierlists/favorites/me`, `DELETE /api/tierlists/:id/favorite`
## `/search`
- 화면 파일: `frontend/src/views/SearchResultsView.vue`
- 역할: 좌측 전역 검색 입력에서 넘긴 키워드로 공개 티어표 전체를 검색하고, 자체 검색 툴바 없이 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 카드 목록으로 표시
- 연동 API: `GET /api/tierlists/public?q=...`
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리`과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`
- 화면 파일: `frontend/src/views/ProfileView.vue`
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장
- 역할: 프로필 표시, 작성자 닉네임 수정, 아바타 미리보기 후 저장, 설정 화면 하단 로그아웃 처리
- 연동 API: `GET /api/auth/me`, `POST /api/auth/profile`
## 공통 레이아웃
- 앱 셸 파일: `frontend/src/App.vue`
- 역할: 상단 내비게이션, 로그인 상태 반영, 아바타 메뉴, 관리자 메뉴 노출 제어, 전역 우측 상단 토스트 렌더링
- 역할: 좌측 내비게이션, 중앙 워크스페이스, 우측 컨텍스트 패널로 구성된 공통 앱 셸 렌더링, 로그인 상태 반영, 최근 즐겨찾기 바로가기와 전역 검색 입력, 관리자 메뉴 노출 제어, 실제 SVG 에셋과 선형 SVG 아이콘이 혼합된 레일 UI, 전역 우측 상단 토스트 렌더링
- 세부: 좌측 패널은 `248px` 기준 폭을 사용하되 축소 시 아이콘 중심의 좁은 레일로 전환하고, 우측 패널은 `320px` 기준 폭을 사용한다. 세 컬럼 모두 상단에 높이 `56px`의 헤더 블록을 유지한다. 중앙 헤더에는 고정 브랜드 `Tier Maker by zenn`이 표시되고, 우측 패널 토글은 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 아이콘만 표시된다.
## 백엔드 진입점
- 서버 엔트리: `backend/index.js`

View File

@@ -9,6 +9,12 @@
- 운영 배포: `frontend(Nginx 정적 서빙 + /api,/uploads 프록시) + backend + mariadb` Docker Compose 구조
- NAS HTTPS 리버스 프록시 운영 시 프런트 Nginx는 백엔드로 `X-Forwarded-Proto: https`를 전달하고, Express 세션은 프록시 환경에서 `secure` 쿠키를 허용하도록 설정한다.
- 프런트 파비콘은 운영 정적 파일 차단 영향을 줄이기 위해 `index.html`의 인라인 데이터 URL로 제공한다.
- 프런트 앱 셸은 `좌측 내비게이션 / 중앙 워크스페이스 / 우측 컨텍스트 패널` 3단 구조로 재정의되었고, preview 모드에서는 이 셸을 숨기고 콘텐츠만 렌더링한다.
- 좌측 패널은 `248px`, 우측 패널은 `320px` 기준 폭을 사용하며, 우측 패널은 상단 토글 버튼으로 접고 펼칠 수 있다.
- 좌측 패널은 필요 시 축소형 레일로 접을 수 있으며, 접힌 상태에서는 아이콘 중심 내비게이션과 축약된 바로가기만 유지한다.
- 이 3단 셸 구조는 홈, 게임 허브, 에디터, 관리자 등 일반 페이지 전반의 공통 뼈대로 유지하고, 페이지별 차이는 중앙/우측에 어떤 콘텐츠를 넣는지만 달라지도록 관리한다.
- 비로그인 상태의 로그인 유도는 좌측 하단 버튼으로만 노출하고, 좌측 상단 사용자 카드 영역에는 별도 게스트 안내 카드를 렌더링하지 않는다.
- 공통 셸의 좌측 내비, 우측 패널, 빠른 점프 버튼은 간단한 선형 SVG 아이콘과 두꺼운 카드형 버튼 문법을 공유한다.
## 데이터 저장 구조
- 메인 데이터베이스: MariaDB `tier_cursor` (기본값)
@@ -19,6 +25,35 @@
- 커스텀 아이템: `backend/uploads/custom/`
- 시드 이미지: `backend/uploads/seeds/`
## 화면 구조
- 좌측 패널
- 사용자 요약, 전체 공개 티어표 검색 입력, 주요 라우트 내비게이션, 최근 즐겨찾기 티어표 바로가기, 관리자 진입 버튼을 배치한다.
- 상단 토글 버튼은 항상 고정되어 있고, 패널을 축소하면 텍스트를 숨기고 아이콘 중심 레일로 전환한다.
- `Settings`는 별도 메뉴 항목으로만 진입하며, 사용자 카드 자체는 정보 표시 용도로만 사용한다.
- 사용자 아바타는 원형 보더 스타일을 유지하고, `Favorites` 영역은 최근 즐겨찾기 티어표 최대 10개를 메인 메뉴보다 작은 밀도의 바로가기 목록으로 보여준 뒤 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결한다.
- 중앙 워크스페이스
- 현재 라우트의 핵심 콘텐츠를 렌더링하는 영역이며, 홈/목록 계열 화면은 카드형 대시보드 레이아웃을 우선 적용한다.
- 공통 `workspaceBody`는 별도 외곽 카드 테두리 없이 셸 여백만 제공하고, 실제 카드/패널 레이어는 각 화면 내부에서만 구성한다.
- 홈, 게임 허브, 내 티어표, 즐겨찾기, 검색 결과 화면은 같은 카드 문법(상단 16:9 썸네일, `제목+좋아요` 1행, `작성자+최종 수정일` 1행)을 공유하며, 데스크톱 기준 기본 4열 카드 그리드를 사용한다.
- 목록 계열 화면의 상단 도구 영역은 통계 카드와 액션 버튼을 공통 높이/반경으로 맞춰, 같은 라이브러리 대시보드로 읽히도록 정리한다.
- 우측 패널
- 현재 화면 문맥에 맞는 설명, 빠른 액션, 계정 상태 같은 보조 정보를 배치한다.
- 에디터/관리자 세부 옵션은 후속 단계에서 이 패널로 점진 이관한다.
- 공통 토글 버튼은 패널이 닫혀 있을 때 중앙 헤더, 열려 있을 때 우측 헤더에 각각 아이콘만 표시하는 방식으로 동작한다.
- 오른쪽 패널 토글은 열기/닫기 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘으로 통일한다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마
- `users`
- `id`: string
@@ -52,6 +87,7 @@
- `gameId`: string
- `title`: string
- `thumbnailSrc`: string
- 사용자가 직접 지정하지 않으면 저장 시 티어표 대표 아이템 이미지로 자동 채운다.
- `description`: string
- `isPublic`: boolean
- `groups`: `{ id, name, itemIds[] }[]`
@@ -80,6 +116,7 @@
- `GET /api/games/:gameId`
- 티어표
- `GET /api/tierlists/public`
- `gameId` 없이 `q`만 전달하면 전체 공개 티어표 검색에 사용한다.
- `GET /api/tierlists/me`
- `GET /api/tierlists/favorites/me`
- `GET /api/tierlists/:id`
@@ -124,7 +161,7 @@
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
@@ -148,11 +185,15 @@
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
- `freeform` 티어표는 보드가 비어 있고 커스텀 아이템이 준비된 상태에서 `템플릿 등록 요청`을 보낼 수 있다.
- `템플릿 등록 요청` 전에는 체크리스트 모달로 `제목 직접 입력`, `보드 비움 상태`를 확인하고 두 조건이 충족될 때만 전송할 수 있다.
- `freeform` 티어표는 커스텀 아이템이 준비된 상태에서 `템플릿 등록 요청`을 보낼 수 있다.
- `템플릿 등록 요청` 전에는 체크리스트 모달로 `제목 직접 입력` 여부를 확인하고, 관리자가 식별하기 쉬운 게임 이름을 입력하도록 안내한다.
- 신규 티어표를 막 저장한 직후에도, 템플릿 요청은 새로 발급된 실제 티어표 ID를 기준으로 이어서 처리한다.
- 기존 게임 티어표는 커스텀 아이템이 포함되어 있으면 `템플릿 업데이트 요청`을 보낼 수 있다.
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
- 티어표에 썸네일을 직접 지정하지 않으면 저장 시 대표 아이템 이미지 하나를 기본 썸네일로 자동 선택한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집 화면의 우측 패널은 공통 `rightRail``localRightRailRoot`에 직접 section들을 쌓는 구조이며, 별도 외곽 래퍼 카드 없이 공통 오른쪽 컬럼 문법을 그대로 따른다.
- 공통 앱 셸은 좌측/중앙/우측 컬럼마다 높이 `56px`의 상단 헤더 블록을 유지하며, 중앙 헤더에는 고정 사이트 타이틀 `Tier Maker by zenn`을 표시한다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.

View File

@@ -1,10 +1,29 @@
# 할 일 및 이슈
## 즉시 확인 필요
- 피그마 기반 리디자인은 현재 공통 셸과 카드 목록 화면, 포커스 화면 안정화까지만 반영된 상태이므로, 에디터/관리자 우측 옵션 패널을 시안 구조에 맞게 실제 기능 패널로 이관하는 작업이 남아 있다.
- 홈/게임 허브/내 티어표/즐겨찾기 카드 문법은 어느 정도 통일됐지만, 아직 실제 SVG 아이콘, 미세 간격, hover/selection 상태 같은 디테일은 더 다듬을 필요가 있다.
- 목록 화면 상단 도구 막대는 공통 카드 문법으로 거의 맞췄지만, 실제 피그마처럼 필터 토글/정렬 상태를 시각적으로 더 강하게 드러내는 디테일은 남아 있다.
- 현재 공통 셸에는 임시 선형 SVG 아이콘을 사용하므로, 최종 머티리얼 아이콘 에셋을 받으면 교체하고 아이콘 크기/정렬을 다시 미세 조정할 필요가 있다.
- 공통 셸과 에디터에는 일부 실제 SVG 아이콘을 연결했지만, 아직 즐겨찾기/설정/관리자 등 나머지 내비 아이콘은 임시 선형 SVG이므로 추가 에셋 교체가 남아 있다.
- 공통 우측 패널의 토글과 독립 컬럼 구조는 반영되었지만, 현재는 안내 카드 중심이므로 실제 화면별 기능 컨트롤을 이 패널로 옮기는 작업이 남아 있다.
- 티어표 편집 화면과 관리자 화면 모두 로컬 우측 패널 구조로 옮겼지만, 아직 세부 카드 밀도와 아이콘/모션 디테일은 피그마 시안 수준으로 더 다듬을 필요가 있다.
- 에디터/관리자 로컬 우측 패널은 셸 카드에서 분리됐지만, 아직 실제 피그마처럼 패널 토글 전환 모션과 상태 강조가 더 필요하다.
- 에디터 로컬 우측 패널은 공통 토글과 연결됐지만, 아직 완전한 피그마 수준의 패널 애니메이션과 내부 카드 재배치는 더 다듬을 필요가 있다.
- 에디터 우측 패널은 셸의 세 번째 컬럼으로 옮겼지만, 내부 카드 간격과 섹션 구분선은 아직 첨부 시안처럼 더 촘촘하게 정리할 필요가 있다.
- 에디터 우측 패널 외곽 래퍼는 제거했으므로, 다음 단계는 공통 오른쪽 컬럼 안에서 입력/버튼/구분선 간격을 시안처럼 더 정교하게 다듬는 작업이다.
- 공통 56px 셸 헤더는 반영했으므로, 다음 단계는 좌/중앙/우 헤더 안에 실제 아이콘/상태 요소를 시안 순서에 맞게 하나씩 채워 넣는 작업이다.
- 좌측 레일은 최근 즐겨찾기와 전역 검색까지 붙었으므로, 다음 단계는 검색 자동완성이나 즐겨찾기 썸네일 품질 같은 디테일을 더 다듬는 작업이다.
- 좌측 레일 축소형은 반영했으므로, 다음 단계는 축소 상태에서 관리자/로그인 진입점과 hover 툴팁 같은 보조 UX를 더 다듬는 작업이다.
- 카드 목록은 4열 기준과 메타 줄 구성까지 통일했으므로, 다음 단계는 필터 상태 배지나 hover·selection 강조 같은 상호작용 디테일을 더 다듬는 작업이다.
- 검색 결과 화면은 좌측 전역 검색 입력만 쓰도록 정리됐으므로, 다음 단계는 결과 필터/정렬 여부를 검토하는 식으로 확장하면 된다.
- 공통 3단 셸 구조는 고정했지만, 관리자/에디터 우측 패널 내부에 아직 바디에 남아 있는 제어 요소를 더 옮겨야 한다.
- 홈 화면 우측 사이드는 CTA 하나만 남긴 상태이므로, 이후 필요할 때도 임시 정보 카드 다수를 다시 넣기보다 실제 필요한 기능만 선별해 추가해야 한다.
- 관리자 화면은 헤더 요약 통계와 카드 계층까지 정리됐지만, 아직 표준 SVG 아이콘 교체와 더 세밀한 상태 색상/선택 상태 표현은 남아 있다.
- 머티리얼 아이콘 SVG는 아직 임시 문자/배지 스타일로 대체된 부분이 있으므로, 최종 아이콘 에셋을 받아 반영하는 작업이 필요하다.
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
- 템플릿 등록/업데이트 요청은 현재 승인/반려만 지원하므로, 필요하면 관리자 코멘트나 사용자용 요청 상태 이력 화면을 추가 검토한다.
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.

View File

@@ -1,5 +1,123 @@
# 업데이트 로그
## 2026-03-30 v1.2.22
- **왼쪽 사이드 축소/확대 추가**: 좌측 레일을 완전히 숨기지 않고 축소형 내비로 접었다 펼 수 있게 바꾸고, 접힌 상태에서는 아이콘 중심으로만 보이도록 레이아웃을 정리
- **좌우 패널 토글 아이콘 통일**: 오른쪽 패널 열기/닫기는 모두 `dock_to_left`, 왼쪽 패널 토글은 `dock_to_right` 아이콘만 사용하도록 통일
- **전역 검색 아이콘 교체**: 좌측 전역 검색 입력에 사용자가 추가한 `search.svg`를 실제 아이콘으로 연결
## 2026-03-30 v1.2.21
- **티어표 카드 문법 통일**: 게임 허브, 검색 결과, 내 티어표, 즐겨찾기 목록의 카드 레이아웃을 `상단 썸네일 / 제목+좋아요 / 작성자+최종 수정일` 2줄 메타 구조로 통일하고, 데스크톱 기준 한 줄 4개 카드가 보이도록 재배치
- **즐겨찾기 화면 날짜 기준 단순화**: `내 즐겨찾기` 화면은 더 이상 즐겨찾기한 시각을 표시하지 않고, 정렬 기준과 무관하게 덱의 마지막 수정일만 카드에 노출하도록 정리
- **좌측 사용자 카드/즐겨찾기 밀도 보정**: 좌측 사용자 아바타를 원형 보더 스타일로 통일하고, `Favorites` 바로가기 섹션은 메인 메뉴보다 덜 강조되도록 썸네일·텍스트·간격을 한 단계 축소
## 2026-03-30 v1.2.20
- **검색 결과 상단 툴바 제거**: `/search` 화면의 중복 검색 폼을 제거하고, 좌측 전역 검색 입력만 검색 진입점으로 사용하도록 단순화
- **왼쪽 즐겨찾기 더보기 아이콘 교체**: 사용자가 추가한 `more.svg`를 좌측 `즐겨찾기 더 보기` 링크 아이콘에 연결
- **중앙 본문 외곽 레이어 제거**: `workspaceBody`의 추가 패딩, 테두리, 둥근 카드 배경을 제거해 중앙 콘텐츠가 한 겹만 안쪽으로 들어온 것처럼 보이도록 셸 여백을 단순화
- **게임 허브 상단 통계 제거**: 게임별 티어표 목록 화면의 `dashboardStat` 카드를 제거해 상단 헤더를 CTA 중심으로 정리
- **우측 패널 토글 동작 정리**: 중앙 헤더에는 패널이 닫혀 있을 때만 열기 아이콘 버튼을, 우측 헤더에는 패널이 열려 있을 때만 닫기 아이콘 버튼을 표시하도록 토글 흐름을 재구성
## 2026-03-30 v1.2.19
- **왼쪽 레일 설정 흐름 단순화**: 사용자 카드 클릭 팝업을 제거하고, 설정은 좌측 `Settings` 메뉴에서만 진입하도록 정리했으며 프로필 화면 하단에 로그아웃 버튼을 추가
- **좌측 즐겨찾기 바로가기 추가**: 좌측 `Favorites` 영역에 최근 즐겨찾기 티어표 최대 10개를 바로가기 형태로 표시하고, 하단 `즐겨찾기 더 보기` 링크로 전체 즐겨찾기 화면에 연결
- **전역 공개 티어표 검색 추가**: 좌측 검색 입력은 이제 전체 공개 티어표를 대상으로 검색하며, 새 `/search` 결과 화면에서 제목/작성자 기준 검색 결과를 카드 목록으로 표시
- **설정 아이콘 반영 및 중복 관리자 버튼 제거**: 사용자가 추가한 `settings.svg`를 좌측 `Settings` 메뉴에 연결하고, 상단 내비에 중복되던 관리자 메뉴 항목은 제거
## 2026-03-30 v1.2.18
- **공통 56px 셸 헤더 도입**: 좌측 사이드, 중앙 워크스페이스, 우측 사이드 상단에 각각 높이 `56px`의 고정 헤더 블록을 두고, 사이트 타이틀 `Tier Maker by zenn`은 중앙 상단 헤더에만 표시되도록 셸 구조를 재정리
- **에디터 메인 래퍼 단순화**: 티어표 편집 화면의 `.layout` 2열 그리드를 제거해 공통 3단 셸 바깥에 중복 컬럼이 생기지 않도록 정리
- **아이템 라벨 overflow 수정**: 편집 화면 우측 아이템 풀에서 긴 아이템 이름이 화면 밖으로 밀려나지 않도록 `minmax(0, 1fr)`와 말줄임 처리 기준을 추가
## 2026-03-30 v1.2.17
- **에디터 우측 패널 래퍼 제거**: 티어표 편집 화면의 `editorSidebar` 외곽 래퍼를 제거하고, 공통 오른쪽 레일 루트에 편집 섹션들이 직접 쌓이도록 구조를 단순화
- **공통 우측 레일 정렬 통일**: `App.vue``localRightRailRoot`에 섹션 스택 정렬을 부여해, 에디터/관리자 같은 로컬 패널 화면도 공통 레일 안에서 같은 방식으로 콘텐츠가 배치되도록 정리
## 2026-03-30 v1.2.16
- **메인 오른쪽 사이드 단순화**: 홈 화면 기준 오른쪽 컬럼의 컨텍스트/계정/점프 카드 3종을 제거하고, 시안에 맞춰 핵심 CTA 버튼만 남기는 구조로 단순화
- **홈 상단 중복 도구 제거**: 중앙 바디 상단에 추가돼 있던 `Visible Games`, `Account`, `즐겨찾기 보기`, `내 리스트 보기`, `커스텀 티어표 만들기` 도구 막대를 제거해, 왼쪽/오른쪽 사이드와 중복되는 이동 요소를 정리
## 2026-03-30 v1.2.15
- **3단 셸 구조 고정**: 홈 화면처럼 `왼쪽 사이드 | 중앙 컨텐츠 | 오른쪽 사이드` 3단 레이아웃을 모든 일반 페이지의 공통 구조로 고정하고, 페이지 이동 시 오른쪽 컬럼이 사라졌다 나타나는 구조를 제거
- **에디터/관리자 우측 패널 공통 컬럼 통합**: 티어표 편집과 관리자 화면의 로컬 우측 패널을 Teleport로 공통 오른쪽 컬럼에 배치해, 바디 내부 2단 레이아웃 대신 셸의 세 번째 컬럼을 공유하도록 재정리
## 2026-03-30 v1.2.14
- **에디터 우측 패널 셸 컬럼 이관**: 티어표 편집 화면의 `editorSidebar``workspaceBody` 내부 보조 칼럼이 아니라 공통 셸의 세 번째 컬럼으로 옮겨, 메인 화면과 같은 `왼쪽 사이드 | 메인 | 오른쪽 사이드` 구조를 사용하도록 재배치
- **공통 토글과 실제 aside 연결**: 상단 패널 토글 버튼은 이제 Teleport로 이동한 에디터 우측 aside를 직접 접고 펴며, 본문 내부 2단 레이아웃처럼 보이던 구조를 제거
## 2026-03-30 v1.2.13
- **에디터 우측 패널 회귀 수정**: 공통 패널 상태를 템플릿에서 잘못 참조해 `editorSidebar`가 항상 닫힌 상태로 계산되던 문제를 수정해, 제목/설명/썸네일/저장 패널이 다시 정상 표시되도록 복구
## 2026-03-30 v1.2.12
- **에디터 우측 패널 토글 연결**: 공통 상단의 패널 토글 버튼이 이제 티어표 편집 화면의 `editorSidebar`에도 직접 연결되어, 숨기면 우측 패널이 접히고 중앙 보드 영역이 넓어지도록 수정
- **로컬 우측 패널 컬럼 충돌 방지**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면에서는 공통 `rightClosed` 셸 컬럼 계산이 다시 끼어들지 않도록 예외 처리를 추가해 레이아웃이 다시 틀어지지 않게 보정
## 2026-03-30 v1.2.11
- **에디터 로컬 우측 패널 분리 보정**: 에디터/관리자처럼 로컬 우측 패널을 쓰는 화면은 공통 `workspaceBody` 카드 컨테이너를 벗기고, 로컬 패널이 중앙 본문 안쪽이 아니라 독립 컬럼처럼 보이도록 셸 구조를 조정
- **에디터 우측 컬럼 간격 보정**: 티어표 편집 화면의 `editorSidebar`가 본문 내부 보조 박스처럼 눌리지 않도록 간격과 최소 폭을 정리해 우측 사이드바 역할이 더 분명하게 보이도록 수정
## 2026-03-30 v1.2.10
- **목록 화면 상단 툴바 밀도 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 상단 영역의 통계 카드와 액션 버튼 높이/반경/배경을 맞춰 공통 셸과 같은 도구 막대 문법으로 정리
- **홈 빠른 진입 흐름 보정**: 홈 화면 툴바에서 중복되던 버튼 흐름을 `즐겨찾기 / 내 리스트 / 커스텀 티어표 만들기` 중심으로 재구성해 실제 사용 동선에 맞게 정리
- **목록 카드 인터랙션 보강**: 주요 카드 목록에 일관된 hover 이동과 배경 전환을 넣어, 대시보드 카드가 더 또렷하게 반응하도록 조정
## 2026-03-30 v1.2.9
- **관리자 대시보드 헤더 보강**: 관리자 화면 상단에 현재 탭 기준 요약 통계 카드를 추가해, 게임/아이템/티어표/회원 상태를 즉시 읽을 수 있게 정리
- **운영 패널 질감 정리**: 우측 `320px` 운영 패널의 탭, 입력, 통계 카드, 버튼 라운드/배경/호버 상태를 공통 셸 톤에 맞춰 더 두꺼운 대시보드 카드 문법으로 통일
- **관리 카드 밀도 개선**: 게임 상세, 커스텀 아이템, 템플릿 요청, 전체 티어표, 회원 카드의 배경층·패딩·반경을 함께 다듬어 시안에 가까운 평평한 관리용 레이아웃으로 보정
## 2026-03-30 v1.2.8
- **실제 SVG 아이콘 연결 시작**: 사용자가 추가한 `grid_view`, `lists`, `dock_to_left`, `dock_to_right` 아이콘을 공통 셸 내비와 우측 패널 토글에 연결해 문자 기반 아이콘을 일부 실제 에셋으로 교체
- **에디터 3열 구조 복구**: 티어표 편집 화면을 `보드 / 아이템 풀 / 우측 편집 사이드바` 구조로 재배치해, 아이템 풀은 보드 옆에서 바로 드래그 가능하고 편집 옵션은 최우측 패널에만 남도록 수정
- **커스텀 아이템 이름 정리 위치 조정**: 커스텀 아이템 이름 수정 목록은 드래그용 아이템 풀 아래가 아니라 우측 편집 사이드바 안으로 옮겨, 보드 배치 흐름과 옵션 정리 흐름을 분리
## 2026-03-30 v1.2.7
- **공통 셸 아이콘형 정리**: 좌측 내비와 우측 보조 패널의 임시 문자 배지를 간단한 SVG 아이콘형으로 바꾸고, 버튼/카드 라운드와 밀도를 통일
- **좌측 레일 정보 밀도 개선**: 사용자 카드, 빠른 검색, 내비 버튼, 하단 로그인/관리자 버튼을 더 두꺼운 카드 문법으로 맞춰 피그마 톤에 가까운 레일 형태로 재정리
- **에디터 패널 감도 보정**: 티어표 편집 화면의 보드, 보드 툴바, 우측 편집 패널, 아이템 풀/드롭존 카드의 배경·경계·라운드를 함께 정리해 공통 셸과 시각 언어를 맞춤
## 2026-03-30 v1.2.6
- **목록형 화면 카드 문법 통일**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 카드형 목록을 동일한 썸네일/제목/작성자/메타 구조로 정리해 대시보드 톤을 맞춤
- **홈 화면 대시보드 재정렬**: 메인 게임 라이브러리 화면에 상단 상태 카드와 CTA를 추가하고, 게임 카드는 `16:9` 썸네일 + ID 메타를 갖는 라이브러리 카드 형태로 재배치
- **게임 허브 헤더/검색 정리**: 게임 허브는 상단 통계와 생성 버튼, 보조 설명을 포함한 헤더로 재구성하고, 공개 티어표 카드도 같은 카드 밀도로 재정리
## 2026-03-30 v1.2.5
- **관리자 로컬 우측 패널 이관**: 관리자 화면도 공통 우측 패널 대신 화면 내부의 `320px` 전용 운영 패널을 사용하도록 정리하고, 탭·검색·필터·빠른 액션을 우측으로 이동
- **관리 화면 본문 집중도 개선**: 중앙 영역은 상단 고정 게임 순서, 선택된 게임 상세, 커스텀 아이템 카드, 템플릿 요청/전체 티어표, 회원 카드 같은 실제 관리 대상만 남기고 빈 상태 안내도 별도 패널로 정리
- **관리자 셸 예외 확장**: 공통 앱 셸에서 `/admin`도 전용 로컬 우측 패널을 사용하는 포커스 화면으로 분류해 generic 우측 문맥 카드가 중복 표시되지 않게 조정
## 2026-03-30 v1.2.4
- **비로그인 중복 안내 제거**: 좌측 사이드 상단의 별도 로그인 안내 카드를 제거하고, 비로그인 상태에서는 좌측 하단 버튼만 `로그인` 진입점으로 사용하도록 단순화
- **에디터 우측 편집 패널 이관**: 티어표 편집 화면의 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 중앙 상단이 아니라 독립 우측 편집 패널로 이동
- **공통 우측 패널 예외 처리**: 티어표 편집 화면은 공통 우측 패널 대신 화면 내부 전용 편집 패널을 사용하도록 조정해, generic 안내 카드가 중복 표시되지 않게 정리
## 2026-03-30 v1.2.2
- **사이드 패널 폭 고정**: 공통 앱 셸의 좌측 패널 폭을 `248px`, 우측 패널 폭을 `320px` 기준으로 재정의해 피그마 시안과 더 가깝게 맞춤
- **우측 패널 토글 추가**: 상단 우측 토글 버튼으로 우측 패널을 접고 펼칠 수 있게 하고, 접힐 때는 중앙 작업 영역이 자연스럽게 확장되도록 전환 애니메이션을 추가
- **우측 패널 독립성 강화**: 우측 패널은 본문과 별도 컬럼으로 유지하고, 닫힐 때도 본문 레이아웃과 분리된 독립 패널처럼 동작하도록 셸 구조를 조정
## 2026-03-30 v1.2.1
- **포커스 화면 폭 복구**: 에디터·관리자·프로필·로그인 화면은 공통 우측 패널을 잠시 숨기고 중앙 작업 폭을 넓혀, 기존 기능 UI가 3단 셸과 충돌하며 깨지던 문제를 완화
- **목록 카드 밀도 재조정**: 홈, 게임 허브, 내 티어표, 즐겨찾기 화면의 기본 컬럼 수를 줄여 현재 셸 폭 안에서도 카드가 과도하게 눌리지 않도록 정리
- **에디터/관리자 패널 안정화**: 내부 작업 패널 색상과 폭을 새 셸 톤에 맞춰 다시 정리해, 중첩 패널 때문에 사용성이 무너지던 부분을 우선 복구
## 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
- **템플릿 등록 요청 모달 레이아웃 보정**: 체크리스트 문구 줄바꿈과 버튼 겹침 문제를 수정하고, 설명은 좌측·상태 배지는 우측에 배치되도록 요청 모달 레이아웃을 다시 정리
- **관리자 티어표 화면 분리**: `티어표 관리` 탭 안에서 `템플릿 요청 관리 / 전체 티어표 관리`를 서브 탭으로 분리해, 요청 목록과 저장된 전체 티어표 목록이 섞여 보이지 않도록 개선

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm440-80h120v-560H640v560Zm-80 0v-560H200v560h360Zm80 0h120-120Z"/></svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v560q0 33-23.5 56.5T760-120H200Zm120-80v-560H200v560h120Zm80 0h360v-560H400v560Zm-80 0H200h120Z"/></svg>

After

Width:  |  Height:  |  Size: 306 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M120-520v-320h320v320H120Zm0 400v-320h320v320H120Zm400-400v-320h320v320H520Zm0 400v-320h320v320H520ZM200-600h160v-160H200v160Zm400 0h160v-160H600v160Zm0 400h160v-160H600v160Zm-400 0h160v-160H200v160Zm400-400Zm0 240Zm-240 0Zm0-240Z"/></svg>

After

Width:  |  Height:  |  Size: 354 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M80-160v-160h160v160H80Zm240 0v-160h560v160H320ZM80-400v-160h160v160H80Zm240 0v-160h560v160H320ZM80-640v-160h160v160H80Zm240 0v-160h560v160H320Z"/></svg>

After

Width:  |  Height:  |  Size: 268 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M360-160q-19 0-36-8.5T296-192L80-480l216-288q11-15 28-23.5t36-8.5h440q33 0 56.5 23.5T880-720v480q0 33-23.5 56.5T800-160H360ZM180-480l180 240h440v-480H360L180-480Zm248.5 28.5Q440-463 440-480t-11.5-28.5Q417-520 400-520t-28.5 11.5Q360-497 360-480t11.5 28.5Q383-440 400-440t28.5-11.5Zm140 0Q580-463 580-480t-11.5-28.5Q557-520 540-520t-28.5 11.5Q500-497 500-480t11.5 28.5Q523-440 540-440t28.5-11.5Zm140 0Q720-463 720-480t-11.5-28.5Q697-520 680-520t-28.5 11.5Q640-497 640-480t11.5 28.5Q663-440 680-440t28.5-11.5ZM580-480Z"/></svg>

After

Width:  |  Height:  |  Size: 639 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M784-120 532-372q-30 24-69 38t-83 14q-109 0-184.5-75.5T120-580q0-109 75.5-184.5T380-840q109 0 184.5 75.5T640-580q0 44-14 83t-38 69l252 252-56 56ZM380-400q75 0 127.5-52.5T560-580q0-75-52.5-127.5T380-760q-75 0-127.5 52.5T200-580q0 75 52.5 127.5T380-400Z"/></svg>

After

Width:  |  Height:  |  Size: 375 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="m370-80-16-128q-13-5-24.5-12T307-235l-119 50L78-375l103-78q-1-7-1-13.5v-27q0-6.5 1-13.5L78-585l110-190 119 50q11-8 23-15t24-12l16-128h220l16 128q13 5 24.5 12t22.5 15l119-50 110 190-103 78q1 7 1 13.5v27q0 6.5-2 13.5l103 78-110 190-118-50q-11 8-23 15t-24 12L590-80H370Zm70-80h79l14-106q31-8 57.5-23.5T639-327l99 41 39-68-86-65q5-14 7-29.5t2-31.5q0-16-2-31.5t-7-29.5l86-65-39-68-99 42q-22-23-48.5-38.5T533-694l-13-106h-79l-14 106q-31 8-57.5 23.5T321-633l-99-41-39 68 86 64q-5 15-7 30t-2 32q0 16 2 31t7 30l-86 65 39 68 99-42q22 23 48.5 38.5T427-266l13 106Zm42-180q58 0 99-41t41-99q0-58-41-99t-99-41q-59 0-99.5 41T342-480q0 58 40.5 99t99.5 41Zm-2-140Z"/></svg>

After

Width:  |  Height:  |  Size: 770 B

View File

@@ -62,6 +62,7 @@ export const api = {
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}`),
searchPublicTierLists: (gameId, q = '') =>
request(`/api/tierlists/public?gameId=${encodeURIComponent(gameId || '')}&q=${encodeURIComponent(q || '')}`),
searchAllPublicTierLists: (q = '') => request(`/api/tierlists/public?q=${encodeURIComponent(q || '')}`),
listMyTierLists: () => request('/api/tierlists/me'),
listMyFavoriteTierLists: ({ q = '', sort = 'favorited' } = {}) =>
request(`/api/tierlists/favorites/me?q=${encodeURIComponent(q)}&sort=${encodeURIComponent(sort)}`),

View File

@@ -8,6 +8,7 @@ import MyTierListsView from '../views/MyTierListsView.vue'
import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
import AdminView from '../views/AdminView.vue'
import ProfileView from '../views/ProfileView.vue'
import SearchResultsView from '../views/SearchResultsView.vue'
export function createRouter() {
return _createRouter({
@@ -20,6 +21,7 @@ export function createRouter() {
{ path: '/login', name: 'login', component: LoginView },
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', name: 'admin', component: AdminView },
{ path: '/profile', name: 'profile', component: ProfileView },
],

View File

@@ -1,57 +1,49 @@
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
background: var(--bg);
font-family: 'Pretendard', 'Inter', 'Segoe UI', sans-serif;
line-height: 1.5;
font-weight: 400;
color: rgba(255, 255, 255, 0.92);
background: #121212;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
* {
box-sizing: border-box;
}
#social .button-icon {
filter: invert(1) brightness(2);
}
html,
body,
#app {
min-height: 100vh;
}
body {
margin: 0;
background: #121212;
}
button,
input,
select,
textarea {
font: inherit;
}
button {
appearance: none;
}
a {
color: inherit;
}
input,
select,
textarea {
color: rgba(255, 255, 255, 0.92);
}
select {
@@ -59,253 +51,24 @@ select {
-webkit-appearance: none;
-moz-appearance: none;
background-image:
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.78) 50%),
linear-gradient(135deg, rgba(255, 255, 255, 0.78) 50%, transparent 50%);
linear-gradient(45deg, transparent 50%, rgba(255, 255, 255, 0.72) 50%),
linear-gradient(135deg, rgba(255, 255, 255, 0.72) 50%, transparent 50%);
background-position:
calc(100% - 18px) calc(50% - 2px),
calc(100% - 12px) calc(50% - 2px);
calc(100% - 20px) calc(50% - 2px),
calc(100% - 14px) calc(50% - 2px);
background-size: 6px 6px, 6px 6px;
background-repeat: no-repeat;
padding-right: 36px;
padding-right: 40px;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
}
h2,
h3,
h4,
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
/* width: 1126px; */
max-width: 100%;
margin: 0 auto;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,12 @@ const toast = useToast()
const favorites = ref([])
const query = ref('')
const sort = ref('favorited')
const sortLabel = computed(() =>
sort.value === 'favorited' ? '즐겨찾기한 날짜' : sort.value === 'updated' ? '최종 업데이트' : '즐겨찾기 수'
)
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -62,6 +57,7 @@ onMounted(loadFavorites)
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">Collection</div>
<h2 class="title"> 즐겨찾기</h2>
<div class="desc">마음에 드는 티어표를 모아보고, 원하는 기준으로 정렬할 있어요.</div>
</div>
@@ -78,28 +74,27 @@ onMounted(loadFavorites)
<div v-if="favorites.length === 0" class="empty">즐겨찾기한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="tierList in favorites" :key="tierList.id" class="row">
<button class="row__body" @click="openTierList(tierList)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="row__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="row__thumbPlaceholder"></div>
<article v-for="tierList in favorites" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ tierList.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(tierList)" class="row__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span>by {{ displayNameOf(tierList) }}</span>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat"> {{ tierList.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
<div class="row__foot">
<div class="row__meta">
<div>{{ tierList.gameName || tierList.gameId }}</div>
<div>{{ sortLabel }}: {{ fmt(sort === 'favorited' ? tierList.favoritedAt : tierList.updatedAt) }}</div>
</div>
<div class="favoriteStat"> {{ tierList.favoriteCount || 0 }}</div>
</div>
</article>
</div>
</section>
@@ -108,7 +103,7 @@ onMounted(loadFavorites)
<style scoped>
.wrap {
display: grid;
gap: 14px;
gap: 18px;
}
.head {
display: flex;
@@ -116,14 +111,23 @@ onMounted(loadFavorites)
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 0;
font-size: 28px;
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
opacity: 0.78;
color: rgba(255, 255, 255, 0.58);
}
.toolbar {
display: flex;
@@ -132,16 +136,16 @@ onMounted(loadFavorites)
}
.input,
.select {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
}
.btn {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
font-weight: 800;
@@ -152,18 +156,25 @@ onMounted(loadFavorites)
}
.list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.row {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
overflow: hidden;
display: grid;
gap: 10px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.row__body {
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
border: 0;
background: transparent;
color: inherit;
@@ -171,76 +182,92 @@ onMounted(loadFavorites)
text-align: left;
cursor: pointer;
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: rgba(255, 255, 255, 0.03);
padding: 14px 14px 0;
box-sizing: border-box;
}
.row__thumb,
.row__thumbPlaceholder {
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
}
.row__thumb {
.boardCard__thumb {
object-fit: cover;
}
.row__thumbPlaceholder {
background: linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
.boardCard__thumbPlaceholder {
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
}
.row__head {
padding: 14px 14px 0;
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.row__title {
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__author {
.boardCard__author {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.row__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.row__meta {
display: grid;
gap: 4px;
opacity: 0.78;
font-size: 13px;
}
.boardCard__date,
.favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border-radius: 999px;
padding: 7px 10px;
font-weight: 800;
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
@media (max-width: 1100px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}

View File

@@ -17,12 +17,10 @@ const error = ref('')
const query = ref('')
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -77,13 +75,13 @@ function submitSearch() {
</script>
<template>
<section class="head">
<div class="head__left">
<div class="kicker">게임</div>
<h2 class="title">{{ gameName || gameId }}</h2>
<p class="desc"> 티어표를 만들거나, 다른 사람들이 올린 티어표를 확인하세.</p>
<section class="dashboardHero">
<div class="dashboardHero__left">
<div class="dashboardHero__eyebrow">Collection</div>
<h2 class="dashboardHero__title">{{ gameName || gameId }}</h2>
<p class="dashboardHero__desc"> 게임의 공개 티어표를 탐색하고, 바로 보드를 만들어 같은 흐름으로 이어갈 있어.</p>
</div>
<div class="head__right">
<div class="dashboardHero__right">
<button class="primary" @click="createNew">{{ auth.user ? '새로운 티어표 만들기' : '로그인 티어표 만들기' }}</button>
</div>
</section>
@@ -91,7 +89,10 @@ function submitSearch() {
<div v-if="error" class="error">{{ error }}</div>
<section class="panel">
<div class="panel__head">
<div class="panel__title">공개 티어표</div>
<div>
<div class="panel__title">공개 티어표</div>
<div class="panel__sub">제목이나 작성자로 빠르게 좁혀볼 있어요.</div>
</div>
<div class="searchBar">
<input v-model="query" class="searchBar__input" placeholder="제목 또는 작성자 검색" @keydown.enter.prevent="submitSearch" />
<button class="searchBar__button" @click="submitSearch">검색</button>
@@ -99,71 +100,91 @@ function submitSearch() {
</div>
<div v-if="tierLists.length === 0" class="empty">아직 공개 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in tierLists" :key="t.id" class="row">
<button class="row__body" @click="openTierList(t.id)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
<article v-for="t in tierLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(t.id)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '♥' : '♡' }} {{ t.favoriteCount || 0 }}
</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</div>
</button>
<div class="row__foot">
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
<div class="favoriteStat" :title="t.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ t.isFavorited ? '★' : '☆' }} {{ t.favoriteCount || 0 }}
</div>
</div>
</article>
</div>
</section>
</template>
<style scoped>
.head {
.dashboardHero {
display: flex;
gap: 14px;
align-items: flex-end;
gap: 18px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
padding: 6px 2px 14px;
padding: 6px 2px 18px;
}
.kicker {
.dashboardHero__left {
display: grid;
gap: 8px;
}
.dashboardHero__right {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.dashboardHero__eyebrow {
font-size: 12px;
opacity: 0.7;
color: rgba(255, 255, 255, 0.42);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.title {
.dashboardHero__title {
margin: 4px 0 6px;
font-size: 26px;
letter-spacing: -0.02em;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.desc {
.dashboardHero__desc {
margin: 0;
opacity: 0.84;
color: rgba(255, 255, 255, 0.58);
max-width: 720px;
}
.primary {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(96, 165, 250, 0.2);
color: rgba(255, 255, 255, 0.92);
padding: 12px 16px;
border-radius: 14px;
border: 1px solid rgba(77, 127, 233, 0.96);
background: rgba(77, 127, 233, 0.88);
color: #fff;
cursor: pointer;
font-weight: 700;
font-weight: 800;
transition:
transform 0.16s ease,
background 0.16s ease,
border-color 0.16s ease;
}
.primary:hover {
background: rgba(96, 165, 250, 0.26);
transform: translateY(-1px);
}
.panel {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 14px;
/* border: 1px solid rgba(255, 255, 255, 0.08); */
background: transparent;
border-radius: 0;
padding: 0;
}
.error {
margin: 10px 0 14px;
@@ -174,6 +195,12 @@ function submitSearch() {
}
.panel__title {
font-weight: 800;
font-size: 18px;
}
.panel__sub {
margin-top: 6px;
color: rgba(255, 255, 255, 0.56);
font-size: 13px;
}
.panel__head {
display: flex;
@@ -181,7 +208,7 @@ function submitSearch() {
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 10px;
margin-bottom: 18px;
}
.searchBar {
display: flex;
@@ -191,16 +218,16 @@ function submitSearch() {
}
.searchBar__input {
min-width: 240px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
padding: 11px 13px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
}
.searchBar__button {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
padding: 11px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
font-weight: 800;
@@ -211,24 +238,26 @@ function submitSearch() {
}
.list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.row {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
display: grid;
gap: 10px;
align-content: start;
min-height: 168px;
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.row:hover {
background: rgba(255, 255, 255, 0.05);
.boardCard:hover {
background: rgba(70, 70, 70, 0.96);
transform: translateY(-2px);
}
.row__body {
.boardCard__body {
text-align: left;
padding: 0;
border: 0;
@@ -237,85 +266,105 @@ function submitSearch() {
cursor: pointer;
width: 100%;
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: rgba(255, 255, 255, 0.03);
padding: 14px 14px 0;
box-sizing: border-box;
}
.row__thumb {
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 18px;
}
.row__thumbPlaceholder {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background:
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.row__title {
.boardCard__title {
font-weight: 800;
min-width: 0;
font-size: 18px;
line-height: 1.35;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__head {
padding: 14px 14px 0;
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 12px;
align-content: start;
gap: 10px;
}
.row__author {
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
opacity: 0.86;
flex: 0 0 auto;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.row__meta {
opacity: 0.78;
font-size: 13px;
}
.row__foot {
padding: 0 14px 14px;
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
margin-top: auto;
}
.boardCard__date,
.favoriteStat {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
border-radius: 999px;
padding: 7px 10px;
font-weight: 800;
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
@media (max-width: 1100px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.dashboardHero__right {
width: 100%;
}
.primary {
width: 100%;
}
.list {
grid-template-columns: 1fr;
}

View File

@@ -41,125 +41,165 @@ function thumbUrl(g) {
</script>
<template>
<section class="topBar">
<div class="topBar__copy">
<h1 class="topBar__title">게임 선택</h1>
<p class="topBar__desc">관리자 고정 순서가 있으면 먼저 보여주고, 게임은 최근 생성순으로 정렬됩니다.</p>
<section class="dashboardHero">
<div class="dashboardHero__copy">
<div class="dashboardHero__eyebrow">Workspace</div>
<h1 class="dashboardHero__title">Game Library</h1>
<p class="dashboardHero__desc">자주 쓰는 게임 템플릿을 빠르게 고르고, 필요하면 바로 커스텀 티어표를 시작할 있어요.</p>
</div>
<button class="customTierBtn" @click="goFreeform">{{ auth.user ? '커스텀 티어표 만들기' : '로그인 커스텀 티어표 만들기' }}</button>
</section>
<div v-if="error" class="error">{{ error }}</div>
<section class="grid">
<button v-for="g in games" :key="g.id" class="card" @click="goGame(g.id)">
<div class="thumbWrap">
<img v-if="thumbUrl(g)" class="thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="thumbFallback">{{ g.name[0] }}</div>
<section class="libraryGrid">
<button v-for="g in games" :key="g.id" class="libraryCard" @click="goGame(g.id)">
<div class="libraryCard__thumbWrap">
<img v-if="thumbUrl(g)" class="libraryCard__thumb" :src="thumbUrl(g)" :alt="g.name" />
<div v-else class="libraryCard__thumbFallback">대표 썸네일</div>
</div>
<div class="libraryCard__body">
<div class="libraryCard__title">{{ g.name }}</div>
<div class="libraryCard__meta">
<span class="libraryCard__metaDot"></span>
<span>{{ g.id }}</span>
</div>
</div>
<div class="card__title">{{ g.name }}</div>
</button>
</section>
</template>
<style scoped>
.topBar {
.dashboardHero {
display: flex;
gap: 16px;
gap: 18px;
justify-content: space-between;
align-items: flex-start;
flex-wrap: wrap;
margin-top: 4px;
margin-top: 2px;
margin-bottom: 18px;
padding: 6px 2px 18px;
}
.topBar__copy {
.dashboardHero__copy {
display: grid;
gap: 6px;
gap: 8px;
max-width: 720px;
}
.topBar__title {
.dashboardHero__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.dashboardHero__title {
margin: 0;
font-size: 30px;
letter-spacing: -0.03em;
}
.topBar__desc {
margin: 0;
opacity: 0.78;
line-height: 1.5;
}
.customTierBtn {
padding: 12px 16px;
border-radius: 14px;
border: 1px solid rgba(96, 165, 250, 0.28);
background: linear-gradient(135deg, rgba(96, 165, 250, 0.2), rgba(16, 185, 129, 0.16));
font-size: 34px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
font-weight: 900;
cursor: pointer;
}
.grid {
.dashboardHero__desc {
margin: 0;
color: rgba(255, 255, 255, 0.58);
line-height: 1.5;
max-width: 720px;
}
.libraryGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
margin-top: 14px;
gap: 18px;
}
.error {
margin-top: 12px;
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.3);
background: rgba(239, 68, 68, 0.12);
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
color: rgba(255, 255, 255, 0.92);
}
.card {
.libraryCard {
text-align: left;
padding: 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.05);
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
display: grid;
gap: 10px;
gap: 12px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.card:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.18);
.libraryCard:hover {
background: rgba(70, 70, 70, 0.96);
transform: translateY(-2px);
}
.thumbWrap {
.libraryCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.06);
background: #555;
overflow: hidden;
display: grid;
place-items: center;
}
.thumb {
.libraryCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbFallback {
font-weight: 900;
font-size: 28px;
opacity: 0.85;
.libraryCard__thumbFallback {
font-size: 13px;
font-weight: 700;
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
}
.card__title {
.libraryCard__body {
display: grid;
gap: 8px;
}
.libraryCard__title {
font-weight: 800;
letter-spacing: -0.02em;
font-size: 16px;
}
@media (max-width: 720px) {
.topBar {
align-items: stretch;
}
.customTierBtn {
width: 100%;
}
.grid {
grid-template-columns: 1fr;
}
.libraryCard__meta {
display: inline-flex;
gap: 8px;
align-items: center;
color: rgba(255, 255, 255, 0.6);
font-size: 13px;
}
@media (min-width: 721px) and (max-width: 1100px) {
.grid {
.libraryCard__metaDot {
width: 10px;
height: 10px;
border-radius: 3px;
background: rgba(255, 255, 255, 0.9);
}
@media (max-width: 1200px) {
.libraryGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 900px) {
.libraryGrid {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.dashboardHero {
align-items: stretch;
}
.dashboardToolbar {
width: 100%;
}
.dashboardToolbar__ghost,
.dashboardToolbar__stat,
.customTierBtn {
flex: 1 1 100%;
}
.libraryGrid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -17,12 +17,10 @@ watch(error, (message) => {
})
function fmt(ts) {
return new Date(ts).toLocaleString(undefined, {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
})
}
@@ -72,25 +70,40 @@ async function removeList(t) {
<template>
<section class="wrap">
<h2 class="title"> 티어표</h2>
<header class="head">
<div>
<div class="head__eyebrow">Library</div>
<h2 class="title"> 티어표</h2>
<div class="desc">직접 저장한 티어표를 같은 카드 레이아웃으로 다시 열고 정리할 있어요.</div>
</div>
<div class="head__stat">
<span class="head__statLabel">Saved Lists</span>
<strong class="head__statValue">{{ myLists.length }}</strong>
</div>
</header>
<div class="card">
<div v-if="myLists.length === 0" class="empty">아직 저장한 티어표가 없어요.</div>
<div v-else class="list">
<article v-for="t in myLists" :key="t.id" class="row">
<button class="row__body" @click="openList(t)">
<div class="row__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="row__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="row__thumbPlaceholder"></div>
<article v-for="t in myLists" :key="t.id" class="boardCard">
<button class="boardCard__body" @click="openList(t)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(t)" class="boardCard__thumb" :src="tierListThumbnailUrl(t)" :alt="t.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="row__head">
<div class="row__title">{{ t.title }}</div>
<div class="row__author">
<img v-if="avatarSrcOf(t)" class="row__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="row__avatar row__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span>by {{ displayNameOf(t) }}</span>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ t.title }}</div>
<div class="favoriteStat"> {{ t.favoriteCount || 0 }}</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(t)" class="boardCard__avatar" :src="avatarSrcOf(t)" :alt="displayNameOf(t)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(t) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(t) }}</span>
</div>
<div class="boardCard__date">{{ fmt(t.updatedAt) }}</div>
</div>
</div>
<div class="row__meta">{{ fmt(t.updatedAt) }}</div>
</button>
<button class="link link--danger" @click="removeList(t)">삭제</button>
</article>
@@ -101,22 +114,60 @@ async function removeList(t) {
<style scoped>
.wrap {
padding: 10px 2px;
padding: 4px 2px;
}
.head {
display: flex;
gap: 16px;
align-items: flex-start;
justify-content: space-between;
flex-wrap: wrap;
margin-bottom: 18px;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 0 0 10px;
font-size: 26px;
letter-spacing: -0.02em;
margin: 4px 0 6px;
font-size: 32px;
letter-spacing: -0.04em;
color: rgba(255, 255, 255, 0.96);
}
.desc {
color: rgba(255, 255, 255, 0.58);
}
.head__stat {
display: grid;
gap: 2px;
min-width: 112px;
padding: 12px 14px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.045);
}
.head__statLabel {
font-size: 11px;
color: rgba(255, 255, 255, 0.48);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.head__statValue {
font-size: 18px;
font-weight: 900;
}
.card {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
padding: 14px;
border: 0;
background: transparent;
border-radius: 0;
padding: 0;
}
.link {
padding: 8px 10px;
border-radius: 10px;
padding: 10px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.06);
color: rgba(255, 255, 255, 0.92);
@@ -128,19 +179,26 @@ async function removeList(t) {
}
.list {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 14px;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.row {
.boardCard {
display: grid;
gap: 10px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.16);
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
color: rgba(255, 255, 255, 0.92);
overflow: hidden;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition:
transform 0.16s ease,
background 0.16s ease;
}
.row__body {
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
flex: 1 1 auto;
min-width: 0;
text-align: left;
@@ -150,75 +208,106 @@ async function removeList(t) {
color: inherit;
padding: 0;
display: grid;
gap: 10px;
}
.row__thumbWrap {
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
background: rgba(255, 255, 255, 0.03);
padding: 14px 14px 0;
box-sizing: border-box;
}
.row__thumb {
.boardCard__thumb {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
border-radius: 18px;
}
.row__thumbPlaceholder {
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
background:
linear-gradient(135deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
border-radius: 18px;
}
.row__title {
.boardCard__title {
font-weight: 900;
min-width: 0;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row__head {
padding: 0 14px;
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
}
.row__author {
.boardCard__author {
min-width: 0;
display: inline-flex;
gap: 8px;
align-items: center;
font-size: 13px;
opacity: 0.84;
}
.row__avatar {
width: 28px;
height: 28px;
border-radius: 999px;
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.row__avatar--fallback {
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 12px;
font-size: 11px;
font-weight: 900;
}
.row__meta {
padding: 0 14px;
margin-top: 6px;
opacity: 0.76;
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
.link--danger {
background: rgba(239, 68, 68, 0.14);
border-color: rgba(239, 68, 68, 0.28);
margin: 0 14px 14px;
margin: 0 18px 18px;
}
@media (max-width: 1100px) {
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.head__stat {
width: 100%;
}
.list {
grid-template-columns: 1fr;
}

View File

@@ -70,6 +70,12 @@ async function saveProfile() {
saving.value = false
}
}
async function logout() {
await auth.logout()
toast.success('로그아웃했어요.')
router.push('/')
}
</script>
<template>
@@ -93,9 +99,12 @@ async function saveProfile() {
<label class="label">아바타 업로드</label>
<input class="file" type="file" accept="image/*" :disabled="saving" @change="onAvatarChange" />
<div class="hint">파일 선택은 미리보기만 바뀌고, 실제 반영은 저장 버튼을 눌렀을 진행됩니다.</div>
<button class="saveBtn" :disabled="saving" @click="saveProfile">
{{ saving ? '저장중...' : '프로필 저장' }}
</button>
<div class="actions">
<button class="saveBtn" :disabled="saving" @click="saveProfile">
{{ saving ? '저장중...' : '프로필 저장' }}
</button>
<button class="logoutBtn" type="button" @click="logout">로그아웃</button>
</div>
</div>
</div>
</section>
@@ -188,7 +197,6 @@ async function saveProfile() {
font-size: 13px;
}
.saveBtn {
margin-top: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
@@ -197,4 +205,19 @@ async function saveProfile() {
cursor: pointer;
font-weight: 800;
}
.actions {
margin-top: 12px;
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.logoutBtn {
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.24);
background: rgba(239, 68, 68, 0.12);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
font-weight: 800;
}
</style>

View File

@@ -0,0 +1,268 @@
<script setup>
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
const route = useRoute()
const router = useRouter()
const tierLists = ref([])
const loading = ref(false)
const error = ref('')
const query = ref('')
function fmt(ts) {
return new Date(ts).toLocaleDateString(undefined, {
year: 'numeric',
month: '2-digit',
day: '2-digit',
})
}
function displayNameOf(tierList) {
return tierList.authorName || '알 수 없음'
}
function avatarSrcOf(tierList) {
return tierList.authorAvatarSrc ? toApiUrl(tierList.authorAvatarSrc) : ''
}
function avatarFallbackOf(tierList) {
return (tierList.authorAccountName || 'u').trim().charAt(0).toUpperCase() || '?'
}
function tierListThumbnailUrl(tierList) {
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
}
function openTierList(tierList) {
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
}
async function loadResults() {
loading.value = true
error.value = ''
try {
const data = await api.searchAllPublicTierLists(query.value)
tierLists.value = data.tierLists || []
} catch (e) {
error.value = '검색 결과를 불러오지 못했어요.'
} finally {
loading.value = false
}
}
watch(
() => route.query.q,
async (nextQuery) => {
query.value = typeof nextQuery === 'string' ? nextQuery : ''
await loadResults()
},
{ immediate: true }
)
</script>
<template>
<section class="wrap">
<div class="head">
<div>
<div class="head__eyebrow">Search</div>
<h2 class="title">전체 티어표 검색</h2>
<div class="desc">공개된 모든 티어표를 제목과 작성자 기준으로 찾아볼 있어요.</div>
</div>
</div>
<div v-if="error" class="error">{{ error }}</div>
<div v-else-if="loading" class="empty">검색 중이에요.</div>
<div v-else-if="tierLists.length === 0" class="empty">검색 결과가 없어요.</div>
<div v-else class="list">
<article v-for="tierList in tierLists" :key="tierList.id" class="boardCard">
<button class="boardCard__body" @click="openTierList(tierList)">
<div class="boardCard__thumbWrap">
<img v-if="tierListThumbnailUrl(tierList)" class="boardCard__thumb" :src="tierListThumbnailUrl(tierList)" :alt="tierList.title" />
<div v-else class="boardCard__thumbPlaceholder">대표 썸네일</div>
</div>
<div class="boardCard__head">
<div class="boardCard__titleRow">
<div class="boardCard__title">{{ tierList.title }}</div>
<div class="favoriteStat" :title="tierList.isFavorited ? '이미 즐겨찾기한 티어표' : '즐겨찾기 수'">
{{ tierList.isFavorited ? '♥' : '♡' }} {{ tierList.favoriteCount || 0 }}
</div>
</div>
<div class="boardCard__metaRow">
<div class="boardCard__author">
<img v-if="avatarSrcOf(tierList)" class="boardCard__avatar" :src="avatarSrcOf(tierList)" :alt="displayNameOf(tierList)" />
<div v-else class="boardCard__avatar boardCard__avatar--fallback">{{ avatarFallbackOf(tierList) }}</div>
<span class="boardCard__authorName">{{ displayNameOf(tierList) }}</span>
</div>
<div class="boardCard__date">{{ fmt(tierList.updatedAt) }}</div>
</div>
</div>
</button>
</article>
</div>
</section>
</template>
<style scoped>
.wrap {
display: grid;
gap: 18px;
}
.head {
display: flex;
gap: 14px;
justify-content: space-between;
align-items: flex-end;
flex-wrap: wrap;
padding: 6px 2px 8px;
}
.head__eyebrow {
font-size: 11px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: rgba(255, 255, 255, 0.42);
}
.title {
margin: 4px 0 0;
font-size: 32px;
color: rgba(255, 255, 255, 0.96);
letter-spacing: -0.04em;
}
.desc {
margin-top: 6px;
color: rgba(255, 255, 255, 0.58);
}
.error {
margin: 0 0 8px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid rgba(239, 68, 68, 0.18);
background: rgba(239, 68, 68, 0.1);
}
.empty {
opacity: 0.76;
}
.list {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 18px;
}
.boardCard {
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.16);
background: rgba(62, 62, 62, 0.82);
overflow: hidden;
display: grid;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
transition: transform 0.16s ease, background 0.16s ease;
}
.boardCard:hover {
transform: translateY(-2px);
background: rgba(70, 70, 70, 0.96);
}
.boardCard__body {
border: 0;
background: transparent;
color: inherit;
padding: 0;
text-align: left;
cursor: pointer;
display: grid;
}
.boardCard__thumbWrap {
width: 100%;
aspect-ratio: 16 / 9;
padding: 14px 14px 0;
box-sizing: border-box;
}
.boardCard__thumb,
.boardCard__thumbPlaceholder {
width: 100%;
height: 100%;
display: block;
border-radius: 18px;
}
.boardCard__thumb {
object-fit: cover;
}
.boardCard__thumbPlaceholder {
background: #555;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.4);
font-size: 13px;
font-weight: 700;
}
.boardCard__head {
padding: 16px 18px 18px;
display: grid;
gap: 10px;
}
.boardCard__titleRow,
.boardCard__metaRow {
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
}
.boardCard__title {
min-width: 0;
font-weight: 800;
font-size: 18px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__author {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
opacity: 0.86;
}
.boardCard__authorName {
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.boardCard__avatar {
width: 22px;
height: 22px;
border-radius: 6px;
object-fit: cover;
background: rgba(255, 255, 255, 0.08);
flex: 0 0 auto;
}
.boardCard__avatar--fallback {
display: grid;
place-items: center;
font-size: 11px;
font-weight: 900;
}
.boardCard__date,
.favoriteStat {
flex: 0 0 auto;
font-size: 13px;
color: rgba(255, 255, 255, 0.64);
white-space: nowrap;
}
@media (max-width: 1400px) {
.list {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
@media (max-width: 1024px) {
.list {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.list {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
@@ -12,8 +12,11 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const toast = useToast()
const globalRightRailOpen = inject('rightRailOpen', ref(true))
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
const gameId = computed(() => route.params.gameId)
const tierListId = computed(() => route.params.tierListId)
const previewMode = computed(() => route.query.preview === '1')
const gameName = ref('')
const groups = ref([
@@ -91,9 +94,8 @@ const customItems = computed(() =>
.filter((item) => item?.origin === 'custom')
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
)
const hasPlacedItems = computed(() => groups.value.some((group) => (group.itemIds || []).length > 0))
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(
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
@@ -104,11 +106,6 @@ const templateRequestChecks = computed(() => [
label: '티어표 이름(게임 이름)을 직접 입력했는지',
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))
@@ -443,14 +440,17 @@ async function persistTierList({ showModal = false } = {}) {
await uploadPendingThumbnail()
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
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())
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!res.tierList?.isFavorited
if (showModal) isSaveModalOpen.value = true
return res
return { ...res, savedTierListId }
}
async function save() {
@@ -514,8 +514,8 @@ async function requestTemplate(type) {
try {
isRequestingTemplate.value = true
await persistTierList({ showModal: false })
await api.requestTierListTemplate(tierListId.value, { type })
const persisted = await persistTierList({ showModal: false })
await api.requestTierListTemplate(persisted.savedTierListId, { type })
if (type === 'create') closeTemplateRequestModal()
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
} catch (e) {
@@ -527,10 +527,6 @@ async function requestTemplate(type) {
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
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') {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
@@ -609,78 +605,33 @@ onUnmounted(() => {
</script>
<template>
<section class="head">
<div class="heroCard">
<div class="heroCard__main">
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
<input
v-model="description"
class="descInput"
placeholder="설명(선택): 이 티어표의 기준/룰"
:readonly="!canEdit"
/>
<div class="hint">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b> 누르세요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
</div>
</div>
<div class="heroCard__side">
<div class="thumbComposer">
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="thumbComposer__header">
<div class="thumbComposer__eyebrow">대표 썸네일</div>
<div class="thumbComposer__caption">목록 카드 상단에 표시됩니다.</div>
</div>
<div class="thumbComposer__preview">
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="thumbComposer__empty">썸네일 없음</div>
</div>
<div v-if="canEdit" class="thumbComposer__actions">
<button class="btn btn--ghost thumbComposer__button" @click="openThumbnailFile">썸네일 선택</button>
<button class="btn btn--danger thumbComposer__button" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">제거</button>
<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>
<div class="actions">
<div class="actions__left">
<button class="btn btn--download" @click="downloadImage">이미지 다운로드</button>
<button v-if="canEdit && !isNewTierList" class="btn btn--danger" @click="removeTierList">삭제</button>
</div>
<div class="actions__right">
<button v-if="canFavorite" class="btn btn--ghost" :disabled="isFavoriteBusy" @click="toggleFavorite">
{{ isFavorited ? ' 즐겨찾기' : ' 즐겨찾기' }} {{ favoriteCount }}
</button>
<button
v-if="canRequestTemplateCreate"
class="btn btn--ghost"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="btn btn--ghost"
:disabled="isRequestingTemplate"
@click="requestTemplate('update')"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button>
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>{{ isPublic ? '공개 ON' : '공개 OFF' }}</span>
</label>
<button v-if="canEdit" class="btn btn--save" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
<div 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>
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
@@ -709,7 +660,7 @@ onUnmounted(() => {
</div>
</div>
<div class="requestChecklist__hint">
제목 명확하, 보드는 비워둔 원본 아이템만 정리되어 있을수록 관리자가 게임 템플릿으로 빠르게 등록하기 쉬워져.
제목 명확하 적어두면 관리자가 어떤 게임 템플릿 요청인지 빠르게 파악할 있어요. 여러 사용자가 비슷한 주제로 요청할 있으니 게임 이름을 구체적으로 적어주세.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -721,245 +672,309 @@ onUnmounted(() => {
</div>
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
<div ref="boardEl" class="board">
<div v-if="canEdit && !isExporting" class="boardTools">
<div class="boardTools__left">
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
</div>
<div class="boardTools__right">
<span class="boardTools__label">아이 크기</span>
<div class="sizePicker">
<button
v-for="size in iconSizeOptions"
:key="size"
class="sizePicker__button"
:class="{ 'sizePicker__button--active': iconSize === size }"
@click="setIconSize(size)"
>
{{ size }}
</button>
</div>
</div>
</div>
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
<div ref="groupListEl" class="rows">
<div v-for="g in groups" :key="g.id" class="row">
<div class="row__label">
<template v-if="isExporting">
<div class="row__exportName">{{ g.name }}</div>
<div class="editorMain">
<section class="head">
<div class="editorMain__headCopy">
<div class="editorMain__title">{{ gameName || gameId }}</div>
<div class="editorMain__subtitle">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이 드래그&드롭이 가능합니다.
</template>
<template v-else>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
</div>
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:ref="(el) => setGroupDropEl(g.id, el)"
>
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, id)"
>
×
</button>
</div>
</section>
<div class="editorCanvas">
<div ref="boardEl" class="board">
<div v-if="canEdit && !isExporting" class="boardTools">
<div class="boardTools__left">
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
</div>
<div class="boardTools__right">
<span class="boardTools__label">아이콘 크기</span>
<div class="sizePicker">
<button
v-for="size in iconSizeOptions"
:key="size"
class="sizePicker__button"
:class="{ 'sizePicker__button--active': iconSize === size }"
@click="setIconSize(size)"
>
{{ size }}
</button>
</div>
</div>
</div>
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
<div ref="groupListEl" class="rows">
<div v-for="g in groups" :key="g.id" class="row">
<div class="row__label">
<template v-if="isExporting">
<div class="row__exportName">{{ g.name }}</div>
</template>
<template v-else>
<span class="grab" title="드래그로 순서 변경" data-group-handle></span>
<input v-model="g.name" class="groupName" :readonly="!canEdit" />
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
</template>
</div>
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:ref="(el) => setGroupDropEl(g.id, el)"
>
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<button
v-if="canEdit && !isExporting"
class="cellRemoveBtn"
type="button"
title="아이템 빼내기"
@pointerdown.stop
@click.stop="removeItemFromGroup(g.id, id)"
>
×
</button>
</div>
</div>
</div>
</div>
<div v-if="isExporting" class="exportBoard__footer">
<span>{{ effectiveAuthorName }}</span>
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
</div>
</div>
<div v-if="isExporting" class="exportBoard__footer">
<span>{{ effectiveAuthorName }}</span>
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">
{{ canEdit ? '보드 바로 옆에서 드래그해 넣을 수 있도록 아이템 풀을 고정합니다.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
<div
v-if="canEdit"
class="dropzone"
:class="{ 'dropzone--active': isDragActive }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragEnter"
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">여러 이미지를 번에 드래그하거나 파일 선택으로 추가할 있어요.</div>
</div>
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
</div>
</div>
</div>
<div class="sidebar">
<div class="sidebar__title">아이템</div>
<div class="sidebar__hint">
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<div ref="poolEl" class="pool" data-list-type="pool">
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
</div>
</div>
<div v-if="canEdit && customItems.length" class="customItemEditor">
<div class="customItemEditor__title">추가한 커스텀 아이템 이름 정리</div>
<div class="customItemEditor__desc">
템플릿 요청 전에 이름을 정리해두면 관리자가 그대로 기본 템플릿으로 반영할 있어요.
</div>
<div class="customItemEditor__list">
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
<input
class="customItemEditor__input"
:value="item.label"
maxlength="60"
placeholder="아이템 이름"
@input="updateCustomItemLabel(item.id, $event.target.value)"
/>
</label>
</div>
</div>
<div
v-if="canEdit"
class="dropzone"
:class="{ 'dropzone--active': isDragActive }"
@dragenter.prevent="onDragEnter"
@dragover.prevent="onDragEnter"
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">여러 이미지를 번에 드래그하거나 파일 선택으로 추가할 있어요.</div>
</div>
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
</div>
</section>
<Teleport :to="localRightRailTarget">
<template v-if="globalRightRailOpen">
<div class="editorSidebar__section">
<div class="editorSidebar__label">Title</div>
<input v-model="title" class="editorSidebar__input" placeholder="Title Text" :readonly="!canEdit" />
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
</div>
<div class="editorSidebar__section">
<div class="editorSidebar__label">Desc</div>
<textarea
v-model="description"
class="editorSidebar__textarea"
placeholder="Description Text"
:readonly="!canEdit"
></textarea>
</div>
<div class="editorSidebar__section">
<div class="editorSidebar__label">대표 썸네일</div>
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="editorSidebar__thumbFrame">
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
</div>
<button v-if="canEdit" class="btn btn--ghost editorSidebar__button" @click="openThumbnailFile">파일 업로드</button>
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
</div>
<div class="editorSidebar__section">
<button v-if="canFavorite" class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
<span> 즐겨찾기</span>
<span>{{ favoriteCount }}</span>
</button>
</div>
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
<div class="editorSidebar__label">커스텀 이름 정리</div>
<div class="customItemEditor customItemEditor--sidebar">
<div class="customItemEditor__desc">
아래에서 이름만 정리해두면 관리자 요청 그대로 전달됩니다.
</div>
<div class="customItemEditor__list">
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
<input
class="customItemEditor__input"
:value="item.label"
maxlength="60"
placeholder="아이템 이름"
@input="updateCustomItemLabel(item.id, $event.target.value)"
/>
</label>
</div>
</div>
</div>
<div class="editorSidebar__section editorSidebar__section--footer">
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
<span>공개</span>
</label>
<div class="editorSidebar__actionGrid">
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<button v-if="canEdit && !isNewTierList" class="btn btn--danger editorSidebar__button" @click="removeTierList">삭제</button>
<button
v-if="canRequestTemplateCreate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="openTemplateRequestModal"
>
템플릿 등록 요청
</button>
<button
v-if="canRequestTemplateUpdate"
class="btn btn--ghost editorSidebar__button"
:disabled="isRequestingTemplate"
@click="requestTemplate('update')"
>
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
</button>
</div>
</template>
</Teleport>
</template>
</template>
<style scoped>
.head {
display: grid;
gap: 14px;
padding: 6px 2px 14px;
gap: 8px;
padding: 2px 2px 8px;
}
.heroCard {
display: grid;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
gap: 18px;
align-items: stretch;
}
.heroCard__main,
.heroCard__side {
.editorMain {
min-width: 0;
}
.heroCard__main {
display: grid;
gap: 12px;
gap: 14px;
}
.heroCard__side {
display: flex;
.editorCanvas {
display: grid;
grid-template-columns: minmax(0, 1fr) 280px;
gap: 16px;
align-items: start;
}
.titleInput {
width: 100%;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04));
color: rgba(255, 255, 255, 0.92);
outline: none;
box-sizing: border-box;
.editorMain__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.04em;
}
.descInput {
width: 100%;
min-height: 92px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
outline: none;
box-sizing: border-box;
}
.hint {
opacity: 0.78;
font-size: 13px;
line-height: 1.6;
padding: 0 2px;
}
.titleNotice {
.editorMain__subtitle {
color: rgba(255, 255, 255, 0.58);
font-size: 13px;
line-height: 1.5;
color: rgba(251, 191, 36, 0.94);
padding: 0 2px;
}
.thumbComposer {
.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;
width: 100%;
padding: 16px;
border-radius: 22px;
border: 1px solid rgba(255, 255, 255, 0.12);
background:
radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 46%),
rgba(255, 255, 255, 0.04);
box-sizing: border-box;
}
.thumbComposer__header {
.previewOnly__row {
display: grid;
gap: 4px;
grid-template-columns: 180px 1fr;
gap: 10px;
}
.thumbComposer__eyebrow {
font-size: 13px;
font-weight: 900;
letter-spacing: 0.02em;
}
.thumbComposer__caption {
font-size: 12px;
opacity: 0.68;
}
.thumbComposer__preview {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(11, 18, 32, 0.78);
}
.thumbComposer__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.thumbComposer__empty {
width: 100%;
height: 100%;
.previewOnly__label {
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.62);
font-size: 13px;
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);
}
.thumbComposer__actions {
.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;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
padding-top: 8px;
}
.thumbComposer__button {
width: 100%;
margin-top: 0;
.previewOnly__poolTitle {
font-weight: 900;
opacity: 0.82;
}
.actions {
.previewOnly__poolGrid {
display: flex;
gap: 14px;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
}
.actions__left,
.actions__right {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
.previewOnly__poolItem {
display: inline-flex;
}
.toggle {
display: inline-flex;
@@ -1025,10 +1040,8 @@ onUnmounted(() => {
margin-top: 0;
}
.layout {
display: grid;
grid-template-columns: 1fr 320px;
gap: 14px;
align-items: start;
display: block;
min-width: 0;
}
.error {
margin: 10px 0 14px;
@@ -1038,11 +1051,12 @@ onUnmounted(() => {
background: rgba(239, 68, 68, 0.12);
}
.board {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(55, 55, 55, 0.86), rgba(42, 42, 42, 0.82));
border-radius: 22px;
padding: 20px;
align-self: start;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
}
.modalOverlay {
position: fixed;
@@ -1125,8 +1139,12 @@ onUnmounted(() => {
gap: 12px;
align-items: center;
justify-content: flex-end;
margin-bottom: 14px;
margin-bottom: 16px;
flex-wrap: wrap;
padding: 10px 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
}
.boardTools__left,
.boardTools__right {
@@ -1151,8 +1169,8 @@ onUnmounted(() => {
.sizePicker__button {
margin: 0;
min-width: 48px;
padding: 8px 10px;
border-radius: 10px;
padding: 9px 10px;
border-radius: 12px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(255, 255, 255, 0.05);
color: rgba(255, 255, 255, 0.92);
@@ -1209,8 +1227,8 @@ onUnmounted(() => {
align-items: stretch;
}
.row__label {
border-radius: 14px;
background: rgba(255, 255, 255, 0.06);
border-radius: 16px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
display: flex;
gap: 8px;
@@ -1235,7 +1253,7 @@ onUnmounted(() => {
.groupName {
width: 100%;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
border-radius: 10px;
padding: 6px 8px;
@@ -1265,7 +1283,7 @@ onUnmounted(() => {
cursor: not-allowed;
}
.row__drop {
border-radius: 14px;
border-radius: 16px;
background: rgba(0, 0, 0, 0.18);
border: 1px solid rgba(255, 255, 255, 0.10);
min-height: calc(var(--thumb-size, 80px) + 24px);
@@ -1322,26 +1340,125 @@ onUnmounted(() => {
object-fit: cover;
}
.sidebar {
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: linear-gradient(180deg, rgba(52, 52, 52, 0.84), rgba(36, 36, 36, 0.8));
border-radius: 22px;
padding: 14px;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
position: sticky;
top: 14px;
}
.editorSidebar__section {
display: grid;
gap: 10px;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(255, 255, 255, 0.02);
}
.editorSidebar__label {
font-size: 11px;
font-weight: 800;
color: rgba(255, 255, 255, 0.52);
text-transform: uppercase;
letter-spacing: 0.12em;
}
.editorSidebar__input,
.editorSidebar__textarea {
width: 100%;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.92);
padding: 11px 12px;
outline: none;
resize: vertical;
}
.editorSidebar__textarea {
min-height: 92px;
}
.editorSidebar__hint {
font-size: 12px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.56);
}
.editorSidebar__hint--warn {
color: rgba(251, 191, 36, 0.92);
}
.editorSidebar__thumbFrame {
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 16px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.08);
background: #4c4c4c;
}
.editorSidebar__thumbImage {
width: 100%;
height: 100%;
object-fit: cover;
}
.editorSidebar__thumbEmpty {
width: 100%;
height: 100%;
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.36);
font-size: 13px;
}
.editorSidebar__button {
width: 100%;
margin-top: 0;
}
.editorSidebar__fileName {
font-size: 12px;
color: rgba(255, 255, 255, 0.56);
word-break: break-word;
}
.editorSidebar__favorite {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
width: 100%;
padding: 11px 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.08);
background: rgba(255, 255, 255, 0.03);
color: rgba(255, 255, 255, 0.9);
font-weight: 800;
cursor: pointer;
}
.editorSidebar__section--footer {
padding-top: 12px;
}
.editorSidebar__actionGrid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.sidebar__title {
font-weight: 900;
margin-bottom: 6px;
margin-bottom: 8px;
font-size: 18px;
letter-spacing: -0.02em;
}
.sidebar__hint {
opacity: 0.78;
font-size: 13px;
margin-bottom: 10px;
margin-bottom: 12px;
line-height: 1.5;
}
.customItemEditor {
margin-top: 12px;
padding: 12px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
margin-top: 0;
padding: 0;
border-radius: 0;
border: 0;
background: transparent;
}
.customItemEditor--sidebar {
display: grid;
gap: 12px;
}
.customItemEditor__title {
font-weight: 900;
@@ -1386,7 +1503,7 @@ onUnmounted(() => {
padding: 14px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.03);
background: rgba(255, 255, 255, 0.04);
text-align: center;
}
.dropzone--active {
@@ -1408,17 +1525,21 @@ onUnmounted(() => {
}
.poolItem {
display: grid;
grid-template-columns: var(--thumb-size, 80px) 1fr;
grid-template-columns: var(--thumb-size, 80px) minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 10px;
border-radius: 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: rgba(0, 0, 0, 0.16);
background: rgba(0, 0, 0, 0.18);
}
.poolItem__label {
min-width: 0;
font-weight: 800;
opacity: 0.9;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hidden {
display: none;
@@ -1431,32 +1552,23 @@ onUnmounted(() => {
border-radius: 14px;
}
@media (max-width: 980px) {
.previewOnly__row {
grid-template-columns: 140px 1fr;
}
.heroCard {
grid-template-columns: 1fr;
}
.layout {
.editorCanvas {
grid-template-columns: 1fr;
}
.actions {
justify-content: stretch;
}
.actions__left,
.actions__right {
width: 100%;
}
.actions__right {
justify-content: flex-end;
}
.row {
grid-template-columns: 150px 1fr;
}
.thumbComposer {
padding: 14px;
border-radius: 18px;
.sidebar {
position: static;
}
.titleInput,
.descInput {
border-radius: 16px;
.editorSidebar__actionGrid {
grid-template-columns: 1fr;
}
.requestChecklist__item {
grid-template-columns: 1fr;
@@ -1468,4 +1580,12 @@ onUnmounted(() => {
width: 100%;
}
}
@media (max-width: 720px) {
.previewOnly {
padding: 14px;
}
.previewOnly__row {
grid-template-columns: 1fr;
}
}
</style>