diff --git a/backend/src/db.js b/backend/src/db.js index b325738..11a2833 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index c695105..c733f5d 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -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(''), diff --git a/docs/history.md b/docs/history.md index 70f249c..eb40684 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,8 @@ # 의사결정 이력 +## 2026-03-26 v0.1.41 +- 관리자 커스텀 아이템 승격은 버튼만 보이는 상태로 끝나면 안 되므로, 프런트 API와 백엔드 라우트가 실제로 함께 연결되어야 기능이 완결된다고 정리했다. + ## 2026-03-26 v0.1.40 - 관리자 기본 아이템 이름 저장은 눌러도 변화가 없으면 혼란스러우므로, 실제 변경이 있을 때만 버튼이 활성화되는 편이 더 명확하다고 판단했다. - 사용자 커스텀 이미지는 관리자 검토 후 특정 게임의 기본 템플릿으로 복제해 가져올 수 있어야 운영 효율이 높아지므로, 게임 선택 기반 승격 흐름을 추가하기로 결정했다. diff --git a/docs/update.md b/docs/update.md index 8dfaf65..b0e3571 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,8 @@ # 업데이트 로그 +## 2026-03-26 v0.1.41 +- **커스텀 아이템 승격 연결 수정**: 관리자 아이템 관리의 `기본 템플릿에 추가` 버튼이 실제 API와 백엔드 승격 라우트로 연결되도록 누락된 프런트/백엔드 구현을 보완 + ## 2026-03-26 v0.1.40 - **기본 아이템 저장 UX 보강**: 관리자 게임 관리에서 아이템 이름이 실제로 바뀐 경우에만 `이름 저장` 버튼이 활성화되도록 조정하고, 저장 중 상태를 버튼에 표시 - **커스텀 아이템 승격 추가**: 관리자 아이템 관리에서 사용자 커스텀 이미지를 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있도록 API와 UI를 추가 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 48d107e..92fd7be 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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 }),