릴리스: v0.1.42 관리자 티어표 관리 추가

This commit is contained in:
2026-03-26 19:02:46 +09:00
parent b9aa714501
commit 3bd9751621
10 changed files with 625 additions and 8 deletions

View File

@@ -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,

View File

@@ -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(''),

View File

@@ -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) })
})