From 66d408dca89d9401d5db3a9b4ba2a36498f35658 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 1 Apr 2026 19:01:07 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.3.49=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=9A=94=EC=B2=AD=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=ED=9D=90=EB=A6=84=EA=B3=BC=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/routes/tierlists.js | 24 +-- docs/history.md | 3 + docs/map.md | 6 +- docs/spec.md | 4 +- docs/todo.md | 4 +- docs/update.md | 4 + frontend/src/views/AdminView.vue | 219 +++++++++++++++----------- frontend/src/views/TierEditorView.vue | 97 +++++------- 8 files changed, 184 insertions(+), 177 deletions(-) diff --git a/backend/src/routes/tierlists.js b/backend/src/routes/tierlists.js index 75b1124..14fa0f7 100644 --- a/backend/src/routes/tierlists.js +++ b/backend/src/routes/tierlists.js @@ -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' }) diff --git a/docs/history.md b/docs/history.md index 873e0d5..e71c3f5 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,8 @@ # 의사결정 이력 +## 2026-04-01 v1.3.49 +- 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다. +- 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다. ## 2026-04-01 v1.3.48 - 관리자 탭 데이터는 첫 진입 로딩만 믿기보다, 인증 완료와 탭 전환 시점에 필요한 목록을 다시 채워 넣는 편이 실제 운영 화면에서 더 안정적이라고 정리했다. - 템플릿 요청 미리보기는 일반 티어표 보기와 완전히 같은 구현을 억지로 분기하기보다, 같은 내부 프레임 문법과 정보 밀도를 먼저 맞춰 체감 차이를 줄이는 쪽이 현실적이라고 판단했다. diff --git a/docs/map.md b/docs/map.md index c80a62e..13ad809 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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` diff --git a/docs/spec.md b/docs/spec.md index e518a13..18ea3cc 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -49,12 +49,12 @@ - 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다. - 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다. - 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다. - - 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. + - 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다. - 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다. - 관리자 화면 - 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다. - 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다. - - 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. + - 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다. - 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다. ## DB 스키마 diff --git a/docs/todo.md b/docs/todo.md index b0f5a66..57b5d3c 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -21,6 +21,6 @@ - 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다. - 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다. -- 관리자 템플릿 요청 미리보기는 실제 완성본 모달과 더 가까운 체감이 되도록, 이후에도 보드 여백·행/열 헤더·남은 아이템 밀도를 한 번 더 비교 QA한다. -- 관리자 템플릿 요청 미리보기는 일반 완성본 보기와 거의 같은 구조로 맞췄으므로, 이후 실제 데이터로 row/column 정렬감과 비어 있는 셀 높이를 한 번 더 비교 QA한다. - 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다. + +- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다. diff --git a/docs/update.md b/docs/update.md index 3bd018e..8cb0222 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-04-01 v1.3.49 +- 티어표 에디터의 템플릿 요청 흐름은 저장된 티어표를 기준으로만 요청을 보낼 수 있도록 다시 정리하고, 요청 모달의 `내 티어리스트에도 저장` 분기는 제거함. 제목이 비어 있는 상태에서 저장하면 `직접 티어표 만들기` 대신 랜덤한 고유 제목을 먼저 부여해 저장본을 만들고, 그 이후에만 템플릿 요청 버튼이 노출되도록 맞춤. +- 관리자 `사용자 템플릿 요청` 카드는 `전체 티어표 관리`와 같은 카드 문법으로 유지하되, 썸네일은 상단에 고정된 클릭 진입점으로 다시 정리하고 카드 본문과 별도 입력 영역의 밀도를 맞춤. +- 템플릿 요청 미리보기는 일반 티어표 완성본과 같은 보드 문법으로 다시 구성하고, `cells` 기반 배치 아이템도 남은 아이템 계산에 정확히 반영해 요청 미리보기와 일반 완성본 보기의 차이를 줄임. ## 2026-04-01 v1.3.48 - 관리자 화면은 새로고침 직후에도 `티어표 관리 / 회원 관리` 목록이 비지 않도록, 관리자 인증이 확정되거나 탭이 바뀔 때 해당 목록을 다시 불러오는 흐름으로 보강함. - 관리자 아이템 모달은 내부 스크롤바를 숨기고 스크롤 체인을 끊어 배경이 함께 움직이지 않게 했고, 게임 선택 패널과 본문 패널의 상단 정렬도 다시 맞춤. diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index d7c2315..5a1a9c8 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1381,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)) } @@ -2278,24 +2285,21 @@ async function saveFeaturedOrder() {
-
-
-
{{ previewTierList.title || '티어표 미리보기' }}
-
{{ previewTierList.description }}
-
- {{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} · - {{ previewTierList.snapshotGroups?.length || 0 }}개 행 · - {{ previewTierList.snapshotItems?.length || 0 }}개 아이템 -
+
+
{{ previewTierList.title || '티어표 미리보기' }}
+
{{ previewTierList.description }}
+
+ {{ previewRequestHasColumns(previewTierList) ? (previewRequestColumns(previewTierList).length + '열 구성') : '단일 열 구성' }} · + {{ previewTierList.snapshotGroups?.length || 0 }}개 행 · + {{ previewTierList.snapshotItems?.length || 0 }}개 아이템
-
-
-
-
+
+ +
{{ column.name || ('열 ' + (columnIndex + 1)) }}
@@ -2303,38 +2307,37 @@ async function saveFeaturedOrder() {
-
{{ group.name }}
-
+
{{ group.name }}
+
-
+
- -
{{ item.label }}
+ +
{{ item.label }}
-
-
남은 아이템
-
+
남은 아이템
+
- -
{{ item.label }}
+ +
{{ item.label }}
@@ -3887,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; @@ -3916,97 +3924,103 @@ async function saveFeaturedOrder() { } .requestPreview { display: grid; - gap: 18px; } -.requestPreview__frame { +.requestPreview__sheet { display: grid; - gap: 26px; + gap: 16px; + width: 100%; + max-width: 1280px; + margin: 0 auto; padding: 28px; border-radius: 24px; border: 1px solid var(--theme-border); background: color-mix(in srgb, var(--theme-main-bg) 92%, transparent); + max-height: min(78vh, 980px); + overflow: auto; + overscroll-behavior: contain; } -.requestPreview__header { - display: grid; - gap: 10px; -} -.requestPreview__heroTitle { - font-size: clamp(30px, 3vw, 48px); - line-height: 1.08; +.requestPreview__title { + font-size: 28px; font-weight: 900; - letter-spacing: -0.04em; + 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.7; - white-space: pre-line; - font-size: 15px; -} -.requestPreview__board, -.requestPreview__pool { +.requestPreview__columns { display: grid; - gap: 14px; + grid-template-columns: 132px 1fr; + gap: 10px; + margin-bottom: 10px; } -.requestPreview__boardHead, -.requestPreview__row { - display: grid; - grid-template-columns: 120px minmax(0, 1fr); - gap: 16px; - align-items: start; -} -.requestPreview__rowLabel, -.requestPreview__poolLabel, -.requestPreview__columnLabel { - font-size: 15px; - font-weight: 900; - color: var(--theme-text-strong); -} -.requestPreview__rowLabel--head { - color: var(--theme-text-faint); -} -.requestPreview__columnLabels, -.requestPreview__cells { - display: grid; - gap: 14px; -} -.requestPreview__columnLabel, -.requestPreview__cell { +.requestPreview__columnsSpacer { min-width: 0; } -.requestPreview__columnLabel { - padding: 8px 4px; - text-align: center; -} -.requestPreview__cell { - min-height: 134px; - padding: 14px; - border-radius: 18px; - border: 1px solid var(--theme-border); - background: var(--theme-surface-soft); -} -.requestPreview__rowItems { +.requestPreview__columnsGrid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(84px, 1fr)); - gap: 12px; + 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__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: 16px; - border: 1px solid rgba(255, 255, 255, 0.12); - background: var(--theme-surface-soft); - min-height: 84px; } .requestPreview__item--muted { opacity: 0.52; - filter: grayscale(0.2) brightness(0.78); + filter: grayscale(0.22) brightness(0.78); } .requestPreview__itemThumb { width: 100%; @@ -4027,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; @@ -4051,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); @@ -4200,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; diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 2536572..c188cbb 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -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(() => {