관리자 인기 지표와 회원 핵심 지표를 보강한다

This commit is contained in:
2026-04-03 12:48:29 +09:00
parent f9767624d1
commit c9b82b28dc
11 changed files with 145 additions and 65 deletions

View File

@@ -64,6 +64,8 @@ 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),
recentActivityAt: Number(row.recent_activity_at || row.created_at || 0),
}
}
@@ -839,6 +841,14 @@ 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'
: isAsc
? 'recent_activity_at ASC, u.created_at ASC, u.email ASC'
: 'recent_activity_at DESC, u.created_at ASC, u.email ASC'
@@ -853,13 +863,17 @@ async function listUsers({ queryText = '', sort = 'recent', direction = 'desc' }
u.is_admin,
u.avatar_src,
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
ORDER BY ${orderBy}
@@ -2414,9 +2428,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 +2500,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 +2511,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 +2579,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 +2588,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,

View File

@@ -350,6 +350,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 +361,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 +374,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 +382,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)
})
@@ -970,7 +976,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', 'created', 'tierlists', 'followers', 'favorites']).optional().default('recent'),
direction: z.enum(['asc', 'desc']).optional().default('desc'),
})
const parsed = schema.safeParse(req.query)

View File

@@ -1,5 +1,10 @@
# 의사결정 이력
## 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

@@ -68,6 +68,7 @@
- `isAdmin`: boolean
- `avatarSrc`: string
- `createdAt`: number
- 관리자 목록 집계 응답에서는 `tierListCount`, `followerCount`, `receivedFavoriteCount`, `recentActivityAt`도 함께 내려준다.
- `emailVerificationTokens`
- `id`: string
- `userId`: string
@@ -182,7 +183,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`
@@ -195,6 +198,7 @@
- `DELETE /api/admin/custom-items/:itemId`
- `DELETE /api/admin/custom-items`
- `GET /api/admin/users`
- `sort=recent|created|tierlists|followers|favorites`, `direction=asc|desc`로 회원을 활동/작성량/팔로워/받은 즐겨찾기 기준으로 정렬한다.
- `PATCH /api/admin/users/:userId`
- `PATCH /api/admin/users/:userId/password`
- `DELETE /api/admin/users/:userId`
@@ -214,6 +218,7 @@
- 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다.
- 관리자 화면에는 별도 `티어표 관리` 탭이 있으며, 내부에서 `템플릿 요청 관리 / 전체 티어표 관리`를 분리해 볼 수 있고, 확인용 완성본은 탐색 UI 없는 preview 전용 모달로 연다.
- `전체 티어표 관리`에서는 공개 티어표를 `추천 지정 / 추천 해제`할 수 있고, 추천 지정된 티어표는 주제별 공개 목록 상단의 `추천 티어표` 섹션에 먼저 노출된다. 비공개 티어표는 추천 지정할 수 없고, 추천글을 비공개로 바꾸면 추천 상태도 함께 해제된다.
- `전체 티어표 관리` 카드에는 받은 즐겨찾기 수를 함께 보여주며, 우측 운영 패널에서 즐겨찾기 많은 순 정렬과 최소 즐겨찾기 수 필터로 추천 후보를 좁힐 수 있다.
- `티어표 관리` 탭의 추가 아이템은 작은 그리드 카드로 표시하고, 클릭 시 `기존 템플릿에 추가 / 새 템플릿 만들기` 모달을 통해 목적지를 선택한다.
- `티어표 관리` 탭에서는 티어표 안의 커스텀 아이템을 개별 또는 일괄로 기존 게임 템플릿에 복제할 수 있다.
- `freeform` 티어표는 관리자 화면에서 새 게임 ID/이름을 입력해 새로운 게임 템플릿으로 복제 생성할 수 있다.
@@ -222,7 +227,9 @@
- 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다.
- 단, 일반 운영자는 최고 관리자 계정의 프로필 이미지/회원 정보/비밀번호/삭제 버튼을 사용할 수 없고, 최고 관리자만 다른 관리자 권한을 변경할 수 있다.
- 관리자 회원 정보 수정은 운영상 필요한 경우 예약어 닉네임도 저장할 수 있지만, 일반 회원가입과 개인 프로필 수정에서는 운영자 사칭성 예약어 닉네임을 계속 차단한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 최근 활동 시각을 함께 표시한다.
- 회원 관리 카드에는 아바타, 작성 티어표 수, 팔로워 수, 받은 즐겨찾기 수, 최근 활동 시각을 함께 표시한다.
- 운영자는 회원 목록을 작성 티어표 수뿐 아니라 팔로워 수와 받은 즐겨찾기 수 기준으로도 정렬할 수 있어, 핵심 작성자를 더 빠르게 찾을 수 있다.
- 회원 비밀번호를 운영자가 임의로 덮어쓰는 기능은 비상 상황용 API로만 유지하고, 일반 회원 관리 카드에서는 비밀번호 초기화 버튼과 모달을 숨긴다. 평소 사용자 비밀번호 변경은 이메일 재설정 메일과 설정 화면 직접 변경을 우선 사용한다.
## 티어표 접근 메모
- `new` 작성 경로는 로그인한 사용자만 진입할 수 있다.

View File

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

View File

@@ -1,5 +1,10 @@
# 업데이트 로그
## 2026-04-03 v1.4.54
- 관리자 `전체 티어표 관리` 카드에 받은 즐겨찾기 수를 표시하고, 우측 운영 패널에 `최근 수정순 / 최근 생성순 / 즐겨찾기 많은 순` 정렬과 `최소 즐겨찾기 수` 필터를 추가해, 운영자가 추천 후보가 될 만한 인기 티어표를 더 빨리 찾을 수 있게 했다.
- 관리자 회원 관리 카드에 `팔로워 수``받은 즐겨찾기 수`를 추가하고, 정렬 기준에도 `팔로워 많은 순`, `받은 즐겨찾기 많은 순`을 붙여 어떤 작성자가 핵심 기여자인지 운영자가 더 쉽게 파악할 수 있게 했다.
- 이메일 인증과 비밀번호 재설정 메일이 들어간 뒤에는 운영자가 회원 비밀번호를 직접 바꾸는 버튼이 평소 화면에 드러나 있을 필요가 작다고 보고, 회원 카드의 `비밀번호 초기화` 버튼과 해당 모달 UI를 숨겼다. 서버의 관리자 비밀번호 변경 API는 비상 상황용 최후 수단으로 남겨두되, 일반 운영 동선에서는 직접 조작처럼 보이지 않도록 정리했다.
## 2026-04-03 v1.4.53
- 티어표 복사 버튼이 타인 티어표에서만 보이도록 묶여 있어 본인 티어표에서는 숨겨지던 문제를 고쳐, 저장된 본인 티어표도 `복사본 만들기`로 새 복사본을 만들 수 있게 복구했다.
- 본인 티어표를 편집 중 저장하지 않은 변경이 있는 상태로 복사본을 만들면 화면에 보이는 최신 수정 내용이 빠질 수 있었으므로, 복사 실행 직전에 현재 수정본을 먼저 저장한 뒤 복사본을 생성하도록 보정했다.

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,11 @@ 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 },
openUserDeleteModal: { type: Function, required: true },
openUserEditModal: { type: Function, required: true },
lockResetIcon: { type: String, required: true },
deleteIcon: { type: String, required: true },
})
@@ -49,7 +46,7 @@ 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>
@@ -59,6 +56,8 @@ const userSortDirectionModel = computed({
<option value="recent">최근 활동순</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,6 +112,8 @@ 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>{{ 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.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
@@ -120,15 +121,6 @@ const userSortDirectionModel = computed({
</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"

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

@@ -4,7 +4,6 @@ import { useRoute, useRouter } from 'vue-router'
import { api } from '../lib/api'
import { editorPath } 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'
@@ -51,6 +50,8 @@ 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)
@@ -824,6 +825,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 +843,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 +1319,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
@@ -1825,14 +1843,11 @@ 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-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 +1921,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>
@@ -2350,6 +2340,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>