Compare commits

...

8 Commits

12 changed files with 757 additions and 118 deletions

View File

@@ -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))
@@ -2176,6 +2391,7 @@ module.exports = {
replaceUploadSourceReferences,
clearImageOptimizationJobs,
getImageAssetStats,
cleanupMissingUploadReferences,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,

View File

@@ -44,6 +44,7 @@ const {
getImageAssetStats,
listRecentImageOptimizationJobs,
clearImageOptimizationJobs,
cleanupMissingUploadReferences,
} = require('../db')
const { requireAdmin } = require('../middleware/auth')
const { createMemoryUpload, writeOptimizedImage, getImageOptimizationQueueState } = require('../lib/image-storage')
@@ -277,11 +278,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 +287,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)
})
@@ -390,6 +387,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 +573,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') {

View File

@@ -1,5 +1,35 @@
# 의사결정 이력
## 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
- 이미지 최적화 기록은 내부 라우트 카테고리를 그대로 보여주면 운영자가 실제 의미를 해석해야 하므로, 관리자 화면에는 기능 기준의 한국어 라벨과 재사용 여부를 함께 보여주는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.62
- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다.
- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다.
## 2026-04-02 v1.3.61
- 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다.
- 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다.
## 2026-04-02 v1.3.60
- 관리자 접근 차단은 유지하되, 이미 로그인된 관리자가 새로고침할 때 홈으로 튕기는 체감은 권한 제어보다 더 큰 문제이므로 인증 결과가 나올 때까지 같은 세션 확인 요청을 기다리는 편이 맞다고 정리했다.
- 관리자 썸네일 드롭존과 에디터 보드 드롭존은 기능은 같아도 현재 상태가 문구와 형태로 바로 드러나야 하므로, 빈 상태와 교체 상태를 텍스트로 구분하고 점선 박스 형태를 더 적극적으로 드러내는 쪽으로 판단했다.

View File

@@ -1,8 +1,15 @@
# 할 일 및 이슈
## 단기 확인
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다.
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.

View File

@@ -1,5 +1,42 @@
# 업데이트 로그
## 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`를 그대로 노출하지 않고, 각각 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타`처럼 사람이 이해할 수 있는 이름으로 표시함.
- 같은 이미지 해시를 다시 업로드해 기존 최적화 파일을 재사용한 경우에는 최근 작업 목록에 `기존 최적화 파일 재사용` 문구를 함께 보여, 새로 압축된 건지 중복 자산이 재사용된 건지 운영자가 바로 구분할 수 있게 함.
## 2026-04-02 v1.3.62
- 티어표 저장과 템플릿 요청 전 커스텀 이미지 업로드에서는 더 이상 `itemsById` 객체 키 순서에 기대지 않고, 실제 화면에 보이는 `아이템 영역 + 보드 배치 순서` 기준으로 아이템 배열을 만들도록 바꿔 저장 중 이미지 목록이 흔들리던 현상을 줄임.
- 따라서 커스텀 아이템 이름 정리 목록, 저장 payload, 템플릿 요청 payload 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함.
- 티어표 삭제 버튼은 이제 템플릿 요청과 같은 기준으로 `저장된 티어표`에서만 노출되며, 실제 삭제도 저장본 ID가 있을 때만 동작하도록 맞춰 저장 전 초안 상태의 어색한 삭제 액션을 제거함.
## 2026-04-02 v1.3.61
- 관리자 게임 관리의 썸네일 드롭존, 관리자 기본 아이템 추가 드롭존, 티어표 에디터의 커스텀 이미지 드롭존에 `add_photo_alternate` 아이콘을 넣어 업로드 영역임을 더 빠르게 인식할 수 있게 정리함.
- 관리자와 에디터 드롭존은 점선 보더 굵기, 라운드, 밝은 배경 톤, 활성화 상태 색 변화, 파일 선택 버튼 크기를 같은 계열로 맞춰 서로 다른 화면에서도 같은 업로드 컴포넌트처럼 읽히도록 통일함.
- 썸네일 드롭존 역시 배경을 일반 입력 필드보다 더 밝고 넓은 업로드 박스처럼 보이게 조정해, 일반 폼 필드와 대표 이미지 교체 영역을 시각적으로 더 분명하게 구분함.
## 2026-04-02 v1.3.60
- 관리자 게임 관리의 대표 썸네일 드롭존은 이제 썸네일이 없을 때는 `클릭 & 드래그`, 이미 등록된 썸네일이 있을 때는 `썸네일 변경`으로 문구가 바뀌어 현재 동작을 더 바로 읽을 수 있게 함.
- 관리자 인증 상태는 라우터 가드와 앱 셸이 동시에 `/api/auth/me`를 호출할 때, 가드가 아직 끝나지 않은 요청을 기다리지 못해 새로고침 직후 홈으로 튕기던 흐름이 있었으므로 인증 스토어에서 진행 중인 `refresh` Promise를 재사용하도록 정리함.

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="ffffff"><path d="M480-480ZM200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h320v80H200v560h560v-320h80v320q0 33-23.5 56.5T760-120H200Zm40-160h480L570-480 450-320l-90-120-120 160Zm440-320v-80h-80v-80h80v-80h80v80h80v80h-80v80h-80Z"/></svg>

After

Width:  |  Height:  |  Size: 347 B

View File

@@ -1,5 +1,7 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
import SvgIcon from '../SvgIcon.vue'
import addPhotoAlternateIcon from '../../assets/icons/add_photo_alternate.svg'
const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
@@ -10,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 },
@@ -34,6 +50,10 @@ const props = defineProps({
function setGameItemListElement(el) {
props.gameItemListRef(el)
}
function setThumbFileElement(el) {
props.thumbFileInputRef(el)
}
</script>
<template>
@@ -89,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">
@@ -112,13 +162,16 @@ function setGameItemListElement(el) {
@dragleave="props.onItemDragLeave"
@drop="props.onItemDrop"
>
<div class="dropZone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropZone__icon" />
</div>
<div class="dropZone__title">이미지를 드래그해서 기본 아이템으로 추가</div>
<div class="dropZone__desc">
여러 파일을 번에 올릴 있고, 저장 라벨은 파일명으로 자동 생성됩니다.
<span v-if="props.stagedRequestDraftCount"> 현재 요청에서 가져온 아이템 {{ props.stagedRequestDraftCount }}개도 함께 검토 중이에요.</span>
</div>
<div class="dropZone__actions">
<button class="btn btn--ghost btn--small" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
<button class="btn btn--ghost btn--small dropZone__button" type="button" @click.stop="props.openItemFilePicker">파일 선택</button>
</div>
</div>
</div>

View File

@@ -19,6 +19,10 @@ const props = defineProps({
<span class="customItemCard__badge" :class="{ 'customItemCard__badge--template': item.sourceType === 'template' }">{{ item.sourceLabel }}</span>
<img class="customItemCard__image" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="customItemCard__title" :title="item.label">{{ item.label }}</div>
<div class="customItemCard__stats">
<span class="customItemCard__stat">참조 {{ item.sharedReferenceCount || 1 }}</span>
<span class="customItemCard__stat">게임 {{ item.sharedLinkedGameCount || item.linkedGames?.length || 0 }}</span>
</div>
</button>
</div>

View File

@@ -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,

View File

@@ -41,9 +41,9 @@ 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)}`),
@@ -55,6 +55,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) =>

View File

@@ -42,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')
@@ -90,6 +90,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('')
@@ -127,6 +128,10 @@ function setItemFileInputRef(el) {
itemFileInput.value = el
}
function setThumbFileInputRef(el) {
thumbFileInput.value = el
}
function scheduleGameItemSortableSync() {
if (gameItemSortableSyncTimer) {
clearTimeout(gameItemSortableSyncTimer)
@@ -444,7 +449,7 @@ watch(
if (tab === 'items') {
customItemQuery.value = ''
customItemOrphanOnly.value = false
customItemFilter.value = 'all'
customItemPage.value = 1
customItemModalGameQuery.value = ''
await refreshCustomItems()
@@ -522,6 +527,51 @@ function formatBytes(value) {
return `${current >= 10 || unitIndex === 0 ? current.toFixed(0) : current.toFixed(1)} ${units[unitIndex]}`
}
function formatImageJobSourceCategory(category) {
switch (String(category || '').trim()) {
case 'custom':
return '커스텀 아이템'
case 'tierlists':
return '티어표 썸네일'
case 'games':
return '게임/템플릿 이미지'
case 'avatars':
return '프로필 아바타'
default:
return '기타 이미지'
}
}
function formatImageJobStatus(status) {
switch (String(status || '').trim()) {
case 'queued':
return '대기'
case 'processing':
return '처리중'
case 'completed':
return '완료'
case 'failed':
return '실패'
default:
return status || '알 수 없음'
}
}
function customItemDeleteImpactText(item) {
if (!item) return ''
const sharedCount = Number(item.sharedReferenceCount || 1)
if (item.sourceType === 'template') {
const base = item.isAssetLibraryItem
? `"${item.label}" 보관 자산 항목을 정리할까요? 라이브러리 항목만 제거되고, 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
: `"${item.label}" 템플릿 항목을 정리할까요? 연결된 템플릿과 같은 게임의 저장된 티어표에서 이 항목이 함께 제거될 수 있어요.`
return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base
}
const base = `"${item.label}" 사용자 업로드 이미지를 삭제할까요? 같은 이미지를 쓰는 다른 참조는 그대로 유지됩니다.`
return sharedCount > 1 ? `${base} 현재 같은 이미지 참조 ${sharedCount}건 중 이 항목만 다룹니다.` : base
}
const imageDiagnosticsCards = computed(() => {
const stats = imageStats.value
if (!stats) return []
@@ -537,6 +587,7 @@ const imageDiagnosticsCards = computed(() => {
const visibleLinkedGames = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
)
const visibleSharedEntries = computed(() => modalTargetCustomItem.value?.sharedEntries || [])
const filteredCustomItemModalGames = computed(() => {
const query = customItemModalGameQuery.value.trim().toLowerCase()
const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id))
@@ -633,6 +684,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]
@@ -651,7 +726,7 @@ function setTab(tab) {
}
if (tab === 'items') {
customItemQuery.value = ''
customItemOrphanOnly.value = false
customItemFilter.value = 'all'
customItemPage.value = 1
refreshCustomItems()
}
@@ -712,7 +787,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
@@ -869,7 +944,7 @@ const {
const {
submitCustomItemSearch,
toggleCustomItemOrphanOnly,
changeCustomItemFilter,
changeCustomItemLimit,
moveCustomItemPage,
openCustomItemModal,
@@ -889,7 +964,7 @@ const {
customItemLimit,
customItemPageCount,
customItemQuery,
customItemOrphanOnly,
customItemFilter,
customItemModalOpen,
customItemDeleteModalOpen,
customItemModalHistoryActive,
@@ -1186,6 +1261,12 @@ function buildModalItemFromTierListItem(item, tierList) {
sourceLabel: matchedItem?.sourceLabel || '티어표 추가 아이템',
ownerName: matchedItem?.ownerName || tierListAuthorDisplayName(tierList),
linkedGames: Array.isArray(matchedItem?.linkedGames) ? matchedItem.linkedGames : [],
sharedReferenceCount: matchedItem?.sharedReferenceCount || 1,
sharedUserReferenceCount: matchedItem?.sharedUserReferenceCount || 0,
sharedTemplateReferenceCount: matchedItem?.sharedTemplateReferenceCount || 0,
sharedAssetReferenceCount: matchedItem?.sharedAssetReferenceCount || 0,
sharedLinkedGameCount: matchedItem?.sharedLinkedGameCount || 0,
sharedEntries: Array.isArray(matchedItem?.sharedEntries) ? matchedItem.sharedEntries : [],
usageCount: matchedItem?.usageCount || 0,
canDelete: typeof matchedItem?.canDelete === 'boolean' ? matchedItem.canDelete : false,
isPromoting: false,
@@ -1452,6 +1533,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"
@@ -1696,6 +1791,18 @@ 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.sharedReferenceCount || 1 }} 참조</span>
<span class="pill" v-if="modalTargetCustomItem.sharedUserReferenceCount">사용자 {{ modalTargetCustomItem.sharedUserReferenceCount }}</span>
<span class="pill" v-if="modalTargetCustomItem.sharedTemplateReferenceCount">템플릿 {{ modalTargetCustomItem.sharedTemplateReferenceCount }}</span>
<span class="pill" v-if="modalTargetCustomItem.sharedAssetReferenceCount">보관 {{ modalTargetCustomItem.sharedAssetReferenceCount }}</span>
</div>
</div>
</div>
<div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
@@ -1735,7 +1842,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>
@@ -1757,6 +1863,25 @@ function userAvatarFallback(user) {
<button v-for="game in visibleLinkedGames" :key="game.id" type="button" class="pill pill--link" @click="jumpToGameAdmin(game.id)">{{ game.name }}</button>
</div>
<div v-else class="hint hint--tight">아직 템플릿에 연결된 게임이 없어요.</div>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">같은 이미지 참조</span>
<div class="customItemModal__metaList">
<div class="customItemModal__metaRow"><span> 참조</span><strong>{{ modalTargetCustomItem.sharedReferenceCount || 1 }}</strong></div>
<div class="customItemModal__metaRow"><span>사용자 업로드</span><strong>{{ modalTargetCustomItem.sharedUserReferenceCount || 0 }}</strong></div>
<div class="customItemModal__metaRow"><span>템플릿 항목</span><strong>{{ modalTargetCustomItem.sharedTemplateReferenceCount || 0 }}</strong></div>
<div class="customItemModal__metaRow"><span>보관 자산</span><strong>{{ modalTargetCustomItem.sharedAssetReferenceCount || 0 }}</strong></div>
</div>
</div>
<div class="customItemModal__linked">
<span class="customItemModal__label">같은 이미지 기록</span>
<div v-if="visibleSharedEntries.length" class="customItemModal__entryList">
<div v-for="entry in visibleSharedEntries" :key="entry.id" class="customItemModal__entry">
<div class="customItemModal__entryTitle">{{ entry.label }}</div>
<div class="customItemModal__entryMeta">{{ entry.sourceLabel }} · {{ entry.ownerName || '알 수 없음' }}</div>
</div>
</div>
<div v-else class="hint hint--tight">같은 이미지를 가리키는 다른 기록이 없어요.</div>
</div>
<div class="customItemModal__actions">
<a class="btn btn--ghost customItemModal__action" :href="toApiUrl(modalTargetCustomItem.src)" :download="modalTargetCustomItem.label">이미지 다운로드</a>
@@ -1774,7 +1899,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>
@@ -1913,36 +2038,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__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">
@@ -1956,14 +2051,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">
@@ -2043,6 +2142,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">
@@ -2075,10 +2179,17 @@ function userAvatarFallback(user) {
<div v-else class="imageJobList">
<article v-for="job in imageRecentJobs" :key="job.id" class="imageJobRow">
<div class="imageJobRow__head">
<strong>{{ job.sourceCategory || 'asset' }}</strong>
<span class="imageJobRow__status">{{ job.status }}</span>
<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 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>
@@ -2685,6 +2796,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;
@@ -2714,17 +2851,23 @@ function userAvatarFallback(user) {
.adminUiScope .thumbDropZone {
position: relative;
width: 100%;
display: block;
display: grid;
gap: 0;
padding: 0;
overflow: hidden;
border-radius: 18px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: var(--theme-pill-bg);
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: left;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
transition: border-color 0.16s ease, background 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
}
.adminUiScope .thumbDropZone--active {
border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
box-shadow: 0 0 0 1px rgba(96, 165, 250, 0.18);
transform: translateY(-1px);
}
@@ -2733,13 +2876,27 @@ function userAvatarFallback(user) {
inset: auto 0 0 0;
display: grid;
place-items: center;
min-height: 52px;
padding: 12px 16px;
gap: 8px;
min-height: 80px;
padding: 16px 18px;
background: linear-gradient(180deg, transparent 0%, color-mix(in srgb, var(--theme-main-bg) 82%, transparent) 46%, color-mix(in srgb, var(--theme-main-bg) 94%, transparent) 100%);
}
.adminUiScope .thumbDropZone__iconWrap {
width: 46px;
height: 46px;
display: grid;
place-items: center;
border-radius: 14px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
}
.adminUiScope .thumbDropZone__icon {
width: 24px;
height: 24px;
opacity: 0.86;
}
.adminUiScope .thumbDropZone__title {
font-weight: 900;
font-size: 13px;
font-size: 14px;
letter-spacing: 0.01em;
color: var(--theme-text);
}
@@ -2757,10 +2914,12 @@ function userAvatarFallback(user) {
}
.adminUiScope .dropZone {
min-height: 180px;
padding: 24px 18px;
border-radius: 16px;
padding: 28px 22px;
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: var(--theme-pill-bg);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
display: grid;
place-items: center;
align-content: center;
@@ -2773,9 +2932,25 @@ function userAvatarFallback(user) {
}
.adminUiScope .dropZone--active {
border-color: rgba(96, 165, 250, 0.56);
background: rgba(96, 165, 250, 0.08);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px);
}
.adminUiScope .dropZone__iconWrap {
width: 52px;
height: 52px;
margin: 0 auto 12px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
}
.adminUiScope .dropZone__icon {
width: 28px;
height: 28px;
opacity: 0.86;
}
.adminUiScope .dropZone__title {
font-weight: 900;
font-size: 16px;
@@ -2794,6 +2969,10 @@ function userAvatarFallback(user) {
flex-wrap: wrap;
justify-content: center;
}
.adminUiScope .dropZone__button {
min-width: 124px;
min-height: 34px;
}
.adminUiScope .itemPreviewCard {
margin-top: 12px;
padding: 12px;
@@ -3016,6 +3195,15 @@ function userAvatarFallback(user) {
line-height: 1.3;
color: var(--theme-text);
}
.adminUiScope .customItemCard__stats {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.adminUiScope .customItemCard__stat {
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal {
display: grid;
grid-template-columns: 340px minmax(0, 1fr);
@@ -3027,14 +3215,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;
@@ -3092,6 +3312,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;
@@ -3100,14 +3321,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;
@@ -3128,6 +3359,25 @@ function userAvatarFallback(user) {
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__entryList {
display: grid;
gap: 8px;
}
.adminUiScope .customItemModal__entry {
padding: 10px 12px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
}
.adminUiScope .customItemModal__entryTitle {
font-size: 13px;
font-weight: 800;
}
.adminUiScope .customItemModal__entryMeta {
margin-top: 4px;
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal__close {
justify-self: end;
border: 0;
@@ -3136,15 +3386,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);
@@ -4045,6 +4286,7 @@ function userAvatarFallback(user) {
.adminUiScope .featuredOrderPanel,
.adminUiScope .section--topGrid,
.adminUiScope .gameManagerGrid,
.adminUiScope .gameSettingsCard,
.adminUiScope .toolbar,
.adminUiScope .itemComposer,
.adminUiScope .tierAdminCard,

View File

@@ -6,6 +6,7 @@ import * as htmlToImage from 'html-to-image'
import SvgIcon from '../components/SvgIcon.vue'
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -118,10 +119,7 @@ const copiedFromLabel = computed(() => {
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표'
})
const customItems = computed(() =>
Object.values(itemsById.value)
.filter((item) => item?.origin === 'custom')
)
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
@@ -165,6 +163,29 @@ function formatExportDate(ts) {
})
}
function getOrderedItemIds() {
const orderedIds = []
const seen = new Set()
const pushId = (itemId) => {
if (!itemId || seen.has(itemId) || !itemsById.value[itemId]) return
seen.add(itemId)
orderedIds.push(itemId)
}
pool.value.forEach(pushId)
groups.value.forEach((group) => {
;(group.cells || []).forEach((cell) => {
;(cell || []).forEach(pushId)
})
})
Object.keys(itemsById.value).forEach(pushId)
return orderedIds
}
function getOrderedItems() {
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
}
function setIconSize(nextSize) {
iconSize.value = nextSize
}
@@ -654,7 +675,7 @@ function buildPayload(existingId) {
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
groups: buildGroupPayload(),
pool: Object.values(itemsById.value),
pool: getOrderedItems(),
}
}
@@ -721,6 +742,7 @@ function closeTemplateUpdateModal() {
}
function openDeleteModal() {
if (!hasSavedTierList.value) return
isDeleteModalOpen.value = true
}
@@ -729,11 +751,12 @@ function closeDeleteModal() {
}
async function confirmDeleteTierList() {
if (!canEdit.value || isNewTierList.value || isDeleting.value) return
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!canEdit.value || !currentTierListId || isDeleting.value) return
error.value = ''
try {
isDeleting.value = true
await api.deleteTierList(tierListId.value)
await api.deleteTierList(currentTierListId)
closeDeleteModal()
toast.success('티어표를 삭제했어요.')
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
@@ -791,7 +814,7 @@ async function requestTemplate(type) {
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
boardItems: getOrderedItems(),
})
if (type === 'create') closeTemplateRequestModal()
@@ -1184,13 +1207,16 @@ onUnmounted(() => {
@dragleave="onDragLeave"
@drop.prevent="onDropFiles"
>
<div>
<div>
<div class="dropzone__iconWrap">
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropzone__icon" />
</div>
<div class="dropzone__title">커스텀 이미지 추가</div>
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 번에 추가할 있어요.</div>
</div>
<div class="dropzone__actions">
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
<button class="btn btn--ghost dropzone__button" @click="openFile">파일 선택</button>
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
</div>
@@ -1298,7 +1324,7 @@ onUnmounted(() => {
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
</div>
<div class="editorSidebar__utilityLinks">
<button v-if="canEdit && !isNewTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">복사해서 티어표로 가져오기</button>
<button
v-if="canRequestTemplateCreate"
@@ -2077,21 +2103,22 @@ onUnmounted(() => {
align-items: center;
justify-content: center;
gap: 18px;
min-height: 176px;
padding: 28px 24px;
border: 2px dashed color-mix(in srgb, var(--theme-accent) 48%, var(--theme-border));
min-height: 180px;
padding: 28px 22px;
border-radius: 22px;
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 82%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 74%, transparent)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 58%);
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
text-align: center;
}
.dropzone--board.dropzone--active {
border-color: color-mix(in srgb, var(--theme-accent) 78%, white);
border-color: rgba(96, 165, 250, 0.56);
background:
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent), color-mix(in srgb, var(--theme-card-bg) 82%, transparent)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 20%, transparent), transparent 58%);
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
transform: translateY(-1px);
}
.dropzone__actions {
@@ -2103,7 +2130,24 @@ onUnmounted(() => {
}
.dropzone__button {
min-width: 148px;
min-width: 124px;
min-height: 34px;
font-size: 11px;
}
/* .dropzone__iconWrap {
width: 52px;
height: 52px;
display: grid;
place-items: center;
border-radius: 16px;
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
} */
.dropzone__icon {
width: 48px;
height: 48px;
opacity: 0.86;
}
.dropzone--board .dropzone__title {
@@ -2313,10 +2357,11 @@ onUnmounted(() => {
}
.dropzone {
margin-top: 12px;
padding: 14px;
padding: 28px 22px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
background: var(--theme-surface-soft);
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
}
.dropzone--active {
border-color: rgba(110, 231, 183, 0.6);