릴리스: v1.2.26 관리자 회원 관리와 셸 UI 개선
This commit is contained in:
@@ -3,6 +3,8 @@ import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watc
|
||||
import Sortable from 'sortablejs'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import lockResetIcon from '../assets/icons/lock_reset.svg'
|
||||
import deleteIcon from '../assets/icons/delete.svg'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
@@ -12,9 +14,8 @@ const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
|
||||
const activeTab = ref('games')
|
||||
const activeTab = ref('featured')
|
||||
const tierlistsMode = ref('requests')
|
||||
const gameMode = ref('existing')
|
||||
|
||||
const games = ref([])
|
||||
const selectedGameId = ref('')
|
||||
@@ -44,6 +45,12 @@ const importModalNewGameId = ref('')
|
||||
const importModalNewGameName = ref('')
|
||||
const previewModalOpen = ref(false)
|
||||
const previewTierList = ref(null)
|
||||
const userPasswordModalOpen = ref(false)
|
||||
const userDeleteModalOpen = ref(false)
|
||||
const userRoleModalOpen = ref(false)
|
||||
const modalTargetUser = ref(null)
|
||||
const modalPasswordDraft = ref('')
|
||||
const modalRoleNextAdmin = ref(false)
|
||||
|
||||
const users = ref([])
|
||||
|
||||
@@ -62,6 +69,7 @@ const itemFileInput = ref(null)
|
||||
const thumbFileInput = ref(null)
|
||||
const featuredListEl = ref(null)
|
||||
const featuredSortable = ref(null)
|
||||
const userAvatarInputs = ref({})
|
||||
|
||||
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
||||
@@ -76,7 +84,8 @@ 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 === 'featured') return '목록 관리'
|
||||
if (activeTab.value === 'game-admin') return '게임 관리'
|
||||
if (activeTab.value === 'items') return '아이템 관리'
|
||||
if (activeTab.value === 'tierlists') {
|
||||
return tierlistsMode.value === 'requests' ? '템플릿 요청 관리' : '전체 티어표 관리'
|
||||
@@ -84,8 +93,11 @@ const activeTabTitle = computed(() => {
|
||||
return '회원 관리'
|
||||
})
|
||||
const activeTabDescription = computed(() => {
|
||||
if (activeTab.value === 'games') {
|
||||
return '홈 노출 순서, 게임 생성, 썸네일, 기본 아이템을 한 화면에서 정리합니다.'
|
||||
if (activeTab.value === 'featured') {
|
||||
return '홈 화면 상단에 고정 노출되는 게임 순서를 따로 관리합니다.'
|
||||
}
|
||||
if (activeTab.value === 'game-admin') {
|
||||
return '게임 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
return '사용자 커스텀 이미지를 검색하고, 미사용 이미지를 정리하거나 템플릿으로 승격할 수 있어요.'
|
||||
@@ -95,7 +107,7 @@ const activeTabDescription = computed(() => {
|
||||
? '사용자 요청 기반으로 새 템플릿 생성이나 템플릿 업데이트를 승인합니다.'
|
||||
: '공개/비공개 포함 전체 티어표를 확인하고, 추가 아이템을 템플릿으로 가져올 수 있어요.'
|
||||
}
|
||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 함께 확인합니다.'
|
||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
|
||||
})
|
||||
const adminOverviewStats = computed(() => {
|
||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
||||
@@ -103,11 +115,18 @@ const adminOverviewStats = computed(() => {
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin || user.draftIsAdmin).length
|
||||
|
||||
if (activeTab.value === 'games') {
|
||||
if (activeTab.value === 'featured') {
|
||||
return [
|
||||
{ label: '전체 게임', value: `${games.value.length}` },
|
||||
{ label: '상단 고정', value: `${featuredGameIds.value.length}/50` },
|
||||
{ label: '추가 가능', value: `${Math.max(0, 50 - featuredGameIds.value.length)}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'game-admin') {
|
||||
return [
|
||||
{ label: '전체 게임', value: `${games.value.length}` },
|
||||
{ label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' },
|
||||
{ label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` },
|
||||
]
|
||||
}
|
||||
if (activeTab.value === 'items') {
|
||||
@@ -199,6 +218,70 @@ async function refreshGames() {
|
||||
}
|
||||
}
|
||||
|
||||
function setUserAvatarInput(userId, el) {
|
||||
if (!userId) return
|
||||
if (!el) {
|
||||
delete userAvatarInputs.value[userId]
|
||||
return
|
||||
}
|
||||
userAvatarInputs.value[userId] = el
|
||||
}
|
||||
|
||||
function isUserDirty(user) {
|
||||
if (!user) return false
|
||||
return user.draftEmail !== user.email || (user.draftNickname || '') !== (user.nickname || '') || !!user.draftIsAdmin !== !!user.isAdmin
|
||||
}
|
||||
|
||||
function openUserAvatarPicker(user) {
|
||||
userAvatarInputs.value[user?.id]?.click()
|
||||
}
|
||||
|
||||
async function uploadUserAvatar(user, file, { remove = false } = {}) {
|
||||
resetMessages()
|
||||
if (!user?.id) return
|
||||
|
||||
try {
|
||||
user.isAvatarBusy = true
|
||||
const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove })
|
||||
const updated = data.user
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === updated.id
|
||||
? {
|
||||
...entry,
|
||||
avatarSrc: updated.avatarSrc || '',
|
||||
email: updated.email,
|
||||
nickname: updated.nickname || '',
|
||||
isAdmin: !!updated.isAdmin,
|
||||
draftEmail: updated.email,
|
||||
draftNickname: updated.nickname || '',
|
||||
draftIsAdmin: !!updated.isAdmin,
|
||||
isAvatarBusy: false,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (updated.id === auth.user?.id) await auth.refresh()
|
||||
await refreshUsers()
|
||||
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
|
||||
} catch (e) {
|
||||
error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.'
|
||||
} finally {
|
||||
const target = users.value.find((entry) => entry.id === user.id)
|
||||
if (target) target.isAvatarBusy = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onUserAvatarChange(user, event) {
|
||||
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null
|
||||
event.target.value = ''
|
||||
if (!file) return
|
||||
await uploadUserAvatar(user, file)
|
||||
}
|
||||
|
||||
async function removeUserAvatar(user) {
|
||||
if (!user?.avatarSrc) return
|
||||
await uploadUserAvatar(user, null, { remove: true })
|
||||
}
|
||||
|
||||
function destroyFeaturedSortable() {
|
||||
if (featuredSortable.value) {
|
||||
featuredSortable.value.destroy()
|
||||
@@ -295,7 +378,7 @@ async function refreshUsers() {
|
||||
draftEmail: user.email,
|
||||
draftNickname: user.nickname || '',
|
||||
draftIsAdmin: !!user.isAdmin,
|
||||
draftPassword: '',
|
||||
isAvatarBusy: false,
|
||||
}))
|
||||
} catch (e) {
|
||||
error.value = '회원 목록을 불러오지 못했어요.'
|
||||
@@ -311,32 +394,6 @@ function resetUploadState() {
|
||||
clearPreviewUrl('thumb')
|
||||
}
|
||||
|
||||
function setGameMode(mode) {
|
||||
resetMessages()
|
||||
gameMode.value = mode
|
||||
selectedGameId.value = ''
|
||||
selectedGame.value = null
|
||||
newGameId.value = ''
|
||||
newGameName.value = ''
|
||||
resetUploadState()
|
||||
}
|
||||
|
||||
function clearPreviewUrl(type) {
|
||||
if (type === 'item' && itemPreviewUrls.value.length) {
|
||||
itemPreviewUrls.value.forEach((url) => URL.revokeObjectURL(url))
|
||||
itemPreviewUrls.value = []
|
||||
}
|
||||
if (type === 'thumb' && thumbPreviewUrl.value) {
|
||||
URL.revokeObjectURL(thumbPreviewUrl.value)
|
||||
thumbPreviewUrl.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function resetFileInput(type) {
|
||||
if (type === 'item' && itemFileInput.value) itemFileInput.value.value = ''
|
||||
if (type === 'thumb' && thumbFileInput.value) thumbFileInput.value.value = ''
|
||||
}
|
||||
|
||||
async function loadGame() {
|
||||
resetMessages()
|
||||
resetUploadState()
|
||||
@@ -373,7 +430,6 @@ async function createGame() {
|
||||
|
||||
const data = await res.json()
|
||||
await refreshGames()
|
||||
gameMode.value = 'existing'
|
||||
selectedGameId.value = data.game.id
|
||||
await loadGame()
|
||||
success.value = '게임이 생성됐어요. 이어서 썸네일과 아이템을 관리할 수 있어요.'
|
||||
@@ -563,50 +619,114 @@ async function saveUser(user) {
|
||||
const updated = data.user
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === updated.id
|
||||
? { ...entry, ...updated, draftEmail: updated.email, draftNickname: updated.nickname || '', draftIsAdmin: !!updated.isAdmin }
|
||||
? {
|
||||
...entry,
|
||||
email: updated.email,
|
||||
nickname: updated.nickname || '',
|
||||
isAdmin: !!updated.isAdmin,
|
||||
draftEmail: updated.email,
|
||||
draftNickname: updated.nickname || '',
|
||||
draftIsAdmin: !!updated.isAdmin,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
if (updated.id === auth.user?.id) await auth.refresh()
|
||||
await refreshUsers()
|
||||
success.value = '회원 정보를 저장했어요.'
|
||||
} catch (e) {
|
||||
error.value = '회원 정보 저장에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function resetUserPassword(user) {
|
||||
function openUserPasswordModal(user) {
|
||||
resetMessages()
|
||||
if (!(user.draftPassword || '').trim()) {
|
||||
error.value = '새 비밀번호를 입력해주세요.'
|
||||
modalTargetUser.value = user || null
|
||||
modalPasswordDraft.value = ''
|
||||
userPasswordModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserPasswordModal() {
|
||||
userPasswordModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
modalPasswordDraft.value = ''
|
||||
}
|
||||
|
||||
async function confirmUserPasswordReset() {
|
||||
resetMessages()
|
||||
if (!modalTargetUser.value?.id) return
|
||||
|
||||
const password = modalPasswordDraft.value.trim()
|
||||
if (!password) {
|
||||
error.value = '초기화할 비밀번호를 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await api.updateAdminUserPassword(user.id, { password: user.draftPassword })
|
||||
user.draftPassword = ''
|
||||
success.value = '비밀번호를 초기화했어요.'
|
||||
await api.updateAdminUserPassword(modalTargetUser.value.id, { password })
|
||||
success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.`
|
||||
closeUserPasswordModal()
|
||||
} catch (e) {
|
||||
error.value = '비밀번호 초기화에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function removeUser(user) {
|
||||
function openUserDeleteModal(user) {
|
||||
resetMessages()
|
||||
if (user.id === auth.user?.id) {
|
||||
error.value = '현재 로그인한 관리자 계정은 직접 삭제할 수 없어요.'
|
||||
return
|
||||
}
|
||||
modalTargetUser.value = user || null
|
||||
userDeleteModalOpen.value = true
|
||||
}
|
||||
|
||||
const ok = window.confirm(`${user.email} 계정을 삭제할까요? 작성한 티어표와 커스텀 이미지도 함께 삭제됩니다.`)
|
||||
if (!ok) return
|
||||
function closeUserDeleteModal() {
|
||||
userDeleteModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
}
|
||||
|
||||
async function confirmUserDelete() {
|
||||
resetMessages()
|
||||
if (!modalTargetUser.value?.id) return
|
||||
|
||||
try {
|
||||
await api.deleteAdminUser(user.id)
|
||||
users.value = users.value.filter((entry) => entry.id !== user.id)
|
||||
success.value = '회원 계정을 삭제했어요.'
|
||||
const deletingSelf = modalTargetUser.value.id === auth.user?.id
|
||||
const deletedName = userDisplayName(modalTargetUser.value)
|
||||
await api.deleteAdminUser(modalTargetUser.value.id)
|
||||
users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id)
|
||||
closeUserDeleteModal()
|
||||
success.value = `${deletedName} 계정을 삭제했어요.`
|
||||
if (deletingSelf) await auth.refresh()
|
||||
} catch (e) {
|
||||
error.value = '회원 삭제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function openUserRoleModal(user) {
|
||||
resetMessages()
|
||||
modalTargetUser.value = user || null
|
||||
modalRoleNextAdmin.value = !user?.draftIsAdmin
|
||||
userRoleModalOpen.value = true
|
||||
}
|
||||
|
||||
function closeUserRoleModal() {
|
||||
userRoleModalOpen.value = false
|
||||
modalTargetUser.value = null
|
||||
modalRoleNextAdmin.value = false
|
||||
}
|
||||
|
||||
function confirmUserRoleDraft() {
|
||||
if (!modalTargetUser.value?.id) return
|
||||
users.value = users.value.map((entry) =>
|
||||
entry.id === modalTargetUser.value.id
|
||||
? {
|
||||
...entry,
|
||||
draftIsAdmin: modalRoleNextAdmin.value,
|
||||
}
|
||||
: entry
|
||||
)
|
||||
const targetLabel = modalRoleNextAdmin.value ? '관리자로 지정했어요. 저장하면 반영됩니다.' : '관리자 권한 해제로 표시했어요. 저장하면 반영됩니다.'
|
||||
closeUserRoleModal()
|
||||
success.value = targetLabel
|
||||
}
|
||||
|
||||
function submitCustomItemSearch() {
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
@@ -946,7 +1066,7 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<template v-if="activeTab === 'games'">
|
||||
<template v-if="activeTab === 'featured'">
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
@@ -996,6 +1116,40 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'game-admin'">
|
||||
<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="refreshGames">게임 목록 새로고침</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">
|
||||
<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>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasSelectedGame" class="panel">
|
||||
<div class="detailHead">
|
||||
@@ -1084,9 +1238,9 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
<div v-else class="panel panel--empty">
|
||||
<div class="emptyState">
|
||||
<div class="emptyState__title">{{ gameMode === 'existing' ? '게임을 선택하면 상세 관리가 열려요.' : '새 게임 정보를 입력한 뒤 생성해 주세요.' }}</div>
|
||||
<div class="emptyState__title">게임을 선택하면 상세 관리가 열려요.</div>
|
||||
<div class="emptyState__desc">
|
||||
{{ gameMode === 'existing' ? '우측 패널에서 등록된 게임을 선택하면 썸네일과 기본 아이템 관리 영역이 활성화됩니다.' : '새 게임을 만들면 바로 선택 상태로 전환되어 썸네일과 기본 아이템 추가를 이어서 진행할 수 있어요.' }}
|
||||
위에서 기존 게임을 선택하거나 새 게임을 만든 뒤, 같은 화면에서 바로 썸네일과 기본 아이템을 정리할 수 있어요.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1235,7 +1389,7 @@ async function saveFeaturedOrder() {
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">회원 관리</div>
|
||||
<div class="hint hint--tight">이메일, 닉네임, 관리자 권한을 수정하고 비밀번호도 직접 초기화할 수 있어요.</div>
|
||||
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화를 진행할 수 있어요.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1244,52 +1398,114 @@ async function saveFeaturedOrder() {
|
||||
<article v-for="user in users" :key="user.id" class="userCard">
|
||||
<div class="userCard__head">
|
||||
<div class="userCard__identity">
|
||||
<div class="userAvatar">
|
||||
<img v-if="userAvatarUrl(user)" class="userAvatar__image" :src="userAvatarUrl(user)" :alt="userDisplayName(user)" />
|
||||
<span v-else class="userAvatar__fallback">{{ userAvatarFallback(user) }}</span>
|
||||
<input
|
||||
:ref="(el) => setUserAvatarInput(user.id, el)"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="srOnlyInput"
|
||||
@change="onUserAvatarChange(user, $event)"
|
||||
/>
|
||||
<div class="userAvatarWrap">
|
||||
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="openUserAvatarPicker(user)">
|
||||
<img v-if="userAvatarUrl(user)" class="userAvatar__image" :src="userAvatarUrl(user)" :alt="userDisplayName(user)" />
|
||||
<span v-else class="userAvatar__fallback">{{ userAvatarFallback(user) }}</span>
|
||||
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-if="user?.avatarSrc"
|
||||
class="userAvatarRemoveButton"
|
||||
type="button"
|
||||
title="회원 썸네일 삭제"
|
||||
:disabled="user.isAvatarBusy"
|
||||
@click.stop="removeUserAvatar(user)"
|
||||
>
|
||||
<img :src="deleteIcon" alt="" />
|
||||
</button>
|
||||
</div>
|
||||
<div>
|
||||
<div class="userCard__identityMeta">
|
||||
<div class="userCard__title">{{ userDisplayName(user) }}</div>
|
||||
<div class="userCard__meta">가입일 {{ fmt(user.createdAt) }}</div>
|
||||
<div class="userCard__meta">{{ user.email }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<span class="roleBadge" :class="{ 'roleBadge--admin': user.draftIsAdmin }">
|
||||
{{ user.draftIsAdmin ? '관리자' : '일반 회원' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="userStats">
|
||||
<div class="userStat">
|
||||
<span class="userStat__label">작성 티어표</span>
|
||||
<strong class="userStat__value">{{ user.tierListCount }}개</strong>
|
||||
</div>
|
||||
<div class="userStat">
|
||||
<span class="userStat__label">최근 활동</span>
|
||||
<strong class="userStat__value">{{ fmt(user.recentActivityAt || user.createdAt) }}</strong>
|
||||
</div>
|
||||
<div v-if="user.draftIsAdmin" class="roleBadge userCard__roleBadge">Administrator</div>
|
||||
|
||||
<div class="userInfoList">
|
||||
<div class="userInfoLine"><span>가입일</span><strong>{{ fmt(user.createdAt) }}</strong></div>
|
||||
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}개</strong></div>
|
||||
<div class="userInfoLine"><span>최근 활동</span><strong>{{ fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
|
||||
</div>
|
||||
|
||||
<input v-model="user.draftEmail" class="input" placeholder="이메일" />
|
||||
<input v-model="user.draftNickname" class="input" placeholder="닉네임" />
|
||||
<label class="checkRow">
|
||||
<input v-model="user.draftIsAdmin" type="checkbox" :disabled="user.id === auth.user?.id" />
|
||||
<span>관리자 권한</span>
|
||||
</label>
|
||||
<button
|
||||
class="userRoleAction"
|
||||
type="button"
|
||||
:disabled="user.id === auth.user?.id"
|
||||
@click="openUserRoleModal(user)"
|
||||
>
|
||||
{{ user.draftIsAdmin ? '관리자 권한 해제' : '관리자 권한 임명' }}
|
||||
</button>
|
||||
|
||||
<div class="passwordBox">
|
||||
<input v-model="user.draftPassword" class="input" type="text" placeholder="새 비밀번호 직접 입력" />
|
||||
<button class="btn btn--ghost" @click="resetUserPassword(user)">비밀번호 초기화</button>
|
||||
</div>
|
||||
|
||||
<div class="userCard__actions">
|
||||
<button class="btn btn--ghost" @click="saveUser(user)">회원 저장</button>
|
||||
<button class="btn btn--danger" :disabled="user.id === auth.user?.id" @click="removeUser(user)">회원 삭제</button>
|
||||
<div class="userCard__actions userCard__actions--compact">
|
||||
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="openUserPasswordModal(user)">
|
||||
<img :src="lockResetIcon" alt="" />
|
||||
</button>
|
||||
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="openUserDeleteModal(user)">
|
||||
<img :src="deleteIcon" alt="" />
|
||||
</button>
|
||||
<button class="btn btn--ghost userSaveButton" :disabled="!isUserDirty(user)" @click="saveUser(user)">회원정보 저장</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">비밀번호 초기화</div>
|
||||
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
|
||||
<div class="modalCard__form">
|
||||
<input v-model="modalPasswordDraft" class="input" type="password" placeholder="초기화할 비밀번호 입력" @keydown.enter.prevent="confirmUserPasswordReset" />
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
|
||||
<button class="btn btn--primary" :disabled="!modalPasswordDraft.trim()" @click="confirmUserPasswordReset">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userDeleteModalOpen" class="modalOverlay" @click.self="closeUserDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">회원 삭제</div>
|
||||
<div class="modalCard__desc">{{ modalTargetUser ? `${modalTargetUser.email} 계정을 삭제할까요? 작성한 티어표와 커스텀 이미지도 함께 삭제됩니다.` : '' }}</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserDeleteModal">취소</button>
|
||||
<button class="btn btn--danger" @click="confirmUserDelete">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="userRoleModalOpen" class="modalOverlay" @click.self="closeUserRoleModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">관리자 권한 변경</div>
|
||||
<div class="modalCard__desc">
|
||||
{{
|
||||
modalTargetUser
|
||||
? modalRoleNextAdmin
|
||||
? `${userDisplayName(modalTargetUser)} 사용자를 관리자로 임명할까요?`
|
||||
: `${userDisplayName(modalTargetUser)} 사용자의 관리자 권한을 해제할까요?`
|
||||
: ''
|
||||
}}
|
||||
</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeUserRoleModal">취소</button>
|
||||
<button class="btn btn--primary" @click="confirmUserRoleDraft">확인</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importModalOpen" class="modalOverlay" @click.self="closeTierListImportModal">
|
||||
<div class="modalCard modalCard--import" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">티어표 아이템 가져오기</div>
|
||||
@@ -1352,48 +1568,50 @@ async function saveFeaturedOrder() {
|
||||
<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 === 'featured' }" @click="setTab('featured')">목록 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'game-admin' }" @click="setTab('game-admin')">게임 관리</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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
<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 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>
|
||||
<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>
|
||||
@@ -1828,6 +2046,17 @@ async function saveFeaturedOrder() {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.gameManagerGrid {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.gameManagerCard__body {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminCard {
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.025);
|
||||
@@ -1917,6 +2146,9 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.btn {
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
white-space: nowrap;
|
||||
word-break: keep-all;
|
||||
margin-top: 12px;
|
||||
padding: 11px 13px;
|
||||
border-radius: 14px;
|
||||
@@ -2146,7 +2378,7 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.customItemCard__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(auto-fit, minmax(132px, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -2182,16 +2414,18 @@ async function saveFeaturedOrder() {
|
||||
gap: 12px;
|
||||
}
|
||||
.userCard {
|
||||
position: relative;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
padding: 16px;
|
||||
padding: 24px 16px 16px;
|
||||
overflow: visible;
|
||||
}
|
||||
.userCard__head {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
display: block;
|
||||
}
|
||||
.userCard__identityMeta {
|
||||
min-width: 0;
|
||||
}
|
||||
.userCard__identity {
|
||||
display: flex;
|
||||
@@ -2199,6 +2433,12 @@ async function saveFeaturedOrder() {
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.userAvatarWrap {
|
||||
position: relative;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.userCard__title {
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -2208,9 +2448,8 @@ async function saveFeaturedOrder() {
|
||||
font-size: 13px;
|
||||
}
|
||||
.userAvatar {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
flex: 0 0 auto;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 999px;
|
||||
@@ -2218,6 +2457,71 @@ async function saveFeaturedOrder() {
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.userAvatarButton {
|
||||
position: relative;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
.userAvatarButton:disabled {
|
||||
cursor: wait;
|
||||
}
|
||||
.userAvatarRemoveButton {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
right: -4px;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
background: rgba(10, 14, 22, 0.96);
|
||||
box-shadow: 0 10px 22px rgba(0, 0, 0, 0.3);
|
||||
cursor: pointer;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transform: translateY(2px) scale(0.96);
|
||||
transition: opacity 160ms ease, transform 160ms ease, background 160ms ease, visibility 160ms ease;
|
||||
}
|
||||
.userAvatarRemoveButton img {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
filter: brightness(0) invert(1);
|
||||
}
|
||||
.userAvatarRemoveButton:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: wait;
|
||||
}
|
||||
.userAvatarRemoveButton:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
.userAvatarButton__overlay {
|
||||
position: absolute;
|
||||
inset: auto 0 0 0;
|
||||
padding: 10px 0 6px;
|
||||
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.88));
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
transition: opacity 160ms ease, transform 160ms ease;
|
||||
}
|
||||
.userAvatarWrap:hover .userAvatarButton__overlay,
|
||||
.userAvatarWrap:focus-within .userAvatarButton__overlay {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
.userAvatarWrap:hover .userAvatarRemoveButton,
|
||||
.userAvatarWrap:focus-within .userAvatarRemoveButton {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
.userAvatar__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -2227,25 +2531,26 @@ async function saveFeaturedOrder() {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.userStats {
|
||||
margin-top: 12px;
|
||||
.userInfoList {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
gap: 8px;
|
||||
}
|
||||
.userStat {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
.userInfoLine {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
.userStat__label {
|
||||
.userInfoLine span {
|
||||
font-size: 12px;
|
||||
opacity: 0.66;
|
||||
color: rgba(255, 255, 255, 0.56);
|
||||
}
|
||||
.userStat__value {
|
||||
.userInfoLine strong {
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
font-size: 14px;
|
||||
font-weight: 900;
|
||||
}
|
||||
@@ -2254,21 +2559,68 @@ async function saveFeaturedOrder() {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.passwordBox {
|
||||
margin-top: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
.userCard__actions--compact {
|
||||
grid-template-columns: auto auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
}
|
||||
.roleBadge {
|
||||
width: fit-content;
|
||||
padding: 6px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.14);
|
||||
border: 1px solid rgba(96, 165, 250, 0.28);
|
||||
background: rgba(96, 165, 250, 0.1);
|
||||
color: rgba(191, 219, 254, 0.92);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
opacity: 0.8;
|
||||
font-weight: 700;
|
||||
}
|
||||
.roleBadge--admin {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
.userCard__roleBadge {
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
right: 6px;
|
||||
z-index: 2;
|
||||
box-shadow: 0 10px 24px rgba(7, 10, 18, 0.28);
|
||||
}
|
||||
.iconActionButton {
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 0;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
cursor: pointer;
|
||||
}
|
||||
.iconActionButton img {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
.iconActionButton:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.iconActionButton--danger {
|
||||
border-color: rgba(239, 68, 68, 0.24);
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
}
|
||||
.userSaveButton:disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
.userRoleAction {
|
||||
width: fit-content;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.46);
|
||||
font-size: 9px;
|
||||
line-height: 1.4;
|
||||
letter-spacing: 0.01em;
|
||||
cursor: pointer;
|
||||
}
|
||||
.userRoleAction:disabled {
|
||||
opacity: 0.35;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.templateRequestList {
|
||||
margin-top: 14px;
|
||||
@@ -2497,6 +2849,7 @@ async function saveFeaturedOrder() {
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.previewFrame {
|
||||
width: 100%;
|
||||
@@ -2532,13 +2885,11 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.featuredOrderPanel,
|
||||
.section--topGrid,
|
||||
.gameManagerGrid,
|
||||
.toolbar,
|
||||
.itemComposer,
|
||||
.tierAdminCard,
|
||||
.userStats,
|
||||
.templateRequestCard__form {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.templateRequestCard__form,
|
||||
.toolbar--secondary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
@@ -2548,7 +2899,22 @@ async function saveFeaturedOrder() {
|
||||
.userCard__identity {
|
||||
width: 100%;
|
||||
}
|
||||
.userInfoLine {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
.userInfoLine strong {
|
||||
text-align: left;
|
||||
}
|
||||
.userCard__actions--compact {
|
||||
grid-template-columns: repeat(3, minmax(0, auto));
|
||||
}
|
||||
.userSaveButton {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.adminHero {
|
||||
padding: 16px;
|
||||
|
||||
Reference in New Issue
Block a user