릴리스: v0.1.10 관리자 업로드 UX 정리
This commit is contained in:
@@ -40,3 +40,8 @@
|
||||
## 2026-03-19 v0.1.9
|
||||
- 로컬과 운영 환경을 완전히 같은 DB 계층으로 맞추기 위해 lowdb fallback을 제거하고 MariaDB만 지원하는 코드베이스로 정리했다.
|
||||
- 마이그레이션 종료 이후에는 레거시 JSON 저장소와 예외 실행 스크립트를 남겨두는 비용이 더 크다고 판단해 삭제하기로 결정했다.
|
||||
|
||||
## 2026-03-19 v0.1.10
|
||||
- 관리자 업로드 작업은 "파일 선택 후 적용"이 더 정확하므로, 썸네일 버튼 문구와 활성화 조건을 그 흐름에 맞추기로 결정했다.
|
||||
- 작은 화면에서 미리보기가 실제 작업 영역을 압박하지 않도록, 아이템 미리보기는 정사각형을 유지하되 최대 크기를 제한하는 방향을 채택했다.
|
||||
- 파일 입력은 업로드 성공 후와 게임 전환 시 초기화해 같은 파일 재선택이 막히지 않도록 정리했다.
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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 및 업로드 파일 절대 기준 주소
|
||||
|
||||
@@ -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` 범위 안에서 보이도록 조정
|
||||
- **파일 재선택 버그 수정**: 아이템 추가나 게임 전환 뒤 파일 입력 값을 초기화해 같은 이미지를 다시 선택해도 정상 인식되도록 수정
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user