릴리스: v0.1.23 홈 게임 정렬과 관리자 순서 관리 추가

This commit is contained in:
2026-03-26 14:59:50 +09:00
parent b58a641453
commit c1575783f0
9 changed files with 304 additions and 45 deletions

View File

@@ -13,6 +13,7 @@ const gameMode = ref('existing')
const games = ref([])
const selectedGameId = ref('')
const selectedGame = ref(null)
const featuredGameIds = ref([])
const customItems = ref([])
const customItemQuery = ref('')
@@ -41,6 +42,12 @@ const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
const featuredGames = computed(() =>
featuredGameIds.value
.map((gameId) => games.value.find((game) => game.id === gameId))
.filter(Boolean)
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
onMounted(async () => {
await auth.refresh()
@@ -66,6 +73,10 @@ async function refreshGames() {
try {
const data = await api.listGames()
games.value = data.games || []
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
.map((game) => game.id)
} catch (e) {
error.value = '게임 목록을 불러오지 못했어요.'
}
@@ -416,13 +427,53 @@ function fmt(ts) {
minute: '2-digit',
})
}
function addFeaturedGame(gameId) {
resetMessages()
if (!gameId || featuredGameIds.value.includes(gameId)) return
if (featuredGameIds.value.length >= 50) {
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
return
}
featuredGameIds.value = [...featuredGameIds.value, gameId]
}
function removeFeaturedGame(gameId) {
resetMessages()
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
}
function moveFeaturedGame(gameId, direction) {
const currentIndex = featuredGameIds.value.indexOf(gameId)
const nextIndex = currentIndex + direction
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
const nextIds = [...featuredGameIds.value]
const [moved] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, moved)
featuredGameIds.value = nextIds
}
async function saveFeaturedOrder() {
resetMessages()
try {
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
games.value = data.games || []
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
.map((game) => game.id)
success.value = '홈 화면 게임 순서를 저장했어요.'
} catch (e) {
error.value = '게임 순서 저장에 실패했어요.'
}
}
</script>
<template>
<section class="wrap">
<h2 class="title">관리자</h2>
<!-- <h2 class="title">관리자</h2> -->
<div class="card">
<div class="desc">기능이 많아진 만큼 관리 영역을 게임, 아이템, 회원 관리로 나눠서 정리합니다.</div>
<!-- <div class="desc">기능이 많아진 만큼 관리 영역을 게임, 아이템, 회원 관리로 나눠서 정리합니다.</div> -->
<div v-if="!auth.user" class="warn">로그인이 필요해요.</div>
<div v-else-if="!isAdmin" class="warn"> 계정은 관리자 권한이 없어요.</div>
@@ -437,6 +488,55 @@ function fmt(ts) {
<div v-if="success" class="success">{{ success }}</div>
<template v-if="activeTab === 'games'">
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title"> 화면 상단 고정 순서</div>
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 있어요.</div>
</div>
<button class="btn btn--primary" @click="saveFeaturedOrder">순서 저장</button>
</div>
<div class="featuredOrderPanel">
<div class="featuredOrderPanel__list">
<div class="section__title">상단 고정 목록</div>
<div v-if="!featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
<div v-else class="featuredList">
<article v-for="(game, index) in featuredGames" :key="game.id" class="featuredCard">
<div class="featuredCard__meta">
<span class="featuredCard__rank">{{ index + 1 }}</span>
<div>
<div class="featuredCard__title">{{ game.name }}</div>
<div class="featuredCard__id">{{ game.id }}</div>
</div>
</div>
<div class="featuredCard__actions">
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="moveFeaturedGame(game.id, -1)">위로</button>
<button class="btn btn--ghost btn--small" :disabled="index === featuredGames.length - 1" @click="moveFeaturedGame(game.id, 1)">아래로</button>
<button class="btn btn--danger btn--small" @click="removeFeaturedGame(game.id)">제외</button>
</div>
</article>
</div>
</div>
<div class="featuredOrderPanel__picker">
<div class="section__title">게임 추가</div>
<div class="featuredPickerList">
<button
v-for="game in availableGamesForFeatured"
:key="game.id"
class="featuredPickerItem"
:disabled="featuredGameIds.length >= 50"
@click="addFeaturedGame(game.id)"
>
<span>{{ game.name }}</span>
<span class="featuredPickerItem__id">{{ game.id }}</span>
</button>
</div>
</div>
</div>
</div>
<div class="modeTabs">
<button class="modeTab" :class="{ 'modeTab--active': gameMode === 'existing' }" @click="setGameMode('existing')">
등록된 게임 선택
@@ -448,12 +548,12 @@ function fmt(ts) {
<div class="panel panel--compact">
<template v-if="gameMode === 'existing'">
<div class="panel__title">등록된 게임 선택</div>
<!-- <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>
<!-- <div class="hint"> 영역은 게임 자체와 관리자 기본 아이템만 관리합니다. 여기서 아이템을 삭제해도 사용자 커스텀 이미지는 삭제되지 않아요.</div> -->
</template>
<template v-else>
@@ -692,7 +792,91 @@ function fmt(ts) {
padding: 14px;
}
.panel--compact {
max-width: 760px;
max-width: 480px;
}
.featuredOrderPanel {
margin-top: 14px;
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(260px, 0.95fr);
gap: 16px;
}
.featuredOrderPanel__list,
.featuredOrderPanel__picker {
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.03);
border-radius: 16px;
padding: 14px;
}
.featuredList,
.featuredPickerList {
margin-top: 10px;
display: grid;
gap: 10px;
max-height: 420px;
overflow: auto;
}
.featuredCard {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.18);
}
.featuredCard__meta {
display: flex;
gap: 12px;
align-items: center;
min-width: 0;
}
.featuredCard__rank {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 999px;
background: rgba(96, 165, 250, 0.18);
font-weight: 900;
flex: 0 0 auto;
}
.featuredCard__title {
font-weight: 900;
}
.featuredCard__id {
margin-top: 4px;
opacity: 0.68;
font-size: 12px;
word-break: break-all;
}
.featuredCard__actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.featuredPickerItem {
width: 100%;
display: flex;
gap: 10px;
justify-content: space-between;
align-items: center;
padding: 12px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(0, 0, 0, 0.16);
color: rgba(255, 255, 255, 0.92);
cursor: pointer;
text-align: left;
}
.featuredPickerItem:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.featuredPickerItem__id {
opacity: 0.64;
font-size: 12px;
}
.panel__title,
.section__title {
@@ -761,7 +945,7 @@ function fmt(ts) {
background: rgba(0, 0, 0, 0.18);
color: rgba(255, 255, 255, 0.92);
outline: none;
margin-top: 10px;
/* margin-top: 10px; */
}
.input--compact {
max-width: 320px;
@@ -794,6 +978,11 @@ function fmt(ts) {
text-align: center;
text-decoration: none;
}
.btn--small {
margin-top: 0;
padding: 8px 10px;
font-size: 11px;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.45;
@@ -1039,6 +1228,7 @@ function fmt(ts) {
margin-top: 0;
}
@media (max-width: 980px) {
.featuredOrderPanel,
.section--topGrid,
.toolbar,
.itemComposer {