릴리스: v1.2.57 관리자 패널 정리와 업데이트 로그 정렬

This commit is contained in:
2026-03-31 14:41:22 +09:00
parent ba6ad0593a
commit 25b893407c
2 changed files with 383 additions and 249 deletions

View File

@@ -64,12 +64,15 @@ const uploadFiles = ref([])
const thumbFile = ref(null)
const itemPreviewUrls = ref([])
const isItemDragOver = ref(false)
const isThumbDragOver = ref(false)
const thumbPreviewUrl = ref('')
const itemFileInput = ref(null)
const thumbFileInput = ref(null)
const featuredListEl = ref(null)
const featuredSortable = ref(null)
const userAvatarInputs = ref({})
const isGameLoading = ref(false)
const gameCreateModalOpen = ref(false)
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
@@ -168,6 +171,30 @@ onUnmounted(() => {
destroyFeaturedSortable()
})
function clearPreviewUrl(kind) {
if (kind === 'item') {
itemPreviewUrls.value.forEach((url) => {
if (url) URL.revokeObjectURL(url)
})
itemPreviewUrls.value = []
return
}
if (thumbPreviewUrl.value) {
URL.revokeObjectURL(thumbPreviewUrl.value)
thumbPreviewUrl.value = ''
}
}
function resetFileInput(kind) {
if (kind === 'item') {
if (itemFileInput.value) itemFileInput.value.value = ''
return
}
if (thumbFileInput.value) thumbFileInput.value.value = ''
}
watch(error, (message) => {
if (!message) return
toast.error(message)
@@ -180,6 +207,15 @@ watch(success, (message) => {
success.value = ''
})
watch(
() => activeTab.value,
async (tab) => {
if (tab === 'game-admin' && selectedGameId.value && !selectedGame.value?.game?.id) {
await loadGame()
}
}
)
function resetMessages() {
error.value = ''
success.value = ''
@@ -201,6 +237,22 @@ function setTierlistsMode(mode) {
tierlistsMode.value = mode
}
function openGameCreateModal() {
resetMessages()
newGameId.value = ''
newGameName.value = ''
gameCreateModalOpen.value = true
}
function closeGameCreateModal() {
gameCreateModalOpen.value = false
}
async function handleSelectedGameChange(event) {
selectedGameId.value = event?.target?.value || ''
await loadGame()
}
async function refreshGames() {
try {
const data = await api.listGames()
@@ -404,6 +456,7 @@ async function loadGame() {
}
try {
isGameLoading.value = true
const data = await api.getGame(selectedGameId.value)
selectedGame.value = {
...data,
@@ -413,7 +466,11 @@ async function loadGame() {
})),
}
} catch (e) {
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
selectedGame.value = null
error.value = '게임 정보를 불러오지 못했어요.'
} finally {
isGameLoading.value = false
}
}
@@ -424,26 +481,56 @@ async function createGame() {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: newGameId.value, name: newGameName.value }),
body: JSON.stringify({ id: newGameId.value.trim(), name: newGameName.value.trim() }),
})
if (!res.ok) throw new Error('failed')
const data = await res.json()
await refreshGames()
selectedGameId.value = data.game.id
closeGameCreateModal()
await loadGame()
success.value = '게임이 생성됐어요. 이어서 썸네일과 아이템을 관리할 수 있어요.'
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
} catch (e) {
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
}
}
function onThumb(event) {
thumbFile.value = event.target.files && event.target.files[0] ? event.target.files[0] : null
function handleThumbFile(file) {
const nextFile = file && (file.type || '').startsWith('image/') ? file : null
thumbFile.value = nextFile
clearPreviewUrl('thumb')
if (thumbFile.value) thumbPreviewUrl.value = URL.createObjectURL(thumbFile.value)
}
function onThumb(event) {
handleThumbFile(event.target.files && event.target.files[0] ? event.target.files[0] : null)
}
function openThumbFilePicker() {
thumbFileInput.value?.click()
}
function onThumbDragEnter(event) {
event.preventDefault()
isThumbDragOver.value = true
}
function onThumbDragOver(event) {
event.preventDefault()
isThumbDragOver.value = true
}
function onThumbDragLeave(event) {
if (event.currentTarget === event.target) isThumbDragOver.value = false
}
function onThumbDrop(event) {
event.preventDefault()
isThumbDragOver.value = false
handleThumbFile(event.dataTransfer?.files?.[0] || null)
}
function onFile(event) {
handleItemFiles(event.target.files)
}
@@ -1122,36 +1209,34 @@ async function saveFeaturedOrder() {
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">게임 선택 생성</div>
<div class="hint hint--tight">우측 사이드가 아니라 에서 게임을 만들고, 게임 선택해 바로 상세 관리로 이어집니다.</div>
<div class="panel__title">게임 관리</div>
<div class="hint hint--tight">등록된 게임을 선택하 아래에서 썸네일과 아이템 바로 수정할 있어요.</div>
</div>
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
</div>
<div class="gameManagerGrid">
<section class="adminCard">
<div class="section__title">등록된 게임 선택</div>
<div class="gameManagerCard__body">
<select v-model="selectedGameId" class="select" @change="loadGame">
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
<option value="">게임을 선택해주세요</option>
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
</select>
<div class="hint hint--tight">선택하면 아래 상세 영역에서 썸네일과 기본 아이템을 바로 수정할 있어요.</div>
</div>
</section>
<section class="adminCard">
<div class="section__title"> 게임 만들기</div>
<div class="gameManagerCard__body">
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
<input v-model="newGameName" class="input" placeholder="게임 이름" />
<button class="btn btn--primary" @click="createGame">게임 생성</button>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
</section>
</div>
</div>
<div v-if="hasSelectedGame" class="panel">
<div v-if="isGameLoading" class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 표시합니다.</div>
</div>
</div>
<div v-else-if="hasSelectedGame" class="panel">
<div class="detailHead">
<div>
<div class="panel__title">선택된 게임 정보</div>
@@ -1166,12 +1251,25 @@ async function saveFeaturedOrder() {
<div class="section section--topGrid">
<section class="adminCard">
<div class="section__title">썸네일 적용</div>
<div class="uploadPreviewCard">
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
<button
class="thumbDropZone"
:class="{ 'thumbDropZone--active': isThumbDragOver }"
type="button"
@click="openThumbFilePicker"
@dragenter="onThumbDragEnter"
@dragover="onThumbDragOver"
@dragleave="onThumbDragLeave"
@drop="onThumbDrop"
>
<img v-if="displayThumbnailUrl" class="selectedThumb" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
<div v-else class="selectedThumb selectedThumb--empty">썸네일 미리보기</div>
</div>
<div v-else class="selectedThumb selectedThumb--empty">대표 썸네일</div>
<div class="thumbDropZone__copy">
<div class="thumbDropZone__title">클릭하거나 드래그해서 썸네일 추가</div>
<div class="thumbDropZone__desc">다른 업로드 화면처럼 이미지 장을 바로 지정할 있어요.</div>
</div>
</button>
<div class="uploadControls">
<input ref="thumbFileInput" type="file" accept="image/*" class="inputFile" @change="onThumb" />
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
</div>
</section>
@@ -1242,6 +1340,7 @@ async function saveFeaturedOrder() {
<div class="emptyState__desc">
위에서 기존 게임을 선택하거나 게임을 만든 , 같은 화면에서 바로 썸네일과 기본 아이템을 정리할 있어요.
</div>
<div v-if="selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 오류가 발생했어요. 다시 선택해보세요.</div>
</div>
</div>
</template>
@@ -1253,11 +1352,25 @@ async function saveFeaturedOrder() {
<article v-for="item in customItems" :key="item.id" class="customItemCard">
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__body">
<div class="customItemCard__title">{{ item.label }}</div>
<div class="customItemCard__meta">파일: {{ item.src.split('/').pop() }}</div>
<div class="customItemCard__meta">업로더: {{ item.ownerName }}</div>
<div class="customItemCard__meta">사용 : {{ item.usageCount }} 티어표</div>
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
<div class="customItemCard__metaList">
<div class="customItemCard__metaRow">
<span>파일</span>
<strong :title="item.src.split('/').pop()">{{ item.src.split('/').pop() }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>업로더</span>
<strong :title="item.ownerName">{{ item.ownerName }}</strong>
</div>
<div class="customItemCard__metaRow">
<span>사용 </span>
<strong>{{ item.usageCount }} 티어표</strong>
</div>
<div class="customItemCard__metaRow">
<span>등록일</span>
<strong>{{ fmt(item.createdAt) }}</strong>
</div>
</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)">
@@ -1462,6 +1575,21 @@ async function saveFeaturedOrder() {
</div>
</template>
<div v-if="gameCreateModalOpen" class="modalOverlay" @click.self="closeGameCreateModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title"> 게임 만들기</div>
<div class="modalCard__desc">게임 이름과 고유 ID를 입력한 생성하면 바로 아래 상세 관리 화면으로 이어집니다.</div>
<div class="modalCard__form">
<input v-model="newGameName" class="input" placeholder="게임 이름" />
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" @keydown.enter.prevent="createGame" />
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeGameCreateModal">취소</button>
<button class="btn btn--primary" :disabled="!newGameId.trim() || !newGameName.trim()" @click="createGame">게임 생성</button>
</div>
</div>
</div>
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">비밀번호 초기화</div>
@@ -1576,47 +1704,7 @@ async function saveFeaturedOrder() {
</div>
</section>
<section v-if="activeTab === 'featured'" class="adminSidebar__panel">
<div class="adminSidebar__label">Featured</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshGames">목록 새로고침</button>
<button class="btn btn--primary" @click="saveFeaturedOrder">순서 저장</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label">상단 고정</span>
<strong class="sidebarStat__value">{{ featuredGameIds.length }}/50</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">추가 가능</span>
<strong class="sidebarStat__value">{{ Math.max(0, 50 - featuredGameIds.length) }}</strong>
</div>
</div>
</section>
<section v-else-if="activeTab === 'game-admin'" class="adminSidebar__panel">
<div class="adminSidebar__label">Game Summary</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
<button v-if="hasSelectedGame" class="btn btn--ghost" @click="loadGame">선택 게임 다시 불러오기</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label">전체 게임</span>
<strong class="sidebarStat__value">{{ games.length }}</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">선택 상태</span>
<strong class="sidebarStat__value">{{ hasSelectedGame ? '활성' : '대기' }}</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">기본 아이템</span>
<strong class="sidebarStat__value">{{ selectedGame?.items?.length || 0 }}</strong>
</div>
</div>
</section>
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
<section v-if="activeTab === 'items'" class="adminSidebar__panel">
<div class="adminSidebar__label">Filters</div>
<div class="adminSidebar__group">
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
@@ -1662,17 +1750,7 @@ async function saveFeaturedOrder() {
전체 티어표 관리
</button>
</div>
<template v-if="tierlistsMode === 'requests'">
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshTemplateRequests">요청 새로고침</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label">대기 요청</span>
<strong class="sidebarStat__value">{{ templateRequests.length }}</strong>
</div>
</div>
</template>
<template v-if="tierlistsMode === 'requests'"></template>
<template v-else>
<div class="adminSidebar__group">
<input
@@ -1703,22 +1781,6 @@ async function saveFeaturedOrder() {
</template>
</section>
<section v-else class="adminSidebar__panel">
<div class="adminSidebar__label">Users</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshUsers">회원 새로고침</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
<span class="sidebarStat__label">가입 회원</span>
<strong class="sidebarStat__value">{{ users.length }}</strong>
</div>
<div class="sidebarStat">
<span class="sidebarStat__label">관리자 </span>
<strong class="sidebarStat__value">{{ users.filter((user) => user.isAdmin).length }}</strong>
</div>
</div>
</section>
</aside>
</Teleport>
</template>
@@ -2229,6 +2291,35 @@ async function saveFeaturedOrder() {
place-items: center;
color: rgba(255, 255, 255, 0.62);
}
.thumbDropZone {
width: 100%;
display: grid;
gap: 14px;
justify-items: start;
padding: 16px;
border-radius: 18px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: rgba(255, 255, 255, 0.03);
text-align: left;
transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.thumbDropZone--active {
border-color: rgba(96, 165, 250, 0.56);
background: rgba(96, 165, 250, 0.08);
transform: translateY(-1px);
}
.thumbDropZone__copy {
display: grid;
gap: 6px;
}
.thumbDropZone__title {
font-weight: 900;
}
.thumbDropZone__desc {
color: rgba(255, 255, 255, 0.68);
font-size: 13px;
line-height: 1.5;
}
.itemComposer {
margin-top: 10px;
display: grid;
@@ -2346,54 +2437,74 @@ async function saveFeaturedOrder() {
.customItemGrid {
margin-top: 14px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(240px, 280px));
gap: 12px;
justify-content: start;
}
.customItemCard {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 18px;
background: rgba(255, 255, 255, 0.04);
overflow: hidden;
display: flex;
gap: 12px;
align-items: flex-start;
display: grid;
grid-template-rows: auto 1fr;
gap: 14px;
padding: 14px;
min-width: 0;
}
.customItemCard__image {
width: clamp(88px, 22vw, 150px);
flex: 0 1 150px;
width: 100%;
aspect-ratio: 1 / 1;
object-fit: cover;
display: block;
border-radius: 12px;
border-radius: 14px;
background: rgba(0, 0, 0, 0.18);
}
.customItemCard__body {
display: flex;
flex-direction: column;
gap: 6px;
display: grid;
gap: 12px;
min-width: 0;
flex: 1 1 auto;
align-content: start;
}
.customItemCard__actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
gap: 10px;
margin-top: 4px;
gap: 8px;
margin-top: auto;
}
.customItemCard__actions > * {
width: 100%;
}
.customItemCard__title {
min-width: 0;
overflow-wrap: anywhere;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
font-weight: 900;
line-height: 1.35;
}
.customItemCard__meta {
opacity: 0.72;
font-size: 13px;
.customItemCard__metaList {
display: grid;
gap: 8px;
}
.customItemCard__metaRow {
display: grid;
gap: 3px;
min-width: 0;
overflow-wrap: anywhere;
word-break: break-word;
}
.customItemCard__metaRow span {
font-size: 11px;
color: rgba(255, 255, 255, 0.46);
}
.customItemCard__metaRow strong {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
color: rgba(255, 255, 255, 0.82);
}
.pager {
margin-top: 16px;