릴리스: v0.1.43 토스트와 즐겨찾기 추가

This commit is contained in:
2026-03-27 10:23:29 +09:00
parent 3bd9751621
commit 61fe758b7c
17 changed files with 559 additions and 209 deletions

View File

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