릴리스: v0.1.38 아이템 이름 수정과 티어표 썸네일 추가

This commit is contained in:
2026-03-26 18:14:54 +09:00
parent f729b6fa82
commit 5d778e9c20
14 changed files with 293 additions and 22 deletions

View File

@@ -24,7 +24,7 @@ const allowedOrigins = (process.env.CORS_ORIGINS || '')
const FileStore = FileStoreFactory(session)
;['uploads/avatars', 'uploads/games', 'uploads/custom', '.sessions'].forEach((relativePath) => {
;['uploads/avatars', 'uploads/games', 'uploads/custom', 'uploads/tierlists', '.sessions'].forEach((relativePath) => {
fs.mkdirSync(path.join(__dirname, relativePath), { recursive: true })
})

View File

@@ -72,6 +72,7 @@ function mapTierListRow(row) {
authorAvatarSrc: row.avatar_src || '',
gameId: row.game_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
description: row.description || '',
isPublic: !!row.is_public,
groups: parseJson(row.groups_json, []),
@@ -195,6 +196,7 @@ async function ensureSchema() {
author_id VARCHAR(64) NOT NULL,
game_id VARCHAR(120) NOT NULL,
title VARCHAR(120) NOT NULL,
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0,
groups_json LONGTEXT NOT NULL,
@@ -209,6 +211,11 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const tierListThumbnailColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'thumbnail_src'")
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
await query(
`
INSERT INTO games (id, name, thumbnail_src, created_at)
@@ -397,6 +404,12 @@ async function createGameItem({ id, gameId, src, label }) {
return mapGameItemRow(rows[0])
}
async function updateGameItemLabel(itemId, label) {
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
}
async function deleteGameItem(itemId) {
const gameItemRows = await query('SELECT game_id FROM game_items WHERE id = ? LIMIT 1', [itemId])
const gameId = gameItemRows[0]?.game_id
@@ -588,6 +601,7 @@ async function listPublicTierLists(gameId) {
t.id,
t.game_id,
t.title,
t.thumbnail_src,
t.created_at,
t.updated_at,
t.author_id,
@@ -607,6 +621,7 @@ async function listPublicTierLists(gameId) {
id: row.id,
gameId: row.game_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
authorId: row.author_id,
@@ -623,6 +638,7 @@ async function listUserTierLists(userId) {
t.id,
t.game_id,
t.title,
t.thumbnail_src,
t.created_at,
t.updated_at,
t.is_public,
@@ -641,6 +657,7 @@ async function listUserTierLists(userId) {
id: row.id,
gameId: row.game_id,
title: row.title,
thumbnailSrc: row.thumbnail_src || '',
createdAt: Number(row.created_at),
updatedAt: Number(row.updated_at),
isPublic: !!row.is_public,
@@ -658,6 +675,7 @@ async function findTierListById(id) {
t.author_id,
t.game_id,
t.title,
t.thumbnail_src,
t.description,
t.is_public,
t.groups_json,
@@ -708,17 +726,17 @@ async function deleteCustomItems(ids) {
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
}
async function saveTierList({ id, authorId, gameId, title, description, isPublic, groups, pool }) {
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
const existing = id ? await findTierListById(id) : null
if (existing) {
await query(
`
UPDATE tierlists
SET title = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ?
`,
[title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
[title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id)
}
@@ -727,11 +745,11 @@ async function saveTierList({ id, authorId, gameId, title, description, isPublic
await query(
`
INSERT INTO tierlists (
id, author_id, game_id, title, description, is_public, groups_json, pool_json, created_at, updated_at
id, author_id, game_id, title, thumbnail_src, description, is_public, groups_json, pool_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id)
}
@@ -755,6 +773,7 @@ module.exports = {
createGame,
updateGameThumbnail,
createGameItem,
updateGameItemLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,

View File

@@ -12,6 +12,7 @@ const {
listGames,
updateGameThumbnail,
createGameItem,
updateGameItemLabel,
deleteGameItem,
deleteGame,
updateGameDisplayOrder,
@@ -116,6 +117,19 @@ router.delete('/games/:gameId/items/:itemId', requireAdmin, async (req, res) =>
res.json({ ok: true })
})
router.patch('/games/:gameId/items/:itemId', requireAdmin, async (req, res) => {
const schema = z.object({ label: z.string().trim().min(1).max(60) })
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const updated = await updateGameItemLabel(req.params.itemId, parsed.data.label)
if (!updated || updated.gameId !== game.id) return res.status(404).json({ error: 'not_found' })
res.json({ item: updated })
})
router.delete('/games/:gameId', requireAdmin, async (req, res) => {
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })

View File

@@ -52,10 +52,19 @@ const upload = multer({
limits: { fileSize: 6 * 1024 * 1024 },
})
const thumbnailUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'tierlists')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 6 * 1024 * 1024 },
})
const tierListUpsertSchema = z.object({
id: z.string().optional(),
gameId: z.string().min(1),
title: z.string().min(1).max(120),
thumbnailSrc: z.string().max(255).optional().default(''),
description: z.string().max(1000).optional().default(''),
isPublic: z.boolean().default(false),
groups: z.array(
@@ -121,6 +130,11 @@ router.post('/custom-items', requireAuth, upload.single('image'), async (req, re
res.json({ item })
})
router.post('/thumbnail', requireAuth, thumbnailUpload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })
res.json({ thumbnailSrc: `/uploads/tierlists/${req.file.filename}` })
})
router.post('/', requireAuth, async (req, res) => {
const parsed = tierListUpsertSchema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -137,6 +151,7 @@ router.post('/', requireAuth, async (req, res) => {
authorId: existing.authorId,
gameId: existing.gameId,
title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
groups: payload.groups,
@@ -150,6 +165,7 @@ router.post('/', requireAuth, async (req, res) => {
authorId: req.session.userId,
gameId: payload.gameId,
title: payload.title,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
groups: payload.groups,