diff --git a/backend/src/db.js b/backend/src/db.js
index c6fe31f..03ee6da 100644
--- a/backend/src/db.js
+++ b/backend/src/db.js
@@ -1951,22 +1951,32 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
return fallbackItem?.src || ''
}
-async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
+async function listAdminTierLists({ queryText = '', gameId = '', 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()
+ const hasGameId = !!(gameId || '').trim()
const search = `%${(queryText || '').trim()}%`
- const whereClause = hasQuery
- ? `
- WHERE
- t.title LIKE ?
- OR g.name LIKE ?
- OR g.id LIKE ?
- OR u.email LIKE ?
- OR u.nickname LIKE ?
- `
- : ''
- const params = hasQuery ? [search, search, search, search, search] : []
+ const whereParts = []
+ const params = []
+
+ if (hasGameId) {
+ whereParts.push('t.game_id = ?')
+ params.push((gameId || '').trim())
+ }
+
+ if (hasQuery) {
+ whereParts.push(`(
+ t.title LIKE ?
+ OR g.name LIKE ?
+ OR g.id LIKE ?
+ OR u.email LIKE ?
+ OR u.nickname LIKE ?
+ )`)
+ params.push(search, search, search, search, search)
+ }
+
+ const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const rows = await query(
`
@@ -2288,6 +2298,18 @@ async function deleteTierList(id) {
await query('DELETE FROM tierlists WHERE id = ?', [id])
}
+async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
+ await query(
+ `
+ UPDATE tierlists
+ SET title = ?, description = ?, is_public = ?, updated_at = ?
+ WHERE id = ?
+ `,
+ [title, description || '', isPublic ? 1 : 0, now(), id]
+ )
+ return findTierListById(id)
+}
+
async function findCustomItemsByIds(ids) {
if (!ids.length) return []
const placeholders = ids.map(() => '?').join(', ')
@@ -2454,6 +2476,7 @@ module.exports = {
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
+ updateAdminTierListMeta,
favoriteTierList,
unfavoriteTierList,
favoriteGame,
diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js
index df3d295..49dc018 100644
--- a/backend/src/routes/admin.js
+++ b/backend/src/routes/admin.js
@@ -22,6 +22,7 @@ const {
updateImageAssetLabel,
deleteGameItem,
deleteGame,
+ deleteTierList,
updateGameDisplayOrder,
listCustomItems,
findCustomItemById,
@@ -33,6 +34,7 @@ const {
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
+ updateAdminTierListMeta,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
@@ -296,6 +298,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
+ gameId: 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),
})
@@ -304,6 +307,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({
queryText: parsed.data.q,
+ gameId: parsed.data.gameId,
page: parsed.data.page,
limit: parsed.data.limit,
currentUserId: req.session?.userId || '',
@@ -693,6 +697,34 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
res.json(result)
})
+router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
+ const schema = z.object({
+ title: z.string().trim().min(1).max(120),
+ description: z.string().max(500).optional().default(''),
+ isPublic: z.boolean(),
+ })
+ const parsed = schema.safeParse(req.body)
+ if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
+
+ const tierList = await findTierListById(req.params.tierListId)
+ if (!tierList) return res.status(404).json({ error: 'not_found' })
+
+ const updated = await updateAdminTierListMeta({
+ id: tierList.id,
+ title: parsed.data.title,
+ description: parsed.data.description || '',
+ isPublic: parsed.data.isPublic,
+ })
+ res.json({ tierList: updated })
+})
+
+router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => {
+ const tierList = await findTierListById(req.params.tierListId)
+ if (!tierList) return res.status(404).json({ error: 'not_found' })
+ await deleteTierList(tierList.id)
+ res.json({ ok: true })
+})
+
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
diff --git a/docs/history.md b/docs/history.md
index 7e06e17..3f8a23b 100644
--- a/docs/history.md
+++ b/docs/history.md
@@ -1,5 +1,9 @@
# 의사결정 이력
+## 2026-04-02 v1.3.70
+- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다.
+- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다.
+
## 2026-04-02 v1.3.69
- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다.
- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다.
diff --git a/docs/todo.md b/docs/todo.md
index 8714f92..28c304e 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -1,6 +1,7 @@
# 할 일 및 이슈
## 단기 확인
+- 관리자 `전체 티어표 관리`의 게임 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
- 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
diff --git a/docs/update.md b/docs/update.md
index b773386..e1ec78d 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,10 @@
# 업데이트 로그
+## 2026-04-02 v1.3.70
+- 관리자 `전체 티어표 관리`는 이제 게임별 필터를 지원해, 특정 게임에서 만들어진 티어표만 따로 골라 보며 공개/비공개 분포를 확인할 수 있게 함.
+- 전체 티어표 카드는 공개 여부를 텍스트 대신 다른 관리자 화면과 같은 배지 형식으로 표시하고, 카드의 `관리` 액션에서 제목·설명 수정, 공개/비공개 전환, 삭제를 바로 처리할 수 있게 보강함.
+- 이를 위해 관리자 전용 티어표 수정/삭제 API와 게임 기준/검색 기준 공개 집계 로직을 함께 추가해, 관리자 화면에서 비공개 개입과 운영성 검수가 한 흐름으로 이어지게 정리함.
+
## 2026-04-02 v1.3.69
- 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함.
- 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈.
diff --git a/frontend/src/components/admin/AdminTierlistsSection.vue b/frontend/src/components/admin/AdminTierlistsSection.vue
index 02831ce..f618c5e 100644
--- a/frontend/src/components/admin/AdminTierlistsSection.vue
+++ b/frontend/src/components/admin/AdminTierlistsSection.vue
@@ -22,6 +22,7 @@ const props = defineProps({
adminTierListPageCount: { type: Number, required: true },
adminTierListTotal: { type: Number, required: true },
adminTierListStats: { type: Object, required: true },
+ openAdminTierListManageModal: { type: Function, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
@@ -151,13 +152,14 @@ const props = defineProps({
{{ tierList.title }}
{{ tierList.description }}
- {{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }}
+ {{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }}
{{ props.fmt(tierList.updatedAt) }}
+ {{ props.tierListVisibilityLabel(tierList) }}
전체 아이템 {{ tierList.itemCount }}개
추가 아이템 {{ tierList.extraItemCount }}개
@@ -177,6 +179,10 @@ const props = defineProps({
+
+
+
+
diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js
index 13c09e7..e83caca 100644
--- a/frontend/src/lib/api.js
+++ b/frontend/src/lib/api.js
@@ -45,10 +45,16 @@ export const api = {
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
),
- listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
- request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
+ listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) =>
+ request(
+ `/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
+ ),
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
+ updateAdminTierList: (tierListId, payload) =>
+ request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
+ deleteAdminTierList: (tierListId) =>
+ request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
const query = new URLSearchParams()
diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue
index 3e0e3f0..f8e50bd 100644
--- a/frontend/src/views/AdminView.vue
+++ b/frontend/src/views/AdminView.vue
@@ -49,6 +49,7 @@ const customItemModalGameSort = ref('recent')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
+const adminTierListGameId = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
@@ -64,6 +65,7 @@ const importModalNewGameId = ref('')
const importModalNewGameName = ref('')
const previewModalOpen = ref(false)
const previewTierList = ref(null)
+const adminTierListManageModalOpen = ref(false)
const activeTemplateRequest = ref(null)
const userEditModalOpen = ref(false)
const userPasswordModalOpen = ref(false)
@@ -81,6 +83,12 @@ const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const customItemModalDraftLabel = ref('')
const customItemModalLabelSaving = ref(false)
+const modalTargetAdminTierList = ref(null)
+const adminTierListDraftTitle = ref('')
+const adminTierListDraftDescription = ref('')
+const adminTierListDraftIsPublic = ref(false)
+const adminTierListSaving = ref(false)
+const adminTierListDeleting = ref(false)
const users = ref([])
const userQuery = ref('')
@@ -288,6 +296,7 @@ const isAnyModalOpen = computed(
importModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
+ adminTierListManageModalOpen.value ||
imageResetModalOpen.value ||
previewModalOpen.value
)
@@ -422,6 +431,8 @@ watch(
if (name === 'adminTierlists') {
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
+ const nextTierListGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
+ if (adminTierListGameId.value !== nextTierListGameId) adminTierListGameId.value = nextTierListGameId
}
},
{ immediate: true }
@@ -447,7 +458,18 @@ watch(
() => tierlistsMode.value,
(mode) => {
if (route.name !== 'adminTierlists') return
- syncAdminRouteQuery({ mode: mode === 'all' ? 'all' : undefined })
+ syncAdminRouteQuery({
+ mode: mode === 'all' ? 'all' : undefined,
+ gameId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
+ })
+ }
+)
+
+watch(
+ () => adminTierListGameId.value,
+ (gameId) => {
+ if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
+ syncAdminRouteQuery({ gameId: gameId || undefined })
}
)
@@ -810,6 +832,7 @@ async function refreshAdminTierLists() {
try {
const data = await api.listAdminTierLists({
q: adminTierListQuery.value,
+ gameId: adminTierListGameId.value,
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
@@ -826,7 +849,7 @@ async function refreshAdminTierLists() {
async function refreshAdminTierListStats() {
if (!auth.user?.isAdmin) return
try {
- const data = await api.getAdminTierListStats({ q: adminTierListQuery.value })
+ const data = await api.getAdminTierListStats({ q: adminTierListQuery.value, gameId: adminTierListGameId.value })
adminTierListStats.value = {
total: data.total || 0,
publicCount: data.publicCount || 0,
@@ -1274,6 +1297,12 @@ function submitAdminTierListSearch() {
refreshAdminTierLists()
}
+function setAdminTierListGameId(gameId) {
+ adminTierListGameId.value = gameId || ''
+ adminTierListPage.value = 1
+ refreshAdminTierLists()
+}
+
function changeAdminTierListLimit(limit) {
adminTierListLimit.value = limit
adminTierListPage.value = 1
@@ -1325,6 +1354,81 @@ function tierListVisibilityLabel(tierList) {
return tierList.isPublic ? '공개' : '비공개'
}
+function openAdminTierListManageModal(tierList) {
+ if (!tierList) return
+ modalTargetAdminTierList.value = tierList
+ adminTierListDraftTitle.value = tierList.title || ''
+ adminTierListDraftDescription.value = tierList.description || ''
+ adminTierListDraftIsPublic.value = !!tierList.isPublic
+ adminTierListManageModalOpen.value = true
+}
+
+function closeAdminTierListManageModal() {
+ adminTierListManageModalOpen.value = false
+ modalTargetAdminTierList.value = null
+ adminTierListDraftTitle.value = ''
+ adminTierListDraftDescription.value = ''
+ adminTierListDraftIsPublic.value = false
+ adminTierListSaving.value = false
+ adminTierListDeleting.value = false
+}
+
+async function saveAdminTierListMeta() {
+ if (!modalTargetAdminTierList.value?.id || adminTierListSaving.value) return
+ const nextTitle = adminTierListDraftTitle.value.trim()
+ if (!nextTitle) {
+ error.value = '티어표 제목을 입력해주세요.'
+ return
+ }
+
+ resetMessages()
+ adminTierListSaving.value = true
+ try {
+ const data = await api.updateAdminTierList(modalTargetAdminTierList.value.id, {
+ title: nextTitle,
+ description: adminTierListDraftDescription.value.trim(),
+ isPublic: !!adminTierListDraftIsPublic.value,
+ })
+ const updated = data.tierList
+ adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
+ if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
+ modalTargetAdminTierList.value = updated
+ await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
+ success.value = '티어표 정보를 수정했어요.'
+ closeAdminTierListManageModal()
+ } catch (e) {
+ error.value = '티어표 정보 수정에 실패했어요.'
+ } finally {
+ adminTierListSaving.value = false
+ }
+}
+
+async function deleteAdminTierListEntry() {
+ if (!modalTargetAdminTierList.value?.id || adminTierListDeleting.value) return
+ const ok = window.confirm(`"${modalTargetAdminTierList.value.title}" 티어표를 삭제할까요? 이 작업은 되돌릴 수 없어요.`)
+ if (!ok) return
+
+ resetMessages()
+ adminTierListDeleting.value = true
+ try {
+ await api.deleteAdminTierList(modalTargetAdminTierList.value.id)
+ adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
+ adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
+ if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
+ await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
+ success.value = '티어표를 삭제했어요.'
+ closeAdminTierListManageModal()
+ if (!adminTierLists.value.length && adminTierListPage.value > 1) {
+ adminTierListPage.value -= 1
+ await refreshAdminTierLists()
+ }
+ } catch (e) {
+ error.value = '티어표 삭제에 실패했어요.'
+ } finally {
+ adminTierListDeleting.value = false
+ }
+}
+
function openAdminTierList(tierList) {
previewTierList.value = tierList
previewModalOpen.value = true
@@ -1634,6 +1738,7 @@ function userAvatarFallback(user) {
:admin-tier-list-page-count="adminTierListPageCount"
:admin-tier-list-total="adminTierListTotal"
:admin-tier-list-stats="adminTierListStats"
+ :open-admin-tier-list-manage-modal="openAdminTierListManageModal"
:move-admin-tier-list-page="moveAdminTierListPage"
/>
@@ -1921,6 +2026,39 @@ function userAvatarFallback(user) {
+
+
+
티어표 관리
+
+ {{ modalTargetAdminTierList ? `${modalTargetAdminTierList.gameName || modalTargetAdminTierList.gameId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
최적화 기록 비우기
@@ -2110,6 +2248,10 @@ function userAvatarFallback(user) {
@keydown.enter.prevent="submitAdminTierListSearch"
/>
+