릴리스: v1.2.69 관리자 게임 관리 재배치와 셸 전환 보정
This commit is contained in:
@@ -65,6 +65,7 @@ const newGameId = ref('')
|
||||
const newGameName = ref('')
|
||||
|
||||
const uploadFiles = ref([])
|
||||
const uploadItemDrafts = ref([])
|
||||
const thumbFile = ref(null)
|
||||
const itemPreviewUrls = ref([])
|
||||
const isItemDragOver = ref(false)
|
||||
@@ -80,7 +81,7 @@ const gameCreateModalOpen = ref(false)
|
||||
|
||||
const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
||||
const canAddItem = computed(() => uploadFiles.value.length > 0 && !!selectedGameId.value)
|
||||
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedGameId.value)
|
||||
const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value)))
|
||||
const adminTierListPageCount = computed(() => Math.max(1, Math.ceil(adminTierListTotal.value / adminTierListLimit.value)))
|
||||
const featuredGames = computed(() =>
|
||||
@@ -437,6 +438,7 @@ async function refreshUsers() {
|
||||
|
||||
function resetUploadState() {
|
||||
uploadFiles.value = []
|
||||
uploadItemDrafts.value = []
|
||||
thumbFile.value = null
|
||||
resetFileInput('item')
|
||||
resetFileInput('thumb')
|
||||
@@ -537,8 +539,14 @@ function handleItemFiles(fileList) {
|
||||
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
|
||||
uploadFiles.value = files
|
||||
clearPreviewUrl('item')
|
||||
uploadItemDrafts.value = []
|
||||
if (!files.length) return
|
||||
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
|
||||
uploadItemDrafts.value = files.map((file, index) => ({
|
||||
file,
|
||||
previewUrl: itemPreviewUrls.value[index],
|
||||
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
|
||||
}))
|
||||
resetFileInput('item')
|
||||
}
|
||||
|
||||
@@ -548,6 +556,7 @@ function openItemFilePicker() {
|
||||
|
||||
function clearItemFiles() {
|
||||
uploadFiles.value = []
|
||||
uploadItemDrafts.value = []
|
||||
clearPreviewUrl('item')
|
||||
resetFileInput('item')
|
||||
}
|
||||
@@ -602,15 +611,18 @@ async function uploadThumbnail() {
|
||||
|
||||
async function uploadItem() {
|
||||
resetMessages()
|
||||
if (!uploadFiles.value.length || !selectedGameId.value) {
|
||||
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
|
||||
error.value = '아이템 파일을 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const fd = new FormData()
|
||||
uploadFiles.value.forEach((file) => fd.append('images', file))
|
||||
const uploadCount = uploadFiles.value.length
|
||||
uploadItemDrafts.value.forEach((entry) => {
|
||||
fd.append('images', entry.file)
|
||||
fd.append('labels', entry.label.trim())
|
||||
})
|
||||
const uploadCount = uploadItemDrafts.value.length
|
||||
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
@@ -1240,17 +1252,10 @@ async function saveFeaturedOrder() {
|
||||
<button class="btn btn--primary" @click="openGameCreateModal">새 게임 생성</button>
|
||||
</div>
|
||||
|
||||
<div class="gameManagerGrid">
|
||||
<section class="adminCard">
|
||||
<div class="section__title">등록된 게임 선택</div>
|
||||
<div class="gameManagerCard__body">
|
||||
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
|
||||
<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 v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
||||
</div>
|
||||
<div class="gameManagerGrid gameManagerGrid--single">
|
||||
<section class="adminCard adminCard--muted">
|
||||
<div class="section__title">아이템 관리 안내</div>
|
||||
<div class="hint hint--tight">게임 선택과 썸네일 지정은 오른쪽 사이드에서 진행하고, 이 본문에서는 기본 아이템 추가와 현재 아이템 관리만 담당합니다.</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1268,37 +1273,9 @@ async function saveFeaturedOrder() {
|
||||
<div class="selectedGame__name">{{ selectedGame.game.name }}</div>
|
||||
<div class="selectedGame__id">{{ selectedGame.game.id }}</div>
|
||||
</div>
|
||||
<div class="detailHead__actions">
|
||||
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section section--topGrid">
|
||||
<section class="adminCard">
|
||||
<div class="section__title">썸네일 적용</div>
|
||||
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
:class="{ 'thumbDropZone--active': isThumbDragOver }"
|
||||
type="button"
|
||||
@click="openThumbFilePicker"
|
||||
@dragenter="onThumbDragEnter"
|
||||
@dragover="onThumbDragOver"
|
||||
@dragleave="onThumbDragLeave"
|
||||
@drop="onThumbDrop"
|
||||
>
|
||||
<img v-if="displayThumbnailUrl" class="selectedThumb" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__title">클릭하거나 드래그해서 썸네일 추가</div>
|
||||
<div class="thumbDropZone__desc">다른 업로드 화면처럼 이미지 한 장을 바로 지정할 수 있어요.</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="uploadControls">
|
||||
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section">
|
||||
<section class="adminCard">
|
||||
<div class="section__title">기본 아이템 추가</div>
|
||||
<div class="itemComposer">
|
||||
@@ -1320,18 +1297,24 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn" :disabled="!canAddItem" @click="uploadItem">
|
||||
아이템 {{ uploadFiles.length || 0 }}개 추가
|
||||
아이템 {{ uploadItemDrafts.length || 0 }}개 추가
|
||||
</button>
|
||||
</div>
|
||||
<div class="itemPreviewCard">
|
||||
<div v-if="itemPreviewUrls.length" class="itemPreviewGrid">
|
||||
<div v-for="(previewUrl, index) in itemPreviewUrls.slice(0, 6)" :key="previewUrl" class="itemPreviewFrame">
|
||||
<img class="itemPreviewImage" :src="previewUrl" :alt="uploadFiles[index]?.name || 'item preview'" />
|
||||
<div v-if="uploadItemDrafts.length" class="itemDraftList">
|
||||
<div v-for="draft in uploadItemDrafts" :key="draft.previewUrl || draft.file.name" class="itemDraftRow">
|
||||
<div class="itemDraftRow__preview">
|
||||
<img class="itemPreviewImage" :src="draft.previewUrl" :alt="draft.file.name || 'item preview'" />
|
||||
</div>
|
||||
<div class="itemDraftRow__body">
|
||||
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
|
||||
<div class="hint hint--tight">{{ draft.file.name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="itemPreviewEmpty">선택한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
|
||||
<div class="thumbLabel thumbLabel--preview">
|
||||
{{ uploadFiles.length ? `선택된 파일 ${uploadFiles.length}개` : '아직 선택된 파일이 없어요.' }}
|
||||
{{ uploadItemDrafts.length ? `추가 예정 아이템 ${uploadItemDrafts.length}개` : '아직 선택된 파일이 없어요.' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1764,7 +1747,44 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-if="activeTab === 'items'" class="adminSidebar__panel">
|
||||
<section v-if="activeTab === 'game-admin'" class="adminSidebar__panel">
|
||||
<div class="adminSidebar__label">Game</div>
|
||||
<div class="adminSidebar__group">
|
||||
<select :value="selectedGameId" class="select" @change="handleSelectedGameChange">
|
||||
<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 v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
||||
</div>
|
||||
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
||||
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
|
||||
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
|
||||
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
:class="{ 'thumbDropZone--active': isThumbDragOver }"
|
||||
type="button"
|
||||
@click="openThumbFilePicker"
|
||||
@dragenter="onThumbDragEnter"
|
||||
@dragover="onThumbDragOver"
|
||||
@dragleave="onThumbDragLeave"
|
||||
@drop="onThumbDrop"
|
||||
>
|
||||
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__title">클릭하거나 드래그해서 썸네일 추가</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="adminSidebar__actions adminSidebar__actions--stack">
|
||||
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
|
||||
</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" />
|
||||
@@ -1944,6 +1964,9 @@ async function saveFeaturedOrder() {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminSidebar__actions--stack .btn {
|
||||
width: 100%;
|
||||
}
|
||||
.adminSidebar__groupTitle {
|
||||
font-size: 13px;
|
||||
font-weight: 800;
|
||||
@@ -2174,6 +2197,9 @@ async function saveFeaturedOrder() {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
.gameManagerGrid--single {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
.gameManagerCard__body {
|
||||
margin-top: 10px;
|
||||
display: grid;
|
||||
@@ -2186,6 +2212,9 @@ async function saveFeaturedOrder() {
|
||||
padding: 16px;
|
||||
min-width: 0;
|
||||
}
|
||||
.adminCard--muted {
|
||||
background: rgba(255, 255, 255, 0.02);
|
||||
}
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
@@ -2240,6 +2269,11 @@ async function saveFeaturedOrder() {
|
||||
.input--labelEdit {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.input--dense {
|
||||
margin-top: 0;
|
||||
padding-top: 9px;
|
||||
padding-bottom: 9px;
|
||||
}
|
||||
.hint {
|
||||
margin-top: 10px;
|
||||
opacity: 0.78;
|
||||
@@ -2351,6 +2385,18 @@ async function saveFeaturedOrder() {
|
||||
place-items: center;
|
||||
color: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
.selectedThumb--sidebar {
|
||||
width: 100%;
|
||||
}
|
||||
.selectedGameSidebar__name {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.selectedGameSidebar__id {
|
||||
font-size: 12px;
|
||||
opacity: 0.68;
|
||||
word-break: break-all;
|
||||
}
|
||||
.thumbDropZone {
|
||||
width: 100%;
|
||||
display: grid;
|
||||
@@ -2433,6 +2479,29 @@ async function saveFeaturedOrder() {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.itemDraftList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.itemDraftRow {
|
||||
display: grid;
|
||||
grid-template-columns: 72px minmax(0, 1fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
.itemDraftRow__preview {
|
||||
width: 72px;
|
||||
height: 72px;
|
||||
overflow: hidden;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
.itemDraftRow__body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
.itemPreviewFrame {
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 12px;
|
||||
|
||||
Reference in New Issue
Block a user