릴리스: v0.1.43 토스트와 즐겨찾기 추가

This commit is contained in:
2026-03-27 10:23:29 +09:00
parent 3bd9751621
commit 61fe758b7c
17 changed files with 559 additions and 209 deletions

View File

@@ -212,6 +212,18 @@ async function ensureSchema() {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
await query(`
CREATE TABLE IF NOT EXISTS favorite_tierlists (
user_id VARCHAR(64) NOT NULL,
tierlist_id VARCHAR(64) NOT NULL,
created_at BIGINT NOT NULL,
PRIMARY KEY (user_id, tierlist_id),
INDEX idx_favorite_tierlists_tierlist_id (tierlist_id),
CONSTRAINT fk_favorite_tierlists_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_favorite_tierlists_tierlist FOREIGN KEY (tierlist_id) REFERENCES tierlists(id) ON DELETE CASCADE
) 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")
@@ -610,7 +622,51 @@ async function findUnusedCustomItems({ queryText = '' } = {}) {
.filter((item) => item.usageCount === 0)
}
async function listPublicTierLists(gameId) {
async function getFavoriteStatsForTierListIds(tierListIds, userId = '') {
const ids = Array.from(new Set((tierListIds || []).filter(Boolean)))
const countMap = new Map()
const favoritedSet = new Set()
if (!ids.length) return { countMap, favoritedSet }
const placeholders = ids.map(() => '?').join(', ')
const countRows = await query(
`
SELECT tierlist_id, COUNT(*) AS favorite_count
FROM favorite_tierlists
WHERE tierlist_id IN (${placeholders})
GROUP BY tierlist_id
`,
ids
)
countRows.forEach((row) => {
countMap.set(row.tierlist_id, Number(row.favorite_count || 0))
})
if (userId) {
const favoriteRows = await query(
`
SELECT tierlist_id
FROM favorite_tierlists
WHERE user_id = ? AND tierlist_id IN (${placeholders})
`,
[userId, ...ids]
)
favoriteRows.forEach((row) => favoritedSet.add(row.tierlist_id))
}
return { countMap, favoritedSet }
}
function applyFavoriteMetaToTierLists(tierLists, favoriteStats) {
return tierLists.map((tierList) => ({
...tierList,
favoriteCount: favoriteStats.countMap.get(tierList.id) || 0,
isFavorited: favoriteStats.favoritedSet.has(tierList.id),
}))
}
async function listPublicTierLists(gameId, currentUserId = '') {
const params = []
let whereClause = 'WHERE t.is_public = 1'
if (gameId) {
@@ -640,7 +696,7 @@ async function listPublicTierLists(gameId) {
params
)
return rows.map((row) => ({
const tierLists = rows.map((row) => ({
id: row.id,
gameId: row.game_id,
title: row.title,
@@ -652,6 +708,12 @@ async function listPublicTierLists(gameId) {
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
currentUserId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
async function listUserTierLists(userId) {
@@ -676,7 +738,7 @@ async function listUserTierLists(userId) {
[userId]
)
return rows.map((row) => ({
const tierLists = rows.map((row) => ({
id: row.id,
gameId: row.game_id,
title: row.title,
@@ -688,6 +750,12 @@ async function listUserTierLists(userId) {
authorAccountName: getUserAccountName(row),
authorAvatarSrc: row.avatar_src || '',
}))
const favoriteStats = await getFavoriteStatsForTierListIds(
tierLists.map((tierList) => tierList.id),
userId
)
return applyFavoriteMetaToTierLists(tierLists, favoriteStats)
}
function uniqueTierListItems(poolItems) {
@@ -704,7 +772,7 @@ function uniqueTierListItems(poolItems) {
return Array.from(map.values())
}
async function listAdminTierLists({ queryText = '', page = 1, limit = 50 } = {}) {
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
@@ -762,15 +830,20 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50 } = {})
const total = allItems.length
const offset = (normalizedPage - 1) * normalizedLimit
const pagedTierLists = allItems.slice(offset, offset + normalizedLimit)
const favoriteStats = await getFavoriteStatsForTierListIds(
pagedTierLists.map((tierList) => tierList.id),
currentUserId
)
return {
tierLists: allItems.slice(offset, offset + normalizedLimit),
tierLists: applyFavoriteMetaToTierLists(pagedTierLists, favoriteStats),
total,
page: normalizedPage,
limit: normalizedLimit,
}
}
async function findTierListById(id) {
async function findTierListById(id, currentUserId = '') {
const rows = await query(
`
SELECT
@@ -797,7 +870,10 @@ async function findTierListById(id) {
`,
[id]
)
return mapTierListRow(rows[0])
const tierList = mapTierListRow(rows[0])
if (!tierList) return null
const favoriteStats = await getFavoriteStatsForTierListIds([tierList.id], currentUserId)
return applyFavoriteMetaToTierLists([tierList], favoriteStats)[0]
}
async function deleteTierList(id) {
@@ -832,7 +908,7 @@ async function deleteCustomItems(ids) {
}
async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', description, isPublic, groups, pool }) {
const existing = id ? await findTierListById(id) : null
const existing = id ? await findTierListById(id, authorId) : null
if (existing) {
await query(
@@ -843,7 +919,7 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
`,
[title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id)
return findTierListById(existing.id, authorId)
}
const createdAt = now()
@@ -856,7 +932,15 @@ async function saveTierList({ id, authorId, gameId, title, thumbnailSrc = '', de
`,
[id, authorId, gameId, title, thumbnailSrc, description || '', isPublic ? 1 : 0, serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id)
return findTierListById(id, authorId)
}
async function favoriteTierList({ userId, tierListId }) {
await query('INSERT IGNORE INTO favorite_tierlists (user_id, tierlist_id, created_at) VALUES (?, ?, ?)', [userId, tierListId, now()])
}
async function unfavoriteTierList({ userId, tierListId }) {
await query('DELETE FROM favorite_tierlists WHERE user_id = ? AND tierlist_id = ?', [userId, tierListId])
}
module.exports = {
@@ -890,6 +974,8 @@ module.exports = {
listUserTierLists,
listAdminTierLists,
findTierListById,
favoriteTierList,
unfavoriteTierList,
deleteTierList,
findCustomItemsByIds,
deleteCustomItems,

View File

@@ -176,6 +176,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
queryText: parsed.data.q,
page: parsed.data.page,
limit: parsed.data.limit,
currentUserId: req.session?.userId || '',
})
res.json(result)
})
@@ -332,6 +333,7 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
name: z.string().trim().min(1).max(120),
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' })
@@ -343,7 +345,10 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
if (!tierList) return res.status(404).json({ error: 'not_found' })
const result = await createGameTemplateFromTierList({
tierList,
tierList: {
...tierList,
pool: parsed.data.itemIds.length ? (tierList.pool || []).filter((item) => parsed.data.itemIds.includes(item.id)) : tierList.pool,
},
gameId: parsed.data.gameId,
gameName: parsed.data.name,
})

View File

@@ -11,6 +11,8 @@ const {
saveTierList,
createCustomItem,
findUserById,
favoriteTierList,
unfavoriteTierList,
} = require('../db')
const { requireAuth } = require('../middleware/auth')
@@ -87,7 +89,7 @@ const tierListUpsertSchema = z.object({
router.get('/public', async (req, res) => {
const gameId = req.query.gameId
const lists = await listPublicTierLists(gameId)
const lists = await listPublicTierLists(gameId, req.session?.userId || '')
res.json({ tierLists: lists })
})
@@ -97,7 +99,7 @@ router.get('/me', requireAuth, async (req, res) => {
})
router.get('/:id', async (req, res) => {
const t = await findTierListById(req.params.id)
const t = await findTierListById(req.params.id, req.session?.userId || '')
if (!t) return res.status(404).json({ error: 'not_found' })
if (!t.isPublic) {
if (!req.session?.userId) return res.status(403).json({ error: 'forbidden' })
@@ -108,7 +110,7 @@ router.get('/:id', async (req, res) => {
})
router.delete('/:id', requireAuth, async (req, res) => {
const tierList = await findTierListById(req.params.id)
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
@@ -116,6 +118,25 @@ router.delete('/:id', requireAuth, async (req, res) => {
res.json({ ok: true })
})
router.post('/:id/favorite', requireAuth, async (req, res) => {
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
if (!tierList.isPublic && tierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
await favoriteTierList({ userId: req.session.userId, tierListId: tierList.id })
const updated = await findTierListById(tierList.id, req.session.userId)
res.json({ tierList: normalizeTierList(updated) })
})
router.delete('/:id/favorite', requireAuth, async (req, res) => {
const tierList = await findTierListById(req.params.id, req.session.userId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
await unfavoriteTierList({ userId: req.session.userId, tierListId: tierList.id })
const updated = await findTierListById(tierList.id, req.session.userId)
res.json({ tierList: normalizeTierList(updated) })
})
router.post('/custom-items', requireAuth, upload.single('image'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })