릴리스: v1.2.69 관리자 게임 관리 재배치와 셸 전환 보정

This commit is contained in:
2026-03-31 16:28:37 +09:00
parent fadfd0ba58
commit 3227181c24
4 changed files with 143 additions and 60 deletions

View File

@@ -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),
})
)
)

View 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줄로 시작하게 바꾸고, 좌우 사이드 토글 아이콘 버튼은 외곽선과 배경을 제거해 더 가볍게 정리함.

View File

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

View File

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