|
|
|
|
@@ -73,6 +73,28 @@ const featuredGames = computed(() =>
|
|
|
|
|
)
|
|
|
|
|
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
|
|
|
|
const importModalItemCount = computed(() => importModalItems.value.length)
|
|
|
|
|
const activeTabTitle = computed(() => {
|
|
|
|
|
if (activeTab.value === 'games') return '게임 관리'
|
|
|
|
|
if (activeTab.value === 'items') return '아이템 관리'
|
|
|
|
|
if (activeTab.value === 'tierlists') {
|
|
|
|
|
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
|
|
|
|
}
|
|
|
|
|
return '회원 관리'
|
|
|
|
|
})
|
|
|
|
|
const activeTabDescription = computed(() => {
|
|
|
|
|
if (activeTab.value === 'games') {
|
|
|
|
|
return '홈 노출 순서, 게임 생성, 썸네일, 기본 아이템을 한 화면에서 정리합니다.'
|
|
|
|
|
}
|
|
|
|
|
if (activeTab.value === 'items') {
|
|
|
|
|
return '사용자 커스텀 이미지를 검색하고, 미사용 이미지를 정리하거나 템플릿으로 승격할 수 있어요.'
|
|
|
|
|
}
|
|
|
|
|
if (activeTab.value === 'tierlists') {
|
|
|
|
|
return tierlistsMode.value === 'requests'
|
|
|
|
|
? '사용자 요청 기반으로 새 템플릿 생성이나 템플릿 업데이트를 승인합니다.'
|
|
|
|
|
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
|
|
|
|
|
}
|
|
|
|
|
return '계정 정보, 권한, 비밀번호와 최근 활동을 함께 확인합니다.'
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
onMounted(async () => {
|
|
|
|
|
await auth.refresh()
|
|
|
|
|
@@ -869,14 +891,15 @@ async function saveFeaturedOrder() {
|
|
|
|
|
<div v-else-if="!isAdmin" class="warn">이 계정은 관리자 권한이 없어요.</div>
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div class="tabs">
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="adminWorkspace">
|
|
|
|
|
<div class="adminMain">
|
|
|
|
|
<header class="adminHero">
|
|
|
|
|
<div class="adminHero__eyebrow">Admin Workspace</div>
|
|
|
|
|
<h2 class="adminHero__title">{{ activeTabTitle }}</h2>
|
|
|
|
|
<p class="adminHero__desc">{{ activeTabDescription }}</p>
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
<template v-if="activeTab === 'games'">
|
|
|
|
|
<template v-if="activeTab === 'games'">
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="sectionHeader">
|
|
|
|
|
<div>
|
|
|
|
|
@@ -927,33 +950,6 @@ async function saveFeaturedOrder() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="modeTabs">
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
|
|
|
|
|
등록된 게임 선택
|
|
|
|
|
</button>
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'new' }" @click="setGameMode('new')">
|
|
|
|
|
새 게임 추가
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="panel panel--compact">
|
|
|
|
|
<template v-if="gameMode === 'existing'">
|
|
|
|
|
<!-- <div class="panel__title">등록된 게임 선택</div> -->
|
|
|
|
|
<select v-model="selectedGameId" class="select" @change="loadGame">
|
|
|
|
|
<option value="">게임을 선택해주세요</option>
|
|
|
|
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
|
|
|
|
</select>
|
|
|
|
|
<!-- <div class="hint">이 영역은 게임 자체와 관리자 기본 아이템만 관리합니다. 여기서 아이템을 삭제해도 사용자 커스텀 이미지는 삭제되지 않아요.</div> -->
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div class="panel__title">새 게임 정보 입력</div>
|
|
|
|
|
<input v-model="newGameId" class="input" placeholder="game id (영문/숫자)" />
|
|
|
|
|
<input v-model="newGameName" class="input" placeholder="게임 이름" />
|
|
|
|
|
<button class="btn btn--primary" @click="createGame">게임 생성</button>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="hasSelectedGame" class="panel">
|
|
|
|
|
<div class="detailHead">
|
|
|
|
|
<div>
|
|
|
|
|
@@ -1039,41 +1035,18 @@ async function saveFeaturedOrder() {
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="activeTab === 'items'">
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="sectionHeader">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="panel__title">사용자 커스텀 아이템 관리</div>
|
|
|
|
|
<div class="hint hint--tight">사용자가 업로드한 이미지를 파일명/라벨 기준으로 검색하고, 한 번에 50개 또는 200개씩 페이지 형태로 확인할 수 있어요.</div>
|
|
|
|
|
<div v-else class="panel panel--empty">
|
|
|
|
|
<div class="emptyState">
|
|
|
|
|
<div class="emptyState__title">{{ gameMode === 'existing' ? '게임을 선택하면 상세 관리가 열려요.' : '새 게임 정보를 입력한 뒤 생성해 주세요.' }}</div>
|
|
|
|
|
<div class="emptyState__desc">
|
|
|
|
|
{{ gameMode === 'existing' ? '우측 패널에서 등록된 게임을 선택하면 썸네일과 기본 아이템 관리 영역이 활성화됩니다.' : '새 게임을 만들면 바로 선택 상태로 전환되어 썸네일과 기본 아이템 추가를 이어서 진행할 수 있어요.' }}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<input v-model="customItemQuery" class="input toolbar__search" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
|
|
|
|
|
<button class="btn btn--ghost toolbar__button" @click="submitCustomItemSearch">검색</button>
|
|
|
|
|
<select :value="customItemLimit" class="select toolbar__select" @change="changeCustomItemLimit(Number($event.target.value))">
|
|
|
|
|
<option :value="50">50개씩 보기</option>
|
|
|
|
|
<option :value="200">200개씩 보기</option>
|
|
|
|
|
</select>
|
|
|
|
|
</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>
|
|
|
|
|
</label>
|
|
|
|
|
<button class="btn btn--danger toolbar__button" :disabled="!customItems.length" @click="removeUnusedCustomItems">
|
|
|
|
|
미사용 이미지 일괄 삭제
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="activeTab === 'items'">
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div v-if="!customItems.length" class="hint">조건에 맞는 커스텀 아이템이 없어요.</div>
|
|
|
|
|
<div v-else class="customItemGrid">
|
|
|
|
|
<article v-for="item in customItems" :key="item.id" class="customItemCard">
|
|
|
|
|
@@ -1101,25 +1074,15 @@ async function saveFeaturedOrder() {
|
|
|
|
|
<button class="btn btn--ghost" :disabled="customItemPage >= customItemPageCount" @click="moveCustomItemPage(1)">다음</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="activeTab === 'tierlists'">
|
|
|
|
|
<div class="modeTabs modeTabs--admin">
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'requests' }" @click="setTierlistsMode('requests')">
|
|
|
|
|
템플릿 요청 관리
|
|
|
|
|
</button>
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'lists' }" @click="setTierlistsMode('lists')">
|
|
|
|
|
전체 티어표 관리
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else-if="activeTab === 'tierlists'">
|
|
|
|
|
<div v-if="tierlistsMode === 'requests'" class="panel">
|
|
|
|
|
<div class="sectionHeader">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="panel__title">사용자 템플릿 요청</div>
|
|
|
|
|
<div class="hint hint--tight">freeform 템플릿 등록 요청과 기존 게임 템플릿 업데이트 요청을 여기서 승인하거나 반려할 수 있어요. 반려한 요청은 대기 목록에서 바로 제외됩니다.</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn--ghost" @click="refreshTemplateRequests">새로고침</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="!templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
|
|
|
|
|
@@ -1166,21 +1129,6 @@ async function saveFeaturedOrder() {
|
|
|
|
|
<div class="panel__title">전체 티어표 관리</div>
|
|
|
|
|
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요. 여기는 요청 목록과 별개로 전체 저장 티어표를 보는 영역입니다.</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<input
|
|
|
|
|
v-model="adminTierListQuery"
|
|
|
|
|
class="input toolbar__search"
|
|
|
|
|
placeholder="제목, 작성자, 게임 이름 검색"
|
|
|
|
|
@keydown.enter.prevent="submitAdminTierListSearch"
|
|
|
|
|
/>
|
|
|
|
|
<button class="btn btn--ghost toolbar__button" @click="submitAdminTierListSearch">검색</button>
|
|
|
|
|
<select :value="adminTierListLimit" class="select toolbar__select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
|
|
|
|
<option :value="50">50개씩 보기</option>
|
|
|
|
|
<option :value="200">200개씩 보기</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
|
|
|
|
|
@@ -1233,68 +1181,15 @@ async function saveFeaturedOrder() {
|
|
|
|
|
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
|
|
|
|
|
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
|
|
|
|
<div class="modalCard__title">티어표 아이템 가져오기</div>
|
|
|
|
|
<div class="modalCard__desc">
|
|
|
|
|
"{{ importModalTierList?.title }}"의 아이템 {{ importModalItemCount }}개를 어디로 가져올지 선택해주세요.
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="importModeTabs">
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'existing' }" @click="importModalMode = 'existing'">
|
|
|
|
|
기존 템플릿에 추가
|
|
|
|
|
</button>
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'new' }" @click="importModalMode = 'new'">
|
|
|
|
|
새 템플릿 만들기
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="importModalMode === 'existing'" class="modalCard__form">
|
|
|
|
|
<select v-model="importModalTargetGameId" class="select">
|
|
|
|
|
<option value="">기존 게임 선택</option>
|
|
|
|
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="modalCard__form">
|
|
|
|
|
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
|
|
|
|
|
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="modalCard__actions">
|
|
|
|
|
<button class="btn btn--ghost" @click="closeTierListImportModal">취소</button>
|
|
|
|
|
<button class="btn btn--primary" @click="confirmTierListImport">
|
|
|
|
|
{{ importModalMode === 'existing' ? '여기로 가져오기' : '새 템플릿 생성' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
|
|
|
|
|
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
|
|
|
|
|
<div class="modalCard__titleRow">
|
|
|
|
|
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
|
|
|
|
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
|
|
|
|
</div>
|
|
|
|
|
<iframe
|
|
|
|
|
v-if="previewTierList"
|
|
|
|
|
class="previewFrame"
|
|
|
|
|
:src="previewTierListUrl(previewTierList)"
|
|
|
|
|
title="티어표 미리보기"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<template v-else>
|
|
|
|
|
<template v-else>
|
|
|
|
|
<div class="panel">
|
|
|
|
|
<div class="sectionHeader">
|
|
|
|
|
<div>
|
|
|
|
|
<div class="panel__title">회원 관리</div>
|
|
|
|
|
<div class="hint hint--tight">이메일, 닉네임, 관리자 권한을 수정하고 비밀번호도 직접 초기화할 수 있어요.</div>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="btn btn--ghost" @click="refreshUsers">새로고침</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="!users.length" class="hint">아직 가입한 회원이 없어요.</div>
|
|
|
|
|
@@ -1346,7 +1241,216 @@ async function saveFeaturedOrder() {
|
|
|
|
|
</article>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</template>
|
|
|
|
|
|
|
|
|
|
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
|
|
|
|
|
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
|
|
|
|
<div class="modalCard__title">티어표 아이템 가져오기</div>
|
|
|
|
|
<div class="modalCard__desc">
|
|
|
|
|
"{{ importModalTierList?.title }}"의 아이템 {{ importModalItemCount }}개를 어디로 가져올지 선택해주세요.
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="importModeTabs">
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'existing' }" @click="importModalMode = 'existing'">
|
|
|
|
|
기존 템플릿에 추가
|
|
|
|
|
</button>
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': importModalMode === 'new' }" @click="importModalMode = 'new'">
|
|
|
|
|
새 템플릿 만들기
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="importModalMode === 'existing'" class="modalCard__form">
|
|
|
|
|
<select v-model="importModalTargetGameId" class="select">
|
|
|
|
|
<option value="">기존 게임 선택</option>
|
|
|
|
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="modalCard__form">
|
|
|
|
|
<input v-model="importModalNewGameId" class="input" placeholder="새 게임 ID" />
|
|
|
|
|
<input v-model="importModalNewGameName" class="input" placeholder="새 게임 이름" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div class="modalCard__actions">
|
|
|
|
|
<button class="btn btn--ghost" @click="closeTierListImportModal">취소</button>
|
|
|
|
|
<button class="btn btn--primary" @click="confirmTierListImport">
|
|
|
|
|
{{ importModalMode === 'existing' ? '여기로 가져오기' : '새 템플릿 생성' }}
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="previewModalOpen" class="modalOverlay" @click.self="closePreviewModal">
|
|
|
|
|
<div class="modalCard modalCard--preview" role="dialog" aria-modal="true">
|
|
|
|
|
<div class="modalCard__titleRow">
|
|
|
|
|
<div class="modalCard__title">{{ previewTierList?.title || '티어표 미리보기' }}</div>
|
|
|
|
|
<button class="btn btn--ghost btn--small" @click="closePreviewModal">닫기</button>
|
|
|
|
|
</div>
|
|
|
|
|
<iframe
|
|
|
|
|
v-if="previewTierList"
|
|
|
|
|
class="previewFrame"
|
|
|
|
|
:src="previewTierListUrl(previewTierList)"
|
|
|
|
|
title="티어표 미리보기"
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<aside class="adminSidebar">
|
|
|
|
|
<section class="adminSidebar__panel">
|
|
|
|
|
<div class="adminSidebar__label">Mode</div>
|
|
|
|
|
<div class="adminSidebar__tabs">
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
|
|
|
|
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-if="activeTab === 'games'" class="adminSidebar__panel">
|
|
|
|
|
<div class="adminSidebar__label">Game Flow</div>
|
|
|
|
|
<div class="modeTabs modeTabs--stack">
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
|
|
|
|
|
등록된 게임 선택
|
|
|
|
|
</button>
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'new' }" @click="setGameMode('new')">
|
|
|
|
|
새 게임 추가
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-if="gameMode === 'existing'" class="adminSidebar__group">
|
|
|
|
|
<div class="adminSidebar__groupTitle">선택할 게임</div>
|
|
|
|
|
<select v-model="selectedGameId" class="select" @change="loadGame">
|
|
|
|
|
<option value="">게임을 선택해주세요</option>
|
|
|
|
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }} ({{ game.id }})</option>
|
|
|
|
|
</select>
|
|
|
|
|
<button class="btn btn--ghost" @click="refreshGames">게임 목록 새로고침</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div v-else class="adminSidebar__group">
|
|
|
|
|
<div class="adminSidebar__groupTitle">새 게임 만들기</div>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
<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">{{ featuredGameIds.length }}/50</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-else-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" />
|
|
|
|
|
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="adminSidebar__group">
|
|
|
|
|
<select :value="customItemLimit" class="select" @change="changeCustomItemLimit(Number($event.target.value))">
|
|
|
|
|
<option :value="50">50개씩 보기</option>
|
|
|
|
|
<option :value="200">200개씩 보기</option>
|
|
|
|
|
</select>
|
|
|
|
|
<select v-model="customItemTargetGameId" class="select">
|
|
|
|
|
<option value="">가져올 게임 선택</option>
|
|
|
|
|
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
|
|
|
|
</select>
|
|
|
|
|
<label class="checkRow checkRow--compact">
|
|
|
|
|
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
|
|
|
|
<span>미사용 커스텀 이미지만 보기</span>
|
|
|
|
|
</label>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="adminSidebar__actions">
|
|
|
|
|
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
|
|
|
|
<button class="btn btn--danger" :disabled="!customItems.length" @click="removeUnusedCustomItems">미사용 이미지 일괄 삭제</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="adminSidebar__stats">
|
|
|
|
|
<div class="sidebarStat">
|
|
|
|
|
<span class="sidebarStat__label">현재 페이지</span>
|
|
|
|
|
<strong class="sidebarStat__value">{{ customItemPage }}/{{ customItemPageCount }}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="sidebarStat">
|
|
|
|
|
<span class="sidebarStat__label">검색 결과</span>
|
|
|
|
|
<strong class="sidebarStat__value">{{ customItemTotal }}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
<section v-else-if="activeTab === 'tierlists'" class="adminSidebar__panel">
|
|
|
|
|
<div class="adminSidebar__label">Tierlists</div>
|
|
|
|
|
<div class="modeTabs modeTabs--stack">
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'requests' }" @click="setTierlistsMode('requests')">
|
|
|
|
|
템플릿 요청 관리
|
|
|
|
|
</button>
|
|
|
|
|
<button class="modeTab" :class="{ 'modeTab--active': tierlistsMode === 'lists' }" @click="setTierlistsMode('lists')">
|
|
|
|
|
전체 티어표 관리
|
|
|
|
|
</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-else>
|
|
|
|
|
<div class="adminSidebar__group">
|
|
|
|
|
<input
|
|
|
|
|
v-model="adminTierListQuery"
|
|
|
|
|
class="input"
|
|
|
|
|
placeholder="제목, 작성자, 게임 이름 검색"
|
|
|
|
|
@keydown.enter.prevent="submitAdminTierListSearch"
|
|
|
|
|
/>
|
|
|
|
|
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
|
|
|
|
|
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
|
|
|
|
<option :value="50">50개씩 보기</option>
|
|
|
|
|
<option :value="200">200개씩 보기</option>
|
|
|
|
|
</select>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="adminSidebar__actions">
|
|
|
|
|
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="adminSidebar__stats">
|
|
|
|
|
<div class="sidebarStat">
|
|
|
|
|
<span class="sidebarStat__label">현재 페이지</span>
|
|
|
|
|
<strong class="sidebarStat__value">{{ adminTierListPage }}/{{ adminTierListPageCount }}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="sidebarStat">
|
|
|
|
|
<span class="sidebarStat__label">검색 결과</span>
|
|
|
|
|
<strong class="sidebarStat__value">{{ adminTierListTotal }}</strong>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</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>
|
|
|
|
|
</div>
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
@@ -1357,6 +1461,92 @@ async function saveFeaturedOrder() {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
}
|
|
|
|
|
.adminWorkspace {
|
|
|
|
|
display: grid;
|
|
|
|
|
grid-template-columns: minmax(0, 1fr) 320px;
|
|
|
|
|
gap: 16px;
|
|
|
|
|
align-items: start;
|
|
|
|
|
}
|
|
|
|
|
.adminMain {
|
|
|
|
|
min-width: 0;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
}
|
|
|
|
|
.adminHero {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
padding: 18px 20px;
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
|
|
|
|
}
|
|
|
|
|
.adminHero__eyebrow {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
letter-spacing: 0.12em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: rgba(255, 255, 255, 0.42);
|
|
|
|
|
}
|
|
|
|
|
.adminHero__title {
|
|
|
|
|
margin: 0;
|
|
|
|
|
font-size: 28px;
|
|
|
|
|
line-height: 1.05;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
letter-spacing: -0.04em;
|
|
|
|
|
}
|
|
|
|
|
.adminHero__desc {
|
|
|
|
|
margin: 0;
|
|
|
|
|
color: rgba(255, 255, 255, 0.66);
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
}
|
|
|
|
|
.adminSidebar {
|
|
|
|
|
position: sticky;
|
|
|
|
|
top: 14px;
|
|
|
|
|
align-self: start;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
}
|
|
|
|
|
.adminSidebar__panel {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 14px;
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
background: rgba(17, 17, 17, 0.9);
|
|
|
|
|
}
|
|
|
|
|
.adminSidebar__label {
|
|
|
|
|
font-size: 11px;
|
|
|
|
|
letter-spacing: 0.12em;
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
color: rgba(255, 255, 255, 0.42);
|
|
|
|
|
}
|
|
|
|
|
.adminSidebar__tabs,
|
|
|
|
|
.adminSidebar__group,
|
|
|
|
|
.adminSidebar__actions,
|
|
|
|
|
.adminSidebar__stats {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 10px;
|
|
|
|
|
}
|
|
|
|
|
.adminSidebar__groupTitle {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
color: rgba(255, 255, 255, 0.84);
|
|
|
|
|
}
|
|
|
|
|
.sidebarStat {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 4px;
|
|
|
|
|
padding: 10px 12px;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
|
|
|
|
background: rgba(255, 255, 255, 0.03);
|
|
|
|
|
}
|
|
|
|
|
.sidebarStat__label {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: rgba(255, 255, 255, 0.56);
|
|
|
|
|
}
|
|
|
|
|
.sidebarStat__value {
|
|
|
|
|
font-size: 14px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
}
|
|
|
|
|
.card {
|
|
|
|
|
border: 0;
|
|
|
|
|
background: transparent;
|
|
|
|
|
@@ -1393,6 +1583,10 @@ async function saveFeaturedOrder() {
|
|
|
|
|
gap: 10px;
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
}
|
|
|
|
|
.modeTabs--stack {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
}
|
|
|
|
|
.tab,
|
|
|
|
|
.modeTab {
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
@@ -1408,16 +1602,39 @@ async function saveFeaturedOrder() {
|
|
|
|
|
background: rgba(255, 255, 255, 0.1);
|
|
|
|
|
border-color: rgba(255, 255, 255, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.adminSidebar__tabs .tab,
|
|
|
|
|
.modeTabs--stack .modeTab {
|
|
|
|
|
width: 100%;
|
|
|
|
|
text-align: left;
|
|
|
|
|
}
|
|
|
|
|
.panel {
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
|
|
|
background: rgba(48, 48, 48, 0.78);
|
|
|
|
|
border-radius: 18px;
|
|
|
|
|
padding: 16px;
|
|
|
|
|
}
|
|
|
|
|
.panel--empty {
|
|
|
|
|
min-height: 240px;
|
|
|
|
|
display: grid;
|
|
|
|
|
place-items: center;
|
|
|
|
|
}
|
|
|
|
|
.panel--compact {
|
|
|
|
|
max-width: 520px;
|
|
|
|
|
}
|
|
|
|
|
.emptyState {
|
|
|
|
|
max-width: 520px;
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 8px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
.emptyState__title {
|
|
|
|
|
font-size: 18px;
|
|
|
|
|
font-weight: 900;
|
|
|
|
|
}
|
|
|
|
|
.emptyState__desc {
|
|
|
|
|
color: rgba(255, 255, 255, 0.66);
|
|
|
|
|
line-height: 1.6;
|
|
|
|
|
}
|
|
|
|
|
.featuredOrderPanel {
|
|
|
|
|
margin-top: 14px;
|
|
|
|
|
display: grid;
|
|
|
|
|
@@ -2198,10 +2415,20 @@ async function saveFeaturedOrder() {
|
|
|
|
|
align-items: center;
|
|
|
|
|
opacity: 0.88;
|
|
|
|
|
}
|
|
|
|
|
.checkRow--compact {
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
}
|
|
|
|
|
.checkRow--toolbar {
|
|
|
|
|
margin-top: 0;
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 980px) {
|
|
|
|
|
.adminWorkspace {
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
}
|
|
|
|
|
.adminSidebar {
|
|
|
|
|
position: static;
|
|
|
|
|
order: -1;
|
|
|
|
|
}
|
|
|
|
|
.featuredOrderPanel,
|
|
|
|
|
.section--topGrid,
|
|
|
|
|
.toolbar,
|
|
|
|
|
@@ -2222,6 +2449,12 @@ async function saveFeaturedOrder() {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
@media (max-width: 640px) {
|
|
|
|
|
.adminHero {
|
|
|
|
|
padding: 16px;
|
|
|
|
|
}
|
|
|
|
|
.adminHero__title {
|
|
|
|
|
font-size: 24px;
|
|
|
|
|
}
|
|
|
|
|
.thumbGrid,
|
|
|
|
|
.customItemGrid,
|
|
|
|
|
.userList {
|
|
|
|
|
|