릴리스: v0.1.10 관리자 업로드 UX 정리

This commit is contained in:
2026-03-19 15:42:12 +09:00
parent beaec9326b
commit f9ae036890
5 changed files with 75 additions and 13 deletions

View File

@@ -40,3 +40,8 @@
## 2026-03-19 v0.1.9
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
## 2026-03-19 v0.1.10
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.

View File

@@ -27,7 +27,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 파일 선택 즉시 미리보기, 아이템 삭제, 게임 삭제
- 역할: 작업 모드 선택, 기존 게임 선택 또는 새 게임 생성, 선택된 게임의 썸네일/아이템 관리, 파일 선택 즉시 미리보기, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`

View File

@@ -83,6 +83,12 @@
- `DELETE /api/admin/games/:gameId/items/:itemId`
- `DELETE /api/admin/games/:gameId`
## 관리자 화면 메모
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
- 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다.
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
## 운영 환경 변수
- 프런트엔드
- `VITE_API_ORIGIN`: API 및 업로드 파일 절대 기준 주소

View File

@@ -67,3 +67,9 @@
- **MariaDB 전용 전환 완료**: `backend/src/db.js`에서 lowdb 분기와 `DB_CLIENT` 기반 fallback을 제거하고 MariaDB 전용 저장 계층으로 정리
- **레거시 파일 제거**: `backend/data/db.json`, `backend/scripts/migrate-lowdb-to-mariadb.js`, `dev:lowdb/start:lowdb/migrate:lowdb` 스크립트 및 `lowdb` 의존성 제거
- **실행 문서 정리**: `README.md`, `docs/local-mariadb.md`, `docs/spec.md`, `docs/todo.md`, `docs/history.md`를 현재 MariaDB 전용 개발/배포 흐름 기준으로 갱신
## 2026-03-19 v0.1.10
- **관리자 썸네일 액션 정리**: 썸네일 버튼 문구를 `썸네일 적용`으로 바꾸고, 파일 선택 전에는 비활성화되도록 조정
- **아이템 추가 폼 정리**: 아이템 이름 입력 너비를 줄이고, 과한 미리보기 안내 문구를 제거해 작업 집중도를 높임
- **반응형 미리보기 보정**: 태블릿 이하 화면에서도 아이템 1:1 미리보기가 최대 `192px` 범위 안에서 보이도록 조정
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정

View File

@@ -23,6 +23,8 @@ const uploadFile = ref(null)
const thumbFile = ref(null)
const itemPreviewUrl = ref('')
const thumbPreviewUrl = ref('')
const itemFileInput = ref(null)
const thumbFileInput = ref(null)
onMounted(async () => {
await auth.refresh()
@@ -60,6 +62,8 @@ function setMode(mode) {
uploadLabel.value = ''
uploadFile.value = null
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
clearPreviewUrl('item')
clearPreviewUrl('thumb')
}
@@ -75,8 +79,25 @@ function clearPreviewUrl(type) {
}
}
function resetFileInput(type) {
if (type === 'item' && itemFileInput.value) {
itemFileInput.value.value = ''
}
if (type === 'thumb' && thumbFileInput.value) {
thumbFileInput.value.value = ''
}
}
async function loadGame() {
resetMessages()
uploadLabel.value = ''
uploadFile.value = null
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
clearPreviewUrl('item')
clearPreviewUrl('thumb')
if (!selectedGameId.value) {
selectedGame.value = null
return
@@ -146,6 +167,7 @@ async function uploadThumbnail() {
if (!res.ok) throw new Error('failed')
thumbFile.value = null
resetFileInput('thumb')
clearPreviewUrl('thumb')
await refreshGames()
await loadGame()
@@ -175,6 +197,7 @@ async function uploadItem() {
uploadLabel.value = ''
uploadFile.value = null
resetFileInput('item')
clearPreviewUrl('item')
await loadGame()
success.value = '아이템이 추가됐어요.'
@@ -219,6 +242,11 @@ async function removeGame() {
const deletedName = selectedGame.value.game.name
selectedGameId.value = ''
selectedGame.value = null
uploadLabel.value = ''
uploadFile.value = null
thumbFile.value = null
resetFileInput('item')
resetFileInput('thumb')
clearPreviewUrl('item')
clearPreviewUrl('thumb')
await refreshGames()
@@ -233,6 +261,9 @@ const displayThumbnailUrl = computed(() => {
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
return ''
})
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value)
</script>
<template>
@@ -295,30 +326,30 @@ const displayThumbnailUrl = computed(() => {
:src="displayThumbnailUrl"
:alt="selectedGame.game.name"
/>
<div v-else class="selectedThumb selectedThumb--empty">16:9 미리보기</div>
<div v-else class="selectedThumb selectedThumb--empty">썸네일 미리보기</div>
<div class="uploadPreviewMeta">
<div class="uploadPreviewTitle">대표 썸네일</div>
<div class="uploadPreviewDesc">
파일을 선택하면 먼저 미리보기로 확인되고, 업로드 버튼을 눌렀을 실제 저장됩니다.
파일을 선택하면 먼저 미리보기로 확인되고, 적용 버튼을 눌렀을 실제 저장됩니다.
</div>
</div>
</div>
<input type="file" accept="image/*" class="inputFile" @change="onThumb" />
<button class="btn" @click="uploadThumbnail">썸네일 업로드</button>
<input ref="thumbFileInput" type="file" accept="image/*" class="inputFile" @change="onThumb" />
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
</div>
<div class="section">
<div class="section__title">아이템 추가</div>
<div class="itemComposer">
<div class="itemComposer__form">
<input v-model="uploadLabel" class="input" placeholder="아이템 이름" />
<input type="file" accept="image/*" class="inputFile" @change="onFile" />
<button class="btn" @click="uploadItem">아이템 추가</button>
<input v-model="uploadLabel" class="input input--compact" placeholder="아이템 이름" />
<input ref="itemFileInput" type="file" accept="image/*" class="inputFile" @change="onFile" />
<button class="btn" :disabled="!canAddItem" @click="uploadItem">아이템 추가</button>
</div>
<div class="itemPreviewCard">
<div class="itemPreviewFrame">
<img v-if="itemPreviewUrl" class="itemPreviewImage" :src="itemPreviewUrl" alt="item preview" />
<div v-else class="itemPreviewEmpty">1:1 미리보기</div>
<div v-else class="itemPreviewEmpty">이미지를 선택해주세요</div>
</div>
<div class="thumbLabel thumbLabel--preview">{{ uploadLabel || '아이템 이름 미리보기' }}</div>
</div>
@@ -449,6 +480,9 @@ const displayThumbnailUrl = computed(() => {
outline: none;
margin-top: 10px;
}
.input--compact {
max-width: 360px;
}
.hint {
margin-top: 10px;
opacity: 0.78;
@@ -468,6 +502,10 @@ const displayThumbnailUrl = computed(() => {
cursor: pointer;
font-weight: 800;
}
.btn:disabled {
cursor: not-allowed;
opacity: 0.45;
}
.btn--primary {
background: rgba(96, 165, 250, 0.2);
}
@@ -503,7 +541,7 @@ const displayThumbnailUrl = computed(() => {
margin-top: 10px;
}
.selectedThumb {
width: 256px;
width: min(100%, 256px);
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: 16px;
@@ -518,7 +556,7 @@ const displayThumbnailUrl = computed(() => {
.itemComposer {
margin-top: 10px;
display: grid;
grid-template-columns: minmax(0, 1fr) 180px;
grid-template-columns: minmax(0, 1fr) minmax(160px, 192px);
gap: 16px;
align-items: start;
}
@@ -532,12 +570,13 @@ const displayThumbnailUrl = computed(() => {
background: rgba(255, 255, 255, 0.04);
}
.itemPreviewFrame {
width: 100%;
width: min(100%, 192px);
aspect-ratio: 1 / 1;
border-radius: 12px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(0, 0, 0, 0.18);
margin: 0 auto;
}
.itemPreviewImage {
width: 100%;
@@ -589,10 +628,16 @@ const displayThumbnailUrl = computed(() => {
.itemComposer {
grid-template-columns: 1fr;
}
.itemPreviewCard {
max-width: 192px;
}
}
@media (max-width: 640px) {
.selectedThumb {
width: 100%;
width: min(100%, 256px);
}
.itemPreviewCard {
max-width: 192px;
}
.thumbGrid {
grid-template-columns: 1fr;