릴리스: v1.2.69 관리자 게임 관리 재배치와 셸 전환 보정
This commit is contained in:
@@ -107,8 +107,10 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
|
||||
const game = await findGameById(req.params.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const manualLabel = typeof req.body?.label === 'string' ? req.body.label.trim() : ''
|
||||
if (manualLabel && manualLabel.length > 60) return res.status(400).json({ error: 'bad_request' })
|
||||
const labelsRaw = req.body?.labels
|
||||
const labels = Array.isArray(labelsRaw) ? labelsRaw : labelsRaw ? [labelsRaw] : []
|
||||
const normalizedLabels = labels.map((label) => (typeof label === 'string' ? label.trim().slice(0, 60) : ''))
|
||||
if (normalizedLabels.some((label) => label.length > 60)) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const items = await Promise.all(
|
||||
files.map((file, index) =>
|
||||
@@ -116,7 +118,7 @@ router.post('/games/:gameId/images', requireAdmin, upload.array('images', 50), a
|
||||
id: nanoid(),
|
||||
gameId: game.id,
|
||||
src: `/uploads/games/${file.filename}`,
|
||||
label: index === 0 && manualLabel ? manualLabel : buildItemLabelFromFilename(file),
|
||||
label: normalizedLabels[index] || buildItemLabelFromFilename(file),
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-31 v1.2.69
|
||||
- 좌우 사이드 축소/확대 시 텍스트를 즉시 `display:none` 처리하던 방식을 줄이고, 폭·투명도 기반 전환으로 바꿔 아이콘이 떨리는 듯한 느낌을 완화함.
|
||||
- 관리자 게임 관리는 오른쪽 사이드에서 게임 선택과 썸네일 지정을 담당하도록 재배치하고, 본문은 기본 아이템 추가/이름 입력/목록 관리에 집중하도록 정리함.
|
||||
- 게임 기본 아이템 추가는 업로드 직후 각 파일 이름을 바로 수정할 수 있는 draft 입력 행을 넣고, 선택한 이름이 서버에 함께 저장되도록 관리자 업로드 API를 확장함.
|
||||
|
||||
## 2026-03-31 v1.2.68
|
||||
- 내 티어표 카드 그리드는 각 카드가 화면 전체 너비를 과도하게 먹지 않도록 최대 폭을 제한해, 1~2개만 있을 때도 적당한 카드 크기를 유지하도록 조정함.
|
||||
- 새 티어표 기본 그룹은 기존 S/A/B/C/D 5줄 대신 S/A/B/C 4줄로 시작하게 바꾸고, 좌우 사이드 토글 아이콘 버튼은 외곽선과 배경을 제거해 더 가볍게 정리함.
|
||||
|
||||
@@ -608,7 +608,9 @@ function submitGlobalSearch() {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
transition: opacity 180ms ease;
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.appUserCard__name {
|
||||
@@ -642,12 +644,14 @@ function submitGlobalSearch() {
|
||||
.searchStub__input {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
max-width: 100%;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: rgba(255, 255, 255, 0.92);
|
||||
outline: none;
|
||||
font: inherit;
|
||||
transition: opacity 180ms ease, width 180ms ease;
|
||||
overflow: hidden;
|
||||
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.searchStub__input::placeholder {
|
||||
@@ -691,7 +695,10 @@ function submitGlobalSearch() {
|
||||
|
||||
.leftNav__label {
|
||||
min-width: 0;
|
||||
max-width: 140px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
transition: opacity 180ms ease, max-width 220ms ease, transform 220ms ease;
|
||||
}
|
||||
|
||||
.leftNav__item--active,
|
||||
@@ -726,13 +733,15 @@ function submitGlobalSearch() {
|
||||
.appShell--leftCollapsed .appUserCard__button,
|
||||
.appShell--leftCollapsed .appUserCard__guest {
|
||||
justify-content: center;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__meta,
|
||||
.appShell--leftCollapsed .leftNav__label,
|
||||
.appShell--leftCollapsed .searchStub__input {
|
||||
display: none;
|
||||
opacity: 0;
|
||||
max-width: 0;
|
||||
transform: translateX(-4px);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .appUserCard__avatar {
|
||||
@@ -742,11 +751,10 @@ function submitGlobalSearch() {
|
||||
|
||||
.appShell--leftCollapsed .searchStub {
|
||||
justify-content: center;
|
||||
padding: 10px 0;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .searchStub__iconButton {
|
||||
width: 100%;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftNav {
|
||||
@@ -755,7 +763,6 @@ function submitGlobalSearch() {
|
||||
|
||||
.appShell--leftCollapsed .leftNav__item {
|
||||
justify-content: center;
|
||||
padding: 11px 0;
|
||||
}
|
||||
|
||||
.appShell--leftCollapsed .leftRail__bottom {
|
||||
|
||||
@@ -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