Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bd9751621 |
@@ -71,6 +71,7 @@ function mapTierListRow(row) {
|
||||
authorAccountName: getUserAccountName(row),
|
||||
authorAvatarSrc: row.avatar_src || '',
|
||||
gameId: row.game_id,
|
||||
gameName: row.game_name || '',
|
||||
title: row.title,
|
||||
thumbnailSrc: row.thumbnail_src || '',
|
||||
description: row.description || '',
|
||||
@@ -689,13 +690,44 @@ async function listUserTierLists(userId) {
|
||||
}))
|
||||
}
|
||||
|
||||
async function findTierListById(id) {
|
||||
function uniqueTierListItems(poolItems) {
|
||||
const map = new Map()
|
||||
;(poolItems || []).forEach((item) => {
|
||||
if (!item?.id || map.has(item.id)) return
|
||||
map.set(item.id, {
|
||||
id: item.id,
|
||||
src: item.src || '',
|
||||
label: item.label || 'item',
|
||||
origin: item.origin || 'game',
|
||||
})
|
||||
})
|
||||
return Array.from(map.values())
|
||||
}
|
||||
|
||||
async function listAdminTierLists({ queryText = '', page = 1, limit = 50 } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const search = `%${(queryText || '').trim()}%`
|
||||
const whereClause = hasQuery
|
||||
? `
|
||||
WHERE
|
||||
t.title LIKE ?
|
||||
OR g.name LIKE ?
|
||||
OR g.id LIKE ?
|
||||
OR u.email LIKE ?
|
||||
OR u.nickname LIKE ?
|
||||
`
|
||||
: ''
|
||||
const params = hasQuery ? [search, search, search, search, search] : []
|
||||
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.game_id,
|
||||
g.name AS game_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
@@ -709,6 +741,57 @@ async function findTierListById(id) {
|
||||
u.avatar_src
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN games g ON g.id = t.game_id
|
||||
${whereClause}
|
||||
ORDER BY t.updated_at DESC, t.created_at DESC
|
||||
`,
|
||||
params
|
||||
)
|
||||
|
||||
const allItems = rows.map((row) => {
|
||||
const tierList = mapTierListRow(row)
|
||||
const poolItems = uniqueTierListItems(tierList.pool)
|
||||
const extraItems = poolItems.filter((item) => item.origin === 'custom')
|
||||
return {
|
||||
...tierList,
|
||||
itemCount: poolItems.length,
|
||||
extraItemCount: extraItems.length,
|
||||
extraItems,
|
||||
}
|
||||
})
|
||||
|
||||
const total = allItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
return {
|
||||
tierLists: allItems.slice(offset, offset + normalizedLimit),
|
||||
total,
|
||||
page: normalizedPage,
|
||||
limit: normalizedLimit,
|
||||
}
|
||||
}
|
||||
|
||||
async function findTierListById(id) {
|
||||
const rows = await query(
|
||||
`
|
||||
SELECT
|
||||
t.id,
|
||||
t.author_id,
|
||||
t.game_id,
|
||||
g.name AS game_name,
|
||||
t.title,
|
||||
t.thumbnail_src,
|
||||
t.description,
|
||||
t.is_public,
|
||||
t.groups_json,
|
||||
t.pool_json,
|
||||
t.created_at,
|
||||
t.updated_at,
|
||||
u.nickname,
|
||||
u.email,
|
||||
u.avatar_src
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN games g ON g.id = t.game_id
|
||||
WHERE t.id = ?
|
||||
LIMIT 1
|
||||
`,
|
||||
@@ -805,6 +888,7 @@ module.exports = {
|
||||
findUnusedCustomItems,
|
||||
listPublicTierLists,
|
||||
listUserTierLists,
|
||||
listAdminTierLists,
|
||||
findTierListById,
|
||||
deleteTierList,
|
||||
findCustomItemsByIds,
|
||||
|
||||
@@ -22,6 +22,8 @@ const {
|
||||
findCustomItemsByIds,
|
||||
deleteCustomItems,
|
||||
listUsers,
|
||||
listAdminTierLists,
|
||||
findTierListById,
|
||||
adminUpdateUser,
|
||||
adminUpdateUserPassword,
|
||||
adminDeleteUser,
|
||||
@@ -161,6 +163,23 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const result = await listAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
async function removeCustomItemFiles(items) {
|
||||
await Promise.all(
|
||||
items.map(async (item) => {
|
||||
@@ -192,6 +211,72 @@ async function promoteCustomItemToGameItem({ customItem, gameId }) {
|
||||
})
|
||||
}
|
||||
|
||||
async function copyUploadIntoGameAsset(src) {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return src || ''
|
||||
|
||||
const originalName = path.basename(src)
|
||||
const nextFilename = buildUploadFilename({ originalname: originalName })
|
||||
const sourcePath = path.join(__dirname, '..', '..', src.replace(/^\//, ''))
|
||||
const targetRelativePath = path.join('uploads', 'games', nextFilename)
|
||||
const targetPath = path.join(__dirname, '..', '..', targetRelativePath)
|
||||
|
||||
await fs.copyFile(sourcePath, targetPath)
|
||||
return `/${targetRelativePath.replace(/\\/g, '/')}`
|
||||
}
|
||||
|
||||
function uniqueTierListPoolItems(tierList) {
|
||||
const seen = new Set()
|
||||
return (tierList?.pool || []).filter((item) => {
|
||||
if (!item?.id || seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
async function promoteTierListItemsToGame({ tierList, gameId, itemIds = [] }) {
|
||||
const allowedIds = new Set((itemIds || []).filter(Boolean))
|
||||
const sourceItems = uniqueTierListPoolItems(tierList).filter((item) => item.origin === 'custom')
|
||||
const itemsToCopy = allowedIds.size ? sourceItems.filter((item) => allowedIds.has(item.id)) : sourceItems
|
||||
const createdItems = []
|
||||
|
||||
for (const item of itemsToCopy) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return createdItems
|
||||
}
|
||||
|
||||
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
|
||||
await createGame({ id: gameId, name: gameName })
|
||||
if (tierList.thumbnailSrc) {
|
||||
const copiedThumb = await copyUploadIntoGameAsset(tierList.thumbnailSrc)
|
||||
await updateGameThumbnail(gameId, copiedThumb)
|
||||
}
|
||||
|
||||
const createdItems = []
|
||||
for (const item of uniqueTierListPoolItems(tierList)) {
|
||||
const copiedSrc = await copyUploadIntoGameAsset(item.src)
|
||||
createdItems.push(
|
||||
await createGameItem({
|
||||
id: nanoid(),
|
||||
gameId,
|
||||
src: copiedSrc,
|
||||
label: item.label,
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
return { game: await findGameById(gameId), items: createdItems }
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -221,6 +306,50 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.post('/tierlists/:tierListId/promote-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().min(1),
|
||||
itemIds: z.array(z.string().min(1)).optional().default([]),
|
||||
})
|
||||
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 tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const items = await promoteTierListItemsToGame({
|
||||
tierList,
|
||||
gameId: game.id,
|
||||
itemIds: parsed.data.itemIds,
|
||||
})
|
||||
res.json({ items })
|
||||
})
|
||||
|
||||
router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
gameId: z.string().trim().min(1).max(120),
|
||||
name: z.string().trim().min(1).max(120),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const exists = await findGameById(parsed.data.gameId)
|
||||
if (exists) return res.status(409).json({ error: 'game_id_taken' })
|
||||
|
||||
const tierList = await findTierListById(req.params.tierListId)
|
||||
if (!tierList) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const result = await createGameTemplateFromTierList({
|
||||
tierList,
|
||||
gameId: parsed.data.gameId,
|
||||
gameName: parsed.data.name,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.delete('/custom-items', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
|
||||
@@ -10,6 +10,7 @@ const {
|
||||
deleteTierList,
|
||||
saveTierList,
|
||||
createCustomItem,
|
||||
findUserById,
|
||||
} = require('../db')
|
||||
const { requireAuth } = require('../middleware/auth')
|
||||
|
||||
@@ -99,7 +100,9 @@ router.get('/:id', async (req, res) => {
|
||||
const t = await findTierListById(req.params.id)
|
||||
if (!t) return res.status(404).json({ error: 'not_found' })
|
||||
if (!t.isPublic) {
|
||||
if (!req.session || req.session.userId !== t.authorId) return res.status(403).json({ error: 'forbidden' })
|
||||
if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' })
|
||||
const currentUser = req.session.userId === t.authorId ? { isAdmin: false } : await findUserById(req.session.userId)
|
||||
if (req.session.userId !== t.authorId && !currentUser?.isAdmin) return res.status(403).json({ error: 'forbidden' })
|
||||
}
|
||||
res.json({ tierList: normalizeTierList(t) })
|
||||
})
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-03-26 v0.1.42
|
||||
- 관리자 운영 관점에서는 공개 목록만으로는 부족하므로, 전체 티어표를 검색하고 추가 아이템까지 확인하는 별도 `티어표 관리` 탭을 두는 편이 더 적합하다고 정리했다.
|
||||
- 게임 기반 티어표의 “사용자 추가 아이템”과 `freeform` 티어표의 “전체 아이템”은 활용 목적이 다르므로, 전자는 기존 게임 템플릿 승격 중심으로, 후자는 새 게임 템플릿 생성 중심으로 다루기로 결정했다.
|
||||
- 관리자는 moderation 목적의 완성본 검토가 필요하므로, 작성자가 아니어도 비공개 티어표 상세를 열람할 수 있게 하기로 했다.
|
||||
|
||||
## 2026-03-26 v0.1.41
|
||||
- 관리자 커스텀 아이템 승격은 버튼만 보이는 상태로 끝나면 안 되므로, 프런트 API와 백엔드 라우트가 실제로 함께 연결되어야 기능이 완결된다고 정리했다.
|
||||
|
||||
|
||||
@@ -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`, `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`
|
||||
- 역할: `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭 분리, 선택된 게임의 썸네일 관리, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템 승격, freeform 티어표의 게임 템플릿화, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
|
||||
- 연동 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/tierlists`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `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`
|
||||
|
||||
@@ -88,6 +88,9 @@
|
||||
- `POST /api/admin/games/:gameId/images`
|
||||
- 여러 이미지를 한 번에 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
|
||||
- `PATCH /api/admin/games/:gameId/items/:itemId`
|
||||
- `GET /api/admin/tierlists`
|
||||
- `POST /api/admin/tierlists/:tierListId/promote-items`
|
||||
- `POST /api/admin/tierlists/:tierListId/create-game-template`
|
||||
- `GET /api/admin/custom-items`
|
||||
- `POST /api/admin/custom-items/:itemId/promote`
|
||||
- `DELETE /api/admin/custom-items/:itemId`
|
||||
@@ -110,11 +113,15 @@
|
||||
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
|
||||
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
|
||||
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
|
||||
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 최근 티어표 전체를 제목/게임/작성자 기준으로 검색하고 공개 여부를 함께 확인할 수 있다.
|
||||
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
|
||||
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
|
||||
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
|
||||
|
||||
## 티어표 접근 메모
|
||||
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.
|
||||
- 비로그인 사용자는 공개된 티어표를 열람만 할 수 있고, 편집 UI와 저장 동작은 비활성화된다.
|
||||
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
|
||||
- 커스텀 이미지 추가는 다중 파일 선택과 드래그 앤 드롭을 모두 지원한다.
|
||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 즉시 확인 필요
|
||||
- 사용자 커스텀 아이템을 관리자 기본 템플릿으로 승격하는 승인/복제 흐름은 아직 없다.
|
||||
- 회원 관리에서 아바타/작성 티어표 수/최근 활동 같은 보조 정보는 아직 표시하지 않는다.
|
||||
- 미사용 커스텀 이미지 일괄 삭제는 현재 "참조가 없는 항목" 기준이며, 보관 기간 정책 같은 운영 규칙은 아직 없다.
|
||||
- 업로드 이미지는 현재 원본 파일을 그대로 저장하므로, 운영 부담이 커지면 서버 저장 전 리사이즈/압축(예: 긴 변 제한, WebP 변환) 도입이 필요하다.
|
||||
- 관리자 기본 아이템 다중 업로드는 현재 파일명 기반 자동 라벨만 지원하므로, 필요하면 업로드 후 일괄 라벨 수정/정렬 UX를 추가 검토한다.
|
||||
- 티어표 썸네일은 현재 업로드/교체만 지원하므로, 필요하면 자동 썸네일 추출이나 업로드 이미지 크롭 UX를 추가 검토한다.
|
||||
- 사용자 커스텀 아이템 승격은 현재 수동 복제 방식이므로, 필요하면 중복 감지나 “비슷한 항목 추천” 같은 보조 UX를 검토한다.
|
||||
- 관리자 티어표 관리의 템플릿 생성은 현재 `freeform`만 직접 지원하므로, 필요하면 일반 게임 티어표의 전체 아이템을 복제한 파생 템플릿 생성 UX도 검토한다.
|
||||
- 관리자 티어표 관리의 추가 아이템 승격은 현재 커스텀(origin=`custom`) 아이템 기준이므로, 필요하면 “기존 게임 아이템과 비교한 차집합” 기준으로 더 정교하게 확장할 수 있다.
|
||||
|
||||
## 배포 전 작업
|
||||
- NAS 실제 도메인 기준으로 `VITE_API_ORIGIN`, `CORS_ORIGINS`, `SESSION_SECRET`, `SESSION_COOKIE_SECURE`, `TRUST_PROXY` 값을 설정한다.
|
||||
@@ -19,6 +20,6 @@
|
||||
## 중기 개선
|
||||
- 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다.
|
||||
- 자동 테스트와 최소한의 배포 체크리스트를 만든다.
|
||||
- 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다.
|
||||
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
|
||||
- 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다.
|
||||
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-03-26 v0.1.42
|
||||
- **관리자 티어표 관리 탭 추가**: 공개/비공개를 포함한 최근 티어표 전체를 관리자 화면에서 검색/페이지네이션으로 확인하고, 제목·작성자·게임·공개 여부를 함께 볼 수 있도록 보강
|
||||
- **추가 아이템 승격 흐름 확장**: 티어표 안에서 사용자가 추가한 커스텀 아이템을 관리자 화면에서 바로 특정 게임의 기본 템플릿으로 개별 또는 일괄 복제할 수 있도록 추가
|
||||
- **커스텀 티어표 템플릿화 추가**: `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 별도 게임 템플릿으로 복제 생성할 수 있도록 지원
|
||||
- **관리자 열람 권한 확장**: 비공개 티어표도 관리자는 편집 화면에서 완성본을 열람할 수 있도록 상세 조회 권한을 확장
|
||||
|
||||
## 2026-03-26 v0.1.41
|
||||
- **커스텀 아이템 승격 연결 수정**: 관리자 아이템 관리의 `기본 템플릿에 추가` 버튼이 실제 API와 백엔드 승격 라우트로 연결되도록 누락된 프런트/백엔드 구현을 보완
|
||||
|
||||
|
||||
@@ -39,8 +39,14 @@ export const api = {
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
promoteAdminTierListItems: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
|
||||
createAdminGameTemplateFromTierList: (tierListId, payload) =>
|
||||
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
|
||||
listAdminUsers: () => request('/api/admin/users'),
|
||||
updateAdminUser: (userId, payload) =>
|
||||
request(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'PATCH', body: payload }),
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
<script setup>
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import Sortable from 'sortablejs'
|
||||
import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
const isAdmin = computed(() => !!auth.user?.isAdmin)
|
||||
|
||||
@@ -24,6 +26,13 @@ const customItemTotal = ref(0)
|
||||
const customItemOrphanOnly = ref(false)
|
||||
const customItemTargetGameId = ref('')
|
||||
|
||||
const adminTierLists = ref([])
|
||||
const adminTierListQuery = ref('')
|
||||
const adminTierListPage = ref(1)
|
||||
const adminTierListLimit = ref(50)
|
||||
const adminTierListTotal = ref(0)
|
||||
const adminTierListTargetGameId = ref('')
|
||||
|
||||
const users = ref([])
|
||||
|
||||
const error = ref('')
|
||||
@@ -46,6 +55,7 @@ const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id)
|
||||
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value)
|
||||
const canAddItem = computed(() => uploadFiles.value.length > 0 && !!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(() =>
|
||||
featuredGameIds.value
|
||||
.map((gameId) => games.value.find((game) => game.id === gameId))
|
||||
@@ -55,7 +65,7 @@ const availableGamesForFeatured = computed(() => games.value.filter((game) => !f
|
||||
|
||||
onMounted(async () => {
|
||||
await auth.refresh()
|
||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshUsers()])
|
||||
await Promise.all([refreshGames(), refreshCustomItems(), refreshAdminTierLists(), refreshUsers()])
|
||||
await syncFeaturedSortable()
|
||||
})
|
||||
|
||||
@@ -76,6 +86,9 @@ function setTab(tab) {
|
||||
if (tab === 'items' && !customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
if (tab === 'tierlists' && !adminTierListTargetGameId.value && games.value.length) {
|
||||
adminTierListTargetGameId.value = games.value[0].id
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshGames() {
|
||||
@@ -85,6 +98,9 @@ async function refreshGames() {
|
||||
if (!customItemTargetGameId.value && games.value.length) {
|
||||
customItemTargetGameId.value = games.value[0].id
|
||||
}
|
||||
if (!adminTierListTargetGameId.value && games.value.length) {
|
||||
adminTierListTargetGameId.value = games.value[0].id
|
||||
}
|
||||
featuredGameIds.value = games.value
|
||||
.filter((game) => game.displayRank != null)
|
||||
.sort((a, b) => a.displayRank - b.displayRank)
|
||||
@@ -141,6 +157,30 @@ async function refreshCustomItems() {
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAdminTierLists() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.listAdminTierLists({
|
||||
q: adminTierListQuery.value,
|
||||
page: adminTierListPage.value,
|
||||
limit: adminTierListLimit.value,
|
||||
})
|
||||
adminTierLists.value = (data.tierLists || []).map((tierList) => ({
|
||||
...tierList,
|
||||
templateGameId: tierList.gameId === 'freeform' ? '' : `${tierList.gameId}-copy`,
|
||||
templateGameName:
|
||||
tierList.gameId === 'freeform'
|
||||
? `${tierList.title} 템플릿`
|
||||
: `${tierList.gameName || tierList.gameId} 확장 템플릿`,
|
||||
}))
|
||||
adminTierListTotal.value = data.total || 0
|
||||
adminTierListPage.value = data.page || 1
|
||||
adminTierListLimit.value = data.limit || adminTierListLimit.value
|
||||
} catch (e) {
|
||||
error.value = '관리자 티어표 목록을 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshUsers() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
@@ -478,6 +518,24 @@ function changeCustomItemLimit(limit) {
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function submitAdminTierListSearch() {
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function changeAdminTierListLimit(limit) {
|
||||
adminTierListLimit.value = limit
|
||||
adminTierListPage.value = 1
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function moveAdminTierListPage(direction) {
|
||||
const nextPage = adminTierListPage.value + direction
|
||||
if (nextPage < 1 || nextPage > adminTierListPageCount.value) return
|
||||
adminTierListPage.value = nextPage
|
||||
refreshAdminTierLists()
|
||||
}
|
||||
|
||||
function moveCustomItemPage(direction) {
|
||||
const nextPage = customItemPage.value + direction
|
||||
if (nextPage < 1 || nextPage > customItemPageCount.value) return
|
||||
@@ -538,6 +596,95 @@ async function promoteCustomItem(item) {
|
||||
}
|
||||
}
|
||||
|
||||
function tierListThumbUrl(tierList) {
|
||||
return tierList.thumbnailSrc ? toApiUrl(tierList.thumbnailSrc) : ''
|
||||
}
|
||||
|
||||
function tierListAuthorDisplayName(tierList) {
|
||||
return tierList.authorName || '알 수 없음'
|
||||
}
|
||||
|
||||
function tierListVisibilityLabel(tierList) {
|
||||
return tierList.isPublic ? '공개' : '비공개'
|
||||
}
|
||||
|
||||
function openAdminTierList(tierList) {
|
||||
router.push(`/editor/${tierList.gameId}/${tierList.id}`)
|
||||
}
|
||||
|
||||
async function promoteTierListExtraItem(tierList, item) {
|
||||
resetMessages()
|
||||
if (!adminTierListTargetGameId.value) {
|
||||
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
item.isPromoting = true
|
||||
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||
gameId: adminTierListTargetGameId.value,
|
||||
itemIds: [item.id],
|
||||
})
|
||||
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
|
||||
success.value = `"${item.label}" 아이템을 기본 템플릿으로 추가했어요. (${data.items?.length || 0}개 반영)`
|
||||
} catch (e) {
|
||||
error.value = '티어표 추가 아이템을 기본 템플릿으로 가져오지 못했어요.'
|
||||
} finally {
|
||||
item.isPromoting = false
|
||||
}
|
||||
}
|
||||
|
||||
async function promoteAllTierListExtraItems(tierList) {
|
||||
resetMessages()
|
||||
if (!adminTierListTargetGameId.value) {
|
||||
error.value = '아이템을 가져올 게임을 먼저 선택해주세요.'
|
||||
return
|
||||
}
|
||||
if (!tierList.extraItems?.length) {
|
||||
error.value = '가져올 추가 아이템이 없어요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
tierList.isPromotingAll = true
|
||||
const data = await api.promoteAdminTierListItems(tierList.id, {
|
||||
gameId: adminTierListTargetGameId.value,
|
||||
itemIds: tierList.extraItems.map((item) => item.id),
|
||||
})
|
||||
if (selectedGameId.value === adminTierListTargetGameId.value) await loadGame()
|
||||
success.value = `${data.items?.length || 0}개의 추가 아이템을 기본 템플릿으로 가져왔어요.`
|
||||
} catch (e) {
|
||||
error.value = '추가 아이템 일괄 가져오기에 실패했어요.'
|
||||
} finally {
|
||||
tierList.isPromotingAll = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createTemplateFromTierList(tierList) {
|
||||
resetMessages()
|
||||
const nextGameId = (tierList.templateGameId || '').trim()
|
||||
const nextName = (tierList.templateGameName || '').trim()
|
||||
|
||||
if (!nextGameId || !nextName) {
|
||||
error.value = '새 게임 ID와 이름을 모두 입력해주세요.'
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
tierList.isCreatingTemplate = true
|
||||
const data = await api.createAdminGameTemplateFromTierList(tierList.id, {
|
||||
gameId: nextGameId,
|
||||
name: nextName,
|
||||
})
|
||||
await refreshGames()
|
||||
success.value = `"${data.game?.name || nextName}" 게임 템플릿을 생성했어요.`
|
||||
} catch (e) {
|
||||
error.value = '커스텀 티어표를 새 게임 템플릿으로 만들지 못했어요.'
|
||||
} finally {
|
||||
tierList.isCreatingTemplate = false
|
||||
}
|
||||
}
|
||||
|
||||
const displayThumbnailUrl = computed(() => {
|
||||
if (thumbPreviewUrl.value) return thumbPreviewUrl.value
|
||||
if (selectedGame.value?.game?.thumbnailSrc) return toApiUrl(selectedGame.value.game.thumbnailSrc)
|
||||
@@ -610,6 +757,7 @@ async function saveFeaturedOrder() {
|
||||
<div class="tabs">
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'games' }" @click="setTab('games')">게임 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'items' }" @click="setTab('items')">아이템 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'tierlists' }" @click="setTab('tierlists')">티어표 관리</button>
|
||||
<button class="tab" :class="{ 'tab--active': activeTab === 'users' }" @click="setTab('users')">회원 관리</button>
|
||||
</div>
|
||||
|
||||
@@ -843,6 +991,111 @@ async function saveFeaturedOrder() {
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'tierlists'">
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">전체 티어표 관리</div>
|
||||
<div class="hint hint--tight">공개/비공개를 포함한 최근 티어표를 모두 확인하고, 추가 아이템을 기존 게임 템플릿으로 승격하거나 커스텀 티어표를 새 게임 템플릿으로 만들 수 있어요.</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<input
|
||||
v-model="adminTierListQuery"
|
||||
class="input toolbar__search"
|
||||
placeholder="제목, 작성자, 게임 이름 검색"
|
||||
@keydown.enter.prevent="submitAdminTierListSearch"
|
||||
/>
|
||||
<button class="btn btn--ghost toolbar__button" @click="submitAdminTierListSearch">검색</button>
|
||||
<select :value="adminTierListLimit" class="select toolbar__select" @change="changeAdminTierListLimit(Number($event.target.value))">
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="toolbar toolbar--secondary">
|
||||
<select v-model="adminTierListTargetGameId" class="select toolbar__select">
|
||||
<option value="">추가 아이템을 넣을 게임 선택</option>
|
||||
<option v-for="game in games" :key="game.id" :value="game.id">{{ game.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="!adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
|
||||
<div v-else class="tierAdminList">
|
||||
<article v-for="tierList in adminTierLists" :key="tierList.id" class="tierAdminCard">
|
||||
<div class="tierAdminCard__preview" @click="openAdminTierList(tierList)">
|
||||
<img v-if="tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="tierListThumbUrl(tierList)" :alt="tierList.title" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__body">
|
||||
<div class="tierAdminCard__head">
|
||||
<div>
|
||||
<div class="tierAdminCard__title">{{ tierList.title }}</div>
|
||||
<div class="tierAdminCard__meta">
|
||||
{{ tierList.gameName || tierList.gameId }} · {{ tierListAuthorDisplayName(tierList) }} · {{ tierListVisibilityLabel(tierList) }}
|
||||
</div>
|
||||
<div class="tierAdminCard__meta">{{ fmt(tierList.updatedAt) }}</div>
|
||||
</div>
|
||||
<button class="btn btn--ghost btn--small" @click="openAdminTierList(tierList)">완성본 보기</button>
|
||||
</div>
|
||||
|
||||
<div class="tierAdminCard__stats">
|
||||
<span class="pill">전체 아이템 {{ tierList.itemCount }}개</span>
|
||||
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}개</span>
|
||||
</div>
|
||||
|
||||
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
|
||||
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
|
||||
<div class="tierAdminItemList">
|
||||
<article v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem">
|
||||
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
|
||||
<div class="tierAdminItem__body">
|
||||
<div class="tierAdminItem__title">{{ item.label }}</div>
|
||||
<div class="tierAdminItem__meta">{{ item.origin === 'custom' ? '사용자 추가 아이템' : '기본 아이템' }}</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
:disabled="!adminTierListTargetGameId || item.isPromoting"
|
||||
@click="promoteTierListExtraItem(tierList, item)"
|
||||
>
|
||||
{{ item.isPromoting ? '추가중...' : '이 아이템 추가' }}
|
||||
</button>
|
||||
</article>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn--ghost btn--small"
|
||||
:disabled="!adminTierListTargetGameId || tierList.isPromotingAll"
|
||||
@click="promoteAllTierListExtraItems(tierList)"
|
||||
>
|
||||
{{ tierList.isPromotingAll ? '가져오는 중...' : '추가 아이템 전체 가져오기' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="tierList.gameId === 'freeform'" class="tierAdminSection">
|
||||
<div class="tierAdminSection__title">커스텀 티어표를 게임 템플릿으로 만들기</div>
|
||||
<div class="tierAdminTemplateForm">
|
||||
<input v-model="tierList.templateGameId" class="input" placeholder="새 게임 ID" />
|
||||
<input v-model="tierList.templateGameName" class="input" placeholder="새 게임 이름" />
|
||||
<button class="btn btn--primary" :disabled="tierList.isCreatingTemplate" @click="createTemplateFromTierList(tierList)">
|
||||
{{ tierList.isCreatingTemplate ? '생성중...' : '새 게임 템플릿 만들기' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div class="pager">
|
||||
<button class="btn btn--ghost" :disabled="adminTierListPage <= 1" @click="moveAdminTierListPage(-1)">이전</button>
|
||||
<div class="pager__info">{{ adminTierListPage }} / {{ adminTierListPageCount }} 페이지 · 총 {{ adminTierListTotal }}개</div>
|
||||
<button class="btn btn--ghost" :disabled="adminTierListPage >= adminTierListPageCount" @click="moveAdminTierListPage(1)">다음</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<div class="panel">
|
||||
<div class="sectionHeader">
|
||||
@@ -1445,6 +1698,123 @@ async function saveFeaturedOrder() {
|
||||
.roleBadge--admin {
|
||||
background: rgba(96, 165, 250, 0.18);
|
||||
}
|
||||
.tierAdminList {
|
||||
margin-top: 14px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.tierAdminCard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 280px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
border-radius: 18px;
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
padding: 14px;
|
||||
}
|
||||
.tierAdminCard__preview {
|
||||
cursor: pointer;
|
||||
}
|
||||
.tierAdminCard__thumb {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
border-radius: 14px;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
.tierAdminCard__thumb--empty {
|
||||
background: linear-gradient(135deg, rgba(255,255,255,0.08), rgba(255,255,255,0.03));
|
||||
}
|
||||
.tierAdminCard__body {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
.tierAdminCard__head {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
}
|
||||
.tierAdminCard__title {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.tierAdminCard__meta {
|
||||
margin-top: 4px;
|
||||
opacity: 0.74;
|
||||
font-size: 13px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tierAdminCard__stats {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.pill--accent {
|
||||
border-color: rgba(251, 191, 36, 0.32);
|
||||
background: rgba(251, 191, 36, 0.12);
|
||||
color: rgba(253, 230, 138, 0.96);
|
||||
}
|
||||
.tierAdminSection {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
border-radius: 14px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
.tierAdminSection__title {
|
||||
font-weight: 800;
|
||||
}
|
||||
.tierAdminItemList {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.tierAdminItem {
|
||||
display: grid;
|
||||
grid-template-columns: 56px minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
}
|
||||
.tierAdminItem__thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
object-fit: cover;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.tierAdminItem__body {
|
||||
min-width: 0;
|
||||
}
|
||||
.tierAdminItem__title {
|
||||
font-weight: 800;
|
||||
word-break: break-word;
|
||||
}
|
||||
.tierAdminItem__meta {
|
||||
margin-top: 4px;
|
||||
opacity: 0.7;
|
||||
font-size: 12px;
|
||||
}
|
||||
.tierAdminTemplateForm {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
|
||||
gap: 10px;
|
||||
}
|
||||
.checkRow {
|
||||
margin-top: 12px;
|
||||
display: inline-flex;
|
||||
@@ -1459,7 +1829,9 @@ async function saveFeaturedOrder() {
|
||||
.featuredOrderPanel,
|
||||
.section--topGrid,
|
||||
.toolbar,
|
||||
.itemComposer {
|
||||
.itemComposer,
|
||||
.tierAdminCard,
|
||||
.tierAdminTemplateForm {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.toolbar--secondary {
|
||||
@@ -1475,6 +1847,10 @@ async function saveFeaturedOrder() {
|
||||
.userList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.tierAdminCard__head,
|
||||
.tierAdminItem {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.customItemCard {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user