diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index f9e4620..3928944 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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), }) ) ) diff --git a/docs/update.md b/docs/update.md index 6a8757c..4e79b80 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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줄로 시작하게 바꾸고, 좌우 사이드 토글 아이콘 버튼은 외곽선과 배경을 제거해 더 가볍게 정리함. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 4476b3a..854268d 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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 { diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index eb1789a..96df402 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -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() { -
-
-
등록된 게임 선택
-
- -
선택하면 아래 상세 영역에서 썸네일과 기본 아이템을 바로 수정할 수 있어요.
-
선택된 게임 ID: {{ selectedGameId }}
-
+
+
+
아이템 관리 안내
+
게임 선택과 썸네일 지정은 오른쪽 사이드에서 진행하고, 이 본문에서는 기본 아이템 추가와 현재 아이템 관리만 담당합니다.
@@ -1268,37 +1273,9 @@ async function saveFeaturedOrder() {
{{ selectedGame.game.name }}
{{ selectedGame.game.id }}
-
- -
-
-
-
썸네일 적용
- - -
- -
-
- +
기본 아이템 추가
@@ -1320,18 +1297,24 @@ async function saveFeaturedOrder() {
-
-
- +
+
+
+ +
+
+ +
{{ draft.file.name }}
+
선택한 기본 아이템 미리보기가 여기에 표시됩니다.
- {{ uploadFiles.length ? `선택된 파일 ${uploadFiles.length}개` : '아직 선택된 파일이 없어요.' }} + {{ uploadItemDrafts.length ? `추가 예정 아이템 ${uploadItemDrafts.length}개` : '아직 선택된 파일이 없어요.' }}
@@ -1764,7 +1747,44 @@ async function saveFeaturedOrder() {
-
+
+
Game
+
+ +
선택한 게임의 썸네일과 기본 아이템 관리가 본문과 연동됩니다.
+
선택된 게임 ID: {{ selectedGameId }}
+
+
+
{{ selectedGame.game.name }}
+
{{ selectedGame.game.id }}
+ + +
+ + +
+
+
+ +
Filters
@@ -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;