Compare commits

..

4 Commits

14 changed files with 351 additions and 113 deletions

View File

@@ -64,6 +64,9 @@ function mapUserRow(row) {
avatarSrc: row.avatar_src || '',
createdAt: Number(row.created_at),
tierListCount: Number(row.tierlist_count || 0),
followerCount: Number(row.follower_count || 0),
receivedFavoriteCount: Number(row.received_favorite_count || 0),
lastLoginAt: Number(row.last_login_at || 0),
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
}
}
@@ -198,6 +201,21 @@ function getUserAccountName(row) {
return email.split('@')[0] || email
}
function getAssetLibrarySourceLabel(src) {
const normalizedSrc = String(src || '').trim()
if (normalizedSrc.includes('/uploads/assets/avatars/')) return '프로필 아바타'
if (normalizedSrc.includes('/uploads/assets/tierlists/')) return '티어표 썸네일'
if (normalizedSrc.includes('/uploads/assets/topics/')) return '썸네일 이미지'
return '보관 자산'
}
function getAssetLibraryKind(src) {
const normalizedSrc = String(src || '').trim()
if (normalizedSrc.includes('/uploads/assets/avatars/')) return 'avatar'
if (normalizedSrc.includes('/uploads/assets/tierlists/') || normalizedSrc.includes('/uploads/assets/topics/')) return 'thumbnail'
return 'asset'
}
async function createPool() {
const rootConnection = await mysql.createConnection({
host: DB_HOST,
@@ -282,6 +300,7 @@ async function ensureSchema() {
email_verified TINYINT(1) NOT NULL DEFAULT 1,
is_admin TINYINT(1) NOT NULL DEFAULT 0,
avatar_src VARCHAR(255) NOT NULL DEFAULT '',
last_login_at BIGINT NOT NULL DEFAULT 0,
created_at BIGINT NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
@@ -292,6 +311,12 @@ async function ensureSchema() {
await query('UPDATE users SET email_verified = 1 WHERE email_verified IS NULL')
}
const userLastLoginColumns = await query("SHOW COLUMNS FROM users LIKE 'last_login_at'")
if (!userLastLoginColumns.length) {
await query('ALTER TABLE users ADD COLUMN last_login_at BIGINT NOT NULL DEFAULT 0 AFTER avatar_src')
await query('UPDATE users SET last_login_at = created_at WHERE last_login_at = 0')
}
await query(`
CREATE TABLE IF NOT EXISTS email_verification_tokens (
id VARCHAR(64) PRIMARY KEY,
@@ -634,7 +659,7 @@ async function countUsers() {
async function findUserByEmail(email) {
const rows = await query(
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at FROM users WHERE email = ? LIMIT 1',
'SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE email = ? LIMIT 1',
[email]
)
const row = rows[0]
@@ -648,7 +673,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
const rows = excludeUserId
? await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?)) AND id <> ?
LIMIT 1
@@ -657,7 +682,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
)
: await query(
`
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at
SELECT id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at
FROM users
WHERE TRIM(LOWER(nickname)) = TRIM(LOWER(?))
LIMIT 1
@@ -671,7 +696,7 @@ async function findUserByNickname(nickname, excludeUserId = '') {
async function findUserById(id) {
const rows = await query(
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE id = ? LIMIT 1',
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE id = ? LIMIT 1',
[id]
)
return mapUserRow(rows[0])
@@ -681,14 +706,24 @@ async function createUser({ id, email, nickname, passwordHash, emailVerified = t
const createdAt = now()
await query(
`
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
INSERT INTO users (id, email, nickname, password_hash, email_verified, is_admin, avatar_src, last_login_at, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', createdAt]
[id, email, nickname || '', passwordHash, emailVerified ? 1 : 0, isAdmin ? 1 : 0, '', isAdmin ? createdAt : 0, createdAt]
)
return findUserById(id)
}
async function touchUserLastLoginAt(userId, timestamp = now()) {
const lastLoginAt = Number(timestamp) || now()
const staleBefore = lastLoginAt - 10 * 60 * 1000
await query(
'UPDATE users SET last_login_at = ? WHERE id = ? AND last_login_at < ?',
[lastLoginAt, userId, staleBefore]
)
return findUserById(userId)
}
async function updateUserPassword({ id, passwordHash }) {
await query('UPDATE users SET password_hash = ? WHERE id = ?', [passwordHash, id])
return findUserById(id)
@@ -814,7 +849,7 @@ async function updateUserProfile({ id, nickname, avatarSrc }) {
async function findPrimaryAdminUser() {
const rows = await query(
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
'SELECT id, email, nickname, email_verified, is_admin, avatar_src, last_login_at, created_at FROM users WHERE is_admin = 1 ORDER BY created_at ASC, email ASC LIMIT 1'
)
return mapUserRow(rows[0])
}
@@ -839,6 +874,18 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
? isAsc
? 'tierlist_count ASC, recent_activity_at ASC, u.email ASC'
: 'tierlist_count DESC, recent_activity_at DESC, u.email ASC'
: sort === 'followers'
? isAsc
? 'follower_count ASC, recent_activity_at ASC, u.email ASC'
: 'follower_count DESC, recent_activity_at DESC, u.email ASC'
: sort === 'favorites'
? isAsc
? 'received_favorite_count ASC, recent_activity_at ASC, u.email ASC'
: 'received_favorite_count DESC, recent_activity_at DESC, u.email ASC'
: sort === 'lastLogin'
? isAsc
? 'u.last_login_at ASC, recent_activity_at ASC, u.email ASC'
: 'u.last_login_at DESC, recent_activity_at DESC, u.email ASC'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
@@ -852,16 +899,21 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
u.email_verified,
u.is_admin,
u.avatar_src,
u.last_login_at,
u.created_at,
COUNT(t.id) AS tierlist_count,
COUNT(DISTINCT t.id) AS tierlist_count,
COUNT(DISTINCT uf.follower_id) AS follower_count,
COUNT(DISTINCT ft.user_id, ft.tierlist_id) AS received_favorite_count,
GREATEST(
u.created_at,
COALESCE(MAX(t.updated_at), 0)
) AS recent_activity_at
FROM users u
LEFT JOIN tierlists t ON t.author_id = u.id
LEFT JOIN user_follows uf ON uf.following_id = u.id
LEFT JOIN favorite_tierlists ft ON ft.tierlist_id = t.id
${where.length ? `WHERE ${where.join(' AND ')}` : ''}
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.created_at
GROUP BY u.id, u.email, u.nickname, u.email_verified, u.is_admin, u.avatar_src, u.last_login_at, u.created_at
ORDER BY ${orderBy}
`,
params
@@ -1800,7 +1852,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: usageMeta.usageMap.get(row.id) || 0,
linkedTemplates,
sourceType: 'user',
sourceLabel: '사용자 업로드',
sourceLabel: '사용자 아이템',
canDelete: true,
}
})
@@ -1820,8 +1872,9 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
ownerEmail: '',
usageCount: 0,
linkedTemplates: [],
sourceType: 'template',
sourceLabel: '관리자 템플릿',
sourceType: 'asset',
sourceLabel: getAssetLibrarySourceLabel(row.src),
assetKind: getAssetLibraryKind(row.src),
canDelete: true,
sourceTopicId: '',
sourceTopicName: '',
@@ -1839,7 +1892,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
usageCount: (templateLinkedBySrc.get(row.src) || new Map()).size,
linkedTemplates: Array.from((templateLinkedBySrc.get(row.src) || new Map()).values()),
sourceType: 'template',
sourceLabel: '관리자 템플릿',
sourceLabel: '템플릿 아이템',
canDelete: true,
sourceTopicId: row.topic_id,
sourceTopicName: row.topic_name || row.topic_id,
@@ -1863,7 +1916,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
siblings.forEach((entry) => {
if (entry.sourceType === 'user') userReferenceCount += 1
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
else if (entry.sourceType === 'asset' || entry.isAssetLibraryItem) assetReferenceCount += 1
else templateReferenceCount += 1
;(entry.linkedTemplates || []).forEach((template) => {
if (template?.id) linkedTemplates.set(template.id, template)
@@ -1885,6 +1938,7 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
label: entry.label,
sourceLabel: entry.sourceLabel,
sourceType: entry.sourceType,
assetKind: entry.assetKind || '',
ownerName: entry.ownerName,
createdAt: entry.createdAt,
sourceTopicId: entry.sourceTopicId || '',
@@ -1902,11 +1956,17 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
case 'template':
return item.sourceType === 'template' && !item.isAssetLibraryItem
case 'asset':
return !!item.isAssetLibraryItem
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
case 'thumbnail':
return item.sourceType === 'asset' && item.assetKind === 'thumbnail'
case 'avatar':
return item.sourceType === 'asset' && item.assetKind === 'avatar'
case 'library':
return item.sourceType === 'user' || (item.sourceType === 'template' && !item.isAssetLibraryItem)
case 'unused-user':
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedTemplates.length === 0
case 'unused-admin':
return !!item.isAssetLibraryItem
return item.sourceType === 'asset' || !!item.isAssetLibraryItem
default:
return true
}
@@ -2414,9 +2474,18 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
return fallbackItem?.src || ''
}
async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
async function listAdminTierLists({
queryText = '',
topicId = '',
page = 1,
limit = 50,
sort = 'recent',
minFavorites = 0,
currentUserId = '',
} = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim()
const hasTopicId = !!resolvedTopicId
@@ -2477,7 +2546,7 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
params
)
const allItems = rows.map((row) => {
const baseItems = rows.map((row) => {
const tierList = mapTierListRow(row)
const poolItems = uniqueTierListItems(tierList.pool)
const extraItems = poolItems.filter((item) => item.origin === 'custom')
@@ -2488,23 +2557,47 @@ async function listAdminTierLists({ queryText = '', topicId = '', page = 1, limi
extraItems,
}
})
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),
baseItems.map((tierList) => tierList.id),
currentUserId
)
const filteredItems = applyFavoriteMetaToTierLists(baseItems, favoriteStats)
.filter((tierList) => Number(tierList.favoriteCount || 0) >= normalizedMinFavorites)
.sort((a, b) => {
if (sort === 'favorites') {
return (
Number(b.favoriteCount || 0) - Number(a.favoriteCount || 0) ||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
Number(b.createdAt || 0) - Number(a.createdAt || 0)
)
}
if (sort === 'created') {
return (
Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
String(a.title || '').localeCompare(String(b.title || ''))
)
}
return (
Number(b.updatedAt || 0) - Number(a.updatedAt || 0) ||
Number(b.createdAt || 0) - Number(a.createdAt || 0) ||
String(a.title || '').localeCompare(String(b.title || ''))
)
})
const total = filteredItems.length
const offset = (normalizedPage - 1) * normalizedLimit
const pagedTierLists = filteredItems.slice(offset, offset + normalizedLimit)
return {
tierLists: applyFavoriteMetaToTierLists(pagedTierLists, favoriteStats),
tierLists: pagedTierLists,
total,
page: normalizedPage,
limit: normalizedLimit,
}
}
async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
async function summarizeAdminTierLists({ queryText = '', topicId = '', minFavorites = 0 } = {}) {
const normalizedMinFavorites = Math.max(Number(minFavorites) || 0, 0)
const hasQuery = !!(queryText || '').trim()
const resolvedTopicId = (topicId || '').trim()
const hasTopicId = !!resolvedTopicId
@@ -2532,6 +2625,7 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
const rows = await query(
`
SELECT t.is_public, t.is_featured
, t.id
FROM tierlists t
INNER JOIN users u ON u.id = t.author_id
INNER JOIN topics tp ON tp.id = t.topic_id
@@ -2540,9 +2634,16 @@ async function summarizeAdminTierLists({ queryText = '', topicId = '' } = {}) {
params
)
const total = rows.length
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
const featuredCount = rows.filter((row) => Number(row.is_featured) === 1).length
const favoriteStats = normalizedMinFavorites > 0
? await getFavoriteStatsForTierListIds(rows.map((row) => row.id), '')
: { countMap: new Map(), favoritedSet: new Set() }
const scopedRows = normalizedMinFavorites > 0
? rows.filter((row) => Number(favoriteStats.countMap.get(row.id) || 0) >= normalizedMinFavorites)
: rows
const total = scopedRows.length
const publicCount = scopedRows.filter((row) => Number(row.is_public) === 1).length
const featuredCount = scopedRows.filter((row) => Number(row.is_featured) === 1).length
return {
total,
publicCount,
@@ -2932,6 +3033,7 @@ module.exports = {
findUserById,
findUserProfileById,
createUser,
touchUserLastLoginAt,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,

View File

@@ -304,7 +304,7 @@ router.delete('/templates/:templateId', requireAdmin, async (req, res) => {
router.patch('/custom-items/:itemId/label', requireAdmin, async (req, res) => {
const schema = z.object({
label: z.string().trim().min(1).max(60),
sourceType: z.enum(['template', 'user']).optional().default('user'),
sourceType: z.enum(['template', 'user', 'asset']).optional().default('user'),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -332,7 +332,10 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
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),
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
filter: z
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused-user', 'unused-admin'])
.optional()
.default('library'),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -350,6 +353,8 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
topicId: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'favorites']).optional().default('recent'),
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
})
@@ -359,6 +364,8 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({
queryText: parsed.data.q,
topicId: parsed.data.topicId,
sort: parsed.data.sort,
minFavorites: parsed.data.minFavorites,
page: parsed.data.page,
limit: parsed.data.limit,
currentUserId: req.session?.userId || '',
@@ -370,6 +377,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
topicId: z.string().trim().max(120).optional().default(''),
minFavorites: z.coerce.number().int().min(0).max(1000000).optional().default(0),
})
const parsed = schema.safeParse(req.query)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
@@ -377,6 +385,7 @@ router.get('/tierlists/stats', requireAdmin, async (req, res) => {
const result = await summarizeAdminTierLists({
queryText: parsed.data.q,
topicId: parsed.data.topicId,
minFavorites: parsed.data.minFavorites,
})
res.json(result)
})
@@ -666,6 +675,15 @@ router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
const result = await listCustomItems({ page: 1, limit: 10000, filterMode: 'all' })
const target = result.items.find((item) => item.id === req.params.itemId)
if (!target) return res.status(404).json({ error: 'not_found' })
if (target.sourceType === 'asset' || String(target.id || '').startsWith('asset:')) {
const assetId = String(target.id).slice('asset:'.length)
const asset = await findImageAssetById(assetId)
if (!asset) return res.status(404).json({ error: 'not_found' })
await deleteImageAssets([assetId])
await removeUploadFiles([asset.src])
return res.json({ ok: true, sourceType: 'asset' })
}
if (target.sourceType === 'template') {
if (String(target.id || '').startsWith('asset:')) {
const assetId = String(target.id).slice('asset:'.length)
@@ -970,7 +988,7 @@ router.delete('/custom-items', requireAdmin, async (req, res) => {
router.get('/users', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
sort: z.enum(['recent', 'created', 'tierlists']).optional().default('recent'),
sort: z.enum(['recent', 'lastLogin', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)

View File

@@ -10,6 +10,7 @@ const {
findUserByNickname,
findUserById,
createUser,
touchUserLastLoginAt,
updateUserPassword,
verifyUserEmail,
createEmailVerificationToken,
@@ -204,7 +205,8 @@ router.post('/signup', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user), verificationRequired: false })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user), verificationRequired: false })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -227,7 +229,8 @@ router.post('/login', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -240,7 +243,7 @@ router.post('/logout', async (req, res) => {
router.get('/me', async (req, res) => {
if (!req.session || !req.session.userId) return res.json({ user: null })
const user = await findUserById(req.session.userId)
const user = await touchUserLastLoginAt(req.session.userId)
if (!user) return res.json({ user: null })
res.json({ user: await serializeUser(user) })
})
@@ -265,7 +268,8 @@ router.post('/email/verify', async (req, res) => {
try {
await establishSession(req, user)
res.json({ user: await serializeUser(user) })
const touchedUser = await touchUserLastLoginAt(user.id)
res.json({ user: await serializeUser(touchedUser || user) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}
@@ -321,7 +325,8 @@ router.post('/password-reset/confirm', async (req, res) => {
try {
await establishSession(req, verifiedUser)
res.json({ user: await serializeUser(verifiedUser) })
const touchedUser = await touchUserLastLoginAt(verifiedUser.id)
res.json({ user: await serializeUser(touchedUser || verifiedUser) })
} catch (err) {
return res.status(500).json({ error: 'session_save_failed' })
}

View File

@@ -1,5 +1,23 @@
# 의사결정 이력
## 2026-04-03 v1.4.57
- 아이템 관리 필터는 전체 데이터 탐색과 실제 반복 아이템 검수를 같은 셀렉트에서 오가야 하므로, `전체 이미지`를 맨 위에 두되 기본값은 여전히 `아이템(템플릿 + 사용자)`로 유지해 운영자가 처음부터 프로필/썸네일 자산에 묻히지 않게 하는 편이 맞다고 판단했다.
- `미사용 사용자 업로드`라는 표현은 계정 탈퇴 잔여물처럼 오해될 수 있으므로, 실제 의미가 “사용자 아이템 레코드는 남아 있지만 현재 저장 티어표/템플릿 참조가 없는 항목”이라는 점에 맞춰 `미사용 아이템`으로 줄이고, 계정 삭제 시 외래키로 같이 삭제되는 항목은 이 범주로 남지 않는다고 정리했다.
## 2026-04-03 v1.4.56
- 아이템 관리의 원래 목적은 “반복 사용 가능한 티어표 아이템”과 “사용자가 올린 커스텀 아이템”을 운영자가 구분해 검수하는 것이었는데, 프로필 아바타나 티어표 썸네일까지 `관리자 템플릿`으로 보이면 의미가 흐려지므로 보관 이미지 자산은 별도 출처와 배지로 분리하는 편이 맞다고 판단했다.
- 평소 운영자가 가장 먼저 봐야 하는 대상도 1회성 썸네일이 아니라 실제 아이템이므로, 아이템 관리 기본 필터는 `전체 이미지`가 아니라 `아이템만 (템플릿+사용자)`로 두고 썸네일/프로필 이미지는 필요할 때만 따로 보게 하는 쪽으로 정리했다.
## 2026-04-03 v1.4.55
- 기존 `최근 활동`은 실제 의미가 “작성한 티어표의 마지막 수정일”에 가까웠는데, 이를 마지막 접속일처럼 읽을 수 있으면 장기 미접속 계정 정리 판단이 틀어질 수 있으므로 `최근 콘텐츠 활동``마지막 접속일`을 아예 분리하는 편이 맞다고 판단했다.
- 마지막 접속일은 로그인 성공 순간만 찍으면 장기 세션 사용자를 놓칠 수 있으므로, 세션이 살아 있는 `/api/auth/me` 확인에서도 일정 간격으로 갱신해 실제 접속 흔적에 더 가깝게 유지하는 쪽으로 정리했다.
- 관리자 회원 카드에서 팔로워/즐겨찾기 수치를 보더라도 실제 공개 프로필과 작성 글 구성을 바로 확인할 진입점이 없으면 운영 판단이 끊기므로, `회원 정보 수정` 옆에 `프로필 보기` 버튼을 같이 두는 편이 자연스럽다고 판단했다.
## 2026-04-03 v1.4.54
- 추천 티어표를 수동으로만 지정할 수 있어도, 운영자가 후보 자체를 찾지 못하면 실무상 큐레이션이 막히므로, 관리자 전체 티어표 목록에 받은 즐겨찾기 수를 직접 보여주고 즐겨찾기 많은 순/최소 즐겨찾기 필터를 먼저 붙이는 편이 맞다고 판단했다.
- 누가 핵심 작성자인지 보는 기준도 작성 티어표 수 하나만으로는 부족하므로, 팔로워 수와 받은 즐겨찾기 수를 회원 관리 카드에 같이 노출하고 이 지표로 정렬할 수 있게 두는 쪽으로 정리했다.
- 이메일 인증/재설정 메일이 들어간 뒤에는 운영자가 평소 화면에서 회원 비밀번호를 직접 덮어쓰는 버튼을 계속 보는 것이 과한 권한처럼 느껴질 수 있으므로, 서버 API는 최후 보루로 남기되 관리자 회원 카드의 비밀번호 초기화 UI는 숨기는 편이 맞다고 판단했다.
## 2026-04-03 v1.4.53
- 본인 티어표 복사 기능이 타인 티어표 전용 조건으로만 남아 있었지만, 실제 사용에서는 자기 작업본을 변형용 복사본으로 다시 만들고 싶은 경우도 많으므로 저장된 본인 티어표에도 복사 버튼을 여는 편이 맞다고 판단했다.
- 편집 중 저장하지 않은 변경이 있는 상태에서 복사본을 만들 때는 마지막 저장본이 아니라 현재 화면 상태가 복사되기를 기대하기 쉬우므로, 본인 편집본 복사는 복사 직전에 현재 원본을 한 번 저장한 뒤 새 복사본을 만드는 쪽으로 정리했다.

View File

@@ -47,7 +47,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `템플릿 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 템플릿 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/받은 즐겨찾기 수 표시/인기순 정렬/최소 즐겨찾기 필터/추천 지정 토글/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원의 작성 티어표·팔로워·받은 즐겨찾기·최근 콘텐츠 활동·마지막 접속일 확인과 회원 정보·권한 수정 및 공개 프로필 보기, 파일 입력 초기화, 아이템 삭제, 템플릿 삭제
- 연동 API: `POST /api/admin/templates`, `POST /api/admin/templates/:templateId/thumbnail`, `POST /api/admin/templates/:templateId/images`, `PATCH /api/admin/templates/:templateId/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`, `GET /api/admin/tierlists/stats`, `PATCH /api/admin/tierlists/:tierListId/featured`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/template-requests/:requestId/link-template`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/templates/:templateId/items/:itemId`, `DELETE /api/admin/templates/:templateId`
## `/profile`

View File

@@ -67,7 +67,9 @@
- `emailVerified`: boolean
- `isAdmin`: boolean
- `avatarSrc`: string
- `lastLoginAt`: number
- `createdAt`: number
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `lastLoginAt`, `recentActivityAt`도 함께 내려준다.
- `emailVerificationTokens`
- `id`: string
- `userId`: string
@@ -137,18 +139,21 @@
- 이메일 인증이 끝나지 않은 계정은 `email_unverified`로 차단한다.
- `POST /api/auth/logout`
- `GET /api/auth/me`
- 로그인 세션이 살아 있는 사용자의 `last_login_at`을 주기적으로 갱신해, 회원 관리에서 `마지막 접속일`을 따로 볼 수 있게 한다.
- `GET /api/auth/meta`
- `POST /api/auth/profile`
- `POST /api/auth/password`
- 로그인한 사용자가 현재 비밀번호를 확인한 뒤 새 비밀번호로 직접 변경한다.
- `POST /api/auth/email/verify`
- `login?verifyToken=...` 링크에서 받은 토큰으로 이메일 인증을 완료하고 바로 로그인 세션을 만든다.
- 인증 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
- `POST /api/auth/email/resend`
- 미인증 계정의 인증 메일을 다시 발송한다.
- `POST /api/auth/password-reset/request`
- 입력한 이메일로 비밀번호 재설정 링크를 발송한다.
- `POST /api/auth/password-reset/confirm`
- `login?resetToken=...` 링크의 토큰과 새 비밀번호로 비밀번호를 재설정하고 바로 로그인 세션을 만든다.
- 재설정 완료 직후 로그인 세션이 열리면서 `last_login_at`도 함께 갱신한다.
- 주제
- `GET /api/topics`
- `GET /api/topics/:topicId`
@@ -182,7 +187,9 @@
- 여러 이미지를 한 번에 최대 `100개`까지 업로드할 수 있고, 별도 라벨이 없으면 파일명 기준으로 기본 아이템 이름을 만든다.
- `PATCH /api/admin/templates/:templateId/items/:itemId`
- `GET /api/admin/tierlists`
- `sort=recent|created|favorites`, `minFavorites`, `topicId`, `q`, `page`, `limit`으로 인기 티어표 후보를 정렬/필터링할 수 있다.
- `GET /api/admin/tierlists/stats`
- 현재 검색어/주제/최소 즐겨찾기 필터가 적용된 범위의 전체/공개/비공개/추천 수를 반환한다.
- `PATCH /api/admin/tierlists/:tierListId/featured`
- `GET /api/admin/template-requests`
- `POST /api/admin/template-requests/:requestId/approve`
@@ -191,10 +198,13 @@
- `POST /api/admin/tierlists/:tierListId/promote-items`
- `POST /api/admin/tierlists/:tierListId/create-template`
- `GET /api/admin/custom-items`
- `filter=library`를 기본값으로 사용해 반복 사용 가능한 `템플릿 아이템 + 사용자 아이템`만 먼저 보여주고, `filter=thumbnail` / `filter=avatar`로는 썸네일 이미지와 프로필 이미지를 따로 조회한다.
- `filter=all|library|template|user|thumbnail|avatar|unused-user`를 사용하며, `filter=asset|unused-admin`은 과거 UI 호환용으로만 유지한다.
- `POST /api/admin/custom-items/:itemId/promote`
- `DELETE /api/admin/custom-items/:itemId`
- `DELETE /api/admin/custom-items`
- `GET /api/admin/users`
- `sort=recent|lastLogin|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 콘텐츠 활동/마지막 접속/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
- `PATCH /api/admin/users/:userId`
- `PATCH /api/admin/users/:userId/password`
- `DELETE /api/admin/users/:userId`
@@ -209,11 +219,14 @@
- 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다.
- 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다.
- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 드래그 또는 위/아래 버튼으로 순서를 저장할 수 있다.
- 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다.
- 사용자 커스텀 아이템은 선택한 게임의 기본 템플릿으로 복제해 가져올 수 있다.
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 사용자 아이템은 사용 횟수(`usageCount`)를 표시하며, `미사용 아이템` 필터에서 저장 티어표/템플릿 참조가 더 이상 없는 항목만 개별/일괄 삭제할 수 있다. 사용자가 계정 탈퇴 등으로 삭제된 경우는 `custom_items.owner_id` 외래키로 레코드가 같이 사라지므로 보통 `미사용 아이템`으로 남지 않는다.
- 아이템 관리 기본 필터는 `아이템(템플릿 + 사용자)`이며, 우측 필터 순서는 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`을 사용한다.
- `/uploads/assets/avatars/``프로필 아바타`, `/uploads/assets/tierlists/``/uploads/assets/topics/``썸네일 이미지`, 그 외 보관 이미지는 `보관 자산` 배지로 표시한다. 실제 템플릿 기본 항목은 `템플릿 아이템`, 사용자 커스텀 항목은 `사용자 아이템` 배지를 사용한다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
@@ -222,7 +235,11 @@
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 콘텐츠 활동, 마지막 접속일을 함께 표시한다.
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
- 마지막 접속일은 로그인/세션 확인 기준, 최근 콘텐츠 활동은 작성한 티어표의 마지막 수정일 기준으로 분리해서 보여준다. 따라서 장기 미접속 계정 정리 판단은 마지막 접속일을 우선 사용하고, 콘텐츠 기여가 최근인지 볼 때는 최근 콘텐츠 활동을 사용한다.
- 회원 카드의 `프로필 보기` 버튼은 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동해, 팔로워/공개 티어표 현황을 관리자 화면 밖에서도 바로 확인할 수 있게 한다.
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.
## 티어표 접근 메모
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.

View File

@@ -1,6 +1,19 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.57`에서 관리자 아이템 필터 순서를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템`으로 바꿨으므로, 우측 셀렉트 순서와 실제 필터링 결과가 같은 의미로 동작하는지 QA한다.
- `썸네일 이미지` 필터에서는 `/uploads/assets/tierlists`, `/uploads/assets/topics`만 모이고, `프로필 이미지` 필터에서는 `/uploads/assets/avatars`만 모이며, 각 카드 배지가 `썸네일 이미지 / 프로필 아바타`로 구분되는지 확인한다.
- `미사용 아이템` 필터는 사용자 아이템 중 저장 티어표 사용 횟수와 템플릿 연결이 모두 0인 항목만 보여주고, 계정 탈퇴로 이미 `custom_items` 레코드가 삭제된 항목이 따로 남지 않는지 확인한다.
- `v1.4.56`에서 아이템 관리 기본 필터를 `아이템만 (템플릿+사용자)`로 바꿨으므로, 관리자 화면 첫 진입 시 프로필 아바타/티어표 썸네일 같은 1회성 자산이 기본 목록에서 빠지고 실제 템플릿/사용자 아이템만 보이는지 확인한다.
- 보관 이미지 자산도 이름 변경, 템플릿에 추가, 개별 삭제가 기존처럼 동작하는지 확인한다.
- 아이템 관리 상단 통계의 `미사용 아이템` 수치는 프로필/썸네일 자산을 포함하지 않고, 실제 사용자 아이템 중 사용 횟수와 템플릿 연결이 모두 0인 항목만 세는지 확인한다.
- `v1.4.55`에서 회원 카드의 `최근 활동``최근 콘텐츠 활동`으로 바꾸고 `마지막 접속일`을 따로 추가했으므로, 티어표를 수정하지 않고 로그인만 한 계정은 마지막 접속일만 갱신되고 최근 콘텐츠 활동은 유지되는지, 반대로 로그인 없이 과거 티어표만 있던 계정은 두 값이 다르게 보이는지 QA한다.
- `/api/auth/me`에서도 `last_login_at`을 10분 단위 이상 간격으로만 갱신하도록 넣었으므로, 새로고침을 반복해도 과도한 DB 쓰기가 생기지 않으면서 실제 재접속 후에는 마지막 접속일이 자연스럽게 갱신되는지 확인한다.
- 관리자 회원 목록의 `마지막 접속순` 정렬과 회원 카드의 `프로필 보기` 버튼이 정상 동작하고, 버튼 클릭 시 해당 회원의 `/users/:userId` 공개 프로필 화면으로 이동하는지 확인한다.
- `v1.4.54`에서 관리자 전체 티어표 카드에 즐겨찾기 수와 인기순 정렬/최소 즐겨찾기 필터를 붙였으므로, 즐겨찾기 많은 순으로 바꿨을 때 실제 받은 즐겨찾기 수가 큰 글부터 보이고 최소값을 올리면 추천 후보만 남는지 확인한다.
- 관리자 전체 티어표 통계 카드도 최소 즐겨찾기 필터가 적용된 범위 기준으로 `전체/추천/공개/비공개` 숫자가 바뀌는지 QA한다.
- `v1.4.54`에서 회원 관리 카드에 팔로워 수와 받은 즐겨찾기 수를 추가했으므로, 팔로워 많은 순/받은 즐겨찾기 많은 순 정렬이 실제 운영 데이터 순서와 맞고 최고 관리자 보호 로직도 그대로 유지되는지 확인한다.
- 관리자 회원 카드에서 `비밀번호 초기화` 버튼과 모달을 숨겼으므로, 일반 운영 동선에서는 비밀번호 직접 조작 UI가 보이지 않고 기존 회원 정보 수정/삭제/썸네일 변경은 그대로 동작하는지 확인한다.
- `v1.4.53`에서 본인 티어표 복사 버튼을 다시 열었으므로, 작성자 본인 편집 모드와 뷰어 모드 모두에서 `복사본 만들기`가 보이고, 복사 후 새 복사본 화면으로 실제 이동하는지 확인한다.
- 본인 티어표를 수정한 뒤 저장하지 않은 상태로 `복사본 만들기`를 누르면 복사 직전에 원본이 먼저 저장되고, 새 복사본이 방금 수정한 최신 내용 기준으로 생성되는지 QA한다.
- `/users/:userId` 작성자 프로필에서 비로그인 사용자는 팔로우 버튼이 안 보이고, 로그인 사용자는 타인 프로필에서 `팔로우 / 팔로잉` 전환과 팔로워 수 갱신이 정상이며, 자기 프로필에서는 팔로우 버튼이 숨겨지는지 확인한다.
@@ -141,7 +154,7 @@
## 중기 개선
- 목록 카드의 작성자 메타를 카드 전체 열기 버튼과 충돌 없이 직접 프로필 링크로 분리하는 후속 UX를 검토한다.
- 추천 티어표는 이번에 관리자 수동 지정부터 붙였으므로, 다음 단계에서는 최근 N일 좋아요 수 기준 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
- 추천 티어표는 전체 누적 즐겨찾기 기준 정렬/필터부터 붙였으므로, 다음 단계에서는 최근 N일 기준 급상승 추천 후보 필터와 추천 섹션 노출 개수 설정을 관리자 화면에 추가할지 검토한다.
- 이메일 인증/비밀번호 재설정 1차 구현이 들어갔으므로, 다음 단계에서는 Gmail 발신 기반이 실제 운영에서 스팸함으로 얼마나 가는지 보고 필요하면 Cloudflare DNS의 SPF/DKIM/DMARC와 도메인 발신 주소 전환을 정리한다.
- 구글 계정 로그인은 아직 붙이지 않았으므로, 이메일 인증 안정화 후 Google OAuth 클라이언트를 만들고 일반 이메일 계정과 같은 이메일의 구글 계정을 자동 연결할지 정책을 먼저 결정한다.
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
@@ -157,7 +170,7 @@
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 라이트모드 공통 토큰 재정비와 카드/아바타/즐겨찾기 버튼 보정까지 반영했으므로, 다음 QA에서는 로그인/홈/주제 허브/에디터/관리자 순으로 실제 플로우를 돌리며 남은 하드코딩 색과 과한 대비가 없는지 확인한다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
- 회원 일괄 작업(다중 선택, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다. 비밀번호는 평소 운영자가 직접 덮어쓰기보다 이메일 재설정 흐름을 우선하므로, 관리자 일괄 비밀번호 초기화는 별도 긴급 대응 정책이 생긴 뒤에만 다시 검토한다.
- 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다.
- 로그인/회원가입/관리자 비밀번호 초기화에 요청 횟수 제한을 추가한다.
- 업로드 파일은 MIME 타입뿐 아니라 파일 시그니처 기반 검증까지 확장한다.

View File

@@ -1,5 +1,27 @@
# 업데이트 로그
## 2026-04-03 v1.4.57
- 관리자 아이템 관리 필터를 `전체 이미지 → 아이템(템플릿 + 사용자) → 템플릿 아이템 → 사용자 아이템 → 썸네일 이미지 → 프로필 이미지 → 미사용 아이템` 순서로 다시 정리해, 전체 조회와 실제 아이템 검수 흐름이 더 직관적으로 이어지게 맞췄다.
- 기본 필터는 계속 `아이템(템플릿 + 사용자)`를 유지하되, 썸네일과 프로필 이미지는 각각 `filter=thumbnail`, `filter=avatar`로 분리 조회할 수 있게 백엔드 필터 enum과 자산 분류 값을 확장했다.
- 보관 자산 배지 문구도 `/uploads/assets/topics/` 경로는 `썸네일 이미지`, 사용자 업로드 항목은 `사용자 아이템`, 템플릿 기본 항목은 `템플릿 아이템`으로 맞춰 `관리자 템플릿`처럼 실제 의미와 어긋나는 표현이 남지 않도록 정리했다.
- `미사용 아이템`은 계정 탈퇴로 같이 삭제된 항목이 아니라, 사용자 아이템 레코드는 남아 있지만 저장 티어표/템플릿에서 더 이상 참조하지 않는 항목이라는 의미가 드러나도록 통계 라벨과 일괄 삭제 버튼 문구를 다시 정돈했다.
## 2026-04-03 v1.4.56
- 관리자 아이템 관리에서 `/uploads/assets/...` 아래의 보관 이미지가 템플릿 기본 아이템이 아닌데도 모두 `관리자 템플릿` 배지로 표시되던 분류를 정리했다.
- 보관 이미지 자산은 이제 `asset` 출처로 분리하고, 경로에 따라 `프로필 아바타`, `티어표 썸네일`, `템플릿 썸네일`, `보관 자산` 배지가 붙도록 바꿔 반복 사용 아이템과 1회성/관리용 이미지를 구분해서 볼 수 있게 했다.
- 아이템 관리 필터 기본값을 `아이템만 (템플릿+사용자)`로 바꾸고, `썸네일·프로필 이미지`, `미사용 썸네일·프로필 이미지` 필터를 따로 제공해 기본 화면에서는 실제 아이템만 먼저 검수할 수 있게 했다.
- 아이템 관리 상단의 `미사용` 통계가 프로필/썸네일 같은 자산까지 `usageCount=0`으로 같이 세면 잘못된 숫자처럼 보일 수 있으므로, `미사용 사용자 아이템`이라는 라벨로 바꾸고 실제 사용자 업로드 아이템 중 템플릿 연결과 사용 횟수가 모두 없는 항목만 세도록 보정했다.
## 2026-04-03 v1.4.55
- 관리자 회원 카드의 `최근 활동`이 실제로는 마지막 접속이 아니라 작성 티어표의 마지막 수정 시각 기준이었으므로, 라벨을 `최근 콘텐츠 활동`으로 분명하게 바꾸고 `마지막 접속일`을 별도 줄로 추가해 두 의미를 분리했다.
- 백엔드 `users``last_login_at`을 추가하고, 로그인/이메일 인증 완료/비밀번호 재설정 완료/세션 기반 `/api/auth/me` 확인 시 해당 시각을 갱신하도록 보강했다. 기존 계정은 마이그레이션 시 `created_at`으로 1차 채워 오래된 계정도 빈 값 없이 정렬할 수 있게 했다.
- 관리자 회원 목록 정렬에 `마지막 접속순`을 추가하고, 회원 카드의 `회원 정보 수정` 옆에 `프로필 보기` 버튼을 붙여 해당 유저의 `/users/:userId` 공개 프로필 화면으로 바로 이동할 수 있게 했다.
## 2026-04-03 v1.4.54
- 관리자 `전체 티어표 관리` 카드에 받은 즐겨찾기 수를 표시하고, 우측 운영 패널에 `최근 수정순 / 최근 생성순 / 즐겨찾기 많은 순` 정렬과 `최소 즐겨찾기 수` 필터를 추가해, 운영자가 추천 후보가 될 만한 인기 티어표를 더 빨리 찾을 수 있게 했다.
- 관리자 회원 관리 카드에 `팔로워 수``받은 즐겨찾기 수`를 추가하고, 정렬 기준에도 `팔로워 많은 순`, `받은 즐겨찾기 많은 순`을 붙여 어떤 작성자가 핵심 기여자인지 운영자가 더 쉽게 파악할 수 있게 했다.
- 이메일 인증과 비밀번호 재설정 메일이 들어간 뒤에는 운영자가 회원 비밀번호를 직접 바꾸는 버튼이 평소 화면에 드러나 있을 필요가 작다고 보고, 회원 카드의 `비밀번호 초기화` 버튼과 해당 모달 UI를 숨겼다. 서버의 관리자 비밀번호 변경 API는 비상 상황용 최후 수단으로 남겨두되, 일반 운영 동선에서는 직접 조작처럼 보이지 않도록 정리했다.
## 2026-04-03 v1.4.53
- 티어표 복사 버튼이 타인 티어표에서만 보이도록 묶여 있어 본인 티어표에서는 숨겨지던 문제를 고쳐, 저장된 본인 티어표도 `복사본 만들기`로 새 복사본을 만들 수 있게 복구했다.
- 본인 티어표를 편집 중 저장하지 않은 변경이 있는 상태로 복사본을 만들면 화면에 보이는 최신 수정 내용이 빠질 수 있었으므로, 복사 실행 직전에 현재 수정본을 먼저 저장한 뒤 복사본을 생성하도록 보정했다.

View File

@@ -16,7 +16,15 @@ const props = defineProps({
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
<span
class="customItemCard__badge"
:class="{
'customItemCard__badge--template': item.sourceType === 'template',
'customItemCard__badge--asset': item.sourceType === 'asset',
}"
>
{{ item.sourceLabel }}
</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
</button>

View File

@@ -159,6 +159,7 @@ const props = defineProps({
<div class="tierAdminCard__stats">
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
<span v-if="tierList.isFeatured" class="pill pill--accent">추천 노출중</span>
<span class="pill pill--soft">즐겨찾기 {{ tierList.favoriteCount || 0 }}</span>
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>

View File

@@ -17,14 +17,12 @@ const props = defineProps({
removeUserAvatar: { type: Function, required: true },
canEditUserAvatar: { type: Function, required: true },
canEditUserInfo: { type: Function, required: true },
canResetUserPassword: { type: Function, required: true },
canDeleteUser: { type: Function, required: true },
roleLabelOf: { type: Function, required: true },
fmt: { type: Function, required: true },
openUserPasswordModal: { type: Function, required: true },
openUserProfile: { type: Function, required: true },
openUserDeleteModal: { type: Function, required: true },
openUserEditModal: { type: Function, required: true },
lockResetIcon: { type: String, required: true },
deleteIcon: { type: String, required: true },
})
@@ -49,16 +47,19 @@ const userSortDirectionModel = computed({
<div class="sectionHeader">
<div>
<div class="panel__title">회원 관리</div>
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화를 진행할 있어.</div>
<div class="hint hint--tight">팔로워·즐겨찾기 지표로 핵심 작성자를 확인하고, 회원 정보와 권한만 최소한으로 관리해.</div>
</div>
</div>
<div class="toolbar toolbar--secondary">
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
<option value="recent">최근 활동순</option>
<option value="recent">최근 콘텐츠 활동순</option>
<option value="lastLogin">마지막 접속순</option>
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
<option value="followers">팔로워 많은 </option>
<option value="favorites">받은 즐겨찾기 많은 </option>
</select>
<select v-model="userSortDirectionModel" class="select toolbar__select" @change="props.submitUserFilters">
<option value="desc">내림차순</option>
@@ -113,22 +114,16 @@ const userSortDirectionModel = computed({
<div class="userInfoList">
<div class="userInfoLine"><span>가입일</span><strong>{{ props.fmt(user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>팔로워</span><strong>{{ user.followerCount || 0 }}</strong></div>
<div class="userInfoLine"><span>받은 즐겨찾기</span><strong>{{ user.receivedFavoriteCount || 0 }}</strong></div>
<div class="userInfoLine"><span>최근 콘텐츠 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>마지막 접속일</span><strong>{{ user.lastLoginAt ? props.fmt(user.lastLoginAt) : '기록 없음' }}</strong></div>
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
</div>
<div class="userCard__actions userCard__actions--compact">
<button
class="iconActionButton"
type="button"
title="비밀번호 초기화"
:disabled="!props.canResetUserPassword(user)"
@click="props.openUserPasswordModal(user)"
>
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
</button>
<button
class="iconActionButton iconActionButton--danger"
type="button"
@@ -146,6 +141,13 @@ const userSortDirectionModel = computed({
>
회원 정보 수정
</button>
<button
class="btn btn--ghost userSaveButton"
type="button"
@click="props.openUserProfile(user)"
>
프로필 보기
</button>
</div>
</article>
</div>

View File

@@ -119,9 +119,19 @@ export function useAdminCustomItems({
closeCustomItemDeleteModal()
closeCustomItemModal()
await refreshCustomItems()
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
success.value =
item.sourceType === 'template'
? '선택한 템플릿 아이템을 제거했어요.'
: item.sourceType === 'asset'
? '선택한 이미지 자산을 삭제했어요.'
: '사용자 업로드 이미지를 삭제했어요.'
} catch (e) {
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
error.value =
item.sourceType === 'template'
? '템플릿 아이템 제거에 실패했어요.'
: item.sourceType === 'asset'
? '이미지 자산 삭제에 실패했어요.'
: '사용자 업로드 이미지 삭제에 실패했어요.'
}
}

View File

@@ -81,10 +81,10 @@ export const api = {
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
),
listAdminTierLists: ({ q = '', topicId = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
getAdminTierListStats: ({ q = '', topicId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}`),
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
getAdminTierListStats: ({ q = '', topicId = '', minFavorites = 0 } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&minFavorites=${encodeURIComponent(minFavorites)}`),
updateAdminTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
updateAdminTierListFeatured: (tierListId, payload) =>

View File

@@ -2,9 +2,8 @@
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { editorPath } from '../lib/paths'
import { editorPath, userProfilePath } from '../lib/paths'
import { toApiUrl } from '../lib/runtime'
import lockResetIcon from '../assets/icons/lock_reset.svg'
import deleteIcon from '../assets/icons/delete.svg'
import SvgIcon from '../components/SvgIcon.vue'
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
@@ -45,12 +44,14 @@ const customItemQuery = ref('')
const customItemPage = ref(1)
const customItemLimit = ref(50)
const customItemTotal = ref(0)
const customItemFilter = ref('all')
const customItemFilter = ref('library')
const customItemModalTargetTemplateId = ref('')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
const adminTierListTopicId = ref('')
const adminTierListSort = ref('recent')
const adminTierListMinFavorites = ref(0)
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
@@ -230,7 +231,7 @@ const activeTabDescription = computed(() => {
return '템플릿 생성, 선택, 썸네일, 기본 아이템 관리를 전용 작업 화면으로 분리했습니다.'
}
if (activeTab.value === 'items') {
return '사용자 업로드와 관리자 템플릿 이미지를 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
return '사용자 아이템과 템플릿 아이템을 함께 검수하고, 필요한 템플릿에 직접 연결할 수 있어요.'
}
if (activeTab.value === 'tierlists') {
return tierlistsMode.value === 'requests'
@@ -241,7 +242,12 @@ const activeTabDescription = computed(() => {
})
const adminOverviewStats = computed(() => {
const pendingRequests = templateRequests.value.length
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
const orphanItems = customItems.value.filter(
(item) =>
item.sourceType === 'user' &&
Number(item.usageCount || 0) === 0 &&
!(Array.isArray(item.linkedTemplates) && item.linkedTemplates.length > 0)
).length
const adminCount = users.value.filter((user) => user.isAdmin).length
if (activeTab.value === 'featured') {
@@ -263,8 +269,9 @@ const adminOverviewStats = computed(() => {
if (activeTab.value === 'items') {
return [
{ label: '검색 결과', value: `${customItemTotal.value}` },
{ label: '미사용', value: `${orphanItems}` },
{ label: '미사용 아이템', value: `${orphanItems}` },
{ label: '템플릿 아이템', value: `${customItems.value.filter((item) => item.sourceType === 'template').length}` },
{ label: '이미지 자산', value: `${customItems.value.filter((item) => item.sourceType === 'asset').length}` },
]
}
if (activeTab.value === 'tierlists') {
@@ -489,7 +496,7 @@ watch(
if (tab === 'items') {
customItemQuery.value = ''
customItemFilter.value = 'all'
customItemFilter.value = 'library'
customItemPage.value = 1
await refreshCustomItems()
return
@@ -598,13 +605,14 @@ function formatImageJobStatus(status) {
function customItemDeleteImpactText(item) {
if (!item) return ''
if (item.sourceType === 'asset' || item.isAssetLibraryItem) {
return `"${item.label}" ${item.sourceLabel || '이미지 자산'} 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
}
if (item.sourceType === 'template') {
return item.isAssetLibraryItem
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
return `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 주제의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
}
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
return `"${item.label}" 사용자 아이템을 삭제할까요? 현재 항목만 정리됩니다.`
}
const imageDiagnosticsCards = computed(() => {
@@ -746,7 +754,7 @@ function setTab(tab) {
}
if (tab === 'items') {
customItemQuery.value = ''
customItemFilter.value = 'all'
customItemFilter.value = 'library'
customItemPage.value = 1
refreshCustomItems()
}
@@ -824,6 +832,8 @@ async function refreshAdminTierLists() {
const data = await api.listAdminTierLists({
q: adminTierListQuery.value,
topicId: adminTierListTopicId.value,
sort: adminTierListSort.value,
minFavorites: adminTierListMinFavorites.value,
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
@@ -840,7 +850,11 @@ async function refreshAdminTierLists() {
async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return
try {
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, topicId: adminTierListTopicId.value })
const data = await api.getAdminTierListStats({
q: adminTierListQuery.value,
topicId: adminTierListTopicId.value,
minFavorites: adminTierListMinFavorites.value,
})
adminTierListStats.value = {
total: data.total || 0,
publicCount: data.publicCount || 0,
@@ -1312,6 +1326,17 @@ function submitAdminTierListSearch() {
refreshAdminTierLists()
}
function changeAdminTierListSort() {
adminTierListPage.value = 1
refreshAdminTierLists()
}
function changeAdminTierListMinFavorites() {
adminTierListMinFavorites.value = Math.max(Number(adminTierListMinFavorites.value) || 0, 0)
adminTierListPage.value = 1
refreshAdminTierLists()
}
function setAdminTierListTopicId(topicId) {
adminTierListTopicId.value = topicId || ''
adminTierListPage.value = 1
@@ -1371,7 +1396,7 @@ function buildModalItemFromTierListItem(item, tierList) {
id,
label: item?.label || matchedItem?.label || '이름 없음',
src: item?.src || matchedItem?.src || '',
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'template' : 'user'),
sourceType: matchedItem?.sourceType || (String(id).startsWith('asset:') ? 'asset' : 'user'),
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedTemplates: Array.isArray(matchedItem?.linkedTemplates) ? matchedItem.linkedTemplates : [],
@@ -1690,6 +1715,11 @@ function userAvatarFallback(user) {
return (user?.email?.trim()?.[0] || '?').toUpperCase()
}
function openUserProfile(user) {
if (!user?.id) return
router.push(userProfilePath(user.id))
}
</script>
<template>
@@ -1825,14 +1855,12 @@ function userAvatarFallback(user) {
:remove-user-avatar="removeUserAvatar"
:can-edit-user-avatar="canEditUserAvatar"
:can-edit-user-info="canEditUserInfo"
:can-reset-user-password="canResetUserPassword"
:can-delete-user="canDeleteUser"
:role-label-of="roleLabelOf"
:fmt="fmt"
:open-user-password-modal="openUserPasswordModal"
:open-user-profile="openUserProfile"
:open-user-delete-modal="openUserDeleteModal"
:open-user-edit-modal="openUserEditModal"
:lock-reset-icon="lockResetIcon"
:delete-icon="deleteIcon"
@update:user-query="userQuery = $event"
@update:user-sort="userSort = $event"
@@ -1906,31 +1934,6 @@ function userAvatarFallback(user) {
</div>
</div>
<div v-if="userPasswordModalOpen" class="modalOverlay" @click.self="closeUserPasswordModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">비밀번호 초기화</div>
<div class="modalCard__desc">{{ modalTargetUser ? `${userDisplayName(modalTargetUser)} 계정에 설정할 새 비밀번호를 입력해주세요.` : '' }}</div>
<div class="modalCard__form">
<label class="field">
<span class="field__label"> 비밀번호</span>
<input
v-model="modalPasswordDraft"
class="field__input"
type="password"
maxlength="120"
placeholder="초기화할 비밀번호 입력"
@keydown.enter.prevent="confirmUserPasswordReset"
/>
<span class="field__hint">6~120 권장 · {{ modalPasswordDraft.length }}/120</span>
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeUserPasswordModal">취소</button>
<button class="btn btn--primary" :disabled="!modalPasswordDraft.trim()" @click="confirmUserPasswordReset">초기화</button>
</div>
</div>
</div>
<div v-if="userDeleteModalOpen" class="modalOverlay" @click.self="closeUserDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">회원 삭제</div>
@@ -2294,16 +2297,17 @@ function userAvatarFallback(user) {
</select>
<select :value="customItemFilter" class="select" @change="changeCustomItemFilter($event.target.value)">
<option value="all">전체 이미지</option>
<option value="user">사용자 업로드</option>
<option value="template">템플릿 사용 이미지</option>
<option value="asset">관리 보관 자산</option>
<option value="unused-user">미사용 사용자 업로드</option>
<option value="unused-admin">미사용 관리자 자산</option>
<option value="library">아이템(템플릿 + 사용자)</option>
<option value="template">템플릿 아이템</option>
<option value="user">사용 아이템</option>
<option value="thumbnail">썸네일 이미지</option>
<option value="avatar">프로필 이미지</option>
<option value="unused-user">미사용 아이템</option>
</select>
</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 사용자 이미지 일괄 삭제</button>
<button class="btn btn--danger" :disabled="customItemFilter !== 'unused-user' || !customItems.length" @click="removeUnusedCustomItems">미사용 아이템 일괄 삭제</button>
</div>
<div class="adminSidebar__stats">
<div class="sidebarStat">
@@ -2350,6 +2354,21 @@ function userAvatarFallback(user) {
<option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option>
</select>
<select v-model="adminTierListSort" class="select" @change="changeAdminTierListSort">
<option value="recent">최근 수정순</option>
<option value="created">최근 생성순</option>
<option value="favorites">즐겨찾기 많은 </option>
</select>
<input
v-model.number="adminTierListMinFavorites"
class="input"
type="number"
min="0"
max="1000000"
placeholder="최소 즐겨찾기 수"
@change="changeAdminTierListMinFavorites"
@keydown.enter.prevent="changeAdminTierListMinFavorites"
/>
</div>
<div class="adminSidebar__actions">
<button class="btn btn--ghost" @click="refreshAdminTierLists">새로고침</button>
@@ -3469,6 +3488,9 @@ function userAvatarFallback(user) {
.adminUiScope .customItemCard__badge--template {
background: rgba(96, 165, 250, 0.18);
}
.adminUiScope .customItemCard__badge--asset {
background: rgba(251, 191, 36, 0.18);
}
.adminUiScope .customItemCard:hover {
border-color: rgba(126, 162, 255, 0.42);
background: rgba(255, 255, 255, 0.06);