릴리스: v1.2.26 관리자 회원 관리와 셸 UI 개선

This commit is contained in:
2026-03-31 14:17:19 +09:00
parent df46e43da5
commit ba6ad0593a
25 changed files with 1944 additions and 733 deletions

View File

@@ -78,6 +78,7 @@ function mapTierListRow(row) {
thumbnailSrc: row.thumbnail_src || '',
description: row.description || '',
isPublic: !!row.is_public,
showCharacterNames: !!row.show_character_names,
groups: parseJson(row.groups_json, []),
pool: parseJson(row.pool_json, []),
createdAt: Number(row.created_at),
@@ -226,6 +227,7 @@ async function ensureSchema() {
thumbnail_src VARCHAR(255) NOT NULL DEFAULT '',
description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0,
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
groups_json LONGTEXT NOT NULL,
pool_json LONGTEXT NOT NULL,
created_at BIGINT NOT NULL,
@@ -277,6 +279,10 @@ async function ensureSchema() {
if (!tierListThumbnailColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN thumbnail_src VARCHAR(255) NOT NULL DEFAULT '' AFTER title")
}
const tierListShowNamesColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'show_character_names'")
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
await query(
`
@@ -396,13 +402,23 @@ async function listUsers() {
return rows.map(mapUserRow)
}
async function adminUpdateUser({ id, email, nickname, isAdmin }) {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
id,
])
async function adminUpdateUser({ id, email, nickname, isAdmin, avatarSrc }) {
if (typeof avatarSrc === 'string') {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ?, avatar_src = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
avatarSrc,
id,
])
} else {
await query('UPDATE users SET email = ?, nickname = ?, is_admin = ? WHERE id = ?', [
email,
nickname || '',
isAdmin ? 1 : 0,
id,
])
}
return findUserById(id)
}
@@ -833,6 +849,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.groups_json,
t.pool_json,
t.created_at,
@@ -962,6 +979,7 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.groups_json,
t.pool_json,
t.created_at,
@@ -1017,6 +1035,7 @@ async function findTierListById(id, currentUserId = '') {
t.thumbnail_src,
t.description,
t.is_public,
t.show_character_names,
t.groups_json,
t.pool_json,
t.created_at,
@@ -1214,7 +1233,7 @@ async function deleteCustomItems(ids) {
await query(`DELETE FROM custom_items WHERE id IN (${placeholders})`, ids)
}
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, showCharacterNames = false, groups, pool }) {
const existing = id ? await findTierListById(id, authorId) : null
await syncOwnedCustomItemLabels({ ownerId: authorId, items: pool })
const nextThumbnailSrc = (thumbnailSrc || '').trim() || getAutoThumbnailSrc(groups, pool)
@@ -1223,10 +1242,10 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
await query(
`
UPDATE tierlists
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, groups_json = ?, pool_json = ?, updated_at = ?
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ?
`,
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id, authorId)
}
@@ -1235,11 +1254,11 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
await query(
`
INSERT INTO tierlists (
id, author_id, game_id, title, thumbnail_src, description, is_public, groups_json, pool_json, created_at, updated_at
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, groups_json, pool_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[id, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
}

View File

@@ -61,6 +61,14 @@ const upload = multer({
limits: { fileSize: 6 * 1024 * 1024 },
})
const avatarUpload = multer({
storage: multer.diskStorage({
destination: (req, file, cb) => cb(null, path.join(__dirname, '..', '..', 'uploads', 'avatars')),
filename: (req, file, cb) => cb(null, buildUploadFilename(file)),
}),
limits: { fileSize: 3 * 1024 * 1024 },
})
router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
const parsed = schema.safeParse(req.body)
@@ -494,6 +502,29 @@ router.patch('/users/:userId', requireAdmin, async (req, res) => {
}
})
router.post('/users/:userId/avatar', requireAdmin, avatarUpload.single('avatar'), async (req, res) => {
const schema = z.object({
removeAvatar: z.union([z.literal('1'), z.undefined()]).optional(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const user = await findUserById(req.params.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar ? '' : req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
const updated = await adminUpdateUser({
id: user.id,
email: user.email,
nickname: user.nickname || '',
isAdmin: !!user.isAdmin,
avatarSrc: nextAvatarSrc,
})
res.json({ user: updated })
})
router.delete('/users/:userId', requireAdmin, async (req, res) => {
if (req.params.userId === req.session.userId) {
return res.status(400).json({ error: 'cannot_delete_self' })

View File

@@ -28,6 +28,7 @@ const signupSchema = z.object({
const profileSchema = z.object({
nickname: z.string().trim().min(1).max(40),
removeAvatar: z.union([z.string(), z.undefined()]).optional(),
})
router.post('/signup', async (req, res) => {
@@ -108,7 +109,12 @@ router.post('/profile', requireAuth, upload.single('avatar'), async (req, res) =
const user = await findUserById(req.session.userId)
if (!user) return res.status(404).json({ error: 'not_found' })
const nextAvatarSrc = req.file ? `/uploads/avatars/${req.file.filename}` : user.avatarSrc || ''
const shouldRemoveAvatar = parsed.data.removeAvatar === '1'
const nextAvatarSrc = shouldRemoveAvatar
? ''
: req.file
? `/uploads/avatars/${req.file.filename}`
: user.avatarSrc || ''
const updated = await updateUserProfile({
id: user.id,
nickname: parsed.data.nickname,

View File

@@ -83,6 +83,7 @@ const tierListUpsertSchema = z.object({
thumbnailSrc: z.string().max(255).optional().default(''),
description: z.string().max(1000).optional().default(''),
isPublic: z.boolean().default(false),
showCharacterNames: z.boolean().optional().default(false),
groups: z.array(
z.object({
id: z.string().min(1),
@@ -244,6 +245,7 @@ router.post('/', requireAuth, async (req, res) => {
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
groups: payload.groups,
pool: normalizedPool,
})
@@ -258,6 +260,7 @@ router.post('/', requireAuth, async (req, res) => {
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
groups: payload.groups,
pool: normalizedPool,
})