Compare commits

..

2 Commits

8 changed files with 234 additions and 177 deletions

View File

@@ -67,7 +67,6 @@ const templateRequestSchema = z.object({
thumbnailSrc: z.string().max(255).optional().default(''),
isPublic: z.boolean().optional().default(false),
showCharacterNames: z.boolean().optional().default(false),
saveToMyTierList: z.boolean().optional().default(true),
groups: z.array(
z.object({
id: z.string().min(1),
@@ -243,31 +242,14 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
}
let savedTierList = null
if (payload.saveToMyTierList) {
savedTierList = await saveTierList({
id: sourceTierList?.id || undefined,
authorId: req.session.userId,
gameId: payload.gameId,
title: payload.requestTitle,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.requestDescription || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: sourceTierList?.sourceTierListId || '',
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedBoardItems,
})
}
if (!payload.sourceTierListId) return res.status(400).json({ error: 'source_tierlist_required' })
try {
const request = await createTemplateRequest({
id: nanoid(),
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
sourceTierListId: sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
@@ -278,7 +260,7 @@ router.post('/template-request', requireAuth, async (req, res) => {
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
return res.json({ request })
} catch (e) {
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
return res.status(409).json({ error: 'template_request_exists' })

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-04-01 v1.3.49
- 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다.
- 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다.
## 2026-04-01 v1.3.48
- 관리자 탭 데이터는 첫 진입 로딩만 믿기보다, 인증 완료와 탭 전환 시점에 필요한 목록을 다시 채워 넣는 편이 실제 운영 화면에서 더 안정적이라고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 완전히 같은 구현을 억지로 분기하기보다, 같은 내부 프레임 문법과 정보 밀도를 먼저 맞춰 체감 차이를 줄이는 쪽이 현실적이라고 판단했다.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청`도 결국 검수용 카드이므로, 요청 전용 카드 문법을 따로 두기보다 `전체 티어표 관리`와 같은 카드 구조를 재사용하는 편이 더 직관적이라고 정리했다.
- 새 템플릿 생성 요청의 기본 게임 ID는 사람이 읽기 어려운 난수보다 요청 단위에서 유일한 임시값을 먼저 채워두고, 승인 전에 관리자가 수정하는 흐름이 더 현실적이라고 판단했다.

View File

@@ -12,8 +12,8 @@
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, 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`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, 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`, `POST /api/tierlists/template-request`
## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue`
@@ -37,7 +37,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, 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`

View File

@@ -49,12 +49,12 @@
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마

View File

@@ -1,6 +1,7 @@
# 할 일 및 이슈
## 중기 개선
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
@@ -20,6 +21,6 @@
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 모달과 더 가까운 체감이 되도록, 이후에도 보드 여백·행/열 헤더·남은 아이템 밀도를 한 번 더 비교 QA한다.
- 관리자 템플릿 요청 미리보기는 일반 완성본 보기와 거의 같은 구조로 맞췄으므로, 이후 실제 데이터로 row/column 정렬감과 비어 있는 셀 높이를 한 번 더 비교 QA한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,13 @@
# 업데이트 로그
## 2026-04-01 v1.3.49
- 티어표 에디터의 템플릿 요청 흐름은 저장된 티어표를 기준으로만 요청을 보낼 수 있도록 다시 정리하고, 요청 모달의 `내 티어리스트에도 저장` 분기는 제거함. 제목이 비어 있는 상태에서 저장하면 `직접 티어표 만들기` 대신 랜덤한 고유 제목을 먼저 부여해 저장본을 만들고, 그 이후에만 템플릿 요청 버튼이 노출되도록 맞춤.
- 관리자 `사용자 템플릿 요청` 카드는 `전체 티어표 관리`와 같은 카드 문법으로 유지하되, 썸네일은 상단에 고정된 클릭 진입점으로 다시 정리하고 카드 본문과 별도 입력 영역의 밀도를 맞춤.
- 템플릿 요청 미리보기는 일반 티어표 완성본과 같은 보드 문법으로 다시 구성하고, `cells` 기반 배치 아이템도 남은 아이템 계산에 정확히 반영해 요청 미리보기와 일반 완성본 보기의 차이를 줄임.
## 2026-04-01 v1.3.48
- 관리자 화면은 새로고침 직후에도 `티어표 관리 / 회원 관리` 목록이 비지 않도록, 관리자 인증이 확정되거나 탭이 바뀔 때 해당 목록을 다시 불러오는 흐름으로 보강함.
- 관리자 아이템 모달은 내부 스크롤바를 숨기고 스크롤 체인을 끊어 배경이 함께 움직이지 않게 했고, 게임 선택 패널과 본문 패널의 상단 정렬도 다시 맞춤.
- 템플릿 요청 미리보기는 누락돼 있던 `requestPreview__frame / __header` 스타일을 보강해 일반 티어표 완성본과 더 비슷한 내부 프레임 구조와 보드 밀도로 다시 정리함.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청` 카드는 별도 요청 전용 레이아웃 대신 `전체 티어표 관리`와 같은 카드 문법으로 맞추고, 왼쪽 썸네일 클릭으로 같은 미리보기 모달이 열리도록 정리함.
- 새 템플릿 요청에는 썸네일 아래에 `게임 이름 / 게임 ID` 입력을 두고, 초기 `게임 ID``new-template` 대신 요청 ID 기반의 임시 고유값으로 채워 나중에 수정하기 쉽게 바꿈.

View File

@@ -302,7 +302,44 @@ watch(
async (tab) => {
if (tab === 'game-admin' && selectedGameId.value && !selectedGame.value?.game?.id) {
await loadGame()
return
}
if (tab === 'items') {
customItemQuery.value = ''
customItemOrphanOnly.value = false
customItemPage.value = 1
customItemModalGameQuery.value = ''
await refreshCustomItems()
return
}
if (tab === 'tierlists') {
if (tierlistsMode.value === 'requests') await refreshTemplateRequests()
else await refreshAdminTierLists()
return
}
if (tab === 'users') {
await refreshUsers()
}
}
)
watch(
() => tierlistsMode.value,
async (mode) => {
if (activeTab.value !== 'tierlists') return
if (mode === 'requests') await refreshTemplateRequests()
else await refreshAdminTierLists()
}
)
watch(
() => auth.user?.id,
async (userId) => {
if (!userId || !auth.user?.isAdmin) return
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers(), refreshTemplateRequests(), refreshImageDiagnostics()])
}
)
@@ -1344,7 +1381,14 @@ function previewRequestGroupCellItems(preview, group, columnIndex) {
}
function previewRequestPoolItems(preview) {
const groupedIds = new Set((preview?.snapshotGroups || []).flatMap((group) => group.itemIds || []))
const groupedIds = new Set(
(preview?.snapshotGroups || []).flatMap((group) => {
if (Array.isArray(group?.cells) && group.cells.length) {
return group.cells.flatMap((cell) => (Array.isArray(cell) ? cell : []))
}
return group.itemIds || []
})
)
return (preview?.snapshotItems || []).filter((item) => !groupedIds.has(item.id))
}
@@ -2241,24 +2285,21 @@ async function saveFeaturedOrder() {
</div>
<div v-if="previewTierList?.requestPreview" class="requestPreview">
<div class="requestPreview__frame">
<div class="requestPreview__header">
<div class="requestPreview__title">{{ previewTierList.title || '티어표 미리보기' }}</div>
<div v-if="previewTierList.description" class="requestPreview__desc">{{ previewTierList.description }}</div>
<div class="requestPreview__meta">
{{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} ·
{{ previewTierList.snapshotGroups?.length || 0 }} ·
{{ previewTierList.snapshotItems?.length || 0 }} 아이템
</div>
<div class="requestPreview__sheet">
<div class="requestPreview__title">{{ previewTierList.title || '티어표 미리보기' }}</div>
<div v-if="previewTierList.description" class="requestPreview__description">{{ previewTierList.description }}</div>
<div class="requestPreview__meta">
{{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} ·
{{ previewTierList.snapshotGroups?.length || 0 }} ·
{{ previewTierList.snapshotItems?.length || 0 }} 아이템
</div>
<div class="requestPreview__board requestPreview__board--full">
<div v-if="previewRequestHasColumns(previewTierList)" class="requestPreview__boardHead">
<div class="requestPreview__rowLabel requestPreview__rowLabel--head"></div>
<div class="requestPreview__columnLabels" :style="previewRequestGridStyle(previewTierList)">
<div v-if="previewRequestHasColumns(previewTierList)" class="requestPreview__columns">
<div class="requestPreview__columnsSpacer" aria-hidden="true"></div>
<div class="requestPreview__columnsGrid" :style="previewRequestGridStyle(previewTierList)">
<div
v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="column.id"
class="requestPreview__columnLabel"
class="requestPreview__columnHeader"
>
{{ column.name || ('열 ' + (columnIndex + 1)) }}
</div>
@@ -2266,38 +2307,37 @@ async function saveFeaturedOrder() {
</div>
<div class="requestPreview__rows">
<div v-for="group in previewTierList.snapshotGroups" :key="group.id" class="requestPreview__row">
<div class="requestPreview__rowLabel">{{ group.name }}</div>
<div class="requestPreview__cells" :style="previewRequestGridStyle(previewTierList)">
<div class="requestPreview__label">{{ group.name }}</div>
<div class="requestPreview__dropGrid" :style="previewRequestGridStyle(previewTierList)">
<div
v-for="(column, columnIndex) in previewRequestColumns(previewTierList)"
:key="group.id + '-' + column.id"
class="requestPreview__cell"
class="requestPreview__dropColumn"
>
<div class="requestPreview__rowItems">
<div class="requestPreview__drop">
<div
v-for="item in previewRequestGroupCellItems(previewTierList, group, columnIndex)"
:key="item.id"
class="requestPreview__item"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
<img class="thumb requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="itemNameOverlay requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div v-if="previewRequestPoolItems(previewTierList).length" class="requestPreview__pool">
<div class="requestPreview__poolLabel">남은 아이템</div>
<div class="requestPreview__rowItems requestPreview__rowItems--pool">
<div class="requestPreview__poolTitle">남은 아이템</div>
<div class="requestPreview__poolGrid">
<div
v-for="item in previewRequestPoolItems(previewTierList)"
:key="item.id"
class="requestPreview__item requestPreview__item--muted"
class="requestPreview__poolItem requestPreview__item requestPreview__item--muted"
>
<img class="requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="requestPreview__itemLabel">{{ item.label }}</div>
<img class="thumb requestPreview__itemThumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div v-if="previewTierList.snapshotShowCharacterNames" class="itemNameOverlay requestPreview__itemLabel">{{ item.label }}</div>
</div>
</div>
</div>
@@ -3457,7 +3497,8 @@ async function saveFeaturedOrder() {
align-content: start;
gap: 18px;
overflow: auto;
padding-right: 6px;
padding-right: 0;
overscroll-behavior: contain;
scrollbar-width: none;
-ms-overflow-style: none;
}
@@ -3849,9 +3890,14 @@ async function saveFeaturedOrder() {
display: grid;
gap: 12px;
align-self: start;
align-content: start;
}
.templateRequestCard__preview {
align-self: start;
display: block;
width: 100%;
line-height: 0;
vertical-align: top;
}
.templateRequestCard__thumbMeta {
display: grid;
@@ -3878,94 +3924,103 @@ async function saveFeaturedOrder() {
}
.requestPreview {
display: grid;
gap: 18px;
}
.requestPreview__summary {
.requestPreview__sheet {
display: grid;
grid-template-columns: minmax(0, 1fr) 220px;
gap: 16px;
align-items: start;
}
.requestPreview__summaryBody {
display: grid;
gap: 8px;
}
.requestPreview__summaryThumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 18px;
max-width: 1280px;
margin: 0 auto;
padding: 28px;
border-radius: 24px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
background: color-mix(in srgb, var(--theme-main-bg) 92%, transparent);
max-height: min(78vh, 980px);
overflow: auto;
overscroll-behavior: contain;
}
.requestPreview__title {
font-size: 28px;
font-weight: 900;
letter-spacing: -0.03em;
}
.requestPreview__description {
margin-top: -8px;
font-size: 14px;
line-height: 1.6;
color: var(--theme-text-muted);
}
.requestPreview__meta {
color: var(--theme-text-soft);
font-size: 13px;
}
.requestPreview__desc {
color: var(--theme-text-muted);
line-height: 1.6;
white-space: pre-line;
}
.requestPreview__board,
.requestPreview__pool {
.requestPreview__columns {
display: grid;
gap: 12px;
grid-template-columns: 132px 1fr;
gap: 10px;
margin-bottom: 10px;
}
.requestPreview__boardHead,
.requestPreview__row {
display: grid;
grid-template-columns: 92px minmax(0, 1fr);
gap: 12px;
align-items: start;
}
.requestPreview__rowLabel,
.requestPreview__poolLabel,
.requestPreview__columnLabel {
font-size: 13px;
font-weight: 800;
color: rgba(255, 255, 255, 0.86);
}
.requestPreview__rowLabel--head {
color: var(--theme-text-faint);
}
.requestPreview__columnLabels,
.requestPreview__cells {
display: grid;
gap: 12px;
}
.requestPreview__columnLabel,
.requestPreview__cell {
.requestPreview__columnsSpacer {
min-width: 0;
}
.requestPreview__cell {
padding: 10px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.requestPreview__rowItems {
.requestPreview__columnsGrid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(72px, 1fr));
gap: 10px;
}
.requestPreview__rowItems--pool {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
.requestPreview__columnHeader {
min-height: 20px;
font-size: 12px;
font-weight: 800;
text-align: center;
opacity: 0.72;
}
.requestPreview__rowItems--pool {
grid-template-columns: repeat(auto-fill, minmax(84px, 1fr));
.requestPreview__rows {
display: grid;
gap: 10px;
}
.requestPreview__row {
display: grid;
grid-template-columns: 132px 1fr;
gap: 10px;
}
.requestPreview__label {
display: grid;
place-items: center;
padding: 10px 12px;
text-align: center;
font-weight: 900;
border-radius: 14px;
background: var(--theme-surface-soft-2);
border: 1px solid var(--theme-border-strong);
}
.requestPreview__dropGrid {
display: grid;
gap: 10px;
}
.requestPreview__dropColumn {
display: grid;
gap: 8px;
}
.requestPreview__drop {
border-radius: 14px;
background: var(--theme-pill-bg);
border: 1px solid var(--theme-border);
min-height: calc(var(--thumb-size, 80px) + 24px);
padding: 10px;
display: flex;
flex-wrap: wrap;
gap: 8px;
align-content: flex-start;
}
.requestPreview__item {
display: inline-flex;
position: relative;
overflow: hidden;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-surface-soft);
min-height: 72px;
border-radius: 16px;
}
.requestPreview__item--muted {
opacity: 0.52;
filter: grayscale(0.2) brightness(0.78);
filter: grayscale(0.22) brightness(0.78);
}
.requestPreview__itemThumb {
width: 100%;
@@ -3986,6 +4041,24 @@ async function saveFeaturedOrder() {
text-align: center;
line-height: 1.3;
}
.requestPreview__pool {
display: grid;
gap: 10px;
padding-top: 8px;
}
.requestPreview__poolTitle {
font-weight: 900;
opacity: 0.82;
}
.requestPreview__poolGrid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.requestPreview__poolItem {
display: inline-flex;
position: relative;
}
.tierAdminList {
margin-top: 14px;
display: grid;
@@ -4010,11 +4083,13 @@ async function saveFeaturedOrder() {
align-self: start;
display: block;
width: 100%;
line-height: 0;
}
.tierAdminCard__thumb {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
object-position: top center;
display: block;
border-radius: 14px;
background: var(--theme-surface-soft);
@@ -4146,6 +4221,7 @@ async function saveFeaturedOrder() {
padding: 20px;
background: color-mix(in srgb, var(--theme-body-bg) 76%, transparent);
backdrop-filter: blur(6px);
overscroll-behavior: contain;
}
.modalCard {
width: min(560px, 100%);
@@ -4158,6 +4234,9 @@ async function saveFeaturedOrder() {
}
.modalCard--preview {
width: min(1200px, 100%);
max-height: calc(100dvh - 40px);
overflow: auto;
overscroll-behavior: contain;
}
.modalCard__titleRow {
display: flex;

View File

@@ -34,6 +34,7 @@ const pool = ref([])
const itemsById = ref({})
const title = ref('')
const persistedTierListId = ref('')
const thumbnailSrc = ref('')
const pendingThumbnailFile = ref(null)
const thumbnailPreviewUrl = ref('')
@@ -48,7 +49,6 @@ const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('')
const templateRequestSaveToMyTierList = ref(true)
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
@@ -94,10 +94,13 @@ const effectiveAuthorName = computed(() => {
if (currentEmail) return currentEmail.split('@')[0] || currentEmail
return (authorAccountName.value || '').trim() || 'unknown'
})
const autoGeneratedTitle = ref(createAutoTierListTitle())
const effectiveTitle = computed(() => {
const customTitle = (title.value || '').trim()
if (customTitle) return customTitle
return (gameName.value || gameId.value || 'Tier Maker').trim()
if (persistedTierListId.value) return persistedTierListId.value
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
return autoGeneratedTitle.value
})
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
const untitledWarning = computed(
@@ -120,11 +123,12 @@ const customItems = computed(() =>
.filter((item) => item?.origin === 'custom')
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
)
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
)
const canRequestTemplateUpdate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
)
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
@@ -136,6 +140,12 @@ watch(error, (message) => {
error.value = ''
})
function createAutoTierListTitle() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
return pick(10) + '-' + pick(10)
}
function formatTitleDate(ts) {
const date = new Date(ts)
const year = date.getFullYear()
@@ -652,9 +662,12 @@ function buildPayload(existingId) {
async function persistTierList({ showModal = false } = {}) {
await uploadPendingCustomItems()
await uploadPendingThumbnail()
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
const payload = buildPayload(currentTierListId || null)
const res = await api.saveTierList(payload)
const savedTierListId = res.tierList?.id || tierListId.value
const savedTierListId = res.tierList?.id || currentTierListId || tierListId.value
persistedTierListId.value = savedTierListId || ''
title.value = res.tierList?.title || payload.title
if (tierListId.value === 'new' && res.tierList?.id) {
await router.replace(`/editor/${gameId.value}/${res.tierList.id}`)
}
@@ -686,7 +699,6 @@ function closeSaveModal() {
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = (title.value || '').trim()
templateRequestDraftDescription.value = (description.value || '').trim()
templateRequestSaveToMyTierList.value = true
}
function openTemplateRequestModal() {
@@ -762,55 +774,39 @@ async function toggleFavorite() {
}
async function requestTemplate(type) {
const shouldSaveToMyTierList = !!templateRequestSaveToMyTierList.value
try {
isRequestingTemplate.value = true
await uploadPendingCustomItems()
const uploadedThumbnailSrc = await uploadPendingThumbnail()
const response = await api.requestTierListTemplate({
title.value = templateRequestDraftTitle.value.trim()
description.value = templateRequestDraftDescription.value.trim()
const saved = await persistTierList({ showModal: false })
const sourceId = saved.savedTierListId || persistedTierListId.value || ''
if (!sourceId) throw new Error('save_required')
await api.requestTierListTemplate({
type,
sourceTierListId: tierListId.value !== 'new' ? tierListId.value : '',
sourceTierListId: sourceId,
gameId: gameId.value,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '',
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: shouldSaveToMyTierList,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
})
const savedTierList = response?.savedTierList
if (savedTierList) {
title.value = savedTierList.title || title.value
description.value = savedTierList.description || ''
updatedAt.value = Number(savedTierList.updatedAt || Date.now())
authorName.value = savedTierList.authorName || effectiveAuthorName.value
authorAccountName.value = savedTierList.authorAccountName || authorAccountName.value
favoriteCount.value = Number(savedTierList.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!savedTierList.isFavorited
if (tierListId.value === 'new' && savedTierList.id) {
await router.replace(`/editor/${gameId.value}/${savedTierList.id}`)
}
}
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(
type === 'create'
? shouldSaveToMyTierList
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.'
: shouldSaveToMyTierList
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.'
)
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
} catch (e) {
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.message === 'save_required') {
toast.error('먼저 현재 티어표를 저장한 뒤 다시 요청해주세요.')
return
}
if (e?.status === 409) {
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
return
@@ -819,12 +815,12 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
if (e?.status === 400 && e?.data?.error === 'source_tierlist_required') {
toast.error('저장된 티어표에서만 템플릿 요청을 보낼 수 있어요.')
return
}
if (e?.status === 500 && shouldSaveToMyTierList) {
toast.error('템플릿 요청 중 내 티어리스트 저장에 실패했어요. 잠시 후 다시 시도해주세요.')
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
}
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
@@ -866,6 +862,7 @@ onMounted(() => {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
@@ -975,14 +972,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 요청 시점 스냅샷만 관리자에게 전달되고, 티어 리스트에는 별도로 남기지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -1014,14 +1003,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 관리자 확인용 요청 스냅샷만 남고, 현재 작업 중인 티어표는 따로 저장하지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
@@ -1285,7 +1266,7 @@ onUnmounted(() => {
<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">