diff --git a/docs/history.md b/docs/history.md index d8a2b73..70f249c 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-03-26 v0.1.40 +- 관리자 기본 아이템 이름 저장은 눌러도 변화가 없으면 혼란스러우므로, 실제 변경이 있을 때만 버튼이 활성화되는 편이 더 명확하다고 판단했다. +- 사용자 커스텀 이미지는 관리자 검토 후 특정 게임의 기본 템플릿으로 복제해 가져올 수 있어야 운영 효율이 높아지므로, 게임 선택 기반 승격 흐름을 추가하기로 결정했다. + ## 2026-03-26 v0.1.39 - 티어표 편집 헤더는 게임명 kicker보다 제목과 설명이 더 중요하므로, 좌측 입력 중심 구조로 재배치하고 썸네일은 우측 보조 카드로 분리하는 편이 더 자연스럽다고 판단했다. - 썸네일 조작 버튼은 모바일에서도 카드와 함께 유지되는 편이 흐름이 덜 끊기므로, 미리보기 아래 별도 줄로 떨어뜨리기보다 카드 내부의 짧은 액션 행으로 묶기로 결정했다. diff --git a/docs/map.md b/docs/map.md index 05ba51a..1255ff8 100644 --- a/docs/map.md +++ b/docs/map.md @@ -27,8 +27,8 @@ ## `/admin` - 화면 파일: `frontend/src/views/AdminView.vue` -- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 -- 연동 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`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `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` +- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제 +- 연동 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/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` diff --git a/docs/spec.md b/docs/spec.md index 4cbf1a0..f1bbb4e 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -89,6 +89,7 @@ - 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다. - `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/users` @@ -102,10 +103,12 @@ - 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다. - 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다. - 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다. +- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다. - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. - 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다. - 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다. +- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다. - 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다. - 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다. diff --git a/docs/todo.md b/docs/todo.md index e29e5d1..9e51bed 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -7,6 +7,7 @@ - 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다. - 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다. - 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다. +- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다. ## 배포 전 작업 - NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다. diff --git a/docs/update.md b/docs/update.md index d8e8f62..8dfaf65 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,9 @@ # 업데이트 로그 +## 2026-03-26 v0.1.40 +- **기본 아이템 저장 UX 보강**: 관리자 게임 관리에서 아이템 이름이 실제로 바뀐 경우에만 `이름 저장` 버튼이 활성화되도록 조정하고, 저장 중 상태를 버튼에 표시 +- **커스텀 아이템 승격 추가**: 관리자 아이템 관리에서 사용자 커스텀 이미지를 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있도록 API와 UI를 추가 + ## 2026-03-26 v0.1.39 - **에디터 헤더 재구성**: 티어표 편집 상단에서 게임명 kicker를 제거하고, 좌측 제목/설명 입력과 우측 썸네일 카드가 나란히 보이는 구조로 재정리 - **썸네일 영역 UX 개선**: 썸네일 미리보기와 선택/제거 버튼을 하나의 카드 안에 묶고, 모바일에서도 버튼이 카드 아래로 무너지지 않도록 밀도 있게 조정 diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 32af1b7..46c92e2 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -22,6 +22,7 @@ const customItemPage = ref(1) const customItemLimit = ref(50) const customItemTotal = ref(0) const customItemOrphanOnly = ref(false) +const customItemTargetGameId = ref('') const users = ref([]) @@ -72,12 +73,18 @@ function resetMessages() { function setTab(tab) { resetMessages() activeTab.value = tab + if (tab === 'items' && !customItemTargetGameId.value && games.value.length) { + customItemTargetGameId.value = games.value[0].id + } } async function refreshGames() { try { const data = await api.listGames() games.value = data.games || [] + if (!customItemTargetGameId.value && games.value.length) { + customItemTargetGameId.value = games.value[0].id + } featuredGameIds.value = games.value .filter((game) => game.displayRank != null) .sort((a, b) => a.displayRank - b.displayRank) @@ -360,13 +367,18 @@ async function saveGameItemLabel(item) { error.value = '아이템 이름을 입력해주세요.' return } + if (nextLabel === item.label) return try { - await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel }) - await loadGame() + item.isSavingLabel = true + const data = await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel }) + item.label = data.item.label + item.draftLabel = data.item.label success.value = '기본 아이템 이름을 수정했어요.' } catch (e) { error.value = '기본 아이템 이름 수정에 실패했어요.' + } finally { + item.isSavingLabel = false } } @@ -506,6 +518,26 @@ async function removeUnusedCustomItems() { } } +async function promoteCustomItem(item) { + resetMessages() + if (!customItemTargetGameId.value) { + error.value = '가져올 게임을 먼저 선택해주세요.' + return + } + + try { + item.isPromoting = true + await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value }) + const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value + if (selectedGameId.value === customItemTargetGameId.value) await loadGame() + success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.` + } catch (e) { + error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.' + } finally { + item.isPromoting = false + } +} + const displayThumbnailUrl = computed(() => { if (thumbPreviewUrl.value) return thumbPreviewUrl.value if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc) @@ -734,7 +766,13 @@ async function saveFeaturedOrder() {
- +
@@ -763,6 +801,10 @@ async function saveFeaturedOrder() {
+
@@ -1038,7 +1083,7 @@ async function saveFeaturedOrder() { align-items: end; } .toolbar--secondary { - grid-template-columns: minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto; align-items: center; } .toolbar__search, @@ -1324,7 +1369,7 @@ async function saveFeaturedOrder() { } .customItemCard__actions { display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); + grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin-top: 4px; } @@ -1417,6 +1462,9 @@ async function saveFeaturedOrder() { .itemComposer { grid-template-columns: 1fr; } + .toolbar--secondary { + grid-template-columns: 1fr; + } .itemPreviewCard { max-width: none; }