Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| badf250967 | |||
| a16b1e1025 | |||
| c1dfea41a5 | |||
| 188576f8ac | |||
| 5db1e57f13 | |||
| 2918a0423c |
@@ -1042,6 +1042,160 @@ async function getReferencedUploadFootprint() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fileExistsForUploadSrc(src) {
|
||||
if (typeof src !== 'string' || !src.startsWith('/uploads/')) return true
|
||||
const absolutePath = path.join(__dirname, '..', src.replace(/^\//, ''))
|
||||
try {
|
||||
await fs.stat(absolutePath)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (error?.code === 'ENOENT') return false
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function stripItemIdsFromGroups(groups, missingItemIds) {
|
||||
let changed = false
|
||||
const nextGroups = (groups || []).map((group) => {
|
||||
const nextItemIds = (group?.itemIds || []).filter((itemId) => !missingItemIds.has(itemId))
|
||||
if (nextItemIds.length !== (group?.itemIds || []).length) changed = true
|
||||
return {
|
||||
...group,
|
||||
itemIds: nextItemIds,
|
||||
}
|
||||
})
|
||||
return { changed, groups: nextGroups }
|
||||
}
|
||||
|
||||
function stripMissingItems(items, missingItemIds, missingSrcs) {
|
||||
let changed = false
|
||||
const nextItems = (items || []).filter((item) => {
|
||||
const shouldRemove =
|
||||
(item?.id && missingItemIds.has(item.id)) ||
|
||||
(typeof item?.src === 'string' && missingSrcs.has(item.src))
|
||||
if (shouldRemove) changed = true
|
||||
return !shouldRemove
|
||||
})
|
||||
return { changed, items: nextItems }
|
||||
}
|
||||
|
||||
async function cleanupMissingUploadReferences() {
|
||||
const stats = {
|
||||
clearedAvatars: 0,
|
||||
clearedGameThumbnails: 0,
|
||||
clearedTierListThumbnails: 0,
|
||||
clearedTemplateRequestThumbnails: 0,
|
||||
deletedGameItems: 0,
|
||||
updatedTierLists: 0,
|
||||
updatedTemplateRequests: 0,
|
||||
deletedCustomItems: 0,
|
||||
}
|
||||
|
||||
const [userRows, gameRows, gameItemRows, customItemRows, tierListRows, templateRequestRows] = await Promise.all([
|
||||
query("SELECT id, avatar_src FROM users WHERE avatar_src <> ''"),
|
||||
query("SELECT id, thumbnail_src FROM games WHERE thumbnail_src <> ''"),
|
||||
query("SELECT id, src FROM game_items WHERE src <> ''"),
|
||||
query("SELECT id, src FROM custom_items WHERE src <> ''"),
|
||||
query("SELECT id, thumbnail_src, groups_json, pool_json FROM tierlists"),
|
||||
query("SELECT id, thumbnail_src_snapshot, groups_json, items_json, board_items_json FROM template_requests"),
|
||||
])
|
||||
|
||||
for (const row of userRows) {
|
||||
if (await fileExistsForUploadSrc(row.avatar_src)) continue
|
||||
await query('UPDATE users SET avatar_src = ? WHERE id = ?', ['', row.id])
|
||||
stats.clearedAvatars += 1
|
||||
}
|
||||
|
||||
for (const row of gameRows) {
|
||||
if (await fileExistsForUploadSrc(row.thumbnail_src)) continue
|
||||
await query('UPDATE games SET thumbnail_src = ? WHERE id = ?', ['', row.id])
|
||||
stats.clearedGameThumbnails += 1
|
||||
}
|
||||
|
||||
for (const row of gameItemRows) {
|
||||
if (await fileExistsForUploadSrc(row.src)) continue
|
||||
await deleteGameItem(row.id)
|
||||
stats.deletedGameItems += 1
|
||||
}
|
||||
|
||||
const missingCustomItemIds = new Set()
|
||||
const missingCustomSrcs = new Set()
|
||||
for (const row of customItemRows) {
|
||||
if (await fileExistsForUploadSrc(row.src)) continue
|
||||
missingCustomItemIds.add(row.id)
|
||||
missingCustomSrcs.add(row.src)
|
||||
}
|
||||
|
||||
for (const row of tierListRows) {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const pool = parseJson(row.pool_json, [])
|
||||
let changed = false
|
||||
let nextThumbnail = row.thumbnail_src || ''
|
||||
|
||||
if (row.thumbnail_src && !(await fileExistsForUploadSrc(row.thumbnail_src))) {
|
||||
nextThumbnail = ''
|
||||
changed = true
|
||||
stats.clearedTierListThumbnails += 1
|
||||
}
|
||||
|
||||
const strippedPool = stripMissingItems(pool, missingCustomItemIds, missingCustomSrcs)
|
||||
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
|
||||
if (strippedPool.changed || strippedGroups.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
await query('UPDATE tierlists SET thumbnail_src = ?, groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', [
|
||||
nextThumbnail,
|
||||
serializeJson(strippedGroups.groups),
|
||||
serializeJson(strippedPool.items),
|
||||
now(),
|
||||
row.id,
|
||||
])
|
||||
stats.updatedTierLists += 1
|
||||
}
|
||||
}
|
||||
|
||||
for (const row of templateRequestRows) {
|
||||
const groups = parseJson(row.groups_json, [])
|
||||
const items = parseJson(row.items_json, [])
|
||||
const boardItems = parseJson(row.board_items_json, [])
|
||||
let changed = false
|
||||
let nextThumbnail = row.thumbnail_src_snapshot || ''
|
||||
|
||||
if (row.thumbnail_src_snapshot && !(await fileExistsForUploadSrc(row.thumbnail_src_snapshot))) {
|
||||
nextThumbnail = ''
|
||||
changed = true
|
||||
stats.clearedTemplateRequestThumbnails += 1
|
||||
}
|
||||
|
||||
const strippedItems = stripMissingItems(items, missingCustomItemIds, missingCustomSrcs)
|
||||
const strippedBoardItems = stripMissingItems(boardItems, missingCustomItemIds, missingCustomSrcs)
|
||||
const strippedGroups = stripItemIdsFromGroups(groups, missingCustomItemIds)
|
||||
if (strippedItems.changed || strippedBoardItems.changed || strippedGroups.changed) changed = true
|
||||
|
||||
if (changed) {
|
||||
await query(
|
||||
'UPDATE template_requests SET thumbnail_src_snapshot = ?, groups_json = ?, items_json = ?, board_items_json = ?, updated_at = ? WHERE id = ?',
|
||||
[
|
||||
nextThumbnail,
|
||||
serializeJson(strippedGroups.groups),
|
||||
serializeJson(strippedItems.items),
|
||||
serializeJson(strippedBoardItems.items),
|
||||
now(),
|
||||
row.id,
|
||||
]
|
||||
)
|
||||
stats.updatedTemplateRequests += 1
|
||||
}
|
||||
}
|
||||
|
||||
if (missingCustomItemIds.size) {
|
||||
await deleteCustomItems(Array.from(missingCustomItemIds))
|
||||
stats.deletedCustomItems = missingCustomItemIds.size
|
||||
}
|
||||
|
||||
return stats
|
||||
}
|
||||
|
||||
async function getImageAssetStats({ month } = {}) {
|
||||
const range = resolveMonthRange(month)
|
||||
const jobWhere = []
|
||||
@@ -1322,7 +1476,7 @@ async function getCustomItemUsageMeta() {
|
||||
}
|
||||
}
|
||||
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnly = false } = {}) {
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
const searchText = (queryText || '').trim()
|
||||
@@ -1444,10 +1598,71 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, orphanOnl
|
||||
sourceGameName: row.game_name || row.game_id,
|
||||
}))
|
||||
|
||||
const allItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
||||
const baseItems = [...customItems, ...templateItems, ...assetLibraryItems]
|
||||
const groupedBySrc = new Map()
|
||||
for (const item of baseItems) {
|
||||
if (!item?.src) continue
|
||||
if (!groupedBySrc.has(item.src)) groupedBySrc.set(item.src, [])
|
||||
groupedBySrc.get(item.src).push(item)
|
||||
}
|
||||
|
||||
const allItems = baseItems
|
||||
.map((item) => {
|
||||
const siblings = groupedBySrc.get(item.src) || [item]
|
||||
const linkedGames = new Map()
|
||||
let userReferenceCount = 0
|
||||
let templateReferenceCount = 0
|
||||
let assetReferenceCount = 0
|
||||
|
||||
siblings.forEach((entry) => {
|
||||
if (entry.sourceType === 'user') userReferenceCount += 1
|
||||
else if (entry.isAssetLibraryItem) assetReferenceCount += 1
|
||||
else templateReferenceCount += 1
|
||||
;(entry.linkedGames || []).forEach((game) => {
|
||||
if (game?.id) linkedGames.set(game.id, game)
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
...item,
|
||||
sharedReferenceCount: siblings.length,
|
||||
sharedUserReferenceCount: userReferenceCount,
|
||||
sharedTemplateReferenceCount: templateReferenceCount,
|
||||
sharedAssetReferenceCount: assetReferenceCount,
|
||||
sharedLinkedGameCount: linkedGames.size,
|
||||
sharedEntries: siblings
|
||||
.slice()
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
.map((entry) => ({
|
||||
id: entry.id,
|
||||
label: entry.label,
|
||||
sourceLabel: entry.sourceLabel,
|
||||
sourceType: entry.sourceType,
|
||||
ownerName: entry.ownerName,
|
||||
createdAt: entry.createdAt,
|
||||
sourceGameId: entry.sourceGameId || '',
|
||||
sourceGameName: entry.sourceGameName || '',
|
||||
usageCount: entry.usageCount || 0,
|
||||
linkedGames: entry.linkedGames || [],
|
||||
isAssetLibraryItem: !!entry.isAssetLibraryItem,
|
||||
})),
|
||||
}
|
||||
})
|
||||
.filter((item) => {
|
||||
if (!orphanOnly) return true
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
|
||||
switch (filterMode) {
|
||||
case 'user':
|
||||
return item.sourceType === 'user'
|
||||
case 'template':
|
||||
return item.sourceType === 'template' && !item.isAssetLibraryItem
|
||||
case 'asset':
|
||||
return !!item.isAssetLibraryItem
|
||||
case 'unused-user':
|
||||
return item.sourceType === 'user' && item.usageCount === 0 && item.linkedGames.length === 0
|
||||
case 'unused-admin':
|
||||
return !!item.isAssetLibraryItem
|
||||
default:
|
||||
return true
|
||||
}
|
||||
})
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
|
||||
@@ -1811,6 +2026,50 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
|
||||
}
|
||||
}
|
||||
|
||||
async function summarizeAdminTierLists({ queryText = '', gameId = '' } = {}) {
|
||||
const hasQuery = !!(queryText || '').trim()
|
||||
const hasGameId = !!(gameId || '').trim()
|
||||
const search = `%${(queryText || '').trim()}%`
|
||||
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(
|
||||
`
|
||||
SELECT t.is_public
|
||||
FROM tierlists t
|
||||
INNER JOIN users u ON u.id = t.author_id
|
||||
INNER JOIN games g ON g.id = t.game_id
|
||||
${whereClause}
|
||||
`,
|
||||
params
|
||||
)
|
||||
|
||||
const total = rows.length
|
||||
const publicCount = rows.filter((row) => Number(row.is_public) === 1).length
|
||||
return {
|
||||
total,
|
||||
publicCount,
|
||||
privateCount: Math.max(0, total - publicCount),
|
||||
}
|
||||
}
|
||||
|
||||
async function findTierListById(id, currentUserId = '') {
|
||||
const rows = await query(
|
||||
`
|
||||
@@ -2176,6 +2435,7 @@ module.exports = {
|
||||
replaceUploadSourceReferences,
|
||||
clearImageOptimizationJobs,
|
||||
getImageAssetStats,
|
||||
cleanupMissingUploadReferences,
|
||||
createGameItem,
|
||||
updateGameItemLabel,
|
||||
updateGameItemDisplayOrder,
|
||||
@@ -2192,6 +2452,7 @@ module.exports = {
|
||||
listFavoriteTierLists,
|
||||
listUserTierLists,
|
||||
listAdminTierLists,
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
favoriteTierList,
|
||||
unfavoriteTierList,
|
||||
|
||||
@@ -31,6 +31,7 @@ const {
|
||||
listUsers,
|
||||
findPrimaryAdminUser,
|
||||
listAdminTierLists,
|
||||
summarizeAdminTierLists,
|
||||
findTierListById,
|
||||
listAdminTemplateRequests,
|
||||
findTemplateRequestById,
|
||||
@@ -44,6 +45,7 @@ const {
|
||||
getImageAssetStats,
|
||||
listRecentImageOptimizationJobs,
|
||||
clearImageOptimizationJobs,
|
||||
cleanupMissingUploadReferences,
|
||||
} = require('../db')
|
||||
const { requireAdmin } = require('../middleware/auth')
|
||||
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
|
||||
@@ -277,11 +279,7 @@ 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),
|
||||
orphanOnly: z
|
||||
.union([z.literal('true'), z.literal('false'), z.boolean()])
|
||||
.optional()
|
||||
.default('false')
|
||||
.transform((value) => value === true || value === 'true'),
|
||||
filter: z.enum(['all', 'user', 'template', 'asset', 'unused-user', 'unused-admin']).optional().default('all'),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
@@ -290,7 +288,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
queryText: parsed.data.q,
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
orphanOnly: parsed.data.orphanOnly,
|
||||
filterMode: parsed.data.filter,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -313,6 +311,21 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/tierlists/stats', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
gameId: z.string().trim().max(120).optional().default(''),
|
||||
})
|
||||
const parsed = schema.safeParse(req.query)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const result = await summarizeAdminTierLists({
|
||||
queryText: parsed.data.q,
|
||||
gameId: parsed.data.gameId,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
|
||||
router.get('/template-requests', requireAdmin, async (req, res) => {
|
||||
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
|
||||
res.json({ requests })
|
||||
@@ -390,6 +403,11 @@ router.post('/image-assets/stats/reset', requireAdmin, async (req, res) => {
|
||||
res.json({ deletedCount })
|
||||
})
|
||||
|
||||
router.post('/image-assets/missing/cleanup', requireAdmin, async (req, res) => {
|
||||
const result = await cleanupMissingUploadReferences()
|
||||
res.json({ result })
|
||||
})
|
||||
|
||||
async function removeUploadFiles(srcs) {
|
||||
await Promise.all(
|
||||
(srcs || []).map(async (src) => {
|
||||
@@ -571,7 +589,7 @@ async function createGameTemplateFromRequest({ templateRequest, gameId, gameName
|
||||
}
|
||||
|
||||
router.delete('/custom-items/:itemId', requireAdmin, async (req, res) => {
|
||||
const result = await listCustomItems({ page: 1, limit: 10000, orphanOnly: false })
|
||||
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 === 'template') {
|
||||
|
||||
@@ -1,5 +1,29 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-02 v1.3.69
|
||||
- 관리자 아이템 라이브러리의 참조 수는 저장 구조 설명에는 도움이 되지만 실제 운영에서는 대부분 의미가 약하므로, 카드와 모달에서 계속 전면에 두기보다 다시 숨기고 필요한 경우 내부 데이터로만 남기는 편이 맞다고 정리했다.
|
||||
- 관리자 상단 요약 수치는 `활성/대기` 같은 상태 문구보다 실제 운영 판단에 바로 쓰이는 `공개/비공개 개수`가 더 중요하므로, 게임 관리와 티어표 관리 모두 공개 상태 집계를 먼저 보여주는 편이 낫다고 판단했다.
|
||||
- 공개/비공개 수치는 현재 페이지 일부를 세면 오해가 생기기 쉬우므로, 검색/게임 기준 전체 결과를 집계하는 별도 관리자 API로 계산하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세는 새 모달을 겹쳐 올리는 방식보다 기존 모달 안에서 `왼쪽 선택 대상 / 오른쪽 작업과 참조 정보` 역할만 분명히 나누는 편이 더 안정적이라고 정리했다.
|
||||
- 같은 이미지를 두 위치에서 반복 노출하면 “모달이 두 개 겹친 것처럼” 느껴질 수 있으므로, 선택 썸네일은 한 곳에만 두고 양쪽 패널은 각자 스크롤되는 구조로 정리하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 같은 이미지 공유 구조는 저장 효율에는 유리하지만 운영자가 관계를 읽기 어렵기 때문에, 카드 단계에서는 참조 수를 바로 보여주고 상세 모달에서는 같은 `src`를 가리키는 기록들을 함께 펼쳐 보여주는 편이 맞다고 정리했다.
|
||||
- 삭제 제한을 과하게 두기보다, 삭제 전 영향 범위를 문구와 개수로 먼저 보여주는 쪽이 운영 측면에서 더 현실적이라고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 누락 파일 수치가 계속 쌓이는 상태에서는 원인 분석만으로는 운영 부담이 줄지 않으므로, 실제 파일이 없는 참조만 골라 썸네일/아바타/게임 아이템/커스텀 아이템 참조를 정리하는 관리자 액션을 제공하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 기록은 “재사용” 자체보다 이번 업로드와 재사용 자산의 관계가 더 중요하므로, 용량 숫자와 설명을 함께 보여주는 편이 운영자가 오해하지 않기 쉽다고 정리했다.
|
||||
- 관리자 아이템 라이브러리는 `사용자 업로드 미사용만` 같은 단일 체크박스보다 출처와 사용 상태를 함께 고를 수 있는 필터 모드가 더 실용적이므로, 사용자·템플릿·보관 자산·미사용 상태를 분리해 보는 구조가 맞다고 판단했다.
|
||||
- 게임 목록이 커질수록 선택 게임 설정을 사이드바 하단에 두는 구조는 스크롤 부담이 커지므로, 공개 상태와 썸네일 관리 액션은 선택된 게임 본문 상단 카드로 올리는 편이 더 안정적이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.3.63
|
||||
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
|
||||
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
|
||||
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
|
||||
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
|
||||
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
|
||||
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
|
||||
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
|
||||
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
|
||||
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
|
||||
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
|
||||
|
||||
## 중기 개선
|
||||
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-02 v1.3.69
|
||||
- 관리자 아이템 라이브러리는 참조 수/공유 기록 UI가 실제 운영 판단에 비해 노이즈가 커 보여 카드 수치, 상세 모달의 같은 이미지 참조 섹션, 삭제 확인 문구의 참조 강조를 걷어내고 다시 항목 자체 관리 흐름 위주로 정리함.
|
||||
- 관리자 게임 관리 상단 요약은 더 이상 `선택 상태`처럼 추상적인 문구를 보여주지 않고, 선택된 게임 기준으로 만들어진 티어표의 `전체 / 공개 / 비공개` 개수를 바로 보여주도록 바꿈.
|
||||
- 전체 티어표 관리 상단에도 검색 결과 기준 `전체 / 공개 / 비공개` 수치를 함께 노출하고, 이를 위해 관리자 티어표 집계 API를 별도로 추가해 페이지 단위가 아니라 실제 전체 결과 기준 숫자를 안정적으로 표시함.
|
||||
|
||||
## 2026-04-02 v1.3.68
|
||||
- 관리자 아이템 상세 모달은 같은 이미지를 왼쪽 선택 카드와 오른쪽 본문에서 두 번 보여주던 중복 미리보기를 제거해, 한 모달 안에서 정보가 겹쳐 보이던 문제를 정리함.
|
||||
- 왼쪽 게임 선택 패널과 오른쪽 상세 정보 패널은 각각 독립 스크롤이 되도록 바꾸고, 스크롤바도 다시 보이게 조정해 긴 목록이나 긴 참조 정보가 있어도 레이아웃이 깨지지 않고 탐색할 수 있게 함.
|
||||
- 현재 선택한 이미지 요약 카드에는 별도 배경과 테두리를 추가해, 기존 클릭 모달의 “선택 대상”과 오른쪽 작업 영역이 한눈에 구분되도록 시각 계층을 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.67
|
||||
- 관리자 아이템 관리 카드에는 이제 같은 `src`를 공유하는 참조 수와 연결 게임 수를 함께 표시해, 같은 이미지가 얼마나 넓게 쓰이는지 목록 단계에서 바로 파악할 수 있게 함.
|
||||
- 아이템 상세 모달은 왼쪽 패널 상단에 현재 선택한 이미지와 `총 참조 / 사용자 업로드 / 템플릿 항목 / 보관 자산` 요약을 보여주고, 오른쪽에는 같은 이미지를 가리키는 다른 기록 목록을 함께 표시해 실제로 어떤 참조들이 묶여 있는지 모달 안에서 바로 확인할 수 있게 함.
|
||||
- 삭제 확인 문구도 이제 단순 타입 설명만 하지 않고 `같은 이미지 참조 n건 중 현재 항목만 다룬다`는 영향을 함께 보여, 삭제 전에 범위를 더 명확히 이해할 수 있게 정리함.
|
||||
|
||||
## 2026-04-02 v1.3.66
|
||||
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
|
||||
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.
|
||||
|
||||
## 2026-04-02 v1.3.65
|
||||
- 관리자 이미지 최적화 패널에 `누락 참조 정리` 액션을 추가해, 실제 파일이 없는 `/uploads/...` 참조만 대상으로 썸네일/아바타는 비우고 누락된 게임 아이템·커스텀 아이템은 관련 티어표/템플릿 요청 참조와 함께 정리할 수 있게 함.
|
||||
- 따라서 예전 수동 파일 정리나 레거시 데이터로 인해 쌓인 `누락 파일`은 단순 통계로만 남지 않고, 관리자 화면에서 실제로 줄일 수 있는 관리 도구를 갖게 됨.
|
||||
|
||||
## 2026-04-02 v1.3.64
|
||||
- 관리자 이미지 최적화 최근 작업에서 `기존 최적화 파일 재사용` 건은 이제 `이번 업로드 용량 · 재사용 자산 용량` 형태로 표시하고, 동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았다는 설명을 함께 보여 혼동을 줄임.
|
||||
- 관리자 아이템 관리는 기존의 `미사용 사용자 업로드만 보기` 체크박스 대신 `전체 / 사용자 업로드 / 템플릿 사용 이미지 / 관리자 보관 자산 / 미사용 사용자 / 미사용 관리자` 필터로 바꿔, 관리자 업로드 자산도 별도로 확인할 수 있게 정리함.
|
||||
- 게임 관리의 선택된 게임 설정은 더 이상 우측 사이드바 아래쪽에 쌓지 않고, 본문 상단에 썸네일과 공개 상태·썸네일 적용·게임 삭제 액션을 함께 둔 카드로 옮겨 게임 목록이 많아져도 작업 영역을 더 안정적으로 읽을 수 있게 조정함.
|
||||
|
||||
## 2026-04-02 v1.3.63
|
||||
- 관리자 이미지 최적화 최근 작업 목록은 더 이상 내부 카테고리 문자열 `custom / tierlists / games / avatars`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
|
||||
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.
|
||||
|
||||
@@ -12,6 +12,20 @@ const props = defineProps({
|
||||
isGameLoading: { type: Boolean, required: true },
|
||||
hasSelectedGame: { type: Boolean, required: true },
|
||||
selectedGame: { type: Object, default: null },
|
||||
displayThumbnailUrl: { type: String, default: '' },
|
||||
canApplyThumbnail: { type: Boolean, required: true },
|
||||
gameVisibilitySaving: { type: Boolean, required: true },
|
||||
thumbFileInputRef: { type: Function, required: true },
|
||||
openThumbFilePicker: { type: Function, required: true },
|
||||
onThumb: { type: Function, required: true },
|
||||
onThumbDragEnter: { type: Function, required: true },
|
||||
onThumbDragOver: { type: Function, required: true },
|
||||
onThumbDragLeave: { type: Function, required: true },
|
||||
onThumbDrop: { type: Function, required: true },
|
||||
isThumbDragOver: { type: Boolean, required: true },
|
||||
uploadThumbnail: { type: Function, required: true },
|
||||
removeGame: { type: Function, required: true },
|
||||
toggleSelectedGameVisibility: { type: Function, required: true },
|
||||
itemFileInputRef: { type: Function, required: true },
|
||||
onFile: { type: Function, required: true },
|
||||
isItemDragOver: { type: Boolean, required: true },
|
||||
@@ -36,6 +50,10 @@ const props = defineProps({
|
||||
function setGameItemListElement(el) {
|
||||
props.gameItemListRef(el)
|
||||
}
|
||||
|
||||
function setThumbFileElement(el) {
|
||||
props.thumbFileInputRef(el)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -91,13 +109,43 @@ function setGameItemListElement(el) {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="props.hasSelectedGame" class="panel">
|
||||
<div class="detailHead">
|
||||
<div>
|
||||
<div class="panel__title">선택된 게임 정보</div>
|
||||
<div class="selectedGame__name">{{ props.selectedGame.game.name }}</div>
|
||||
<div class="selectedGame__id">{{ props.selectedGame.game.id }}</div>
|
||||
<section class="adminCard gameSettingsCard">
|
||||
<div class="gameSettingsCard__media">
|
||||
<input :ref="setThumbFileElement" type="file" accept="image/*" class="srOnlyInput" @change="props.onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
:class="{ 'thumbDropZone--active': props.isThumbDragOver }"
|
||||
type="button"
|
||||
@click="props.openThumbFilePicker"
|
||||
@dragenter="props.onThumbDragEnter"
|
||||
@dragover="props.onThumbDragOver"
|
||||
@dragleave="props.onThumbDragLeave"
|
||||
@drop="props.onThumbDrop"
|
||||
>
|
||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedGame.game.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__iconWrap">
|
||||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
|
||||
</div>
|
||||
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="gameSettingsCard__body">
|
||||
<div class="panel__title">게임 설정</div>
|
||||
<div class="gameSettingsCard__meta">{{ props.selectedGame.game.name }} · {{ props.selectedGame.game.id }}</div>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': props.gameVisibilitySaving }">
|
||||
<input :checked="!!props.selectedGame.game.isPublic" type="checkbox" @change="props.toggleSelectedGameVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ props.selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
</label>
|
||||
<div class="gameSettingsCard__actions">
|
||||
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="props.removeGame">게임 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="section">
|
||||
<section class="adminCard">
|
||||
|
||||
@@ -21,6 +21,7 @@ const props = defineProps({
|
||||
adminTierListPage: { type: Number, required: true },
|
||||
adminTierListPageCount: { type: Number, required: true },
|
||||
adminTierListTotal: { type: Number, required: true },
|
||||
adminTierListStats: { type: Object, required: true },
|
||||
moveAdminTierListPage: { type: Function, required: true },
|
||||
})
|
||||
</script>
|
||||
@@ -128,6 +129,11 @@ const props = defineProps({
|
||||
<div class="sectionHeader">
|
||||
<div>
|
||||
<div class="panel__title">전체 티어표 관리</div>
|
||||
<div class="tierAdminHeaderStats">
|
||||
<span class="pill">전체 {{ props.adminTierListStats.total || 0 }}개</span>
|
||||
<span class="pill pill--soft">공개 {{ props.adminTierListStats.publicCount || 0 }}개</span>
|
||||
<span class="pill pill--soft">비공개 {{ props.adminTierListStats.privateCount || 0 }}개</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ export function useAdminCustomItems({
|
||||
customItemLimit,
|
||||
customItemPageCount,
|
||||
customItemQuery,
|
||||
customItemOrphanOnly,
|
||||
customItemFilter,
|
||||
customItemModalOpen,
|
||||
customItemDeleteModalOpen,
|
||||
customItemModalHistoryActive,
|
||||
@@ -33,7 +33,8 @@ export function useAdminCustomItems({
|
||||
refreshCustomItems()
|
||||
}
|
||||
|
||||
function toggleCustomItemOrphanOnly() {
|
||||
function changeCustomItemFilter(filter) {
|
||||
customItemFilter.value = filter
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
@@ -186,7 +187,7 @@ export function useAdminCustomItems({
|
||||
|
||||
return {
|
||||
submitCustomItemSearch,
|
||||
toggleCustomItemOrphanOnly,
|
||||
changeCustomItemFilter,
|
||||
changeCustomItemLimit,
|
||||
moveCustomItemPage,
|
||||
pushCustomItemModalHistoryState,
|
||||
|
||||
@@ -41,12 +41,14 @@ export const api = {
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminGameItem: (gameId, itemId, payload) =>
|
||||
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) =>
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}`
|
||||
`/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)}`),
|
||||
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
|
||||
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
|
||||
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
|
||||
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
|
||||
const query = new URLSearchParams()
|
||||
@@ -55,6 +57,7 @@ export const api = {
|
||||
return request(`/api/admin/image-assets/stats?${query.toString()}`)
|
||||
},
|
||||
resetAdminImageAssetStats: (payload) => request('/api/admin/image-assets/stats/reset', { method: 'POST', body: payload || {} }),
|
||||
cleanupAdminMissingImageReferences: () => request('/api/admin/image-assets/missing/cleanup', { method: 'POST', body: {} }),
|
||||
listAdminUnusedImageAssets: ({ limit = 100, minAgeHours = 24 } = {}) => request(`/api/admin/image-assets/orphans?limit=${encodeURIComponent(limit)}&minAgeHours=${encodeURIComponent(minAgeHours)}`),
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminCustomItem: (itemId, payload) =>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { api } from '../lib/api'
|
||||
import { toApiUrl } from '../lib/runtime'
|
||||
import lockResetIcon from '../assets/icons/lock_reset.svg'
|
||||
import deleteIcon from '../assets/icons/delete.svg'
|
||||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||||
import SvgIcon from '../components/SvgIcon.vue'
|
||||
import AdminFeaturedSection from '../components/admin/AdminFeaturedSection.vue'
|
||||
import AdminGamesSection from '../components/admin/AdminGamesSection.vue'
|
||||
@@ -43,7 +42,7 @@ const customItemQuery = ref('')
|
||||
const customItemPage = ref(1)
|
||||
const customItemLimit = ref(50)
|
||||
const customItemTotal = ref(0)
|
||||
const customItemOrphanOnly = ref(false)
|
||||
const customItemFilter = ref('all')
|
||||
const customItemModalTargetGameId = ref('')
|
||||
const customItemModalGameQuery = ref('')
|
||||
const customItemModalGameSort = ref('recent')
|
||||
@@ -53,6 +52,8 @@ const adminTierListQuery = ref('')
|
||||
const adminTierListPage = ref(1)
|
||||
const adminTierListLimit = ref(50)
|
||||
const adminTierListTotal = ref(0)
|
||||
const adminTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
|
||||
const selectedGameTierListStats = ref({ total: 0, publicCount: 0, privateCount: 0 })
|
||||
const templateRequests = ref([])
|
||||
const importModalOpen = ref(false)
|
||||
const importModalMode = ref('existing')
|
||||
@@ -91,6 +92,7 @@ const imageRecentJobs = ref([])
|
||||
const imageStatsMonth = ref('')
|
||||
const imageStatsLimit = ref(12)
|
||||
const imageResetModalOpen = ref(false)
|
||||
const imageMissingCleanupBusy = ref(false)
|
||||
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
@@ -128,6 +130,10 @@ function setItemFileInputRef(el) {
|
||||
itemFileInput.value = el
|
||||
}
|
||||
|
||||
function setThumbFileInputRef(el) {
|
||||
thumbFileInput.value = el
|
||||
}
|
||||
|
||||
function scheduleGameItemSortableSync() {
|
||||
if (gameItemSortableSyncTimer) {
|
||||
clearTimeout(gameItemSortableSyncTimer)
|
||||
@@ -224,7 +230,6 @@ const activeTabDescription = computed(() => {
|
||||
return '계정 정보, 권한, 비밀번호와 최근 활동을 더 가볍게 확인하고 수정합니다.'
|
||||
})
|
||||
const adminOverviewStats = computed(() => {
|
||||
const publishedTierLists = adminTierLists.value.filter((tierList) => tierList.isPublic).length
|
||||
const pendingRequests = templateRequests.value.length
|
||||
const orphanItems = customItems.value.filter((item) => item.usageCount === 0).length
|
||||
const adminCount = users.value.filter((user) => user.isAdmin).length
|
||||
@@ -239,7 +244,9 @@ const adminOverviewStats = computed(() => {
|
||||
if (activeTab.value === 'game-admin') {
|
||||
return [
|
||||
{ label: '전체 게임', value: `${games.value.length}` },
|
||||
{ label: '선택 상태', value: hasSelectedGame.value ? '활성' : '대기' },
|
||||
{ label: '티어표 전체', value: `${selectedGameTierListStats.value.total || 0}` },
|
||||
{ label: '공개', value: `${selectedGameTierListStats.value.publicCount || 0}` },
|
||||
{ label: '비공개', value: `${selectedGameTierListStats.value.privateCount || 0}` },
|
||||
{ label: '기본 아이템', value: `${selectedGame.value?.items?.length || 0}` },
|
||||
]
|
||||
}
|
||||
@@ -259,8 +266,9 @@ const adminOverviewStats = computed(() => {
|
||||
{ label: '업데이트 요청', value: `${templateRequests.value.filter((request) => request.type === 'update').length}` },
|
||||
]
|
||||
: [
|
||||
{ label: '검색 결과', value: `${adminTierListTotal.value}` },
|
||||
{ label: '공개 티어표', value: `${publishedTierLists}` },
|
||||
{ label: '검색 결과', value: `${adminTierListStats.value.total || 0}` },
|
||||
{ label: '공개', value: `${adminTierListStats.value.publicCount || 0}` },
|
||||
{ label: '비공개', value: `${adminTierListStats.value.privateCount || 0}` },
|
||||
{ label: '현재 페이지', value: `${adminTierListPage.value}/${adminTierListPageCount.value}` },
|
||||
]
|
||||
}
|
||||
@@ -427,6 +435,14 @@ watch(
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => selectedGame.value?.game?.id || '',
|
||||
async (gameId) => {
|
||||
await refreshSelectedGameTierListStats(gameId)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
watch(
|
||||
() => tierlistsMode.value,
|
||||
(mode) => {
|
||||
@@ -445,7 +461,7 @@ watch(
|
||||
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemOrphanOnly.value = false
|
||||
customItemFilter.value = 'all'
|
||||
customItemPage.value = 1
|
||||
customItemModalGameQuery.value = ''
|
||||
await refreshCustomItems()
|
||||
@@ -553,6 +569,17 @@ function formatImageJobStatus(status) {
|
||||
}
|
||||
}
|
||||
|
||||
function customItemDeleteImpactText(item) {
|
||||
if (!item) return ''
|
||||
if (item.sourceType === 'template') {
|
||||
return item.isAssetLibraryItem
|
||||
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
|
||||
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
|
||||
}
|
||||
|
||||
return `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 현재 항목만 정리됩니다.`
|
||||
}
|
||||
|
||||
const imageDiagnosticsCards = computed(() => {
|
||||
const stats = imageStats.value
|
||||
if (!stats) return []
|
||||
@@ -664,6 +691,30 @@ async function confirmImageReset() {
|
||||
}
|
||||
}
|
||||
|
||||
async function cleanupMissingImageReferences() {
|
||||
const ok = window.confirm('파일이 실제로 없는 이미지 참조만 정리할까요? 누락된 썸네일은 비워지고, 누락된 게임/커스텀 아이템은 관련 참조와 함께 정리됩니다.')
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
imageMissingCleanupBusy.value = true
|
||||
const data = await api.cleanupAdminMissingImageReferences()
|
||||
await Promise.all([refreshImageDiagnostics(), refreshGames(), refreshCustomItems(), refreshTemplateRequests()])
|
||||
const result = data.result || {}
|
||||
success.value =
|
||||
`누락 참조를 정리했어요. ` +
|
||||
`아바타 ${result.clearedAvatars || 0}건, ` +
|
||||
`게임 썸네일 ${result.clearedGameThumbnails || 0}건, ` +
|
||||
`티어표 썸네일 ${result.clearedTierListThumbnails || 0}건, ` +
|
||||
`요청 썸네일 ${result.clearedTemplateRequestThumbnails || 0}건, ` +
|
||||
`게임 아이템 ${result.deletedGameItems || 0}건, ` +
|
||||
`커스텀 아이템 ${result.deletedCustomItems || 0}건`
|
||||
} catch (e) {
|
||||
error.value = '누락 이미지 참조 정리에 실패했어요.'
|
||||
} finally {
|
||||
imageMissingCleanupBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function setTab(tab) {
|
||||
resetMessages()
|
||||
const nextRouteName = adminRouteNameByTab[tab]
|
||||
@@ -682,7 +733,7 @@ function setTab(tab) {
|
||||
}
|
||||
if (tab === 'items') {
|
||||
customItemQuery.value = ''
|
||||
customItemOrphanOnly.value = false
|
||||
customItemFilter.value = 'all'
|
||||
customItemPage.value = 1
|
||||
refreshCustomItems()
|
||||
}
|
||||
@@ -743,7 +794,7 @@ async function refreshCustomItems() {
|
||||
q: customItemQuery.value,
|
||||
page: customItemPage.value,
|
||||
limit: customItemLimit.value,
|
||||
orphanOnly: customItemOrphanOnly.value,
|
||||
filter: customItemFilter.value,
|
||||
})
|
||||
customItems.value = data.items || []
|
||||
customItemTotal.value = data.total || 0
|
||||
@@ -766,11 +817,44 @@ async function refreshAdminTierLists() {
|
||||
adminTierListTotal.value = data.total || 0
|
||||
adminTierListPage.value = data.page || 1
|
||||
adminTierListLimit.value = data.limit || adminTierListLimit.value
|
||||
await refreshAdminTierListStats()
|
||||
} catch (e) {
|
||||
error.value = '관리자 티어표 목록을 불러오지 못했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAdminTierListStats() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
const data = await api.getAdminTierListStats({ q: adminTierListQuery.value })
|
||||
adminTierListStats.value = {
|
||||
total: data.total || 0,
|
||||
publicCount: data.publicCount || 0,
|
||||
privateCount: data.privateCount || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
adminTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshSelectedGameTierListStats(gameId = '') {
|
||||
if (!auth.user?.isAdmin || !gameId) {
|
||||
selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await api.getAdminTierListStats({ gameId })
|
||||
selectedGameTierListStats.value = {
|
||||
total: data.total || 0,
|
||||
publicCount: data.publicCount || 0,
|
||||
privateCount: data.privateCount || 0,
|
||||
}
|
||||
} catch (e) {
|
||||
selectedGameTierListStats.value = { total: 0, publicCount: 0, privateCount: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTemplateRequests() {
|
||||
if (!auth.user?.isAdmin) return
|
||||
try {
|
||||
@@ -900,7 +984,7 @@ const {
|
||||
|
||||
const {
|
||||
submitCustomItemSearch,
|
||||
toggleCustomItemOrphanOnly,
|
||||
changeCustomItemFilter,
|
||||
changeCustomItemLimit,
|
||||
moveCustomItemPage,
|
||||
openCustomItemModal,
|
||||
@@ -920,7 +1004,7 @@ const {
|
||||
customItemLimit,
|
||||
customItemPageCount,
|
||||
customItemQuery,
|
||||
customItemOrphanOnly,
|
||||
customItemFilter,
|
||||
customItemModalOpen,
|
||||
customItemDeleteModalOpen,
|
||||
customItemModalHistoryActive,
|
||||
@@ -1483,6 +1567,20 @@ function userAvatarFallback(user) {
|
||||
:is-game-loading="isGameLoading"
|
||||
:has-selected-game="hasSelectedGame"
|
||||
:selected-game="selectedGame"
|
||||
:display-thumbnail-url="displayThumbnailUrl"
|
||||
:can-apply-thumbnail="canApplyThumbnail"
|
||||
:game-visibility-saving="gameVisibilitySaving"
|
||||
:thumb-file-input-ref="setThumbFileInputRef"
|
||||
:open-thumb-file-picker="openThumbFilePicker"
|
||||
:on-thumb="onThumb"
|
||||
:on-thumb-drag-enter="onThumbDragEnter"
|
||||
:on-thumb-drag-over="onThumbDragOver"
|
||||
:on-thumb-drag-leave="onThumbDragLeave"
|
||||
:on-thumb-drop="onThumbDrop"
|
||||
:is-thumb-drag-over="isThumbDragOver"
|
||||
:upload-thumbnail="uploadThumbnail"
|
||||
:remove-game="removeGame"
|
||||
:toggle-selected-game-visibility="toggleSelectedGameVisibility"
|
||||
:item-file-input-ref="setItemFileInputRef"
|
||||
:on-file="onFile"
|
||||
:is-item-drag-over="isItemDragOver"
|
||||
@@ -1535,6 +1633,7 @@ function userAvatarFallback(user) {
|
||||
:admin-tier-list-page="adminTierListPage"
|
||||
:admin-tier-list-page-count="adminTierListPageCount"
|
||||
:admin-tier-list-total="adminTierListTotal"
|
||||
:admin-tier-list-stats="adminTierListStats"
|
||||
:move-admin-tier-list-page="moveAdminTierListPage"
|
||||
/>
|
||||
|
||||
@@ -1727,6 +1826,16 @@ function userAvatarFallback(user) {
|
||||
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
|
||||
<div v-if="modalTargetCustomItem" class="customItemModal">
|
||||
<aside class="customItemModal__pickerPanel">
|
||||
<div class="customItemModal__selected">
|
||||
<img class="customItemModal__selectedImage" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
|
||||
<div class="customItemModal__selectedMeta">
|
||||
<div class="customItemModal__selectedTitle">{{ modalTargetCustomItem.label }}</div>
|
||||
<div class="customItemModal__selectedChips">
|
||||
<span class="pill">{{ modalTargetCustomItem.sourceLabel }}</span>
|
||||
<span class="pill" v-if="modalTargetCustomItem.ownerName">{{ modalTargetCustomItem.ownerName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="customItemModal__pickerHead">
|
||||
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
|
||||
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
|
||||
@@ -1766,7 +1875,6 @@ function userAvatarFallback(user) {
|
||||
<div class="customItemModal__source">{{ modalTargetCustomItem.sourceLabel }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<img class="customItemModal__image" :src="toApiUrl(modalTargetCustomItem.src)" :alt="modalTargetCustomItem.label" />
|
||||
<div class="customItemModal__labelEditor">
|
||||
<label class="field">
|
||||
<span class="field__label">아이템 이름</span>
|
||||
@@ -1805,7 +1913,7 @@ function userAvatarFallback(user) {
|
||||
<div v-if="customItemDeleteModalOpen" class="modalOverlay" @click.self="closeCustomItemDeleteModal">
|
||||
<div class="modalCard" role="dialog" aria-modal="true">
|
||||
<div class="modalCard__title">아이템 삭제</div>
|
||||
<div class="modalCard__desc">{{ !modalTargetCustomItem ? '' : modalTargetCustomItem.sourceType === 'template' ? '"' + modalTargetCustomItem.label + '" 항목을 정리할까요? 게임에 연결된 항목이면 해당 템플릿과 저장된 같은 게임의 티어표에서도 함께 빠질 수 있고, 보관 자산이면 라이브러리에서만 제거됩니다.' : '"' + modalTargetCustomItem.label + '" 이미지를 삭제할까요? 사용자 업로드이면서 어디에도 연결되지 않은 이미지에만 삭제를 허용합니다.' }}</div>
|
||||
<div class="modalCard__desc">{{ customItemDeleteImpactText(modalTargetCustomItem) }}</div>
|
||||
<div class="modalCard__actions">
|
||||
<button class="btn btn--ghost" @click="closeCustomItemDeleteModal">취소</button>
|
||||
<button class="btn btn--danger" @click="removeCustomItem()">삭제</button>
|
||||
@@ -1944,39 +2052,6 @@ function userAvatarFallback(user) {
|
||||
</div>
|
||||
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
|
||||
</div>
|
||||
<div v-if="hasSelectedGame" class="adminSidebar__group">
|
||||
<div class="selectedGameSidebar__name">{{ selectedGame.game.name }}</div>
|
||||
<div class="selectedGameSidebar__id">{{ selectedGame.game.id }}</div>
|
||||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': gameVisibilitySaving }">
|
||||
<input :checked="!!selectedGame.game.isPublic" type="checkbox" @change="toggleSelectedGameVisibility($event.target.checked)" />
|
||||
<span class="toggleSwitch__label">{{ selectedGame.game.isPublic ? '템플릿 공개중' : '비공개 상태' }}</span>
|
||||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||||
</label>
|
||||
<input ref="thumbFileInput" type="file" accept="image/*" class="srOnlyInput" @change="onThumb" />
|
||||
<button
|
||||
class="thumbDropZone"
|
||||
:class="{ 'thumbDropZone--active': isThumbDragOver }"
|
||||
type="button"
|
||||
@click="openThumbFilePicker"
|
||||
@dragenter="onThumbDragEnter"
|
||||
@dragover="onThumbDragOver"
|
||||
@dragleave="onThumbDragLeave"
|
||||
@drop="onThumbDrop"
|
||||
>
|
||||
<img v-if="displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="displayThumbnailUrl" :alt="selectedGame.game.name" />
|
||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||
<div class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__iconWrap">
|
||||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
|
||||
</div>
|
||||
<div class="thumbDropZone__title">{{ displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||
</div>
|
||||
</button>
|
||||
<div class="adminSidebar__actions adminSidebar__actions--stack">
|
||||
<button class="btn" :disabled="!canApplyThumbnail" @click="uploadThumbnail">썸네일 적용</button>
|
||||
<button class="btn btn--danger" @click="removeGame">게임 삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
|
||||
@@ -1990,14 +2065,18 @@ function userAvatarFallback(user) {
|
||||
<option :value="50">50개씩 보기</option>
|
||||
<option :value="200">200개씩 보기</option>
|
||||
</select>
|
||||
<label class="checkRow checkRow--compact">
|
||||
<input v-model="customItemOrphanOnly" type="checkbox" @change="toggleCustomItemOrphanOnly" />
|
||||
<span>미사용 사용자 업로드만 보기</span>
|
||||
</label>
|
||||
<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>
|
||||
</select>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--ghost" @click="refreshCustomItems">새로고침</button>
|
||||
<button class="btn btn--danger" :disabled="!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">
|
||||
@@ -2077,6 +2156,11 @@ function userAvatarFallback(user) {
|
||||
<button class="btn btn--ghost" @click="refreshImageDiagnostics">현황 새로고침</button>
|
||||
<button class="btn btn--ghost" @click="openImageResetModal">기록 비우기</button>
|
||||
</div>
|
||||
<div class="adminSidebar__actions">
|
||||
<button class="btn btn--danger" :disabled="!imageStats?.missingReferencedCount || imageMissingCleanupBusy" @click="cleanupMissingImageReferences">
|
||||
{{ imageMissingCleanupBusy ? '누락 참조 정리중...' : '누락 참조 정리' }}
|
||||
</button>
|
||||
</div>
|
||||
<div class="hint hint--tight">{{ imageStatsPeriodLabel }}</div>
|
||||
<div v-if="imageDiagnosticsCards.length" class="adminSidebar__stats adminSidebar__stats--grid">
|
||||
<article v-for="stat in imageDiagnosticsCards" :key="stat.label" class="sidebarStat">
|
||||
@@ -2112,8 +2196,14 @@ function userAvatarFallback(user) {
|
||||
<strong>{{ formatImageJobSourceCategory(job.sourceCategory) }}</strong>
|
||||
<span class="imageJobRow__status">{{ formatImageJobStatus(job.status) }}</span>
|
||||
</div>
|
||||
<div class="hint hint--tight">{{ formatBytes(job.originalByteSize) }} → {{ formatBytes(job.optimizedByteSize) }}</div>
|
||||
<div v-if="job.reusedAsset" class="hint hint--tight">기존 최적화 파일 재사용</div>
|
||||
<div class="hint hint--tight">
|
||||
{{
|
||||
job.reusedAsset
|
||||
? `이번 업로드 ${formatBytes(job.originalByteSize)} · 재사용 자산 ${formatBytes(job.optimizedByteSize)}`
|
||||
: `${formatBytes(job.originalByteSize)} → ${formatBytes(job.optimizedByteSize)}`
|
||||
}}
|
||||
</div>
|
||||
<div v-if="job.reusedAsset" class="hint hint--tight">동일한 최적화 결과가 이미 있어 새 파일을 다시 만들지 않았어요.</div>
|
||||
<div class="hint hint--tight">{{ fmt(job.queuedAt) }}</div>
|
||||
</article>
|
||||
</div>
|
||||
@@ -2720,6 +2810,32 @@ function userAvatarFallback(user) {
|
||||
opacity: 0.72;
|
||||
word-break: break-all;
|
||||
}
|
||||
.adminUiScope .gameSettingsCard {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
.adminUiScope .gameSettingsCard__media {
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .gameSettingsCard__body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: center;
|
||||
}
|
||||
.adminUiScope .gameSettingsCard__meta {
|
||||
color: var(--theme-text-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
.adminUiScope .gameSettingsCard__actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
/* flex-wrap: wrap; */
|
||||
}
|
||||
.adminUiScope .selectedThumb {
|
||||
width: min(100%, 256px);
|
||||
aspect-ratio: 16 / 9;
|
||||
@@ -3104,14 +3220,46 @@ function userAvatarFallback(user) {
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
padding: 28px 22px;
|
||||
border-right: 1px solid var(--theme-border);
|
||||
background: var(--theme-pill-bg);
|
||||
overflow: auto;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerHead {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminUiScope .customItemModal__selected {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
.adminUiScope .customItemModal__selectedImage {
|
||||
width: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
border-radius: 18px;
|
||||
object-fit: cover;
|
||||
border: 1px solid var(--theme-border);
|
||||
background: var(--theme-surface-soft);
|
||||
}
|
||||
.adminUiScope .customItemModal__selectedMeta {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
.adminUiScope .customItemModal__selectedTitle {
|
||||
font-size: 18px;
|
||||
font-weight: 900;
|
||||
}
|
||||
.adminUiScope .customItemModal__selectedChips {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerEyebrow {
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.12em;
|
||||
@@ -3169,6 +3317,7 @@ function userAvatarFallback(user) {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
padding: 24px 28px 28px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.adminUiScope .customItemModal__content {
|
||||
min-width: 0;
|
||||
@@ -3177,14 +3326,24 @@ function userAvatarFallback(user) {
|
||||
align-content: start;
|
||||
gap: 18px;
|
||||
overflow: auto;
|
||||
padding-right: 0;
|
||||
padding-right: 8px;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar,
|
||||
.adminUiScope .customItemModal__content::-webkit-scrollbar {
|
||||
width: 0;
|
||||
height: 0;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar-thumb,
|
||||
.adminUiScope .customItemModal__content::-webkit-scrollbar-thumb {
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.16);
|
||||
}
|
||||
.adminUiScope .customItemModal__pickerPanel::-webkit-scrollbar-track,
|
||||
.adminUiScope .customItemModal__content::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.adminUiScope .customItemModal__labelEditor {
|
||||
display: flex;
|
||||
@@ -3213,15 +3372,6 @@ function userAvatarFallback(user) {
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
}
|
||||
.adminUiScope .customItemModal__image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
max-height: min(360px, 34dvh);
|
||||
object-fit: cover;
|
||||
border-radius: 24px;
|
||||
background: radial-gradient(circle at top, rgba(77, 127, 233, 0.18), rgba(255, 255, 255, 0.02) 52%), rgba(255, 255, 255, 0.03);
|
||||
border: 1px solid var(--theme-border);
|
||||
}
|
||||
.adminUiScope .customItemModal__label {
|
||||
font-size: 11px;
|
||||
color: var(--theme-text-faint);
|
||||
@@ -3846,6 +3996,12 @@ function userAvatarFallback(user) {
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.adminUiScope .tierAdminHeaderStats {
|
||||
margin-top: 10px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.adminUiScope .pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -4122,6 +4278,7 @@ function userAvatarFallback(user) {
|
||||
.adminUiScope .featuredOrderPanel,
|
||||
.adminUiScope .section--topGrid,
|
||||
.adminUiScope .gameManagerGrid,
|
||||
.adminUiScope .gameSettingsCard,
|
||||
.adminUiScope .toolbar,
|
||||
.adminUiScope .itemComposer,
|
||||
.adminUiScope .tierAdminCard,
|
||||
|
||||
Reference in New Issue
Block a user