Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b9aa714501 | |||
| 6ddc82b1c7 |
@@ -472,6 +472,28 @@ async function createCustomItem({ id, ownerId, src, label }) {
|
||||
return { id, ownerId, src, label, origin: 'custom', createdAt }
|
||||
}
|
||||
|
||||
async function findCustomItemById(id) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT id, owner_id, src, label, created_at
|
||||
FROM custom_items
|
||||
WHERE id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
[id]
|
||||
)
|
||||
|
||||
const row = rows[0]
|
||||
if (!row) return null
|
||||
return {
|
||||
id: row.id,
|
||||
ownerId: row.owner_id,
|
||||
src: row.src,
|
||||
label: row.label,
|
||||
createdAt: Number(row.created_at),
|
||||
}
|
||||
}
|
||||
|
||||
async function getCustomItemUsageMap() {
|
||||
const rows = await query('SELECT groups_json, pool_json FROM tierlists')
|
||||
const usageMap = new Map()
|
||||
@@ -778,6 +800,7 @@ module.exports = {
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
createCustomItem,
|
||||
findCustomItemById,
|
||||
listCustomItems,
|
||||
findUnusedCustomItems,
|
||||
listPublicTierLists,
|
||||
|
||||
@@ -17,6 +17,7 @@ const {
|
||||
deleteGame,
|
||||
updateGameDisplayOrder,
|
||||
listCustomItems,
|
||||
findCustomItemById,
|
||||
findUnusedCustomItems,
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
@@ -174,6 +175,23 @@ async function removeCustomItemFiles(items) {
|
||||
)
|
||||
}
|
||||
|
||||
async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
||||
const originalName = path.basename(customItem.src || '')
|
||||
const nextFilename = buildUploadFilename({ originalname: originalName })
|
||||
const sourcePath = path.join(__dirname, '..', '..', customItem.src.replace(/^\//, ''))
|
||||
const targetRelativePath = path.join('uploads', 'games', nextFilename)
|
||||
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
|
||||
|
||||
await fs.copyFile(sourcePath, targetPath)
|
||||
|
||||
return createGameItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
src: `/${targetRelativePath.replace(/\\/g, '/')}`,
|
||||
label: customItem.label,
|
||||
})
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 200, orphanOnly: false })
|
||||
const target = result.items.find((item) => item.id === req.params.itemId)
|
||||
@@ -186,6 +204,23 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
res.json({ ok: true })
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const game = await findGameById(parsed.data.gameId)
|
||||
if (!game) return res.status(404).json({ error: 'game_not_found' })
|
||||
|
||||
const customItem = await findCustomItemById(req.params.itemId)
|
||||
if (!customItem) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const item = await promoteCustomItemToGameItem({ customItem, gameId: game.id })
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-26 v0.1.41
|
||||
- 관리자 커스텀 아이템 승격은 버튼만 보이는 상태로 끝나면 안 되므로, 프런트 API와 백엔드 라우트가 실제로 함께 연결되어야 기능이 완결된다고 정리했다.
|
||||
|
||||
## 2026-03-26 v0.1.40
|
||||
- 관리자 기본 아이템 이름 저장은 눌러도 변화가 없으면 혼란스러우므로, 실제 변경이 있을 때만 버튼이 활성화되는 편이 더 명확하다고 판단했다.
|
||||
- 사용자 커스텀 이미지는 관리자 검토 후 특정 게임의 기본 템플릿으로 복제해 가져올 수 있어야 운영 효율이 높아지므로, 게임 선택 기반 승격 흐름을 추가하기로 결정했다.
|
||||
|
||||
## 2026-03-26 v0.1.39
|
||||
- 티어표 편집 헤더는 게임명 kicker보다 제목과 설명이 더 중요하므로, 좌측 입력 중심 구조로 재배치하고 썸네일은 우측 보조 카드로 분리하는 편이 더 자연스럽다고 판단했다.
|
||||
- 썸네일 조작 버튼은 모바일에서도 카드와 함께 유지되는 편이 흐름이 덜 끊기므로, 미리보기 아래 별도 줄로 떨어뜨리기보다 카드 내부의 짧은 액션 행으로 묶기로 결정했다.
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
|
||||
## `/admin`
|
||||
- 화면 파일: `frontend/src/views/AdminView.vue`
|
||||
- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
- 역할: `게임 관리 / 아이템 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
|
||||
|
||||
## `/profile`
|
||||
- 화면 파일: `frontend/src/views/ProfileView.vue`
|
||||
|
||||
@@ -89,6 +89,7 @@
|
||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `POST /api/admin/custom-items/:itemId/promote`
|
||||
- `DELETE /api/admin/custom-items/:itemId`
|
||||
- `DELETE /api/admin/custom-items`
|
||||
- `GET /api/admin/users`
|
||||
@@ -102,10 +103,12 @@
|
||||
- 썸네일은 16:9 비율 미리보기 후 `썸네일 적용` 버튼으로 실제 반영한다.
|
||||
- 게임 기본 아이템 추가는 드래그 앤 드롭 또는 다중 파일 선택으로 처리하고, 미리보기 카드에서 여러 장을 함께 확인할 수 있다.
|
||||
- 현재 기본 아이템 목록에서는 등록된 아이템 이름을 직접 수정하고 저장할 수 있다.
|
||||
- 기본 아이템 이름 저장 버튼은 값이 실제로 바뀐 경우에만 활성화된다.
|
||||
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
|
||||
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
|
||||
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||
|
||||
## 배포 전 작업
|
||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-26 v0.1.41
|
||||
- **커스텀 아이템 승격 연결 수정**: 관리자 아이템 관리의 `기본 템플릿에 추가` 버튼이 실제 API와 백엔드 승격 라우트로 연결되도록 누락된 프런트/백엔드 구현을 보완
|
||||
|
||||
## 2026-03-26 v0.1.40
|
||||
- **기본 아이템 저장 UX 보강**: 관리자 게임 관리에서 아이템 이름이 실제로 바뀐 경우에만 `이름 저장` 버튼이 활성화되도록 조정하고, 저장 중 상태를 버튼에 표시
|
||||
- **커스텀 아이템 승격 추가**: 관리자 아이템 관리에서 사용자 커스텀 이미지를 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있도록 API와 UI를 추가
|
||||
|
||||
## 2026-03-26 v0.1.39
|
||||
- **에디터 헤더 재구성**: 티어표 편집 상단에서 게임명 kicker를 제거하고, 좌측 제목/설명 입력과 우측 썸네일 카드가 나란히 보이는 구조로 재정리
|
||||
- **썸네일 영역 UX 개선**: 썸네일 미리보기와 선택/제거 버튼을 하나의 카드 안에 묶고, 모바일에서도 버튼이 카드 아래로 무너지지 않도록 밀도 있게 조정
|
||||
|
||||
@@ -39,6 +39,8 @@ export const api = {
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||
),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
listAdminUsers: () => request('/api/admin/users'),
|
||||
updateAdminUser: (userId, payload) =>
|
||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||
|
||||
@@ -22,6 +22,7 @@ const customItemPage = ref(1)
|
||||
const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemOrphanOnly = ref(false)
|
||||
const customItemTargetGameId = ref('')
|
||||
|
||||
const users = ref([])
|
||||
|
||||
@@ -72,12 +73,18 @@ function resetMessages() {
|
||||
function setTab(tab) {
|
||||
resetMessages()
|
||||
activeTab.value = tab
|
||||
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGames() {
|
||||
try {
|
||||
const data = await api.listGames()
|
||||
games.value = data.games || []
|
||||
if (!customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
featuredGameIds.value = games.value
|
||||
.filter((game) => game.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
@@ -360,13 +367,18 @@ async function saveGameItemLabel(item) {
|
||||
error.value = '아이템 이름을 입력해주세요.'
|
||||
return
|
||||
}
|
||||
if (nextLabel === item.label) return
|
||||
|
||||
try {
|
||||
await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel })
|
||||
await loadGame()
|
||||
item.isSavingLabel = true
|
||||
const data = await api.updateAdminGameItem(selectedGameId.value, item.id, { label: nextLabel })
|
||||
item.label = data.item.label
|
||||
item.draftLabel = data.item.label
|
||||
success.value = '기본 아이템 이름을 수정했어요.'
|
||||
} catch (e) {
|
||||
error.value = '기본 아이템 이름 수정에 실패했어요.'
|
||||
} finally {
|
||||
item.isSavingLabel = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -506,6 +518,26 @@ async function removeUnusedCustomItems() {
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteCustomItem(item) {
|
||||
resetMessages()
|
||||
if (!customItemTargetGameId.value) {
|
||||
error.value = '가져올 게임을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
item.isPromoting = true
|
||||
await api.promoteAdminCustomItem(item.id, { gameId: customItemTargetGameId.value })
|
||||
const targetGameName = games.value.find((game) => game.id === customItemTargetGameId.value)?.name || customItemTargetGameId.value
|
||||
if (selectedGameId.value === customItemTargetGameId.value) await loadGame()
|
||||
success.value = `"${item.label}" 아이템을 ${targetGameName} 기본 템플릿으로 추가했어요.`
|
||||
} catch (e) {
|
||||
error.value = '커스텀 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||
} finally {
|
||||
item.isPromoting = false
|
||||
}
|
||||
}
|
||||
|
||||
const displayThumbnailUrl = computed(() => {
|
||||
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
||||
@@ -734,7 +766,13 @@ async function saveFeaturedOrder() {
|
||||
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
|
||||
<div class="thumbCard__actions">
|
||||
<button class="btn btn--ghost btn--small" @click="saveGameItemLabel(item)">이름 저장</button>
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
|
||||
@click="saveGameItemLabel(item)"
|
||||
>
|
||||
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
|
||||
</button>
|
||||
<button class="btn btn--danger btn--small" @click="removeGameItem(item.id)">아이템 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -763,6 +801,10 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<select v-model="customItemTargetGameId" class="select toolbar__select">
|
||||
<option value="">가져올 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
<label class="checkRow checkRow--toolbar">
|
||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||
<span>미사용 커스텀 이미지만 보기</span>
|
||||
@@ -784,6 +826,9 @@ async function saveFeaturedOrder() {
|
||||
<div class="customItemCard__meta">{{ fmt(item.createdAt) }}</div>
|
||||
<div class="customItemCard__actions">
|
||||
<a class="btn btn--small btn--ghost" :href="toApiUrl(item.src)" :download="item.label">이미지 다운로드</a>
|
||||
<button class="btn btn--small btn--ghost" :disabled="!customItemTargetGameId || item.isPromoting" @click="promoteCustomItem(item)">
|
||||
{{ item.isPromoting ? '추가중...' : '기본 템플릿에 추가' }}
|
||||
</button>
|
||||
<button class="btn btn--small btn--danger" :disabled="item.usageCount > 0" @click="removeCustomItem(item)">개별 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1038,7 +1083,7 @@ async function saveFeaturedOrder() {
|
||||
align-items: end;
|
||||
}
|
||||
.toolbar--secondary {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
}
|
||||
.toolbar__search,
|
||||
@@ -1324,7 +1369,7 @@ async function saveFeaturedOrder() {
|
||||
}
|
||||
.customItemCard__actions {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
@@ -1417,6 +1462,9 @@ async function saveFeaturedOrder() {
|
||||
.itemComposer {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.toolbar--secondary {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.itemPreviewCard {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user