Compare commits

..

2 Commits

7 changed files with 178 additions and 52 deletions

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-03-26 v0.1.40
- 관리자 기본 아이템 이름 저장은 눌러도 변화가 없으면 혼란스러우므로, 실제 변경이 있을 때만 버튼이 활성화되는 편이 더 명확하다고 판단했다.
- 사용자 커스텀 이미지는 관리자 검토 후 특정 게임의 기본 템플릿으로 복제해 가져올 수 있어야 운영 효율이 높아지므로, 게임 선택 기반 승격 흐름을 추가하기로 결정했다.
## 2026-03-26 v0.1.39
- 티어표 편집 헤더는 게임명 kicker보다 제목과 설명이 더 중요하므로, 좌측 입력 중심 구조로 재배치하고 썸네일은 우측 보조 카드로 분리하는 편이 더 자연스럽다고 판단했다.
- 썸네일 조작 버튼은 모바일에서도 카드와 함께 유지되는 편이 흐름이 덜 끊기므로, 미리보기 아래 별도 줄로 떨어뜨리기보다 카드 내부의 짧은 액션 행으로 묶기로 결정했다.
## 2026-03-26 v0.1.38
- 관리자 기본 아이템은 업로드 시점에만 이름을 정할 수 있으면 운영 중 수정이 어려우므로, 목록에서 직접 이름을 바꾸고 저장할 수 있게 하기로 결정했다.
- 게임별 티어표 목록도 식별성이 중요하므로, 사용자가 편집 시 별도 썸네일을 지정할 수 있게 하고 목록 카드에서는 게임 카드와 비슷한 상단 썸네일 구조를 사용하기로 결정했다.

View File

@@ -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`

View File

@@ -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`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
@@ -118,6 +121,7 @@
- 제목이 비어 있는 상태로 저장하면 내부 제목은 현재 게임명을 기본값으로 사용한다.
- 제목 입력이 비어 있는 동안에는 무분별한 도배 방지를 위한 관리자 임의 삭제 가능성 안내 문구를 표시한다.
- 티어표는 편집 화면에서 16:9 썸네일 이미지를 별도로 선택해 저장할 수 있고, 목록 카드에서는 그 이미지를 상단 대표 썸네일로 사용한다.
- 편집 화면 상단 헤더는 좌측 제목/설명, 우측 썸네일 카드 구조를 사용하며 모바일에서는 한 열로 접힌다.
- 티어표 편집기의 아이콘 기본 크기는 `80px`이며, 사용자가 `48 / 60 / 80 / 100 / 120` 단계로 즉시 조절할 수 있다.
- 공개 티어표 목록과 `내 티어표` 목록은 제목 옆에 작성자 아바타와 표시명을 함께 보여준다.
- 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다.

View File

@@ -7,6 +7,7 @@
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
## 배포 전 작업
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.

View File

@@ -1,5 +1,13 @@
# 업데이트 로그
## 2026-03-26 v0.1.40
- **기본 아이템 저장 UX 보강**: 관리자 게임 관리에서 아이템 이름이 실제로 바뀐 경우에만 `이름 저장` 버튼이 활성화되도록 조정하고, 저장 중 상태를 버튼에 표시
- **커스텀 아이템 승격 추가**: 관리자 아이템 관리에서 사용자 커스텀 이미지를 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있도록 API와 UI를 추가
## 2026-03-26 v0.1.39
- **에디터 헤더 재구성**: 티어표 편집 상단에서 게임명 kicker를 제거하고, 좌측 제목/설명 입력과 우측 썸네일 카드가 나란히 보이는 구조로 재정리
- **썸네일 영역 UX 개선**: 썸네일 미리보기와 선택/제거 버튼을 하나의 카드 안에 묶고, 모바일에서도 버튼이 카드 아래로 무너지지 않도록 밀도 있게 조정
## 2026-03-26 v0.1.38
- **관리자 기본 아이템 이름 수정 추가**: 게임 관리 화면의 현재 기본 아이템 목록에서 이름을 직접 수정하고 저장할 수 있도록 API와 UI를 보강
- **티어표 썸네일 추가**: 티어표 편집 화면에서 별도 썸네일 이미지를 선택해 저장할 수 있도록 업로드 흐름을 추가하고, 게임별 공개 티어표/내 티어표 목록은 게임 카드처럼 상단 썸네일 + 하단 제목/작성자 정보 카드 구조로 변경

View File

@@ -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() {
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
<div class="thumbCard__actions">
<button class="btn btn--ghost btn--small" @click="saveGameItemLabel(item)">이름 저장</button>
<button
class="btn btn--ghost btn--small"
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
@click="saveGameItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
</div>
</div>
@@ -763,6 +801,10 @@ async function saveFeaturedOrder() {
</div>
<div class="toolbar toolbar--secondary">
<select v-model="customItemTargetGameId" class="select toolbar__select">
<option value="">가져올 게임 선택</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
</select>
<label class="checkRow checkRow--toolbar">
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
<span>미사용 커스텀 이미지만 보기</span>
@@ -784,6 +826,9 @@ async function saveFeaturedOrder() {
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
<div class="customItemCard__actions">
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
<button class="btn btn--small btn--ghost" :disabled="!customItemTargetGameId || item.isPromoting" @click="promoteCustomItem(item)">
{{ item.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
</button>
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
</div>
</div>
@@ -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;
}

View File

@@ -482,34 +482,41 @@ onUnmounted(() => {
<template>
<section class="head">
<div class="head__meta">
<div class="kicker">{{ gameName || gameId }}</div>
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
<div v-if="canEdit" class="thumbComposer">
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
<div class="thumbComposer__preview">
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
<div v-else class="thumbComposer__empty">썸네일 없음</div>
</div>
<div class="thumbComposer__actions">
<button class="btn btn--ghost" @click="openThumbnailFile">썸네일 선택</button>
<button class="btn btn--danger" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">썸네일 제거</button>
<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>
<input
v-model="description"
class="descInput"
placeholder="설명(선택): 이 티어표의 기준/룰"
:readonly="!canEdit"
/>
<div class="hint">
<template v-if="canEdit">
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b> 누르세요.
</template>
<template v-else>
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 있어요.
</template>
<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>
</div>
</div>
</div>
</div>
<div class="actions">
@@ -630,30 +637,41 @@ onUnmounted(() => {
gap: 14px;
padding: 6px 2px 14px;
}
.head__meta {
.heroCard {
display: grid;
gap: 10px;
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
gap: 18px;
align-items: stretch;
}
.kicker {
font-size: 12px;
opacity: 0.7;
.heroCard__main,
.heroCard__side {
min-width: 0;
}
.heroCard__main {
display: grid;
gap: 12px;
}
.heroCard__side {
display: flex;
}
.titleInput {
width: min(100%, 920px);
width: 100%;
font-size: 22px;
font-weight: 800;
letter-spacing: -0.02em;
padding: 10px 12px;
border-radius: 14px;
padding: 14px 16px;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
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;
}
.descInput {
width: min(100%, 920px);
padding: 10px 12px;
border-radius: 14px;
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);
@@ -663,24 +681,47 @@ onUnmounted(() => {
.hint {
opacity: 0.78;
font-size: 13px;
line-height: 1.6;
padding: 0 2px;
}
.titleNotice {
font-size: 13px;
line-height: 1.5;
color: rgba(251, 191, 36, 0.94);
padding: 0 2px;
}
.thumbComposer {
display: grid;
gap: 10px;
width: min(100%, 920px);
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 {
display: grid;
gap: 4px;
}
.thumbComposer__eyebrow {
font-size: 13px;
font-weight: 900;
letter-spacing: 0.02em;
}
.thumbComposer__caption {
font-size: 12px;
opacity: 0.68;
}
.thumbComposer__preview {
width: min(100%, 360px);
width: 100%;
aspect-ratio: 16 / 9;
border-radius: 16px;
border-radius: 18px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.04);
background: rgba(11, 18, 32, 0.78);
}
.thumbComposer__image {
width: 100%;
@@ -693,11 +734,16 @@ onUnmounted(() => {
display: grid;
place-items: center;
color: rgba(255, 255, 255, 0.62);
font-size: 13px;
}
.thumbComposer__actions {
display: flex;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
flex-wrap: wrap;
}
.thumbComposer__button {
width: 100%;
margin-top: 0;
}
.actions {
display: flex;
@@ -774,7 +820,7 @@ onUnmounted(() => {
}
.btn--ghost {
width: 100%;
margin-top: 10px;
margin-top: 0;
}
.layout {
display: grid;
@@ -1073,6 +1119,9 @@ onUnmounted(() => {
border-radius: 14px;
}
@media (max-width: 980px) {
.heroCard {
grid-template-columns: 1fr;
}
.layout {
grid-template-columns: 1fr;
}
@@ -1089,5 +1138,13 @@ onUnmounted(() => {
.row {
grid-template-columns: 150px 1fr;
}
.thumbComposer {
padding: 14px;
border-radius: 18px;
}
.titleInput,
.descInput {
border-radius: 16px;
}
}
</style>