릴리스: v0.1.43 토스트와 즐겨찾기 추가
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/useToast'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const toast = useToast()
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
|
||||
const activeTab = ref('games')
|
||||
@@ -31,7 +33,13 @@ const adminTierListQuery = ref('')
|
||||
const adminTierListPage = ref(1)
|
||||
const adminTierListLimit = ref(50)
|
||||
const adminTierListTotal = ref(0)
|
||||
const adminTierListTargetGameId = ref('')
|
||||
const importModalOpen = ref(false)
|
||||
const importModalMode = ref('existing')
|
||||
const importModalTierList = ref(null)
|
||||
const importModalItems = ref([])
|
||||
const importModalTargetGameId = ref('')
|
||||
const importModalNewGameId = ref('')
|
||||
const importModalNewGameName = ref('')
|
||||
|
||||
const users = ref([])
|
||||
|
||||
@@ -62,6 +70,7 @@ const featuredGames = computed(() =>
|
||||
.filter(Boolean)
|
||||
)
|
||||
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
|
||||
const importModalItemCount = computed(() => importModalItems.value.length)
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
@@ -75,6 +84,18 @@ onUnmounted(() => {
|
||||
destroyFeaturedSortable()
|
||||
})
|
||||
|
||||
watch(error, (message) => {
|
||||
if (!message) return
|
||||
toast.error(message)
|
||||
error.value = ''
|
||||
})
|
||||
|
||||
watch(success, (message) => {
|
||||
if (!message) return
|
||||
toast.success(message)
|
||||
success.value = ''
|
||||
})
|
||||
|
||||
function resetMessages() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
@@ -86,9 +107,6 @@ function setTab(tab) {
|
||||
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
if (tab === 'tierlists' && !adminTierListTargetGameId.value && games.value.length) {
|
||||
adminTierListTargetGameId.value = games.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGames() {
|
||||
@@ -98,9 +116,6 @@ async function refreshGames() {
|
||||
if (!customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
if (!adminTierListTargetGameId.value && games.value.length) {
|
||||
adminTierListTargetGameId.value = games.value[0].id
|
||||
}
|
||||
featuredGameIds.value = games.value
|
||||
.filter((game) => game.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
@@ -165,14 +180,7 @@ async function refreshAdminTierLists() {
|
||||
page: adminTierListPage.value,
|
||||
limit: adminTierListLimit.value,
|
||||
})
|
||||
adminTierLists.value = (data.tierLists || []).map((tierList) => ({
|
||||
...tierList,
|
||||
templateGameId: tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`,
|
||||
templateGameName:
|
||||
tierList.gameId === 'freeform'
|
||||
? `${tierList.title} 템플릿`
|
||||
: `${tierList.gameName || tierList.gameId} 확장 템플릿`,
|
||||
}))
|
||||
adminTierLists.value = data.tierLists || []
|
||||
adminTierListTotal.value = data.total || 0
|
||||
adminTierListPage.value = data.page || 1
|
||||
adminTierListLimit.value = data.limit || adminTierListLimit.value
|
||||
@@ -612,76 +620,73 @@ function openAdminTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
}
|
||||
|
||||
async function promoteTierListExtraItem(tierList, item) {
|
||||
function openTierListImportModal(tierList, items) {
|
||||
resetMessages()
|
||||
if (!adminTierListTargetGameId.value) {
|
||||
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
|
||||
const nextItems = (items || []).filter(Boolean)
|
||||
if (!nextItems.length) {
|
||||
error.value = '가져올 아이템이 없어요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
item.isPromoting = true
|
||||
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||
gameId: adminTierListTargetGameId.value,
|
||||
itemIds: [item.id],
|
||||
})
|
||||
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
|
||||
success.value = `"${item.label}" 아이템을 기본 템플릿으로 추가했어요. (${data.items?.length || 0}개 반영)`
|
||||
} catch (e) {
|
||||
error.value = '티어표 추가 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||
} finally {
|
||||
item.isPromoting = false
|
||||
}
|
||||
importModalTierList.value = tierList
|
||||
importModalItems.value = nextItems
|
||||
importModalMode.value = 'existing'
|
||||
importModalTargetGameId.value = ''
|
||||
importModalNewGameId.value = tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`
|
||||
importModalNewGameName.value =
|
||||
tierList.gameId === 'freeform' ? `${tierList.title} 템플릿` : `${tierList.gameName || tierList.gameId} 파생 템플릿`
|
||||
importModalOpen.value = true
|
||||
}
|
||||
|
||||
async function promoteAllTierListExtraItems(tierList) {
|
||||
resetMessages()
|
||||
if (!adminTierListTargetGameId.value) {
|
||||
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
if (!tierList.extraItems?.length) {
|
||||
error.value = '가져올 추가 아이템이 없어요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
tierList.isPromotingAll = true
|
||||
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||
gameId: adminTierListTargetGameId.value,
|
||||
itemIds: tierList.extraItems.map((item) => item.id),
|
||||
})
|
||||
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
|
||||
success.value = `${data.items?.length || 0}개의 추가 아이템을 기본 템플릿으로 가져왔어요.`
|
||||
} catch (e) {
|
||||
error.value = '추가 아이템 일괄 가져오기에 실패했어요.'
|
||||
} finally {
|
||||
tierList.isPromotingAll = false
|
||||
}
|
||||
function closeTierListImportModal() {
|
||||
importModalOpen.value = false
|
||||
importModalTierList.value = null
|
||||
importModalItems.value = []
|
||||
}
|
||||
|
||||
async function createTemplateFromTierList(tierList) {
|
||||
async function confirmTierListImport() {
|
||||
resetMessages()
|
||||
const nextGameId = (tierList.templateGameId || '').trim()
|
||||
const nextName = (tierList.templateGameName || '').trim()
|
||||
|
||||
if (!nextGameId || !nextName) {
|
||||
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
|
||||
if (!importModalTierList.value || !importModalItems.value.length) {
|
||||
error.value = '가져올 티어표 정보를 확인하지 못했어요.'
|
||||
return
|
||||
}
|
||||
|
||||
const tierList = importModalTierList.value
|
||||
const itemIds = importModalItems.value.map((item) => item.id)
|
||||
|
||||
try {
|
||||
tierList.isCreatingTemplate = true
|
||||
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
|
||||
gameId: nextGameId,
|
||||
name: nextName,
|
||||
})
|
||||
await refreshGames()
|
||||
success.value = `"${data.game?.name || nextName}" 게임 템플릿을 생성했어요.`
|
||||
if (importModalMode.value === 'existing') {
|
||||
if (!importModalTargetGameId.value) {
|
||||
error.value = '아이템을 추가할 기존 게임을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||
gameId: importModalTargetGameId.value,
|
||||
itemIds,
|
||||
})
|
||||
if (selectedGameId.value === importModalTargetGameId.value) await loadGame()
|
||||
success.value = `${data.items?.length || 0}개의 아이템을 기존 템플릿에 추가했어요.`
|
||||
} else {
|
||||
const nextGameId = (importModalNewGameId.value || '').trim()
|
||||
const nextGameName = (importModalNewGameName.value || '').trim()
|
||||
if (!nextGameId || !nextGameName) {
|
||||
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
|
||||
gameId: nextGameId,
|
||||
name: nextGameName,
|
||||
itemIds,
|
||||
})
|
||||
await refreshGames()
|
||||
success.value = `"${data.game?.name || nextGameName}" 템플릿을 생성했어요.`
|
||||
}
|
||||
|
||||
closeTierListImportModal()
|
||||
} catch (e) {
|
||||
error.value = '커스텀 티어표를 새 게임 템플릿으로 만들지 못했어요.'
|
||||
} finally {
|
||||
tierList.isCreatingTemplate = false
|
||||
error.value = '티어표 아이템 가져오기에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -761,9 +766,6 @@ async function saveFeaturedOrder() {
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error">{{ error }}</div>
|
||||
<div v-if="success" class="success">{{ success }}</div>
|
||||
|
||||
<template v-if="activeTab === 'games'">
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
@@ -1015,13 +1017,6 @@ async function saveFeaturedOrder() {
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<select v-model="adminTierListTargetGameId" class="select toolbar__select">
|
||||
<option value="">추가 아이템을 넣을 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
|
||||
<div v-else class="tierAdminList">
|
||||
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
|
||||
@@ -1050,37 +1045,15 @@ async function saveFeaturedOrder() {
|
||||
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
|
||||
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
|
||||
<div class="tierAdminItemList">
|
||||
<article v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem">
|
||||
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="openTierListImportModal(tierList, [item])">
|
||||
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="tierAdminItem__body">
|
||||
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||
<div class="tierAdminItem__meta">{{ item.origin === 'custom' ? '사용자 추가 아이템' : '기본 아이템' }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
:disabled="!adminTierListTargetGameId || item.isPromoting"
|
||||
@click="promoteTierListExtraItem(tierList, item)"
|
||||
>
|
||||
{{ item.isPromoting ? '추가중...' : '이 아이템 추가' }}
|
||||
</button>
|
||||
</article>
|
||||
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
:disabled="!adminTierListTargetGameId || tierList.isPromotingAll"
|
||||
@click="promoteAllTierListExtraItems(tierList)"
|
||||
>
|
||||
{{ tierList.isPromotingAll ? '가져오는 중...' : '추가 아이템 전체 가져오기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tierList.gameId === 'freeform'" class="tierAdminSection">
|
||||
<div class="tierAdminSection__title">커스텀 티어표를 게임 템플릿으로 만들기</div>
|
||||
<div class="tierAdminTemplateForm">
|
||||
<input v-model="tierList.templateGameId" class="input" placeholder="새 게임 ID" />
|
||||
<input v-model="tierList.templateGameName" class="input" placeholder="새 게임 이름" />
|
||||
<button class="btn btn--primary" :disabled="tierList.isCreatingTemplate" @click="createTemplateFromTierList(tierList)">
|
||||
{{ tierList.isCreatingTemplate ? '생성중...' : '새 게임 템플릿 만들기' }}
|
||||
<div class="tierAdminSection__actions">
|
||||
<button class="btn btn--ghost btn--small" @click="openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
|
||||
<button v-if="tierList.gameId === 'freeform'" class="btn btn--primary btn--small" @click="openTierListImportModal(tierList, tierList.extraItems)">
|
||||
새 템플릿으로 가져오기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1094,6 +1067,43 @@ async function saveFeaturedOrder() {
|
||||
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
|
||||
</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>
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
@@ -1778,43 +1788,85 @@ async function saveFeaturedOrder() {
|
||||
.tierAdminSection__title {
|
||||
font-weight: 800;
|
||||
}
|
||||
.tierAdminSection__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.tierAdminItemList {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.tierAdminItem {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
justify-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.tierAdminItem__thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
width: min(100%, 72px);
|
||||
aspect-ratio: 1;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.tierAdminItem__body {
|
||||
min-width: 0;
|
||||
}
|
||||
.tierAdminItem__title {
|
||||
width: 100%;
|
||||
font-weight: 800;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tierAdminItem__meta {
|
||||
margin-top: 4px;
|
||||
opacity: 0.7;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tierAdminTemplateForm {
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 20px;
|
||||
background: rgba(3, 7, 18, 0.66);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
.modalCard {
|
||||
width: min(560px, 100%);
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 18px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(11, 18, 32, 0.96);
|
||||
}
|
||||
.modalCard__title {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.modalCard__desc {
|
||||
opacity: 0.78;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.modalCard__form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
.modalCard__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.importModeTabs {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.checkRow {
|
||||
margin-top: 12px;
|
||||
display: inline-flex;
|
||||
@@ -1830,8 +1882,7 @@ async function saveFeaturedOrder() {
|
||||
.section--topGrid,
|
||||
.toolbar,
|
||||
.itemComposer,
|
||||
.tierAdminCard,
|
||||
.tierAdminTemplateForm {
|
||||
.tierAdminCard {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.toolbar--secondary {
|
||||
@@ -1847,9 +1898,8 @@ async function saveFeaturedOrder() {
|
||||
.userList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tierAdminCard__head,
|
||||
.tierAdminItem {
|
||||
grid-template-columns: 1fr;
|
||||
.tierAdminCard__head {
|
||||
display: grid;
|
||||
}
|
||||
.customItemCard {
|
||||
align-items: stretch;
|
||||
|
||||
Reference in New Issue
Block a user