Compare commits

..

20 Commits

Author SHA1 Message Date
074d028f04 릴리스: v1.3.86 티어표 아이콘 크기 저장 지원 2026-04-02 16:54:59 +09:00
208e9709f8 릴리스: v1.3.85 모바일 열 헤더/행 라벨 보정 2026-04-02 16:48:07 +09:00
4ed7f275ba 릴리스: v1.3.84 모바일 열 배지 위치 및 헤더 정리 2026-04-02 16:45:43 +09:00
88ce413c31 릴리스: v1.3.83 모바일 열 배지 추가 2026-04-02 16:42:17 +09:00
7f7475fb20 릴리스: v1.3.82 프리뷰 메타와 요청 카드 동작 정리 2026-04-02 16:38:57 +09:00
8a44b51cce 릴리스: v1.3.81 티어표 공유 링크 복사 추가 2026-04-02 16:34:09 +09:00
9d63ed2e76 릴리스: v1.3.80 공통 오른쪽 레일 카피라이트 적용 2026-04-02 16:29:40 +09:00
99eb79f2c3 릴리스: v1.3.79 관리자 카피라이트 위치 고정 2026-04-02 16:26:04 +09:00
6b8abea203 릴리스: v1.3.78 축소 액션 아이콘과 카피라이트 정리 2026-04-02 16:22:12 +09:00
d692798358 릴리스: v1.3.77 축소 레일 티어표 만들기 버튼 추가 2026-04-02 16:13:50 +09:00
49d4946735 릴리스: v1.3.76 왼쪽 레일 검색/축소 정리 2026-04-02 16:10:36 +09:00
bd53cf96dc 릴리스: v1.3.75 관리자 모달/사이드바 정리 2026-04-02 16:04:07 +09:00
66c3b1e7b7 릴리스: v1.3.74 관리자 선택 제약과 카피라이트 추가 2026-04-02 15:55:10 +09:00
494f04d9a7 릴리스: v1.3.73 관리자 게임 선택 UX 정리 2026-04-02 15:50:20 +09:00
5aae278fd3 릴리스: v1.3.72 관리자 게임 라우트 초기화 오류 수정 2026-04-02 15:37:18 +09:00
14dfe0ad75 릴리스: v1.3.71 관리자 게임 선택 모달 통합 2026-04-02 15:34:07 +09:00
a7cfb97131 릴리스: v1.3.70 관리자 티어표 필터와 관리 액션 보강 2026-04-02 15:28:52 +09:00
badf250967 릴리스: v1.3.69 관리자 티어표 집계와 아이템 UI 정리 2026-04-02 15:21:03 +09:00
a16b1e1025 릴리스: v1.3.68 관리자 아이템 모달 레이아웃 안정화 2026-04-02 15:12:36 +09:00
c1dfea41a5 릴리스: v1.3.67 아이템 참조 요약과 영향 표시 2026-04-02 15:04:20 +09:00
15 changed files with 1038 additions and 230 deletions

View File

@@ -138,6 +138,7 @@ function mapTierListRow(row) {
description: row.description || '',
isPublic: !!row.is_public,
showCharacterNames: !!row.show_character_names,
iconSize: Number(row.icon_size || 80),
sourceTierListId: row.source_tierlist_id || '',
sourceSnapshotTitle: row.source_snapshot_title || '',
sourceSnapshotAuthor: row.source_snapshot_author || '',
@@ -314,6 +315,7 @@ async function ensureSchema() {
description TEXT NOT NULL,
is_public TINYINT(1) NOT NULL DEFAULT 0,
show_character_names TINYINT(1) NOT NULL DEFAULT 0,
icon_size INT NOT NULL DEFAULT 80,
source_tierlist_id VARCHAR(64) NULL DEFAULT NULL,
source_snapshot_title VARCHAR(120) NOT NULL DEFAULT '',
source_snapshot_author VARCHAR(120) NOT NULL DEFAULT '',
@@ -455,9 +457,13 @@ async function ensureSchema() {
if (!tierListShowNamesColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN show_character_names TINYINT(1) NOT NULL DEFAULT 0 AFTER is_public")
}
const tierListIconSizeColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'icon_size'")
if (!tierListIconSizeColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN icon_size INT NOT NULL DEFAULT 80 AFTER show_character_names")
}
const tierListSourceIdColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_tierlist_id'")
if (!tierListSourceIdColumns.length) {
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER show_character_names")
await query("ALTER TABLE tierlists ADD COLUMN source_tierlist_id VARCHAR(64) NULL DEFAULT NULL AFTER icon_size")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
}
@@ -1598,7 +1604,56 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
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) => {
switch (filterMode) {
case 'user':
@@ -1798,6 +1853,7 @@ async function listFavoriteTierLists(userId, { queryText = '', sort = 'favorited
t.description,
t.is_public,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
t.source_snapshot_title,
t.source_snapshot_author,
@@ -1902,22 +1958,32 @@ function getAutoThumbnailSrc(groups = [], pool = []) {
return fallbackItem?.src || ''
}
async function listAdminTierLists({ queryText = '', page = 1, limit = 50, currentUserId = '' } = {}) {
async function listAdminTierLists({ queryText = '', gameId = '', page = 1, limit = 50, currentUserId = '' } = {}) {
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
const normalizedPage = Math.max(Number(page) || 1, 1)
const hasQuery = !!(queryText || '').trim()
const hasGameId = !!(gameId || '').trim()
const search = `%${(queryText || '').trim()}%`
const whereClause = hasQuery
? `
WHERE
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
`
: ''
const params = hasQuery ? [search, search, search, search, search] : []
const whereParts = []
const params = []
if (hasGameId) {
whereParts.push('t.game_id = ?')
params.push((gameId || '').trim())
}
if (hasQuery) {
whereParts.push(`(
t.title LIKE ?
OR g.name LIKE ?
OR g.id LIKE ?
OR u.email LIKE ?
OR u.nickname LIKE ?
)`)
params.push(search, search, search, search, search)
}
const whereClause = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : ''
const rows = await query(
`
@@ -1931,6 +1997,7 @@ async function listAdminTierLists({ queryText = '', page = 1, limit = 50, curren
t.description,
t.is_public,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
t.source_snapshot_title,
t.source_snapshot_author,
@@ -1977,6 +2044,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(
`
@@ -1990,6 +2101,7 @@ async function findTierListById(id, currentUserId = '') {
t.description,
t.is_public,
t.show_character_names,
t.icon_size,
t.source_tierlist_id,
t.source_snapshot_title,
t.source_snapshot_author,
@@ -2195,6 +2307,18 @@ async function deleteTierList(id) {
await query('DELETE FROM tierlists WHERE id = ?', [id])
}
async function updateAdminTierListMeta({ id, title, description = '', isPublic }) {
await query(
`
UPDATE tierlists
SET title = ?, description = ?, is_public = ?, updated_at = ?
WHERE id = ?
`,
[title, description || '', isPublic ? 1 : 0, now(), id]
)
return findTierListById(id)
}
async function findCustomItemsByIds(ids) {
if (!ids.length) return []
const placeholders = ids.map(() => '?').join(', ')
@@ -2231,6 +2355,7 @@ async function saveTierList({
description,
isPublic,
showCharacterNames = false,
iconSize = 80,
sourceTierListId = '',
sourceSnapshotTitle = '',
sourceSnapshotAuthor = '',
@@ -2245,10 +2370,10 @@ async function saveTierList({
await query(
`
UPDATE tierlists
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
SET title = ?, thumbnail_src = ?, description = ?, is_public = ?, show_character_names = ?, icon_size = ?, source_tierlist_id = ?, source_snapshot_title = ?, source_snapshot_author = ?, groups_json = ?, pool_json = ?, updated_at = ?
WHERE id = ?
`,
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
[title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), now(), existing.id]
)
return findTierListById(existing.id, authorId)
}
@@ -2258,11 +2383,11 @@ async function saveTierList({
await query(
`
INSERT INTO tierlists (
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
id, author_id, game_id, title, thumbnail_src, description, is_public, show_character_names, icon_size, source_tierlist_id, source_snapshot_title, source_snapshot_author, groups_json, pool_json, created_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
[nextId, authorId, gameId, title, nextThumbnailSrc, description || '', isPublic ? 1 : 0, showCharacterNames ? 1 : 0, Number(iconSize) || 80, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(nextId, authorId)
}
@@ -2281,6 +2406,7 @@ async function duplicateTierListForUser({ tierList, targetUserId }) {
description: tierList.description || '',
isPublic: false,
showCharacterNames: !!tierList.showCharacterNames,
iconSize: Number(tierList.iconSize || 80),
sourceTierListId: tierList.id,
sourceSnapshotTitle: tierList.title || '',
sourceSnapshotAuthor: tierList.authorName || tierList.authorAccountName || '',
@@ -2359,7 +2485,9 @@ module.exports = {
listFavoriteTierLists,
listUserTierLists,
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
favoriteTierList,
unfavoriteTierList,
favoriteGame,

View File

@@ -22,6 +22,7 @@ const {
updateImageAssetLabel,
deleteGameItem,
deleteGame,
deleteTierList,
updateGameDisplayOrder,
listCustomItems,
findCustomItemById,
@@ -31,7 +32,9 @@ const {
listUsers,
findPrimaryAdminUser,
listAdminTierLists,
summarizeAdminTierLists,
findTierListById,
updateAdminTierListMeta,
listAdminTemplateRequests,
findTemplateRequestById,
updateTemplateRequestStatus,
@@ -295,6 +298,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
router.get('/tierlists', requireAdmin, async (req, res) => {
const schema = z.object({
q: z.string().trim().max(120).optional().default(''),
gameId: z.string().trim().max(120).optional().default(''),
page: z.coerce.number().int().min(1).optional().default(1),
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
})
@@ -303,6 +307,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
const result = await listAdminTierLists({
queryText: parsed.data.q,
gameId: parsed.data.gameId,
page: parsed.data.page,
limit: parsed.data.limit,
currentUserId: req.session?.userId || '',
@@ -310,6 +315,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 })
@@ -677,6 +697,34 @@ router.post('/tierlists/:tierListId/create-game-template', requireAdmin, async (
res.json(result)
})
router.patch('/tierlists/:tierListId', requireAdmin, async (req, res) => {
const schema = z.object({
title: z.string().trim().min(1).max(120),
description: z.string().max(500).optional().default(''),
isPublic: z.boolean(),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
const updated = await updateAdminTierListMeta({
id: tierList.id,
title: parsed.data.title,
description: parsed.data.description || '',
isPublic: parsed.data.isPublic,
})
res.json({ tierList: updated })
})
router.delete('/tierlists/:tierListId', requireAdmin, async (req, res) => {
const tierList = await findTierListById(req.params.tierListId)
if (!tierList) return res.status(404).json({ error: 'not_found' })
await deleteTierList(tierList.id)
res.json({ ok: true })
})
router.post('/template-requests/:requestId/approve', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })

View File

@@ -92,6 +92,7 @@ const tierListUpsertSchema = z.object({
description: z.string().max(1000).optional().default(''),
isPublic: z.boolean().default(false),
showCharacterNames: z.boolean().optional().default(false),
iconSize: z.number().int().min(48).max(112).optional().default(80),
sourceTierListId: z.string().max(64).optional().default(''),
sourceSnapshotTitle: z.string().max(120).optional().default(''),
sourceSnapshotAuthor: z.string().max(120).optional().default(''),
@@ -289,6 +290,7 @@ router.post('/', requireAuth, async (req, res) => {
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
iconSize: Number(payload.iconSize || 80),
sourceTierListId: payload.sourceTierListId || existing.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || existing.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || existing.sourceSnapshotAuthor || '',
@@ -307,6 +309,7 @@ router.post('/', requireAuth, async (req, res) => {
description: payload.description || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
iconSize: Number(payload.iconSize || 80),
sourceTierListId: payload.sourceTierListId || '',
sourceSnapshotTitle: payload.sourceSnapshotTitle || '',
sourceSnapshotAuthor: payload.sourceSnapshotAuthor || '',

View File

@@ -1,5 +1,72 @@
# 의사결정 이력
## 2026-04-02 v1.3.86
- 아이콘 크기는 이미지 다운로드 결과에만 반영되고 저장본에는 남지 않으면 사용자가 체감상 “저장되지 않는 설정”으로 느끼게 되므로, 티어표 본문 설정으로 저장하는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.83
- 모바일에서 열 헤더가 칸과 시각적으로 분리되는 문제는 전체 레이아웃을 다시 갈아엎기보다, 각 칸 안에 열 이름 배지를 같이 보여주는 편이 가장 적은 변경으로 효과를 낸다고 정리했다.
- 배지를 쓰는 반응형 구간에서는 기존 상단 열 헤더까지 남겨두면 중복 정보가 되므로, 같은 브레이크포인트에서 헤더는 숨기고 칸 배지 하나만 남기는 편이 맞다고 정리했다.
- 반응형 보정은 한 미디어 구간 안에서 서로 다른 규칙이 다시 덮어쓰지 않게 정리해야 하므로, 모바일용 `1fr` 레이아웃을 선언한 뒤 예전 `140px/150px` 규칙은 제거하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.82
- 프리뷰 완성본도 결국 공유/열람용 결과물이므로, 이미지 다운로드 결과와 같은 작성자/저장 시각 메타를 같이 보여주는 편이 자연스럽다고 정리했다.
- 관리자 템플릿 요청 카드는 “요청 티어표 보기”가 실제로 새창 이동용이라면 하단 버튼과 썸네일 클릭을 둘 다 유지하기보다, 썸네일 클릭 하나로 통합하는 편이 더 단순하고 직관적이라고 판단했다.
## 2026-04-02 v1.3.81
- 저장된 티어표 공유는 별도 새 페이지를 만들기보다, 이미 완성본 열람에 쓰고 있는 `preview=1` 주소를 그대로 공유 링크로 재사용하는 편이 가장 단순하고 일관적이라고 정리했다.
- 공유 액션은 저장/삭제처럼 저장본 전제의 보조 기능이므로, 메인 저장 버튼 영역보다 하단 유틸리티 링크 영역에 두는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.3.79
- 카피라이트처럼 앱 전체 브랜딩 성격의 footer는 관리자 텔레포트 안에 두기보다, `App.vue`의 공통 오른쪽 레일 footer로 두는 편이 위치도 안정적이고 화면 간 일관성도 높다고 정리했다.
## 2026-04-02 v1.3.78
- 축소 상태에서는 텍스트가 사라지므로 같은 `티어표 만들기` 계열 액션이라도 커스텀 제작과 템플릿 기반 제작을 아이콘으로 구분해 주는 편이 맞다고 정리했다.
- 관리자 우측 카피라이트처럼 “사이드바 하단”에 붙어야 하는 정보는 텔레포트 루트의 형제 노드로 두기보다, 실제 사이드바 컨테이너 내부의 마지막 행으로 두는 편이 레이아웃상 안전하다고 판단했다.
## 2026-04-02 v1.3.77
- 왼쪽 레일을 접었을 때 하단 액션을 완전히 숨기면 `새 티어표 만들기` 진입점이 사라지므로, 펼친 상태의 하단 위치는 유지하되 축소 상태에서는 같은 위치의 아이콘 전용 버튼으로 바꿔 남겨두는 편이 맞다고 정리했다.
## 2026-04-02 v1.3.76
- 왼쪽 사이드 레일을 접었을 때는 텍스트가 사라진 뒤에도 행 높이가 제각각이면 아이콘 전용 탐색기로 읽히지 않으므로, 아바타/검색/내비 항목의 높이를 같은 규격으로 통일하는 편이 맞다고 정리했다.
- 왼쪽 레일 검색은 화면에 따라 티어표 검색으로 바뀌면 사용자가 사이드 검색과 메인 검색 역할을 구분하기 어려우므로, 사이드는 게임 검색으로 고정하고 티어표 검색은 메인 화면 문맥에 맡기는 편이 더 자연스럽다고 판단했다.
## 2026-04-02 v1.3.75
- 관리자 공용 모달은 기본 카드 여백을 계속 쓰되, 내부에 자체 셸을 가진 대형 상세 모달까지 같은 패딩을 강제로 받으면 오히려 레이아웃이 무너지므로 예외 클래스로 분리하는 편이 맞다고 정리했다.
- 관리자 표기 링크는 텍스트만 두기보다, 추후 주소 변경이 쉬운 한 곳짜리 상수와 새 창 링크로 관리하는 편이 운영 측면에서 더 낫다고 판단했다.
- 왼쪽 사이드 레일 접힘 상태는 요소를 좁히는 것만으로는 높이와 정렬 문제가 계속 남으므로, 메타 텍스트는 실제로 숨기고 아이콘 중심 문법으로 따로 정리하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.74
- 관리자 공용 게임 선택 모달은 단순 검색만 제공하기보다, 현재 문맥에서 이미 선택 불가능한 대상을 `이미 추가됨`으로 명시하고 막아 주는 편이 운영 실수를 줄이는 데 더 효과적이라고 정리했다.
- 프로젝트 표기는 관리자 헤더 상단보다 사이드바 최하단의 작은 카피라이트 문구로 빼는 편이 정보 밀도를 덜 방해한다고 판단했다.
## 2026-04-02 v1.3.73
- 게임 선택이 여러 관리자 화면에 퍼지기 시작한 시점에서는 일부 화면만 셀렉트나 내부 리스트를 유지하기보다, 공용 검색 모달 하나로 통일하는 편이 장기적으로 더 일관되고 확장에 강하다고 정리했다.
- 검색 입력과 실행 버튼은 세로로 같은 문법으로 쌓기보다, 입력은 입력끼리 실행은 액션으로 읽히게 한 줄 배치로 적당히 구분해주는 편이 운영 화면에서 덜 답답하다고 판단했다.
## 2026-04-02 v1.3.72
- 라우트 복원용 watcher가 composable 반환값 초기화보다 먼저 돌 수 있는 구간에서는 직접 함수를 즉시 호출하기보다, 초기화 완료 뒤 실행되도록 한 템포 미루는 편이 안전하다고 정리했다.
## 2026-04-02 v1.3.71
- 관리자에서 게임 선택 지점이 늘어나는 구조라면 각 화면마다 셀렉트/긴 리스트를 따로 두기보다, 공용 검색 모달 하나로 통일하는 편이 이후 100개 이상 게임이 쌓여도 더 안정적이라고 정리했다.
- 아이템 모달은 참조 정보 정리 뒤에도 왼쪽 선택 요약 카드가 여전히 과하다고 판단해, 예전처럼 게임 선택 자체에 더 집중한 구조로 한 단계 더 되돌리는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.70
- 관리자 티어표 목록은 페이지 단위 열람만으로는 운영 개입이 부족하므로, 게임별 필터와 카드 단위 관리 액션을 함께 붙여 실제 검수 도구로 쓰는 편이 맞다고 정리했다.
- 공개 여부는 문장 속 메타보다 배지로 따로 떼어 보여주는 편이 다른 관리자 카드들과 문법이 맞고, 공개/비공개 전환도 같은 관리 모달 안에서 바로 처리하는 쪽이 운영 흐름상 자연스럽다고 판단했다.
## 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
- 누락 참조 정리 도구는 커스텀 아이템 누락이 없어도 티어표/요청 썸네일 누락을 항상 따로 정리해야 하므로, 썸네일 정리를 커스텀 아이템 분기에 묶어두면 안 된다고 정리했다.

View File

@@ -1,6 +1,29 @@
# 할 일 및 이슈
## 단기 확인
- 티어표 `아이콘 크기`는 이제 저장 데이터로 승격됐으므로, 저장 후 재진입/프리뷰/복사본 생성에서 같은 크기가 유지되는지 한 번 더 QA한다.
- 티어표 편집/프리뷰 모바일 열 배지는 새로 붙였으므로, 실제 좁은 화면에서 칸 상단 배지와 아이템 썸네일이 겹치지 않고 열 구분이 자연스러운지 한 번 더 QA한다.
- 모바일 열 배지는 같은 구간에서 상단 열 제목을 숨기도록 다시 맞췄으므로, 720px 안팎뿐 아니라 980px 이하 전 구간에서 중복 표기 없이 자연스러운지 한 번 더 QA한다.
- 모바일 티어표 편집 레이아웃은 행 라벨 폭을 다시 덮어쓰던 규칙을 걷어냈으므로, 실제 980px 이하 구간에서 행 라벨이 과하게 넓지 않고 칸 폭을 충분히 남기는지 한 번 더 QA한다.
- 프리뷰 완성본 하단 메타는 새로 붙였으므로, 작성자/저장 시각이 공개 열람 화면과 이미지 다운로드 결과 기준에서 모두 자연스럽게 읽히는지 한 번 더 QA한다.
- 관리자 템플릿 요청 카드는 썸네일 클릭이 새창 열기 역할로 바뀌었으므로, 썸네일 클릭과 `확인하기` 액션이 서로 헷갈리지 않는지 한 번 더 QA한다.
- 티어표 만들기 화면의 `공유하기`는 저장된 티어표에서만 노출되므로, 저장 직후/수정 중/복사본/읽기 전용 상태 각각에서 노출 조건과 클립보드 복사가 자연스러운지 한 번 더 QA한다.
- 우측 카피라이트는 이제 공통 오른쪽 레일 footer이므로, 관리자 화면뿐 아니라 홈/프로필 등 오른쪽 사이드가 보이는 화면에서도 같은 최하단 위치에 유지되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 홈과 게임 허브에서 서로 다른 아이콘을 쓰도록 나눴으므로, 실제로 두 문맥이 한눈에 구분되는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태 최하단의 `티어표 만들기` 아이콘 버튼은 새로 추가했으므로, 홈/게임 허브에서 실제로 같은 위치 감각으로 동작하는지 한 번 더 QA한다.
- 관리자 우측 카피라이트 문구는 사이드바 내부 최하단으로 다시 옮겼으므로, 실제 관리자 화면에서 스크롤/창 크기 변화에도 계속 보이는지 한 번 더 QA한다.
- 왼쪽 레일 축소 상태는 아이콘 줄 높이를 50px 기준으로 통일했으므로, 실제 데스크톱에서 아바타/검색/메뉴 아이콘이 시각적으로 같은 리듬으로 보이는지 한 번 더 QA한다.
- 왼쪽 레일 검색은 이제 항상 게임 검색으로 홈으로 이동하므로, 홈이 아닌 화면에서 사이드 검색 후 게임 목록 결과로 자연스럽게 이동하는지 한 번 더 QA한다.
- 앱 왼쪽 사이드 레일은 접힘 상태 레이아웃을 다시 손봤으므로, 데스크톱에서 접기/펼치기 반복 시 아바타 영역 높이, 아이콘 중앙 정렬, 검색 버튼 간격, 네비게이션 히트 영역이 모두 자연스러운지 한 번 더 QA한다.
- 관리자 우측 사이드바 하단 카피라이트 링크는 새 창 외부 링크로 바꿨으므로, 실제 클릭 시 `zenn.town` 연결과 hover 대비가 자연스러운지 한 번 더 QA한다.
- 관리자 아이템 상세 모달은 공통 패딩 예외 처리를 다시 넣었으므로, 대형 상세 모달과 일반 게임 선택 모달이 각각 기대한 크기로 보이는지 한 번 더 QA한다.
- 아이템 관리 모달의 공용 게임 선택기에서는 이미 연결된 게임이 비활성화되므로, 실제 운영 데이터에서 중복 연결 방지와 `이미 추가됨` 표시가 기대대로 읽히는지 한 번 더 QA한다.
- 공용 게임 선택 모달을 아이템 관리 모달에도 붙였으므로, 관리자 `게임 관리 / 전체 티어표 관리 / 아이템 관리` 세 흐름에서 같은 검색/선택 UX가 자연스럽게 느껴지는지 한 번 더 QA한다.
- 관리자 `/admin/games?gameId=...` 직접 진입과 새로고침에서 선택 게임이 정상 복원되고 콘솔 오류가 없는지 한 번 더 QA한다.
- 공용 `게임 선택` 검색 모달은 새로 붙였으므로, 게임 관리 선택/전체 티어표 관리 필터 양쪽에서 검색어 입력, 선택, 해제, 뒤로가기 흐름을 한 번 더 QA한다.
- 관리자 `전체 티어표 관리`의 게임 필터와 관리 모달은 새로 붙였으므로, 실제 운영 데이터에서 필터 전환 후 공개/비공개 집계, 제목 수정, 비공개 전환, 삭제 흐름이 자연스럽게 이어지는지 한 번 더 QA한다.
- 관리자 게임 관리 상단의 `전체 / 공개 / 비공개` 티어표 수치와 전체 티어표 관리 상단 집계가 실제 운영 데이터와 맞는지, 공개 전환 직후 숫자가 자연스럽게 갱신되는지 한 번 더 QA한다.
- 관리자 아이템 상세 모달은 왼쪽 선택 카드와 오른쪽 상세 본문으로 다시 정리했으므로, 데스크톱/모바일에서 긴 게임 목록과 긴 참조 목록이 각각 자연스럽게 스크롤되는지 한 번 더 QA한다.
- 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다.
- 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다.
- 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다.
@@ -8,6 +31,7 @@
- 관리자 이미지 최적화 현황은 최근 작업 라벨을 읽기 쉬운 이름으로 바꿨으므로, 실제 운영 데이터에서 `커스텀 아이템 / 티어표 썸네일 / 게임·템플릿 이미지 / 프로필 아바타` 흐름이 기대와 맞게 찍히는지 한 번 더 QA한다.
- 관리자 게임 설정 카드를 본문 상단으로 옮겼으므로, 긴 게임 목록 상태에서 선택-썸네일 교체-공개 전환-삭제 흐름이 스크롤 없이 자연스러운지 한 번 더 QA한다.
- `누락 참조 정리`는 실제 파일이 없는 참조만 지우도록 넣었으므로, 운영 데이터에서 실행했을 때 누락 수치가 줄고 대표 썸네일/티어표/템플릿 요청 표시가 기대대로 비워지는지 한 번 더 QA한다.
- 아이템 관리의 같은 이미지 참조 요약과 상세 모달은 붙였으므로, 실제 운영 데이터에서 카드 수치와 모달 목록이 기대한 참조 관계와 맞는지 한 번 더 QA한다.
## 중기 개선
- 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다.
@@ -29,16 +53,10 @@
- production에서 SESSION_SECRET 누락 시 서버가 부팅되지 않도록 강제한다.
- helmet 기반 보안 헤더와 업로드 정적 응답 헤더를 정리한다.
- 책 아이콘 기반 사용법 모달은 제작 흐름뿐 아니라 복사, 템플릿 업데이트 요청, 새 템플릿 요청까지 확장했으므로, 실제 16:9 스크린샷 자산과 단계별 문구를 운영 톤에 맞게 채운다.
- 관리자 아이템 라이브러리에서 동일 이미지(src)를 여러 템플릿이 공유하는 경우, 필요하면 묶어서 보거나 대표 카드로 합쳐 보는 후속 정리 옵션을 검토한다.
- 라이트모드 최종 QA 시 홈/설정/관리자/에디터를 실제 사용 흐름으로 돌리며, 남아 있는 하드코딩 텍스트 색과 플레이스홀더 배경을 한 번 더 점검한다.
- 관리자 아이템 라이브러리는 보관 자산까지 노출되므로, 이후에는 `활성 템플릿 / 보관 자산` 분리 필터나 그룹 보기까지 검토한다.
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,82 @@
# 업데이트 로그
## 2026-04-02 v1.3.86
- 티어표 편집의 `아이콘 크기`는 이제 임시 화면 상태가 아니라 저장 데이터에 함께 포함되며, 저장 후 다시 열기와 프리뷰 화면에서도 같은 크기로 복원되도록 정리함.
- 이를 위해 티어표 저장 payload, 서버 검증, DB 저장/조회에 `iconSize`를 추가하고 기존 데이터는 기본값 `80`으로 안전하게 보정되게 맞춤.
## 2026-04-02 v1.3.83
- 티어표 편집/프리뷰 화면에서 열을 여러 개 쓰는 경우, 모바일처럼 좁은 화면에서는 기존 상단 열 헤더만으로 각 칸의 의미를 읽기 어려웠으므로 각 칸 상단에 작은 열 이름 배지를 추가함.
- 이 배지는 모바일 구간에서만 보이고 데스크톱 레이아웃은 그대로 유지되므로, 작은 화면에서는 `메인 / 밸런스 / 서포트` 같은 열 맥락을 스크롤 중에도 잃지 않게 정리함.
- 이후 배지가 칸 기준이 아니라 화면 한쪽에 겹치던 문제를 바로잡기 위해 각 칸을 기준점으로 다시 잡았고, 배지가 보이는 구간에서는 기존 상단 열 제목을 함께 숨겨 중복 표기를 제거함.
- 추가로 같은 미디어 구간 안에서 행/열 모바일 레이아웃을 다시 `140px/150px`로 덮어쓰던 중복 규칙을 제거해, 모바일에서는 행 라벨이 화면 절반을 차지하지 않고 실제로 한 줄 전체 폭 기준 레이아웃으로 정리되게 맞춤.
## 2026-04-02 v1.3.82
- 프리뷰 전용 완성본 화면에도 이미지 다운로드 결과와 같은 하단 메타를 붙여, 작성자 이름과 마지막 저장 시각을 바로 확인할 수 있게 정리함.
- 관리자 `티어표 관리 > 템플릿 요청 관리`에서는 더 이상 썸네일 클릭으로 요청 미리보기 모달을 열지 않고, 썸네일 자체가 `요청 티어표 보기` 새창 링크 역할을 하도록 바꿨으며, 하단의 중복 `요청 티어표 보기` 버튼은 제거함.
## 2026-04-02 v1.3.81
- 티어표 만들기 화면에는 저장된 티어표에서만 보이는 `공유하기` 액션을 추가하고, 누르면 현재 티어표의 완성본 링크(`preview=1`)를 클립보드에 복사한 뒤 토스트로 안내하도록 정리함.
- 공유 링크는 관리자가 새 창에서 보던 완성본 주소와 같은 문법을 사용하므로, 저장된 티어표를 그대로 외부에 전달하거나 다시 열람하는 흐름으로 바로 이어짐.
## 2026-04-02 v1.3.79
- 우측 카피라이트는 관리자 전용 레이아웃에서 분리해 앱 공통 `rightRail` footer로 올렸고, 이제 관리자 페이지뿐 아니라 오른쪽 사이드가 보이는 모든 화면에서 같은 최하단 위치에 표시됨.
- 따라서 관리자 패널 길이나 페이지별 로컬 사이드바 내용과 무관하게, 카피라이트는 항상 오른쪽 레일 전체 기준 바닥에 고정되는 공통 footer 역할로 정리됨.
## 2026-04-02 v1.3.78
- 왼쪽 레일 축소 상태의 하단 액션 아이콘은 문맥에 따라 구분되도록 바꿔, 홈의 `커스텀 티어표 만들기``dashboard_customize` 아이콘을 쓰고 게임 허브의 일반 `티어표 만들기``add_notes` 아이콘을 유지하도록 정리함.
- 관리자 우측 카피라이트 문구는 사이드바 바깥 형제로 밀려 보이지 않을 수 있었으므로, 다시 관리자 사이드바 `aside` 내부 최하단으로 옮겨 레이아웃 안에서 안정적으로 보이게 정리함.
## 2026-04-02 v1.3.77
- 왼쪽 사이드 레일을 축소했을 때도 홈과 게임 허브에서 바로 새 티어표를 만들 수 있도록, 최하단 액션 영역에 `add_notes` 아이콘 기반의 축소 전용 `티어표 만들기` 버튼을 추가함.
- 펼친 상태에서는 기존 텍스트 버튼을 그대로 유지하고, 축소 상태에서는 같은 위치에 아이콘 버튼만 남기도록 분기해 하단 액션 위치 감각은 유지하면서도 좁은 레일 폭에 맞게 정리함.
## 2026-04-02 v1.3.76
- 앱 왼쪽 사이드 레일은 축소 상태에서 아바타, 검색 버튼, 네비게이션 아이콘 버튼 높이를 모두 50px 기준으로 맞추고 검색 아래 여백도 정리해, 아이콘만 보이는 상태에서도 각 줄 높이가 제각각처럼 보이지 않게 정리함.
- 왼쪽 사이드 검색은 라우트에 따라 의미가 바뀌지 않도록 `게임 템플릿 검색`으로 고정하고, 축소 검색 모달 역시 같은 플레이스홀더와 같은 동작으로 홈 게임 목록 검색을 수행하도록 통일함.
## 2026-04-02 v1.3.75
- 관리자 공용 모달 카드의 기본 `padding: 20px`는 그대로 두되, 아이템 상세처럼 내부 레이아웃이 이미 큰 셸을 가진 모달은 `modalCard--customItem`에서 다시 덮어쓰지 않도록 분리해 상세 모달 크기와 내부 배치가 무너지지 않게 정리함.
- 관리자 우측 사이드바 최하단의 카피라이트 문구는 이제 별도 상수 URL을 참조하는 외부 링크로 바꿔 새 창에서 열리게 했고, 추후 주소를 바꿔야 할 때 한 곳만 수정하면 되도록 정리함.
- 앱 왼쪽 사이드 레일의 접힘 상태는 메타 텍스트를 단순히 투명하게 남겨두는 대신 실제로 숨기고, 아바타/검색/내비 아이콘을 다시 중앙 정렬해 접었을 때 높이가 비정상적으로 늘어나거나 간격이 남아 보이던 레이아웃을 정리함.
## 2026-04-02 v1.3.74
- 아이템 관리 상세에서 템플릿 추가 대상 게임을 고를 때, 이미 해당 이미지가 연결된 게임은 공용 게임 선택 모달에서 `이미 추가됨`으로 표시하고 비활성화해 중복 추가 실수를 미리 막도록 정리함.
- 관리자 우측 사이드바 최하단에는 작은 카피라이트 문구를 추가해, 헤더에 관리 정보만 남기고 프로젝트 표기는 하단에서 조용히 보이도록 정리함.
## 2026-04-02 v1.3.73
- 전체 티어표 관리 카드 썸네일은 `draggable="false"`로 바꿔, 미리보기 진입 시 브라우저 기본 이미지 드래그가 클릭을 방해하지 않도록 정리함.
- 관리자 사이드바의 검색 입력과 검색 버튼은 한 줄로 묶어, 입력/선택/실행 버튼이 모두 같은 크기의 세로 스택처럼 보이던 답답함을 조금 줄이고 역할 구분을 더 분명하게 함.
- 아이템 관리 상세 모달의 템플릿 추가 대상 선택도 내부 전용 게임 리스트 대신 공용 `게임 선택` 검색 모달을 쓰도록 바꿔, 향후 게임 수가 많아져도 같은 선택 문법으로 이어지게 정리함.
## 2026-04-02 v1.3.72
- 관리자 화면 초기화 중 `/admin/games?gameId=...` 경로를 즉시 처리하는 watcher가 `loadGame` 초기화보다 먼저 실행되어 브라우저 콘솔에 `Cannot access 'loadGame' before initialization` 오류가 나던 문제를 수정함.
- 게임 라우트 진입 시 실제 게임 로딩 호출은 컴포넌트 초기화가 끝난 뒤 microtask로 미뤄 실행하도록 바꿔, 첫 진입/새로고침에서도 게임 선택 복원 흐름이 안전하게 이어지게 정리함.
## 2026-04-02 v1.3.71
- 관리자 아이템 모달은 최근 추가했던 선택 요약 카드를 다시 걷어내고, 더 단순한 `게임 선택 패널 + 상세 작업 영역` 구조로 되돌려 이전 흐름에 가깝게 정리함.
- 관리자 `게임 관리``전체 티어표 관리`의 게임 선택은 긴 셀렉트/목록 대신 공용 `게임 선택` 검색 모달로 바꿔, 게임 수가 많아져도 이름·ID 검색으로 바로 찾아 선택할 수 있게 함.
- 전체 티어표 관리의 게임 필터 해제도 같은 모달 흐름에 맞춰 `모든 게임 보기`로 처리하고, 사이드바에는 현재 선택된 게임만 요약 카드로 보여줘 긴 리스트가 계속 쌓이지 않게 정리함.
## 2026-04-02 v1.3.70
- 관리자 `전체 티어표 관리`는 이제 게임별 필터를 지원해, 특정 게임에서 만들어진 티어표만 따로 골라 보며 공개/비공개 분포를 확인할 수 있게 함.
- 전체 티어표 카드는 공개 여부를 텍스트 대신 다른 관리자 화면과 같은 배지 형식으로 표시하고, 카드의 `관리` 액션에서 제목·설명 수정, 공개/비공개 전환, 삭제를 바로 처리할 수 있게 보강함.
- 이를 위해 관리자 전용 티어표 수정/삭제 API와 게임 기준/검색 기준 공개 집계 로직을 함께 추가해, 관리자 화면에서 비공개 개입과 운영성 검수가 한 흐름으로 이어지게 정리함.
## 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
- `누락 참조 정리`는 처음엔 누락 커스텀 아이템이 있을 때만 티어표/요청 썸네일까지 함께 보던 조건 때문에, 누락 썸네일만 남아 있으면 수치가 줄지 않던 문제가 있었으므로 분기를 풀어 티어표 썸네일과 요청 썸네일도 항상 실제 파일 존재 여부를 확인해 정리하도록 수정함.
- 정리 완료 메시지에도 `티어표 썸네일`, `요청 썸네일` 항목을 추가해 어떤 종류의 누락 참조가 실제로 정리됐는지 바로 알 수 있게 함.

View File

@@ -9,6 +9,8 @@ import iconDockToRight from './assets/icons/dock_to_right.svg'
import iconGridView from './assets/icons/grid_view.svg'
import iconFavorite from './assets/icons/favorite.svg'
import iconLists from './assets/icons/lists.svg'
import iconAddNotes from './assets/icons/add_notes.svg'
import iconDashboardCustomize from './assets/icons/dashboard_customize.svg'
import iconSearch from './assets/icons/search.svg'
import iconSettings from './assets/icons/settings.svg'
import iconMenuBook from './assets/icons/menu_book.svg'
@@ -19,11 +21,12 @@ const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const { toasts, dismissToast } = useToast()
const RIGHT_RAIL_COPYRIGHT_URL = 'https://zenn.town/@murabito'
const leftRailCollapsed = ref(false)
const rightRailOpen = ref(true)
const searchQuery = ref('')
const searchPlaceholder = computed(() => (route.name === 'home' ? '게임 템플릿 검색' : '전체 티어표 검색'))
const leftRailSearchPlaceholder = '게임 템플릿 검색'
const isCollapsedSearchOpen = ref(false)
const isGuideModalOpen = ref(false)
const themeMode = ref('dark')
@@ -135,11 +138,11 @@ const gameHubViewMode = computed(() => (route.query.view === 'list' ? 'list' : '
const leftBottomPrimaryAction = computed(() => {
if (!authReady.value) return null
if (route.name === 'home' && auth.user) {
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new' }
return { label: '커스텀 티어표 만들기', to: '/editor/freeform/new', iconSrc: iconDashboardCustomize }
}
if (route.name === 'gameHub') {
const target = `/editor/${route.params.gameId}/new`
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}` }
return { label: '새 티어표 만들기', to: auth.user ? target : `/login?redirect=${target}`, iconSrc: iconAddNotes }
}
return null
})
@@ -391,11 +394,7 @@ function handleLeftRailSearch() {
function submitGlobalSearch() {
const query = (searchQuery.value || '').trim()
isCollapsedSearchOpen.value = false
if (route.name === 'home') {
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
return
}
router.push(query ? `/search?q=${encodeURIComponent(query)}` : '/search')
router.push(query ? `/?q=${encodeURIComponent(query)}` : '/')
}
@@ -444,7 +443,7 @@ function submitGlobalSearch() {
<SvgIcon :src="iconSearch" :size="24" />
</span>
</button>
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : searchPlaceholder" />
<input v-model="searchQuery" class="searchStub__input" type="search" :placeholder="leftRailCollapsed ? '' : leftRailSearchPlaceholder" />
</form>
<nav class="leftNav">
@@ -468,6 +467,15 @@ function submitGlobalSearch() {
</div>
<div class="leftRail__bottom">
<RouterLink v-if="leftBottomPrimaryAction" :to="leftBottomPrimaryAction.to" class="adminButton">{{ leftBottomPrimaryAction.label }}</RouterLink>
<RouterLink
v-if="leftBottomPrimaryAction"
:to="leftBottomPrimaryAction.to"
class="leftRail__collapsedAction"
:title="leftBottomPrimaryAction.label"
:aria-label="leftBottomPrimaryAction.label"
>
<SvgIcon :src="leftBottomPrimaryAction.iconSrc || iconAddNotes" :size="24" />
</RouterLink>
<button v-if="showSettingsGuideButton" class="adminButton adminButton--icon" type="button" @click="openGuideModal()">
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
<span>가이드 보기</span>
@@ -513,12 +521,12 @@ function submitGlobalSearch() {
</section>
</main>
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="searchPlaceholder" @click.self="closeCollapsedSearch">
<div v-if="isCollapsedSearchOpen" class="collapsedSearchModal" role="dialog" aria-modal="true" :aria-label="leftRailSearchPlaceholder" @click.self="closeCollapsedSearch">
<form class="collapsedSearchBar" @submit.prevent="submitGlobalSearch">
<span class="collapsedSearchBar__icon">
<SvgIcon :src="iconSearch" :size="24" />
</span>
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="searchPlaceholder" autofocus />
<input v-model="searchQuery" class="collapsedSearchBar__input" type="search" :placeholder="leftRailSearchPlaceholder" autofocus />
</form>
</div>
@@ -621,6 +629,11 @@ function submitGlobalSearch() {
</button>
</section>
</template>
<div class="rightRail__footer">
<span>Copyright © 2026 </span>
<a :href="RIGHT_RAIL_COPYRIGHT_URL" target="_blank" rel="noreferrer">zenn</a>
<span>. All rights reserved.</span>
</div>
</div>
</div>
</aside>
@@ -741,8 +754,11 @@ function submitGlobalSearch() {
}
.rightRail__content {
flex: 0 0 auto;
flex: 1 1 auto;
min-height: 0;
overflow: visible;
display: flex;
flex-direction: column;
}
.ghostIcon {
@@ -953,21 +969,24 @@ function submitGlobalSearch() {
}
.appShell--leftCollapsed .appUserCard {
margin-bottom: 10px;
min-height: 50px;
margin-bottom: 0;
}
.appShell--leftCollapsed .appUserCard__button,
.appShell--leftCollapsed .appUserCard__guest {
width: 100%;
height: 50px;
min-height: 44px;
padding: 0;
gap: 0;
justify-content: center;
}
.appShell--leftCollapsed .appUserCard__meta,
.appShell--leftCollapsed .leftNav__label,
.appShell--leftCollapsed .searchStub__input {
opacity: 0;
max-width: 0;
transform: translateX(-4px);
pointer-events: none;
display: none;
}
.appShell--leftCollapsed .appUserCard__avatar {
@@ -976,11 +995,16 @@ function submitGlobalSearch() {
}
.appShell--leftCollapsed .searchStub {
height: 50px;
margin-bottom: 0;
padding: 11px 0;
gap: 0;
justify-content: center;
}
.appShell--leftCollapsed .searchStub__iconButton {
width: auto;
width: 100%;
height: 100%;
}
.appShell--leftCollapsed .leftNav {
@@ -988,14 +1012,24 @@ function submitGlobalSearch() {
}
.appShell--leftCollapsed .leftNav__item {
width: 100%;
min-height: 50px;
height: 50px;
padding: 11px 0;
gap: 0;
justify-content: center;
}
.appShell--leftCollapsed .leftRail__bottom {
display: none;
.appShell--leftCollapsed .leftNav__glyph {
width: 28px;
height: 28px;
}
.appShell--leftCollapsed .leftRail__content {
display: grid;
align-content: start;
justify-items: stretch;
gap: 10px;
overflow: hidden;
}
@@ -1007,6 +1041,10 @@ function submitGlobalSearch() {
padding-top: 12px;
}
.leftRail__collapsedAction {
display: none;
}
.adminButton {
width: 100%;
display: inline-flex;
@@ -1031,6 +1069,29 @@ function submitGlobalSearch() {
flex: 0 0 auto;
}
.appShell--leftCollapsed .leftRail__bottom {
display: grid;
gap: 10px;
}
.appShell--leftCollapsed .leftRail__bottom .adminButton {
display: none;
}
.appShell--leftCollapsed .leftRail__bottom .leftRail__collapsedAction {
width: 100%;
min-height: 50px;
height: 50px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 14px;
border: 1px solid var(--theme-border-strong);
background: var(--theme-surface-soft);
color: var(--theme-text);
text-decoration: none;
}
.appMain {
min-width: 0;
min-height: 0;
@@ -1145,13 +1206,31 @@ function submitGlobalSearch() {
}
.rightRail__bottom {
display: flex;
align-items: flex-end;
justify-content: flex-end;
margin-top: auto;
display: grid;
gap: 10px;
padding-top: 12px;
}
.rightRail__footer {
padding: 0 4px 2px;
font-size: 9px;
line-height: 1.4;
text-align: center;
color: var(--theme-text-faint);
opacity: 0.72;
}
.rightRail__footer a {
color: #00ffff;
text-decoration: none;
}
.rightRail__footer a:hover {
color: #00ffff;
text-decoration: underline;
}
.settingsThemePanel {
display: grid;
gap: 10px;
@@ -1575,9 +1654,10 @@ function submitGlobalSearch() {
}
.localRightRailRoot {
min-height: auto;
display: grid;
align-content: start;
flex: 1 1 auto;
min-height: 100%;
display: flex;
flex-direction: column;
gap: 14px;
}

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="M200-120q-33 0-56.5-23.5T120-200v-560q0-33 23.5-56.5T200-840h560q33 0 56.5 23.5T840-760v268q-19-9-39-15.5t-41-9.5v-243H200v560h242q3 22 9.5 42t15.5 38H200Zm0-120v40-560 243-3 280Zm80-40h163q3-21 9.5-41t14.5-39H280v80Zm0-160h244q32-30 71.5-50t84.5-27v-3H280v80Zm0-160h400v-80H280v80ZM720-40q-83 0-141.5-58.5T520-240q0-83 58.5-141.5T720-440q83 0 141.5 58.5T920-240q0 83-58.5 141.5T720-40Zm-20-80h40v-100h100v-40H740v-100h-40v100H600v40h100v100Z"/></svg>

After

Width:  |  Height:  |  Size: 566 B

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="M120-840h320v320H120v-320Zm80 80v160-160Zm320-80h320v320H520v-320Zm80 80v160-160ZM120-440h320v320H120v-320Zm80 80v160-160Zm440-80h80v120h120v80H720v120h-80v-120H520v-80h120v-120Zm-40-320v160h160v-160H600Zm-400 0v160h160v-160H200Zm0 400v160h160v-160H200Z"/></svg>

After

Width:  |  Height:  |  Size: 377 B

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="M680-80q-50 0-85-35t-35-85q0-6 3-28L282-392q-16 15-37 23.5t-45 8.5q-50 0-85-35t-35-85q0-50 35-85t85-35q24 0 45 8.5t37 23.5l281-164q-2-7-2.5-13.5T560-760q0-50 35-85t85-35q50 0 85 35t35 85q0 50-35 85t-85 35q-24 0-45-8.5T598-672L317-508q2 7 2.5 13.5t.5 14.5q0 8-.5 14.5T317-452l281 164q16-15 37-23.5t45-8.5q50 0 85 35t35 85q0 50-35 85t-85 35Zm0-80q17 0 28.5-11.5T720-200q0-17-11.5-28.5T680-240q-17 0-28.5 11.5T640-200q0 17 11.5 28.5T680-160ZM200-440q17 0 28.5-11.5T240-480q0-17-11.5-28.5T200-520q-17 0-28.5 11.5T160-480q0 17 11.5 28.5T200-440Zm508.5-291.5Q720-743 720-760t-11.5-28.5Q697-800 680-800t-28.5 11.5Q640-777 640-760t11.5 28.5Q663-720 680-720t28.5-11.5ZM680-200ZM200-480Zm480-280Z"/></svg>

After

Width:  |  Height:  |  Size: 810 B

View File

@@ -21,6 +21,8 @@ const props = defineProps({
adminTierListPage: { type: Number, required: true },
adminTierListPageCount: { type: Number, required: true },
adminTierListTotal: { type: Number, required: true },
adminTierListStats: { type: Object, required: true },
openAdminTierListManageModal: { type: Function, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
@@ -37,10 +39,17 @@ const props = defineProps({
<div v-else class="templateRequestList">
<article v-for="request in props.templateRequests" :key="request.id" class="tierAdminCard templateRequestCard templateRequestCard--aligned">
<div class="templateRequestCard__side">
<button class="tierAdminCard__preview templateRequestCard__preview" type="button" @click="props.openTemplateRequestPreview(request)">
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" />
<a
class="tierAdminCard__preview templateRequestCard__preview"
:href="props.templateRequestSourceUrl(request) || undefined"
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
:aria-disabled="!props.templateRequestSourceUrl(request)"
@click.prevent="props.templateRequestSourceUrl(request) && window.open(props.templateRequestSourceUrl(request), '_blank', 'noopener,noreferrer')"
>
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
</a>
<div class="templateRequestCard__thumbMeta">
<template v-if="request.type === 'create'">
<label class="templateRequestField">
@@ -95,17 +104,7 @@ const props = defineProps({
</div>
<div class="templateRequestCard__footer">
<div class="templateRequestCard__footerLeft">
<a
v-if="props.templateRequestSourceUrl(request)"
class="btn btn--ghost btn--small"
:href="props.templateRequestSourceUrl(request)"
target="_blank"
rel="noreferrer"
>
요청 티어표 보기
</a>
</div>
<div class="templateRequestCard__footerLeft"></div>
<div class="templateRequestCard__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{
@@ -128,6 +127,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>
@@ -135,7 +139,7 @@ const props = defineProps({
<div v-else class="tierAdminList">
<article v-for="tierList in props.adminTierLists" :key="tierList.id" class="tierAdminCard">
<button class="tierAdminCard__preview" type="button" @click="props.openAdminTierList(tierList)">
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" />
<img v-if="props.tierListThumbUrl(tierList)" class="tierAdminCard__thumb" :src="props.tierListThumbUrl(tierList)" :alt="tierList.title" draggable="false" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
@@ -145,13 +149,14 @@ const props = defineProps({
<div class="tierAdminCard__title">{{ tierList.title }}</div>
<div v-if="tierList.description" class="tierAdminCard__desc">{{ tierList.description }}</div>
<div class="tierAdminCard__meta">
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }} · {{ props.tierListVisibilityLabel(tierList) }}
{{ tierList.gameName || tierList.gameId }} · {{ props.tierListAuthorDisplayName(tierList) }}
</div>
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
</div>
</div>
<div class="tierAdminCard__stats">
<span class="pill" :class="tierList.isPublic ? 'pill--public' : 'pill--private'">{{ props.tierListVisibilityLabel(tierList) }}</span>
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
@@ -171,6 +176,10 @@ const props = defineProps({
</button>
</div>
</div>
<div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="props.openAdminTierListManageModal(tierList)">관리</button>
</div>
</div>
</article>
</div>

View File

@@ -16,8 +16,6 @@ export function useAdminCustomItems({
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
refreshCustomItems,
@@ -62,8 +60,6 @@ export function useAdminCustomItems({
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
@@ -75,8 +71,6 @@ export function useAdminCustomItems({
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
if (fromPopState) {
customItemModalHistoryActive.value = false

View File

@@ -45,8 +45,16 @@ export const api = {
request(
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
),
listAdminTierLists: ({ q = '', page = 1, limit = 50 } = {}) =>
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
listAdminTierLists: ({ q = '', gameId = '', page = 1, limit = 50 } = {}) =>
request(
`/api/admin/tierlists?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`
),
getAdminTierListStats: ({ q = '', gameId = '' } = {}) =>
request(`/api/admin/tierlists/stats?q=${encodeURIComponent(q)}&gameId=${encodeURIComponent(gameId)}`),
updateAdminTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'PATCH', body: payload }),
deleteAdminTierList: (tierListId) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}`, { method: 'DELETE' }),
listAdminTemplateRequests: () => request('/api/admin/template-requests'),
getAdminImageAssetStats: ({ month = '', limit = 12 } = {}) => {
const query = new URLSearchParams()

View File

@@ -34,8 +34,10 @@ const games = ref([])
const selectedGameId = ref('')
const selectedGame = ref(null)
const featuredGameIds = ref([])
const gameAdminQuery = ref('')
const gameAdminSort = ref('recent')
const gamePickerModalOpen = ref(false)
const gamePickerMode = ref('game-admin')
const gamePickerQuery = ref('')
const gamePickerSort = ref('recent')
const customItems = ref([])
const customItemQuery = ref('')
@@ -44,14 +46,15 @@ const customItemLimit = ref(50)
const customItemTotal = ref(0)
const customItemFilter = ref('all')
const customItemModalTargetGameId = ref('')
const customItemModalGameQuery = ref('')
const customItemModalGameSort = ref('recent')
const adminTierLists = ref([])
const adminTierListQuery = ref('')
const adminTierListGameId = ref('')
const adminTierListPage = ref(1)
const adminTierListLimit = ref(50)
const adminTierListTotal = ref(0)
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')
@@ -62,6 +65,7 @@ const importModalNewGameId = ref('')
const importModalNewGameName = ref('')
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const adminTierListManageModalOpen = ref(false)
const activeTemplateRequest = ref(null)
const userEditModalOpen = ref(false)
const userPasswordModalOpen = ref(false)
@@ -79,6 +83,12 @@ const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const customItemModalDraftLabel = ref('')
const customItemModalLabelSaving = ref(false)
const modalTargetAdminTierList = ref(null)
const adminTierListDraftTitle = ref('')
const adminTierListDraftDescription = ref('')
const adminTierListDraftIsPublic = ref(false)
const adminTierListSaving = ref(false)
const adminTierListDeleting = ref(false)
const users = ref([])
const userQuery = ref('')
@@ -187,8 +197,8 @@ const featuredGames = computed(() =>
.filter(Boolean)
)
const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id)))
const filteredAdminGames = computed(() => {
const query = gameAdminQuery.value.trim().toLowerCase()
const filteredGamePickerGames = computed(() => {
const query = gamePickerQuery.value.trim().toLowerCase()
const list = games.value.filter((game) => {
if (!query) return true
const haystack = `${game.name || ''} ${game.id || ''}`.toLowerCase()
@@ -196,10 +206,11 @@ const filteredAdminGames = computed(() => {
})
return list.slice().sort((a, b) => {
if (gameAdminSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
if (gamePickerSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const customItemTargetGame = computed(() => games.value.find((game) => game.id === customItemModalTargetGameId.value) || null)
const importModalItemCount = computed(() => importModalItems.value.length)
const activeTabTitle = computed(() => {
if (activeTab.value === 'featured') return '목록 관리'
@@ -228,7 +239,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
@@ -243,7 +253,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}` },
]
}
@@ -263,8 +275,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}` },
]
}
@@ -277,6 +290,7 @@ const adminOverviewStats = computed(() => {
const isAnyModalOpen = computed(
() =>
gameCreateModalOpen.value ||
gamePickerModalOpen.value ||
userEditModalOpen.value ||
userPasswordModalOpen.value ||
userDeleteModalOpen.value ||
@@ -284,6 +298,7 @@ const isAnyModalOpen = computed(
importModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
adminTierListManageModalOpen.value ||
imageResetModalOpen.value ||
previewModalOpen.value
)
@@ -411,13 +426,17 @@ watch(
const nextGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
if (nextGameId && nextGameId !== selectedGameId.value) {
selectedGameId.value = nextGameId
loadGame()
queueMicrotask(() => {
if (selectedGameId.value === nextGameId) void loadGame()
})
}
return
}
if (name === 'adminTierlists') {
const nextMode = route.query.mode === 'all' ? 'all' : 'requests'
if (tierlistsMode.value !== nextMode) tierlistsMode.value = nextMode
const nextTierListGameId = typeof route.query.gameId === 'string' ? route.query.gameId : ''
if (adminTierListGameId.value !== nextTierListGameId) adminTierListGameId.value = nextTierListGameId
}
},
{ immediate: true }
@@ -431,11 +450,30 @@ watch(
}
)
watch(
() => selectedGame.value?.game?.id || '',
async (gameId) => {
await refreshSelectedGameTierListStats(gameId)
},
{ immediate: true }
)
watch(
() => tierlistsMode.value,
(mode) => {
if (route.name !== 'adminTierlists') return
syncAdminRouteQuery({ mode: mode === 'all' ? 'all' : undefined })
syncAdminRouteQuery({
mode: mode === 'all' ? 'all' : undefined,
gameId: mode === 'all' && adminTierListGameId.value ? adminTierListGameId.value : undefined,
})
}
)
watch(
() => adminTierListGameId.value,
(gameId) => {
if (route.name !== 'adminTierlists' || tierlistsMode.value !== 'all') return
syncAdminRouteQuery({ gameId: gameId || undefined })
}
)
@@ -451,7 +489,6 @@ watch(
customItemQuery.value = ''
customItemFilter.value = 'all'
customItemPage.value = 1
customItemModalGameQuery.value = ''
await refreshCustomItems()
return
}
@@ -557,6 +594,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 []
@@ -572,21 +620,7 @@ const imageDiagnosticsCards = computed(() => {
const visibleLinkedGames = computed(() =>
(modalTargetCustomItem.value?.linkedGames || []).filter((game) => game?.id && game.id !== 'freeform')
)
const filteredCustomItemModalGames = computed(() => {
const query = customItemModalGameQuery.value.trim().toLowerCase()
const linkedIds = new Set(visibleLinkedGames.value.map((game) => game.id))
const list = games.value.filter((game) => {
if (!query) return true
return `${game.name || ''} ${game.id || ''}`.toLowerCase().includes(query)
})
return list.slice().sort((a, b) => {
const linkedDelta = Number(linkedIds.has(a.id)) - Number(linkedIds.has(b.id))
if (linkedDelta !== 0) return linkedDelta
if (customItemModalGameSort.value === 'oldest') return Number(a.createdAt || 0) - Number(b.createdAt || 0)
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
})
})
const linkedCustomItemGameIds = computed(() => new Set(visibleLinkedGames.value.map((game) => game.id).filter(Boolean)))
const imageStatsPeriodLabel = computed(() => (imageStatsMonth.value ? `${imageStatsMonth.value} 기준` : '전체 기간'))
const imageStatsYearOptions = computed(() => {
@@ -787,6 +821,7 @@ async function refreshAdminTierLists() {
try {
const data = await api.listAdminTierLists({
q: adminTierListQuery.value,
gameId: adminTierListGameId.value,
page: adminTierListPage.value,
limit: adminTierListLimit.value,
})
@@ -794,11 +829,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, gameId: adminTierListGameId.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 {
@@ -956,8 +1024,6 @@ const {
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
refreshCustomItems,
@@ -1218,6 +1284,42 @@ function submitAdminTierListSearch() {
refreshAdminTierLists()
}
function setAdminTierListGameId(gameId) {
adminTierListGameId.value = gameId || ''
adminTierListPage.value = 1
refreshAdminTierLists()
}
function openGamePickerModal(mode = 'game-admin') {
gamePickerMode.value = mode
gamePickerQuery.value = ''
gamePickerSort.value = 'recent'
gamePickerModalOpen.value = true
}
function closeGamePickerModal() {
gamePickerModalOpen.value = false
gamePickerQuery.value = ''
}
async function chooseGameFromPicker(gameId) {
if (!gameId) return
if (gamePickerMode.value === 'tierlists-filter') {
setAdminTierListGameId(gameId)
closeGamePickerModal()
return
}
if (gamePickerMode.value === 'custom-item-target') {
if (linkedCustomItemGameIds.value.has(gameId)) return
customItemModalTargetGameId.value = gameId
closeGamePickerModal()
return
}
await selectAdminGame(gameId)
closeGamePickerModal()
}
function changeAdminTierListLimit(limit) {
adminTierListLimit.value = limit
adminTierListPage.value = 1
@@ -1269,6 +1371,81 @@ function tierListVisibilityLabel(tierList) {
return tierList.isPublic ? '공개' : '비공개'
}
function openAdminTierListManageModal(tierList) {
if (!tierList) return
modalTargetAdminTierList.value = tierList
adminTierListDraftTitle.value = tierList.title || ''
adminTierListDraftDescription.value = tierList.description || ''
adminTierListDraftIsPublic.value = !!tierList.isPublic
adminTierListManageModalOpen.value = true
}
function closeAdminTierListManageModal() {
adminTierListManageModalOpen.value = false
modalTargetAdminTierList.value = null
adminTierListDraftTitle.value = ''
adminTierListDraftDescription.value = ''
adminTierListDraftIsPublic.value = false
adminTierListSaving.value = false
adminTierListDeleting.value = false
}
async function saveAdminTierListMeta() {
if (!modalTargetAdminTierList.value?.id || adminTierListSaving.value) return
const nextTitle = adminTierListDraftTitle.value.trim()
if (!nextTitle) {
error.value = '티어표 제목을 입력해주세요.'
return
}
resetMessages()
adminTierListSaving.value = true
try {
const data = await api.updateAdminTierList(modalTargetAdminTierList.value.id, {
title: nextTitle,
description: adminTierListDraftDescription.value.trim(),
isPublic: !!adminTierListDraftIsPublic.value,
})
const updated = data.tierList
adminTierLists.value = adminTierLists.value.map((tierList) => (tierList.id === updated.id ? { ...tierList, ...updated } : tierList))
if (previewTierList.value?.id === updated.id) previewTierList.value = { ...previewTierList.value, ...updated }
modalTargetAdminTierList.value = updated
await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
success.value = '티어표 정보를 수정했어요.'
closeAdminTierListManageModal()
} catch (e) {
error.value = '티어표 정보 수정에 실패했어요.'
} finally {
adminTierListSaving.value = false
}
}
async function deleteAdminTierListEntry() {
if (!modalTargetAdminTierList.value?.id || adminTierListDeleting.value) return
const ok = window.confirm(`"${modalTargetAdminTierList.value.title}" 티어표를 삭제할까요? 이 작업은 되돌릴 수 없어요.`)
if (!ok) return
resetMessages()
adminTierListDeleting.value = true
try {
await api.deleteAdminTierList(modalTargetAdminTierList.value.id)
adminTierLists.value = adminTierLists.value.filter((tierList) => tierList.id !== modalTargetAdminTierList.value.id)
adminTierListTotal.value = Math.max(0, adminTierListTotal.value - 1)
if (previewTierList.value?.id === modalTargetAdminTierList.value.id) previewTierList.value = null
await Promise.all([refreshAdminTierListStats(), refreshSelectedGameTierListStats(selectedGame.value?.game?.id || '')])
success.value = '티어표를 삭제했어요.'
closeAdminTierListManageModal()
if (!adminTierLists.value.length && adminTierListPage.value > 1) {
adminTierListPage.value -= 1
await refreshAdminTierLists()
}
} catch (e) {
error.value = '티어표 삭제에 실패했어요.'
} finally {
adminTierListDeleting.value = false
}
}
function openAdminTierList(tierList) {
previewTierList.value = tierList
previewModalOpen.value = true
@@ -1577,6 +1754,8 @@ function userAvatarFallback(user) {
:admin-tier-list-page="adminTierListPage"
:admin-tier-list-page-count="adminTierListPageCount"
:admin-tier-list-total="adminTierListTotal"
:admin-tier-list-stats="adminTierListStats"
:open-admin-tier-list-manage-modal="openAdminTierListManageModal"
:move-admin-tier-list-page="moveAdminTierListPage"
/>
@@ -1772,32 +1951,16 @@ function userAvatarFallback(user) {
<div class="customItemModal__pickerHead">
<div class="customItemModal__pickerEyebrow">GAME PICKER</div>
<div class="customItemModal__pickerTitle">템플릿으로 추가할 게임</div>
</div>
<div class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 게임</div>
<div class="adminSelectionCard__title">{{ customItemTargetGame?.name || '아직 선택하지 않음' }}</div>
<div class="adminSelectionCard__meta">{{ customItemTargetGame?.id || '게임을 골라 주세요.' }}</div>
</div>
<div class="customItemModal__pickerActions">
<button class="btn btn--ghost" type="button" @click="openGamePickerModal('custom-item-target')">게임 선택</button>
<button class="btn btn--ghost btn--small customItemModal__createGameButton" type="button" @click="openGameCreateModal"> 템플릿 만들기</button>
</div>
<div class="customItemModal__pickerControls">
<input v-model="customItemModalGameQuery" class="input input--dense" placeholder="게임 이름, ID 검색" />
<select v-model="customItemModalGameSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
</div>
<div class="customItemModal__gameList">
<button
v-for="game in filteredCustomItemModalGames"
:key="game.id"
type="button"
class="customItemModal__gameItem"
:class="{
'customItemModal__gameItem--active': customItemModalTargetGameId === game.id,
'customItemModal__gameItem--linked': visibleLinkedGames.some((entry) => entry.id === game.id),
}"
@click="customItemModalTargetGameId = game.id"
>
<span class="customItemModal__gameName">{{ game.name }}</span>
<span class="customItemModal__gameMeta">{{ game.id }}</span>
<span v-if="visibleLinkedGames.some((entry) => entry.id === game.id)" class="customItemModal__gameState">이미 포함됨</span>
</button>
</div>
</aside>
<div class="customItemModal__body">
<button class="customItemModal__close" type="button" @click="closeCustomItemModal">닫기</button>
@@ -1808,7 +1971,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>
@@ -1844,10 +2006,62 @@ function userAvatarFallback(user) {
</div>
</div>
<div v-if="gamePickerModalOpen" class="modalOverlay" @click.self="closeGamePickerModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__titleRow">
<div>
<div class="modalCard__title">게임 선택</div>
<div class="modalCard__desc">
{{ gamePickerMode === 'tierlists-filter' ? '특정 게임의 티어표만 보려면 게임을 선택하세요.' : '관리할 게임을 검색해서 바로 열 수 있어요.' }}
</div>
</div>
<button class="btn btn--ghost btn--small" @click="closeGamePickerModal">닫기</button>
</div>
<div class="modalCard__form">
<input v-model="gamePickerQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
<select v-model="gamePickerSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
<button
v-if="gamePickerMode === 'tierlists-filter' && adminTierListGameId"
class="btn btn--ghost"
type="button"
@click="setAdminTierListGameId(''); closeGamePickerModal()"
>
모든 게임 보기
</button>
</div>
<div class="gamePickerModalList">
<button
v-for="game in filteredGamePickerGames"
:key="game.id"
class="adminGamePicker__item"
:class="{
'adminGamePicker__item--active': gamePickerMode === 'tierlists-filter'
? adminTierListGameId === game.id
: gamePickerMode === 'custom-item-target'
? customItemModalTargetGameId === game.id
: selectedGameId === game.id,
'adminGamePicker__item--disabled': gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id),
}"
type="button"
:disabled="gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id)"
@click="chooseGameFromPicker(game.id)"
>
<span class="adminGamePicker__name">{{ game.name }}</span>
<span class="adminGamePicker__meta">{{ game.id }}</span>
<span v-if="gamePickerMode === 'custom-item-target' && linkedCustomItemGameIds.has(game.id)" class="adminGamePicker__state">이미 추가됨</span>
</button>
<div v-if="!filteredGamePickerGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
</div>
</div>
</div>
<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>
@@ -1855,6 +2069,39 @@ function userAvatarFallback(user) {
</div>
</div>
<div v-if="adminTierListManageModalOpen" class="modalOverlay" @click.self="closeAdminTierListManageModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 관리</div>
<div class="modalCard__desc">
{{ modalTargetAdminTierList ? `${modalTargetAdminTierList.gameName || modalTargetAdminTierList.gameId} · ${tierListAuthorDisplayName(modalTargetAdminTierList)}` : '' }}
</div>
<div class="modalCard__form">
<label class="field">
<span class="field__label">제목</span>
<input v-model="adminTierListDraftTitle" class="field__input" maxlength="120" placeholder="티어표 제목" />
</label>
<label class="field">
<span class="field__label">설명</span>
<textarea v-model="adminTierListDraftDescription" class="field__input field__input--textarea" rows="4" maxlength="500" placeholder="설명 수정"></textarea>
</label>
<label class="toggleSwitch">
<input v-model="adminTierListDraftIsPublic" type="checkbox" />
<span class="toggleSwitch__label">{{ adminTierListDraftIsPublic ? '공개 상태' : '비공개 상태' }}</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeAdminTierListManageModal">취소</button>
<button class="btn btn--danger" :disabled="adminTierListDeleting" @click="deleteAdminTierListEntry">
{{ adminTierListDeleting ? '삭제중...' : '삭제' }}
</button>
<button class="btn btn--primary" :disabled="adminTierListSaving || !adminTierListDraftTitle.trim()" @click="saveAdminTierListMeta">
{{ adminTierListSaving ? '저장중...' : '저장' }}
</button>
</div>
</div>
</div>
<div v-if="imageResetModalOpen" class="modalOverlay" @click.self="closeImageResetModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">최적화 기록 비우기</div>
@@ -1965,24 +2212,11 @@ function userAvatarFallback(user) {
<div class="adminSidebar__label">Game</div>
<div class="adminSidebar__group">
<button class="btn btn--primary" @click="openGameCreateModal"> 게임 생성</button>
<input v-model="gameAdminQuery" class="input" placeholder="게임 이름 또는 ID 검색" />
<select v-model="gameAdminSort" class="select">
<option value="recent">최신순</option>
<option value="oldest">오래된순</option>
</select>
<div class="adminGamePicker">
<button
v-for="game in filteredAdminGames"
:key="game.id"
class="adminGamePicker__item"
:class="{ 'adminGamePicker__item--active': selectedGameId === game.id }"
type="button"
@click="selectAdminGame(game.id)"
>
<span class="adminGamePicker__name">{{ game.name }}</span>
<span class="adminGamePicker__meta">{{ game.id }}</span>
</button>
<div v-if="!filteredAdminGames.length" class="hint hint--tight">검색 결과가 없어요.</div>
<button class="btn btn--ghost" @click="openGamePickerModal('game-admin')">게임 선택</button>
<div v-if="selectedGame?.game" class="adminSelectionCard">
<div class="adminSelectionCard__label">선택한 게임</div>
<div class="adminSelectionCard__title">{{ selectedGame.game.name }}</div>
<div class="adminSelectionCard__meta">{{ selectedGame.game.id }}</div>
</div>
<div v-if="selectedGameId && !hasSelectedGame && !isGameLoading" class="hint hint--tight">선택된 게임 ID: {{ selectedGameId }}</div>
</div>
@@ -1991,8 +2225,10 @@ function userAvatarFallback(user) {
<section v-else-if="activeTab === 'items'" class="adminSidebar__panel">
<div class="adminSidebar__label">Filters</div>
<div class="adminSidebar__group">
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
<div class="adminSidebar__inlineRow">
<input v-model="customItemQuery" class="input" placeholder="파일명, 라벨, 업로더 검색" @keydown.enter.prevent="submitCustomItemSearch" />
<button class="btn btn--ghost" @click="submitCustomItemSearch">검색</button>
</div>
</div>
<div class="adminSidebar__group">
<select :value="customItemLimit" class="select" @change="changeCustomItemLimit(Number($event.target.value))">
@@ -2037,13 +2273,22 @@ function userAvatarFallback(user) {
<template v-if="tierlistsMode === 'requests'"></template>
<template v-else>
<div class="adminSidebar__group">
<input
v-model="adminTierListQuery"
class="input"
placeholder="제목, 작성자, 게임 이름 검색"
@keydown.enter.prevent="submitAdminTierListSearch"
/>
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
<div class="adminSidebar__inlineRow">
<input
v-model="adminTierListQuery"
class="input"
placeholder="제목, 작성자, 게임 이름 검색"
@keydown.enter.prevent="submitAdminTierListSearch"
/>
<button class="btn btn--ghost" @click="submitAdminTierListSearch">검색</button>
</div>
<button class="btn btn--ghost" @click="openGamePickerModal('tierlists-filter')">게임 선택</button>
<div v-if="adminTierListGameId" class="adminSelectionCard">
<div class="adminSelectionCard__label">필터된 게임</div>
<div class="adminSelectionCard__title">{{ games.find((game) => game.id === adminTierListGameId)?.name || adminTierListGameId }}</div>
<div class="adminSelectionCard__meta">{{ adminTierListGameId }}</div>
<button class="btn btn--ghost btn--small" @click="setAdminTierListGameId('')">필터 해제</button>
</div>
<select :value="adminTierListLimit" class="select" @change="changeAdminTierListLimit(Number($event.target.value))">
<option :value="50">50개씩 보기</option>
<option :value="200">200개씩 보기</option>
@@ -2278,6 +2523,15 @@ function userAvatarFallback(user) {
display: grid;
gap: 10px;
}
.adminUiScope .adminSidebar__inlineRow {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.adminUiScope .adminSidebar__inlineRow .btn {
white-space: nowrap;
}
.adminUiScope .adminSidebar__group--monthPicker {
align-items: start;
}
@@ -2329,6 +2583,11 @@ function userAvatarFallback(user) {
border-color: rgba(77, 127, 233, 0.58);
background: rgba(77, 127, 233, 0.12);
}
.adminUiScope .adminGamePicker__item--disabled {
cursor: not-allowed;
opacity: 0.58;
border-style: dashed;
}
.adminUiScope .adminGamePicker__name {
font-size: 13px;
font-weight: 800;
@@ -2340,6 +2599,39 @@ function userAvatarFallback(user) {
overflow: hidden;
text-overflow: ellipsis;
}
.adminUiScope .adminGamePicker__state {
margin-top: 4px;
font-size: 11px;
color: var(--theme-text-faint);
}
.adminUiScope .gamePickerModalList {
margin-top: 14px;
display: grid;
gap: 8px;
max-height: min(56dvh, 520px);
overflow: auto;
}
.adminUiScope .adminSelectionCard {
display: grid;
gap: 6px;
padding: 12px 13px;
border-radius: 14px;
border: 1px solid var(--theme-border);
background: var(--theme-pill-bg);
}
.adminUiScope .adminSelectionCard__label {
font-size: 11px;
color: var(--theme-text-faint);
}
.adminUiScope .adminSelectionCard__title {
font-size: 14px;
font-weight: 800;
}
.adminUiScope .adminSelectionCard__meta {
font-size: 11px;
color: var(--theme-text-soft);
word-break: break-word;
}
.adminUiScope .sidebarStat {
display: grid;
gap: 4px;
@@ -2766,8 +3058,9 @@ function userAvatarFallback(user) {
}
.adminUiScope .gameSettingsCard__actions {
display: flex;
justify-content: space-between;
gap: 10px;
flex-wrap: wrap;
/* flex-wrap: wrap; */
}
.adminUiScope .selectedThumb {
width: min(100%, 256px);
@@ -3153,9 +3446,12 @@ 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;
@@ -3171,46 +3467,13 @@ function userAvatarFallback(user) {
font-size: 18px;
font-weight: 900;
}
.adminUiScope .customItemModal__pickerControls {
.adminUiScope .customItemModal__pickerActions {
display: grid;
gap: 10px;
}
.adminUiScope .customItemModal__gameList {
display: grid;
gap: 8px;
max-height: 440px;
overflow: auto;
}
.adminUiScope .customItemModal__createGameButton {
justify-self: start;
}
.adminUiScope .customItemModal__gameItem {
display: grid;
gap: 4px;
padding: 12px 13px;
border-radius: 16px;
border: 1px solid var(--theme-border);
background: var(--theme-surface-soft);
color: var(--theme-text);
text-align: left;
cursor: pointer;
}
.adminUiScope .customItemModal__gameItem--active {
border-color: rgba(96, 165, 250, 0.42);
background: rgba(96, 165, 250, 0.12);
}
.adminUiScope .customItemModal__gameItem--linked {
border-style: dashed;
}
.adminUiScope .customItemModal__gameName {
font-size: 13px;
font-weight: 800;
}
.adminUiScope .customItemModal__gameMeta,
.adminUiScope .customItemModal__gameState {
font-size: 11px;
color: var(--theme-text-soft);
}
.adminUiScope .customItemModal__body {
min-width: 0;
min-height: 0;
@@ -3218,6 +3481,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;
@@ -3226,14 +3490,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;
@@ -3262,15 +3536,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);
@@ -3895,6 +4160,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;
@@ -3931,6 +4202,16 @@ function userAvatarFallback(user) {
background: rgba(251, 191, 36, 0.12);
color: rgba(253, 230, 138, 0.96);
}
.adminUiScope .pill--public {
border-color: rgba(52, 211, 153, 0.34);
background: rgba(52, 211, 153, 0.14);
color: rgba(209, 250, 229, 0.98);
}
.adminUiScope .pill--private {
border-color: rgba(251, 191, 36, 0.32);
background: rgba(251, 191, 36, 0.12);
color: rgba(253, 230, 138, 0.96);
}
.adminUiScope .pill--link {
color: var(--theme-text);
cursor: pointer;
@@ -4010,11 +4291,17 @@ function userAvatarFallback(user) {
width: min(560px, 100%);
display: grid;
gap: 14px;
padding: 20px;
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
}
.adminUiScope .modalCard:not(.modalCard--customItem) {
padding: 20px;
}
.adminUiScope .modalCard.modalCard--customItem {
gap: 0;
padding: 0;
}
.adminUiScope .modalCard--preview {
width: min(1200px, 100%);
max-height: calc(100dvh - 40px);

View File

@@ -7,6 +7,7 @@ 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 shareIcon from '../assets/icons/share.svg'
import { api } from '../lib/api'
import { toApiUrl } from '../lib/runtime'
import { useAuthStore } from '../stores/auth'
@@ -130,6 +131,12 @@ const canRequestTemplateUpdate = computed(
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const templateRequestTargetLabel = computed(() => (gameId.value === 'freeform' ? '새로운 템플릿' : (gameName.value || gameId.value || '선택한 게임')))
const shareTierListUrl = computed(() => {
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
if (!savedTierListId) return ''
if (typeof window === 'undefined') return `/editor/${gameId.value}/${savedTierListId}?preview=1`
return new URL(`/editor/${gameId.value}/${savedTierListId}?preview=1`, window.location.origin).toString()
})
watch(error, (message) => {
if (!message) return
@@ -671,6 +678,7 @@ function buildPayload(existingId) {
description: (description.value || '').trim(),
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
iconSize: Number(iconSize.value || 80),
sourceTierListId: sourceTierListId.value || '',
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
@@ -712,6 +720,32 @@ async function save() {
}
}
async function copyShareUrl() {
if (!shareTierListUrl.value) {
toast.error('먼저 티어표를 저장한 뒤 공유할 수 있어요.')
return
}
try {
if (navigator?.clipboard?.writeText) {
await navigator.clipboard.writeText(shareTierListUrl.value)
} else {
const helper = document.createElement('textarea')
helper.value = shareTierListUrl.value
helper.setAttribute('readonly', '')
helper.style.position = 'absolute'
helper.style.left = '-9999px'
document.body.appendChild(helper)
helper.select()
document.execCommand('copy')
helper.remove()
}
toast.success('공유 링크를 클립보드에 복사했어요.')
} catch (e) {
toast.error('공유 링크를 복사하지 못했어요.')
}
}
function closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -890,6 +924,7 @@ onMounted(() => {
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
@@ -941,6 +976,7 @@ onUnmounted(() => {
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
<div class="previewOnly__drop">
<div v-if="columns.length > 1" class="previewOnly__columnBadge">{{ column.name || ' ' + (columnIndex + 1) }}</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
@@ -958,6 +994,10 @@ onUnmounted(() => {
</div>
</div>
</div>
<div class="previewOnly__footer">
<span>{{ effectiveAuthorName }}</span>
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
</div>
</div>
</section>
@@ -966,7 +1006,7 @@ onUnmounted(() => {
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
<div class="modalCard__desc">티어표가 저장되었어요. 이어서 수정한 다시 저장할 수도 있어요.</div>
<div class="modalCard__desc">티어표가 저장되었어요.<br />이어서 수정한 다시 저장할 수도 있어요.</div>
<div class="modalCard__actions">
<button class="btn btn--save" @click="closeSaveModal">확인</button>
</div>
@@ -1165,13 +1205,14 @@ onUnmounted(() => {
</div>
<div class="row__content" :style="{ '--column-count': columns.length }">
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
<div
<div
class="row__drop"
:data-list-type="'group'"
:data-group-id="g.id"
:data-column-index="columnIndex"
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
>
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || ' ' + (columnIndex + 1) }}</div>
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
@@ -1324,6 +1365,10 @@ 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="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
<SvgIcon :src="shareIcon" :size="16" />
<span>공유하기</span>
</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
@@ -1466,6 +1511,7 @@ onUnmounted(() => {
border: 1px solid var(--theme-border-strong);
}
.previewOnly__drop {
position: relative;
border-radius: 14px;
background: var(--theme-pill-bg);
border: 1px solid var(--theme-border);
@@ -1476,6 +1522,10 @@ onUnmounted(() => {
gap: 8px;
align-content: flex-start;
}
.previewOnly__columnBadge,
.row__columnBadge {
display: none;
}
.previewOnly__cell {
display: inline-flex;
position: relative;
@@ -1502,6 +1552,15 @@ onUnmounted(() => {
opacity: 0.52;
filter: grayscale(0.22) brightness(0.78);
}
.previewOnly__footer {
display: flex;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
padding-top: 8px;
color: var(--theme-text-soft);
font-size: 13px;
}
.toggleSwitch {
display: inline-flex;
align-items: center;
@@ -2282,6 +2341,9 @@ onUnmounted(() => {
background: transparent;
color: var(--theme-text-muted);
font-size: 14px;
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
}
@@ -2293,6 +2355,10 @@ onUnmounted(() => {
.editorSidebar__utilityLink--danger {
color: rgba(248, 113, 113, 0.96);
}
.editorSidebar__utilityLink--share {
color: var(--theme-text-soft);
}
.sidebar__title {
font-weight: 900;
margin-bottom: 8px;
@@ -2434,8 +2500,45 @@ onUnmounted(() => {
border-radius: 14px;
}
@media (max-width: 980px) {
.previewOnly__row {
grid-template-columns: 140px 1fr;
.previewOnly__row,
.row {
grid-template-columns: 1fr;
}
.previewOnly__columns,
.boardColumnsHeader {
display: none;
}
.previewOnly__columnsSpacer,
.boardColumnsHeader__spacer {
display: none;
}
.previewOnly__dropGrid,
.boardColumnsHeader__grid {
grid-template-columns: 1fr;
}
.previewOnly__drop,
.row__drop {
padding-top: 40px;
}
.previewOnly__columnBadge,
.row__columnBadge {
position: absolute;
top: 10px;
left: 10px;
display: inline-flex;
align-items: center;
max-width: calc(100% - 20px);
padding: 5px 10px;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.08);
color: var(--theme-text-soft);
font-size: 11px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.heroCard {
grid-template-columns: 1fr;
@@ -2446,9 +2549,6 @@ onUnmounted(() => {
.row__content {
grid-template-columns: 1fr;
}
.row {
grid-template-columns: 150px 1fr;
}
.sidebar {
position: static;
}
@@ -2477,20 +2577,6 @@ onUnmounted(() => {
.previewOnly {
padding: 14px;
}
.previewOnly__columns,
.previewOnly__row,
.boardColumnsHeader,
.row {
grid-template-columns: 1fr;
}
.previewOnly__columnsSpacer,
.boardColumnsHeader__spacer {
display: none;
}
.previewOnly__dropGrid,
.boardColumnsHeader__grid {
grid-template-columns: 1fr;
}
.pool {
grid-template-columns: repeat(4, minmax(0, 1fr));
}