Compare commits

...

14 Commits

Author SHA1 Message Date
0a3fce2130 릴리스: v1.3.55 관리자 요청 썸네일 승계 및 배지 색상 구분 2026-04-02 11:54:53 +09:00
1d8e8581b8 릴리스: v1.3.54 관리자 요청 카드 정보 밀도 조정 2026-04-02 11:49:13 +09:00
036fc84fa6 릴리스: v1.3.53 관리자 featured/custom item 로직 분리 2026-04-02 11:40:10 +09:00
472b511b89 릴리스: v1.3.52 관리자 스타일 범위 및 티어표 모드 정리 2026-04-02 11:35:30 +09:00
6f8de5adf3 관리자 회원 관리 로직 composable 분리 2026-04-02 11:28:25 +09:00
147ff963ab 관리자 화면 분리 및 요청/아이템 관리 흐름 정리 2026-04-02 11:23:33 +09:00
66d408dca8 릴리스: v1.3.49 템플릿 요청 저장 흐름과 관리자 미리보기 정리 2026-04-01 19:01:07 +09:00
d5b4de1629 릴리스: v1.3.48 관리자 미리보기와 새로고침 로딩 보정 2026-04-01 18:37:01 +09:00
6828b868bc 릴리스: v1.3.47 관리자 템플릿 요청 카드 정렬 2026-04-01 18:24:01 +09:00
397461b7c0 릴리스: v1.3.46 관리자 요청 미리보기 정리 2026-04-01 18:11:16 +09:00
bd3ef5d13d 릴리스: v1.3.45 템플릿 요청 저장 오류 수정 2026-04-01 18:05:59 +09:00
322b72c511 릴리스: v1.3.44 관리자 미리보기 흐름 복구 2026-04-01 18:02:49 +09:00
508806bacd 릴리스: v1.3.43 템플릿 요청 토스트 상태 보정 2026-04-01 17:58:19 +09:00
c3af696cae 릴리스: v1.3.42 템플릿 요청 저장 분기 호환 보강 2026-04-01 17:53:02 +09:00
24 changed files with 2880 additions and 1548 deletions

View File

@@ -85,6 +85,7 @@ function mapGameItemRow(row) {
gameId: row.game_id,
src: row.src,
label: row.label,
displayOrder: row.display_order == null ? null : Number(row.display_order),
createdAt: Number(row.created_at),
}
}
@@ -271,12 +272,18 @@ async function ensureSchema() {
game_id VARCHAR(120) NOT NULL,
src VARCHAR(255) NOT NULL,
label VARCHAR(120) NOT NULL,
display_order INT NULL DEFAULT NULL,
created_at BIGINT NOT NULL,
INDEX idx_game_items_game_id (game_id),
CONSTRAINT fk_game_items_game FOREIGN KEY (game_id) REFERENCES games(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
`)
const gameItemDisplayOrderColumns = await query("SHOW COLUMNS FROM game_items LIKE 'display_order'")
if (!gameItemDisplayOrderColumns.length) {
await query('ALTER TABLE game_items ADD COLUMN display_order INT NULL DEFAULT NULL AFTER label')
}
await query(`
CREATE TABLE IF NOT EXISTS custom_items (
id VARCHAR(64) PRIMARY KEY,
@@ -443,6 +450,8 @@ async function ensureSchema() {
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")
} else if (tierListSourceIdColumns[0]?.Null !== 'YES') {
await query('ALTER TABLE tierlists MODIFY source_tierlist_id VARCHAR(64) NULL DEFAULT NULL')
}
const tierListSourceTitleColumns = await query("SHOW COLUMNS FROM tierlists LIKE 'source_snapshot_title'")
if (!tierListSourceTitleColumns.length) {
@@ -666,14 +675,23 @@ async function findGameById(id) {
async function listGameItems(gameId) {
const rows = await query(
'SELECT id, game_id, src, label, created_at FROM game_items WHERE game_id = ? ORDER BY created_at ASC',
`
SELECT id, game_id, src, label, display_order, created_at
FROM game_items
WHERE game_id = ?
ORDER BY
CASE WHEN display_order IS NULL THEN 1 ELSE 0 END ASC,
display_order ASC,
created_at DESC,
id DESC
`,
[gameId]
)
return rows.map(mapGameItemRow)
}
async function findGameItemById(itemId) {
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
}
@@ -1075,23 +1093,42 @@ async function clearImageOptimizationJobs({ month } = {}) {
}
async function createGameItem({ id, gameId, src, label }) {
const createdAt = now()
await query('INSERT INTO game_items (id, game_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [
const minOrderRows = await query('SELECT MIN(display_order) AS min_display_order FROM game_items WHERE game_id = ?', [gameId])
const nextDisplayOrder =
minOrderRows[0]?.min_display_order == null ? 0 : Number(minOrderRows[0].min_display_order) - 1
await query('INSERT INTO game_items (id, game_id, src, label, display_order, created_at) VALUES (?, ?, ?, ?, ?, ?)', [
id,
gameId,
src,
label,
nextDisplayOrder,
createdAt,
])
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [id])
return mapGameItemRow(rows[0])
}
async function updateGameItemLabel(itemId, label) {
await query('UPDATE game_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query('SELECT id, game_id, src, label, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
const rows = await query('SELECT id, game_id, src, label, display_order, created_at FROM game_items WHERE id = ? LIMIT 1', [itemId])
return mapGameItemRow(rows[0])
}
async function updateGameItemDisplayOrder(gameId, itemIds) {
const normalizedIds = Array.from(new Set((itemIds || []).filter(Boolean)))
const existingItems = await listGameItems(gameId)
const existingIdSet = new Set(existingItems.map((item) => item.id))
const orderedIds = normalizedIds.filter((id) => existingIdSet.has(id))
const remainingIds = existingItems.map((item) => item.id).filter((id) => !orderedIds.includes(id))
const finalIds = [...orderedIds, ...remainingIds]
await Promise.all(
finalIds.map((itemId, index) => query('UPDATE game_items SET display_order = ? WHERE id = ? AND game_id = ?', [index + 1, itemId, gameId]))
)
return listGameItems(gameId)
}
async function updateCustomItemLabel(itemId, label) {
await query('UPDATE custom_items SET label = ? WHERE id = ?', [label, itemId])
const rows = await query(`
@@ -1914,7 +1951,11 @@ async function findTemplateRequestById(id) {
return mapTemplateRequestRow(rows[0])
}
async function listAdminTemplateRequests({ status = 'pending' } = {}) {
async function listAdminTemplateRequests({ status = 'pending', statuses = [] } = {}) {
const requestedStatuses = Array.isArray(statuses) && statuses.length ? statuses : [status]
const validStatuses = requestedStatuses.filter((entry) => typeof entry === 'string' && entry.trim())
const normalizedStatuses = validStatuses.length ? validStatuses : ['pending']
const placeholders = normalizedStatuses.map(() => '?').join(', ')
const rows = await query(
`
SELECT
@@ -1943,10 +1984,16 @@ async function listAdminTemplateRequests({ status = 'pending' } = {}) {
INNER JOIN users u ON u.id = tr.requester_id
LEFT JOIN games sg ON sg.id = tr.source_game_id
LEFT JOIN games tg ON tg.id = tr.target_game_id
WHERE tr.status = ?
ORDER BY tr.created_at DESC
WHERE tr.status IN (${placeholders})
ORDER BY
CASE tr.status
WHEN 'pending' THEN 0
WHEN 'reviewing' THEN 1
ELSE 2
END,
tr.created_at DESC
`,
[status]
normalizedStatuses
)
return rows.map(mapTemplateRequestRow)
@@ -2019,6 +2066,7 @@ async function saveTierList({
return findTierListById(existing.id, authorId)
}
const nextId = id || nanoid()
const createdAt = now()
await query(
`
@@ -2027,9 +2075,9 @@ async function saveTierList({
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`,
[id, 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, sourceTierListId || null, sourceSnapshotTitle || '', sourceSnapshotAuthor || '', serializeJson(groups), serializeJson(pool), createdAt, createdAt]
)
return findTierListById(id, authorId)
return findTierListById(nextId, authorId)
}
async function duplicateTierListForUser({ tierList, targetUserId }) {
@@ -2108,6 +2156,7 @@ module.exports = {
getImageAssetStats,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,

View File

@@ -15,6 +15,7 @@ const {
updateGameThumbnail,
createGameItem,
updateGameItemLabel,
updateGameItemDisplayOrder,
updateCustomItemLabel,
updateImageAssetLabel,
deleteGameItem,
@@ -92,13 +93,21 @@ function canManageAdminRole(actingUser, primaryAdmin) {
}
router.post('/games', requireAdmin, async (req, res) => {
const schema = z.object({ id: z.string().min(1), name: z.string().min(1).max(60) })
const schema = z.object({
id: z.string().min(1),
name: z.string().min(1).max(60),
thumbnailSrc: z.string().max(255).optional().default(''),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const exists = await findGameById(parsed.data.id)
if (exists) return res.status(409).json({ error: 'game_id_taken' })
const game = await createGame({ id: parsed.data.id, name: parsed.data.name })
res.json({ game })
if (parsed.data.thumbnailSrc) {
const copiedThumb = await copyUploadIntoGameAsset(parsed.data.thumbnailSrc)
await updateGameThumbnail(game.id, copiedThumb)
}
res.json({ game: await findGameById(game.id) })
})
router.patch('/games/display-order', requireAdmin, async (req, res) => {
@@ -115,6 +124,20 @@ router.patch('/games/display-order', requireAdmin, async (req, res) => {
res.json({ games: updatedGames })
})
router.patch('/games/:gameId/items/display-order', requireAdmin, async (req, res) => {
const schema = z.object({
itemIds: z.array(z.string().min(1)).min(1),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const game = await findGameById(req.params.gameId)
if (!game) return res.status(404).json({ error: 'not_found' })
const items = await updateGameItemDisplayOrder(game.id, parsed.data.itemIds)
res.json({ items })
})
router.post('/games/:gameId/thumbnail', requireAdmin, upload.single('thumbnail'), async (req, res) => {
if (!req.file) return res.status(400).json({ error: 'file_required' })
const game = await findGameById(req.params.gameId)
@@ -262,7 +285,7 @@ router.get('/tierlists', requireAdmin, async (req, res) => {
})
router.get('/template-requests', requireAdmin, async (req, res) => {
const requests = await listAdminTemplateRequests({ status: 'pending' })
const requests = await listAdminTemplateRequests({ statuses: ['pending', 'reviewing'] })
res.json({ requests })
})
@@ -429,6 +452,16 @@ async function promoteSnapshotItemsToGame({ items, gameId }) {
return createdItems
}
function pickTemplateRequestItems(templateRequest, itemIds = [], itemLabels = {}) {
const requestedIds = new Set((itemIds || []).filter(Boolean))
const items = Array.isArray(templateRequest?.items) ? templateRequest.items : []
const filtered = requestedIds.size ? items.filter((item) => item?.id && requestedIds.has(item.id)) : items
return filtered.map((item) => ({
...item,
label: typeof itemLabels?.[item.id] === 'string' && itemLabels[item.id].trim() ? itemLabels[item.id].trim().slice(0, 60) : item.label,
}))
}
async function createGameTemplateFromTierList({ tierList, gameId, gameName }) {
await createGame({ id: gameId, name: gameName })
if (tierList.thumbnailSrc) {
@@ -610,6 +643,64 @@ router.post('/template-requests/:requestId/approve', requireAdmin, async (req, r
res.json({ request, ...result })
})
router.post('/template-requests/:requestId/review', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status === 'completed' || templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
return res.status(409).json({ error: 'request_already_handled' })
}
if (templateRequest.status === 'reviewing') {
return res.json({ request: templateRequest })
}
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
res.json({ request })
})
router.post('/template-requests/:requestId/promote-items', requireAdmin, async (req, res) => {
const schema = z.object({
gameId: z.string().trim().min(1).max(120),
itemIds: z.array(z.string().min(1)).optional().default([]),
itemLabels: z.record(z.string().min(1).max(60)).optional().default({}),
})
const parsed = schema.safeParse(req.body)
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (!['pending', 'reviewing'].includes(templateRequest.status)) {
return res.status(409).json({ error: 'request_already_handled' })
}
const game = await findGameById(parsed.data.gameId)
if (!game) return res.status(404).json({ error: 'game_not_found' })
const items = await promoteSnapshotItemsToGame({
items: pickTemplateRequestItems(templateRequest, parsed.data.itemIds, parsed.data.itemLabels),
gameId: game.id,
})
const request =
templateRequest.status === 'reviewing'
? templateRequest
: await updateTemplateRequestStatus({ id: templateRequest.id, status: 'reviewing' })
res.json({ request, items })
})
router.post('/template-requests/:requestId/complete', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })
if (templateRequest.status === 'completed') return res.json({ request: templateRequest })
if (templateRequest.status === 'rejected' || templateRequest.status === 'approved') {
return res.status(409).json({ error: 'request_already_handled' })
}
const request = await updateTemplateRequestStatus({ id: templateRequest.id, status: 'completed' })
res.json({ request })
})
router.post('/template-requests/:requestId/reject', requireAdmin, async (req, res) => {
const templateRequest = await findTemplateRequestById(req.params.requestId)
if (!templateRequest) return res.status(404).json({ error: 'not_found' })

View File

@@ -67,7 +67,6 @@ const templateRequestSchema = z.object({
thumbnailSrc: z.string().max(255).optional().default(''),
isPublic: z.boolean().optional().default(false),
showCharacterNames: z.boolean().optional().default(false),
saveToMyTierList: z.boolean().optional().default(true),
groups: z.array(
z.object({
id: z.string().min(1),
@@ -243,31 +242,14 @@ router.post('/template-request', requireAuth, async (req, res) => {
if (sourceTierList.authorId !== req.session.userId) return res.status(403).json({ error: 'forbidden' })
}
let savedTierList = null
if (payload.saveToMyTierList) {
savedTierList = await saveTierList({
id: sourceTierList?.id || undefined,
authorId: req.session.userId,
gameId: payload.gameId,
title: payload.requestTitle,
thumbnailSrc: payload.thumbnailSrc || '',
description: payload.requestDescription || '',
isPublic: !!payload.isPublic,
showCharacterNames: !!payload.showCharacterNames,
sourceTierListId: sourceTierList?.sourceTierListId || '',
sourceSnapshotTitle: sourceTierList?.sourceSnapshotTitle || '',
sourceSnapshotAuthor: sourceTierList?.sourceSnapshotAuthor || '',
groups: payload.groups,
pool: normalizedBoardItems,
})
}
if (!payload.sourceTierListId) return res.status(400).json({ error: 'source_tierlist_required' })
try {
const request = await createTemplateRequest({
id: nanoid(),
type: payload.type,
requesterId: req.session.userId,
sourceTierListId: savedTierList?.id || sourceTierList?.id || '',
sourceTierListId: sourceTierList?.id || '',
sourceGameId: payload.gameId,
targetGameId: payload.type === 'update' ? payload.gameId : '',
title: payload.requestTitle,
@@ -278,7 +260,7 @@ router.post('/template-request', requireAuth, async (req, res) => {
boardItems: normalizedBoardItems,
showCharacterNames: !!payload.showCharacterNames,
})
return res.json({ request, savedTierList: savedTierList ? normalizeTierList(savedTierList) : null })
return res.json({ request })
} catch (e) {
if (e?.code === 'TEMPLATE_REQUEST_EXISTS') {
return res.status(409).json({ error: 'template_request_exists' })

View File

@@ -1,5 +1,58 @@
# 의사결정 이력
## 2026-04-02 v1.3.55
- 관리자 요청/업로드 배지는 문구만 다르면 빠르게 구분하기 어려우므로, 같은 `pill` 구조를 유지하되 색으로도 역할을 나누는 편이 운영 판단에 더 적합하다고 정리했다.
- 신규 템플릿 요청으로 새 게임을 만들 때는 아이템만 가져오고 썸네일이 비어 있으면 식별성이 떨어지므로, 요청 썸네일도 기본값으로 함께 승계하는 편이 맞다고 판단했다.
## 2026-04-02 v1.3.54
- 관리자 요청 카드는 운영자가 이미 흐름을 알고 있다는 전제에서, 설명형 힌트보다 즉시 판단에 필요한 메타와 액션만 남기는 편이 더 적합하다고 정리했다.
- 요청 종류 표시는 중복 텍스트보다 오른쪽 상단의 짧은 상태 배지 하나로 고정하고, 하단 액션 줄은 `보조 링크는 왼쪽 / 실제 처리 버튼은 오른쪽` 구조가 더 읽기 쉽다고 판단했다.
## 2026-04-02 v1.3.53
- 관리자 후속 리팩터링은 남은 큰 액션 묶음인 `상단 고정 게임 정렬``커스텀 아이템 검수`부터 composable로 분리하는 편이 `AdminView.vue` 체감 복잡도를 가장 빨리 낮춘다고 판단했다.
- 이 단계에서도 레이아웃이나 문구보다 로직 책임 경계를 먼저 옮기고, 실제 스타일 파일 분리는 그 다음 단계로 이어가는 편이 안전하다고 정리했다.
## 2026-04-02 v1.3.52
- 관리자 화면은 본문을 컴포넌트로 나눈 뒤에도 같은 시각 문법을 유지해야 하므로, `scoped`를 유지한 채 각 섹션에 스타일을 복붙하기보다 관리자 범위 공통 스타일로 다시 묶는 편이 더 안전하다고 정리했다.
- `템플릿 요청 관리 / 전체 티어표 관리` 내부 모드 값은 URL과 버튼 상태가 어긋나지 않도록 `all` 하나로 통일하는 편이 맞다고 판단했다.
- 릴리스 기록은 문서 버전만 올라가고 태그가 빠지면 추적이 끊기므로, 뒤늦게라도 누락 태그를 다시 맞춰 버전 흐름을 복구하는 편이 낫다고 정리했다.
## 2026-04-02 v1.3.51
- 관리자 리팩터링은 본문 분리 다음 단계에서 `회원 관리`처럼 모달과 부수 액션이 많은 영역을 composable로 떼어내는 편이 효과가 크다고 판단했다.
- 이 단계에서는 UI 문구나 사용자가 이미 손본 CSS를 다시 건드리기보다, 현재 동작을 유지한 채 책임 경계만 옮기는 쪽이 더 안전하다고 정리했다.
## 2026-04-02 v1.3.50
- 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다.
- 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다.
- 관리자 화면은 기능이 많아진 만큼 단일 `/admin` 상태보다 섹션별 경로를 갖는 편이 뒤로가기와 직접 진입 모두에서 더 안정적이라고 정리했다.
- 관리자 URL은 보이기만 막는 수준이 아니라, 라우터 단계에서 비로그인/비관리자 접근 자체를 차단하는 편이 맞다고 정리했다.
- 티어표 에디터의 커스텀 아이템 이름 편집 목록은 “사전순 재정렬”보다 입력 안정성이 더 중요하므로, 실시간 라벨 기준 정렬은 제거하는 쪽으로 결정했다.
- 게임 기본 아이템은 최신 추가 항목이 먼저 보이도록 하되, 관리자가 필요하면 직접 드래그해 기준 순서를 고정할 수 있어야 한다고 판단했다.
- 관리자 리팩터링은 한 번에 로직까지 갈아엎기보다, 먼저 각 관리 본문을 섹션 컴포넌트로 분리해 `AdminView.vue`의 책임을 줄이는 단계형 접근이 더 안전하다고 정리했다.
- 본문 템플릿 분리 다음 단계에서는 `게임 관리``템플릿 요청`처럼 상태가 무거운 영역부터 composable로 옮겨, 뷰 파일과 업무 로직 파일의 경계를 먼저 세우는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.49
- 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다.
- 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다.
## 2026-04-01 v1.3.48
- 관리자 탭 데이터는 첫 진입 로딩만 믿기보다, 인증 완료와 탭 전환 시점에 필요한 목록을 다시 채워 넣는 편이 실제 운영 화면에서 더 안정적이라고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 완전히 같은 구현을 억지로 분기하기보다, 같은 내부 프레임 문법과 정보 밀도를 먼저 맞춰 체감 차이를 줄이는 쪽이 현실적이라고 판단했다.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청`도 결국 검수용 카드이므로, 요청 전용 카드 문법을 따로 두기보다 `전체 티어표 관리`와 같은 카드 구조를 재사용하는 편이 더 직관적이라고 정리했다.
- 새 템플릿 생성 요청의 기본 게임 ID는 사람이 읽기 어려운 난수보다 요청 단위에서 유일한 임시값을 먼저 채워두고, 승인 전에 관리자가 수정하는 흐름이 더 현실적이라고 판단했다.
## 2026-04-01 v1.3.46
- 관리자 전체 티어표 카드에서는 좌측 영역 전체를 버튼처럼 만드는 것보다, 실제 썸네일 이미지만 미리보기 진입점으로 읽히게 두는 편이 카드 정보 구조가 덜 흔들린다고 정리했다.
- 템플릿 요청 미리보기는 일반 티어표 보기와 다른 요약 레이아웃을 새로 두기보다, 같은 내부 프레임 문법 안에서 보드 자체를 먼저 보여주는 편이 사용자가 더 자연스럽게 이해한다고 판단했다.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`은 별도 부가 기능이 아니라 실제 저장본 생성 경로를 타므로, 새 저장본 ID는 호출자에 기대지 말고 저장 함수 내부에서 항상 보장하는 편이 더 안전하다고 정리했다.
- 개발 단계의 내부 조치 문구인 `백엔드 재시작` 같은 표현은 사용자 토스트에 직접 노출하지 않고, 운영형 재시도 안내로 낮추는 편이 맞다고 판단했다.
## 2026-04-01 v1.3.44
- 관리자 티어표 목록에서는 `보기` 버튼을 없애더라도 완성본 확인 기능 자체는 유지해야 하므로, 별도 액션 버튼보다 카드 썸네일 클릭을 미리보기 진입점으로 쓰는 편이 더 자연스럽다고 정리했다.
- 템플릿 요청 미리보기도 별도 요약 카드보다 실제 보드 구조를 우선 보여주는 쪽이 관리자 검수 흐름에 더 맞으므로, 일반 티어표 미리보기와 가까운 방향으로 통일하기로 했다.
## 2026-03-30 v1.2.25
- 홈 게임 카드는 메인 썸네일까지 없애는 것보다, 큰 썸네일은 유지하고 ID 옆의 작은 보조 표시만 제거하는 편이 원래 의도와 맞다고 정리했다.
- 좌우 하단 액션 여백은 `margin`으로 푸터 전체를 밀기보다, 푸터 내부 `padding-bottom`으로 확보해야 버튼 자체는 항상 보이고 여백만 남는다고 판단했다.

View File

@@ -12,8 +12,8 @@
## `/editor/:gameId/new`, `/editor/:gameId/:tierListId`
- 화면 파일: `frontend/src/views/TierEditorView.vue`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`
- 역할: 티어 그룹 편집, 티어 행 추가/삭제, 보드 옆 아이템 풀에서 관리자 아이템/커스텀 아이템 다중 드래그 앤 드롭 업로드, 공통 오른쪽 레일 안에 직접 배치되는 우측 편집 섹션에서 티어표 제목/설명/대표 썸네일/공개 여부/저장 제어와 커스텀 아이템 이름 정리, 즐겨찾기 토글, PNG 다운로드, 저장된 티어표 기준 템플릿 등록/업데이트 요청
- 연동 API: `GET /api/games/:gameId`, `GET /api/tierlists/:id`, `POST /api/tierlists/:id/favorite`, `DELETE /api/tierlists/:id/favorite`, `POST /api/tierlists/thumbnail`, `POST /api/tierlists/custom-items`, `POST /api/tierlists`, `POST /api/tierlists/template-request`
## `/login`
- 화면 파일: `frontend/src/views/LoginView.vue`
@@ -37,7 +37,7 @@
## `/admin`
- 화면 파일: `frontend/src/views/AdminView.vue`
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/완성본 이동, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 역할: 공통 우측 패널 대신 전용 `320px` 운영 패널을 사용해 `게임 관리 / 아이템 관리 / 티어표 관리 / 회원 관리` 탭과 검색·필터·빠른 작업을 우측에 배치하고, 중앙에는 선택된 게임 상세, 커스텀 아이템 목록, 템플릿 요청/전체 티어표, 회원 카드 등 실제 관리 대상만 표시, 기본 아이템 다중 드래그 앤 드롭 업로드, 기본 아이템 이름 수정, 사용자 커스텀 아이템 검색/페이지네이션/사용 횟수 확인/미사용 이미지 개별·일괄 삭제, 사용자 커스텀 아이템의 기본 템플릿 승격, 전체 티어표 검색/페이지네이션/공개 여부 확인/썸네일 클릭 기반 완성본 보기, 티어표의 추가 커스텀 아이템을 모달 기반으로 기존 템플릿 또는 새 템플릿에 가져오기, freeform 티어표의 게임 템플릿화, 사용자 템플릿 등록/업데이트 요청 승인·반려와 일반 완성본과 같은 보드 문법의 요청 미리보기, 회원 비밀번호 초기화 포함 회원 관리, 파일 입력 초기화, 아이템 삭제, 게임 삭제
- 연동 API: `POST /api/admin/games`, `POST /api/admin/games/:gameId/thumbnail`, `POST /api/admin/games/:gameId/images`, `PATCH /api/admin/games/:gameId/items/:itemId`, `GET /api/admin/custom-items`, `POST /api/admin/custom-items/:itemId/promote`, `DELETE /api/admin/custom-items/:itemId`, `DELETE /api/admin/custom-items`, `GET /api/admin/tierlists`, `GET /api/admin/template-requests`, `POST /api/admin/template-requests/:requestId/approve`, `POST /api/admin/template-requests/:requestId/reject`, `POST /api/admin/tierlists/:tierListId/promote-items`, `POST /api/admin/tierlists/:tierListId/create-game-template`, `GET /api/admin/users`, `PATCH /api/admin/users/:userId`, `PATCH /api/admin/users/:userId/password`, `DELETE /api/admin/users/:userId`, `DELETE /api/admin/games/:gameId/items/:itemId`, `DELETE /api/admin/games/:gameId`
## `/profile`

View File

@@ -49,12 +49,12 @@
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 공통 상단 토글 버튼은 Teleport로 이동한 로컬 편집 패널의 접힘/펼침 상태와도 연결되어, 우측 패널을 숨기면 중앙 보드 영역이 확장된다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다.
- 제목, 설명, 대표 썸네일, 공개 여부, 저장/삭제/요청 액션을 우측 로컬 패널에 배치한다. 템플릿 등록/업데이트 요청 버튼은 저장된 티어표가 있을 때만 노출하며, 제목이 비어 있는 상태에서 저장하면 랜덤 고유 제목을 먼저 부여해 저장본을 만든다.
- 보드 바로 옆에는 드래그용 아이템 풀을 별도 패널로 두고, 커스텀 아이템 이름 정리 목록은 우측 편집 패널 내부에서 관리한다.
- 관리자 화면
- 공통 우측 패널 대신 전용 로컬 운영 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 관리 목록은 메인 컬럼에, 우측 운영 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다.
- 우측 로컬 패널에는 `게임/아이템/티어표/회원 관리` 탭, 검색, 필터, 새로고침, 빠른 작업 제어를 배치하고, 중앙 영역에는 실제 관리 대상 목록과 상세만 렌더링한다. 템플릿 요청 카드는 전체 티어표 카드와 같은 썸네일 좌측/정보 우측 구조를 따르며, 요청 미리보기는 일반 티어표 완성본과 같은 행·열 보드 문법으로 검수한다.
- 상단 헤더에는 현재 탭 기준 요약 통계 카드를 배치해 운영 상태를 먼저 읽고, 각 관리 카드는 공통 대시보드 카드 문법(두꺼운 반경, 얕은 레이어 배경, 강조된 액션 버튼)을 공유한다.
## DB 스키마

View File

@@ -1,6 +1,14 @@
# 할 일 및 이슈
## 중기 개선
- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다.
- 관리자 본문 컴포넌트 분리와 `게임/템플릿 요청/회원 관리/아이템 관리/목록 관리` composable 분리는 시작했으므로, 다음 단계에서는 공통 모달 상태를 어느 계층에서 소유할지 정리하고 남은 관리자 유틸 함수를 더 줄인다.
- 관리자 화면은 섹션 경로 분리까지 끝났으므로, 다음 단계에서는 `AdminView.vue`를 실제 레이아웃 뷰와 섹션별 라우트 컴포넌트로 더 쪼갤지 결정한다.
- 관리자 공통 스타일은 `adminUiScope` 기준으로 다시 묶었으므로, 다음 단계에서는 각 섹션을 별도 파일로 완전히 분리할 때 스타일도 `admin.css` 또는 섹션별 스타일로 옮길지 결정한다.
- 관리자 요청 카드 밀도는 줄였으므로, 다음 단계에서는 전체 티어표 카드와 요청 카드의 상단/하단 액션 정렬을 한 번 더 통일할지 비교 QA한다.
- 신규 템플릿 요청 썸네일 기본 승계는 붙였으므로, 다음 단계에서는 요청 아이템 반영 후 `처리 완료`까지의 관리자 흐름을 실제 데이터로 한 번 더 QA한다.
- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치``관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다.
- 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다.
- 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다.
- 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다.
- 회원 일괄 작업(다중 선택, 일괄 비밀번호 초기화, 활동 저조 계정 정리) 같은 관리 보조 기능을 추가한다.
@@ -20,4 +28,6 @@
- 가이드 모달과 관리자 아이템 모달은 현재 같은 톤의 큰 셸을 쓰므로, 이후 공통 모달 레이아웃 컴포넌트로 분리할지 검토한다.
- 관리자 아이템 라이브러리 이름 변경은 템플릿·사용자 업로드·보관 자산까지 모두 가능하므로, 이후에는 일괄 이름 정리나 중복 이름 감지 보조 기능까지 검토한다.
- 템플릿 요청 500 오류 대응으로 `template_requests` 레거시 컬럼 자동 마이그레이션은 반영했으므로, 이후에는 실제 운영 DB에서 재시작 후 요청/승인 흐름을 한 번 더 확인한다.
- 관리자 템플릿 요청 카드와 전체 티어표 카드가 같은 문법으로 맞춰졌으므로, 이후에는 라이트모드까지 포함해 두 카드의 썸네일 높이·입력창 밀도·아이템 그리드를 한 번 더 비교 QA한다.
- 템플릿 요청 저장 흐름은 저장된 티어표 기준으로 바뀌었으므로, 이후 실제 데이터로 빈 제목 저장 시 자동 생성 제목·요청 버튼 노출 시점·관리자 요청 미리보기 밀도를 한 번 더 비교 QA한다.

View File

@@ -1,5 +1,74 @@
# 업데이트 로그
## 2026-04-02 v1.3.55
- 관리자 요청 카드 오른쪽 상단의 `신규 템플릿 / 보유 템플릿` 배지는 서로 다른 색상으로 분리해, 카드 타입을 텍스트보다 더 빠르게 구분할 수 있게 조정함.
- 게임 관리의 기본 아이템 추가 미리보기에서도 `요청 아이템 / 직접 추가 파일` 배지를 서로 다른 색상으로 구분해, 요청 반영분과 직접 업로드분이 한눈에 섞이지 않도록 정리함.
- 신규 템플릿 요청에서 `새 게임 만들기`를 진행할 때는 요청 티어표 대표 썸네일도 함께 새 게임 썸네일로 복사되도록 보강해, 관리자가 이후 수정하더라도 초기 식별용 썸네일은 바로 이어받을 수 있게 함.
## 2026-04-02 v1.3.54
- 관리자 `티어표 관리` 요청 카드에서는 사용법 힌트 문구와 중복 타입 텍스트를 제거해, 카드 본문이 관리 정보만 더 빠르게 읽히도록 정리함.
- `신규 템플릿 / 보유 템플릿` 구분은 카드 오른쪽 상단의 별도 배지로 옮기고, 기존 `추가 아이템 / 확인함 여부` 배지는 그대로 유지해 정보 계층을 더 단순하게 맞춤.
- `요청 티어표 보기` 링크는 하단 액션 줄의 왼쪽으로 옮기고 `확인하기 / 처리 완료` 버튼은 오른쪽에 정렬해, 실제 작업 버튼과 보조 링크의 역할이 한 줄 안에서도 분명하게 보이도록 조정함.
## 2026-04-02 v1.3.53
- 관리자 리팩터링 4차로 `목록 관리` 정렬 로직과 `아이템 관리` 모달/삭제/승격 액션을 각각 `useAdminFeaturedGames`, `useAdminCustomItems` composable로 분리해 `AdminView.vue`의 직접 액션 코드를 더 줄임.
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태 중심으로 더 가까워졌고, 상단 고정 게임 정렬과 커스텀 아이템 처리 흐름은 각 영역 책임에 맞는 파일로 옮겨 유지보수 범위를 좁힘.
## 2026-04-02 v1.3.52
- 관리자 본문 섹션을 컴포넌트로 나눈 뒤 `AdminView.vue` 스타일이 `scoped`에 묶여 자식 컴포넌트까지 제대로 닿지 않던 문제를 정리하고, 관리자 전용 공통 스타일을 `adminUiScope` 범위로 다시 묶어 각 페이지 CSS가 함께 살아나도록 보강함.
- 템플릿 요청 카드의 신규 게임 입력 영역에는 `게임 이름 / 게임 ID` 필드 스타일을 다시 붙여, 요청 카드만 따로 풀린 것처럼 보이던 레이아웃을 복구함.
- 관리자 사이드바의 `전체 티어표 관리` 모드는 내부 값이 `lists``all`로 엇갈리던 상태를 `all` 기준으로 통일해, 버튼 활성 상태와 실제 목록 전환이 어긋나지 않게 정리함.
- 운영 이력 정합성을 위해 누락돼 있던 릴리스 태그도 다시 점검하고, `v1.3.50`, `v1.3.51`, `v1.3.52` 흐름으로 이어서 관리함.
## 2026-04-02 v1.3.51
- 관리자 리팩터링 3차로 회원 관리 액션을 `useAdminUsers` composable로 분리해, 아바타 변경, 회원 정보 수정, 비밀번호 초기화, 권한 변경, 삭제 모달 흐름을 `AdminView.vue` 밖으로 옮김.
- 따라서 관리자 메인 뷰는 섹션 연결과 공통 상태에 더 집중하고, 회원 관리 로직은 다른 관리자 영역과 같은 composable 분리 기준으로 맞추기 시작함.
- 이번 정리에서도 관리자 화면에 직접 반영돼 있던 텍스트와 게임 관리 CSS 수정분은 유지한 채 구조만 옮기도록 정리함.
## 2026-04-02 v1.3.50
- 관리자 `템플릿 요청 관리` 카드에서는 대표 썸네일을 다시 복구해 게임 이름/ID와 함께 요청 대상을 더 빠르게 식별할 수 있게 정리함.
- `확인하기` 후 게임을 불러오면서 요청 아이템 임시 목록이 비워지던 흐름을 수정하고, 신규 게임 생성 직후에도 요청 아이템이 기본 아이템 추가 미리보기에 유지되도록 보강함.
- 관리자 상단 작업 모드는 `/admin/featured`, `/admin/games`, `/admin/items`, `/admin/tierlists`, `/admin/users` 경로로 나눠 뒤로가기 시 관리자 밖으로 바로 이탈하던 흐름을 줄임.
- 관리자 경로는 이제 라우터 가드에서 로그인/관리자 여부를 먼저 확인하고, 권한이 없으면 관리자 화면 자체에 접근하지 못하도록 홈으로 되돌림.
- 티어표 에디터의 커스텀 아이템 이름 편집 목록은 입력 중 실시간 라벨 정렬을 제거해, 입력 도중 포커스가 풀리거나 글자가 끊기던 현상을 막음.
- 게임 기본 아이템은 최신 추가 항목이 앞에 오도록 기본 정렬 기준을 바꾸고, 관리자 게임 관리 화면에서 현재 목록을 그대로 드래그해 순서를 저장할 수 있게 함.
- 관리자 대형 단일 뷰 정리를 시작하면서 `목록/게임/아이템/티어표/회원 관리` 본문을 섹션 컴포넌트로 분리해, `AdminView.vue`는 상태·모달·사이드바 중심 셸로 가볍게 정리함.
- 관리자 리팩터링 2차로 `게임 관리``템플릿 요청 처리` 로직을 `useAdminGameManager`, `useAdminTemplateRequests` composable로 분리해, `AdminView.vue` 스크립트에서도 섹션별 책임이 더 명확해지도록 정리함.
## 2026-04-01 v1.3.49
- 티어표 에디터의 템플릿 요청 흐름은 저장된 티어표를 기준으로만 요청을 보낼 수 있도록 다시 정리하고, 요청 모달의 `내 티어리스트에도 저장` 분기는 제거함. 제목이 비어 있는 상태에서 저장하면 `직접 티어표 만들기` 대신 랜덤한 고유 제목을 먼저 부여해 저장본을 만들고, 그 이후에만 템플릿 요청 버튼이 노출되도록 맞춤.
- 관리자 `사용자 템플릿 요청` 카드는 `전체 티어표 관리`와 같은 카드 문법으로 유지하되, 썸네일은 상단에 고정된 클릭 진입점으로 다시 정리하고 카드 본문과 별도 입력 영역의 밀도를 맞춤.
- 템플릿 요청 미리보기는 일반 티어표 완성본과 같은 보드 문법으로 다시 구성하고, `cells` 기반 배치 아이템도 남은 아이템 계산에 정확히 반영해 요청 미리보기와 일반 완성본 보기의 차이를 줄임.
## 2026-04-01 v1.3.48
- 관리자 화면은 새로고침 직후에도 `티어표 관리 / 회원 관리` 목록이 비지 않도록, 관리자 인증이 확정되거나 탭이 바뀔 때 해당 목록을 다시 불러오는 흐름으로 보강함.
- 관리자 아이템 모달은 내부 스크롤바를 숨기고 스크롤 체인을 끊어 배경이 함께 움직이지 않게 했고, 게임 선택 패널과 본문 패널의 상단 정렬도 다시 맞춤.
- 템플릿 요청 미리보기는 누락돼 있던 `requestPreview__frame / __header` 스타일을 보강해 일반 티어표 완성본과 더 비슷한 내부 프레임 구조와 보드 밀도로 다시 정리함.
## 2026-04-01 v1.3.47
- 관리자 `사용자 템플릿 요청` 카드는 별도 요청 전용 레이아웃 대신 `전체 티어표 관리`와 같은 카드 문법으로 맞추고, 왼쪽 썸네일 클릭으로 같은 미리보기 모달이 열리도록 정리함.
- 새 템플릿 요청에는 썸네일 아래에 `게임 이름 / 게임 ID` 입력을 두고, 초기 `게임 ID``new-template` 대신 요청 ID 기반의 임시 고유값으로 채워 나중에 수정하기 쉽게 바꿈.
- 요청 카드 오른쪽에는 제목, 설명, 요청 메타, 추가 아이템 목록, 승인/반려 버튼을 같은 정보 계층으로 배치해 전체 티어표 관리와 읽는 흐름을 통일함.
## 2026-04-01 v1.3.46
- 관리자 `전체 티어표 관리`의 썸네일 영역은 카드 좌측 전체가 눌리는 버튼처럼 보이지 않도록 이미지 영역만 상단에 붙여 클릭 진입점으로 유지하고, 카드 본문과의 시각적 분리를 다시 다듬음.
- `템플릿 요청 관리` 미리보기는 별도 썸네일 요약형이 아니라, 제목·설명·행/열 보드·남은 아이템이 하나의 내부 프레임 안에서 이어지는 실제 티어표 완성본형 레이아웃으로 다시 정리함.
## 2026-04-01 v1.3.45
- 템플릿 요청에서 `내 티어리스트에도 저장`이 켜져 있을 때 발생하던 500 오류는 새 저장본 생성 시 `tierlists.id``undefined`가 들어가던 문제였고, 이제 `saveTierList()`가 생성 시 자동으로 `nanoid()`를 부여하도록 고쳐 저장 분기 자체를 안정화함.
- 사용자에게 노출되던 `백엔드를 재시작해주세요` 문구는 제거하고, 저장 분기 실패 시에도 일반적인 재시도 안내만 보이도록 조정함.
- 루트에 잘못 남아 있던 `update.md` 진입점 파일은 제거하고, 업데이트 기록은 다시 `docs/update.md` 한 곳으로 정리함.
## 2026-04-01 v1.3.44
- 관리자 `전체 티어표 관리`에서는 별도 `완성본 보기` 버튼은 다시 두지 않되, 카드 썸네일 자체를 눌러 기존처럼 완성본 미리보기 모달을 열 수 있게 복구함.
- `템플릿 요청 관리`의 요청 미리보기는 요약 썸네일 중심 레이아웃을 줄이고, 실제 보드 구조를 먼저 읽는 방향으로 정리해 일반 티어표 완성본을 보는 흐름과 더 비슷하게 맞춤.
## 2026-04-01 v1.3.43
- 템플릿 요청 모달은 `내 티어리스트에도 저장` 토글 상태를 요청 직전에 별도로 고정해 사용하도록 바꿔, 모달이 닫히며 draft가 초기화된 뒤 성공 토스트가 반대로 나오던 문제를 바로잡음.
- 따라서 저장을 끈 상태에서는 `요청만 보냈어요` 문구가 정확히 유지되고, 저장을 켠 상태에서 500이 나는 경우에는 저장 단계에서 실패했다는 안내를 더 분명하게 보여주도록 보강함.
## 2026-04-01 v1.3.42
- 템플릿 요청 시 `내 티어리스트에도 저장`이 켜져 있을 때만 500 오류가 날 수 있던 레거시 `tierlists.source_tierlist_id` nullability 문제도 함께 보강해, 오래된 DB 스키마에서도 요청 전 저장 흐름이 막히지 않도록 정리함.
- 따라서 템플릿 요청 관련 레거시 호환 보정은 `template_requests``tierlists` 양쪽에 모두 반영됐고, 실제 적용을 위해서는 백엔드 재시작 후 재확인이 필요함.
## 2026-04-01 v1.3.41
- 템플릿 요청 등록 시 500 오류가 날 수 있던 레거시 DB 호환 문제를 보강해, 기존 `template_requests` 테이블에 `request_type`, `source_game_id`, `target_game_id`, `status` 컬럼이 빠져 있어도 서버 시작 시 자동으로 마이그레이션되도록 함.
- 따라서 저장 여부와 무관하게 템플릿 요청 흐름은 유지되고, 구버전 DB를 사용 중이더라도 백엔드 재시작 후 같은 요청이 정상 저장되도록 안정성을 높임.

View File

@@ -35,7 +35,8 @@ provide('localRightRailTarget', '#local-right-rail-root')
const authReady = computed(() => auth.hydrated)
const isAdmin = computed(() => authReady.value && !!auth.user?.isAdmin)
const isPreviewMode = computed(() => route.query.preview === '1')
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor', 'admin'].includes(String(route.name || '')))
const isAdminRoute = computed(() => String(route.name || '').startsWith('admin'))
const usesLocalRightRail = computed(() => ['editEditor', 'newEditor'].includes(String(route.name || '')) || isAdminRoute.value)
const isRightRailOverlay = computed(() => viewportWidth.value <= 1200)
const isMobileLayout = computed(() => viewportWidth.value <= 860)
const avatarUrl = computed(() => (auth.user?.avatarSrc ? toApiUrl(auth.user.avatarSrc) : ''))
@@ -179,7 +180,7 @@ const routeMeta = computed(() => {
action: () => router.push('/'),
}
}
if (route.name === 'admin') {
if (isAdminRoute.value) {
return {
title: 'Admin Workspace',
subtitle: '게임·아이템·회원 관리',
@@ -471,7 +472,7 @@ function submitGlobalSearch() {
<SvgIcon :src="iconMenuBook" :size="18" class="adminButton__icon" />
<span>가이드 보기</span>
</button>
<RouterLink v-if="authReady && isAdmin" to="/admin" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-if="authReady && isAdmin" to="/admin/featured" class="adminButton">관리자 메뉴</RouterLink>
<RouterLink v-else-if="authReady && !auth.user" to="/login" class="adminButton">로그인</RouterLink>
</div>
</div>

View File

@@ -0,0 +1,64 @@
<script setup>
const props = defineProps({
featuredGames: { type: Array, required: true },
availableGamesForFeatured: { type: Array, required: true },
featuredGameIds: { type: Array, required: true },
featuredListRef: { type: Function, required: true },
saveFeaturedOrder: { type: Function, required: true },
moveFeaturedGame: { type: Function, required: true },
removeFeaturedGame: { type: Function, required: true },
addFeaturedGame: { type: Function, required: true },
})
</script>
<template>
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title"> 화면 상단 고정 순서</div>
<div class="hint hint--tight">여기에 넣은 게임은 지정한 순서대로 먼저 노출되고, 나머지 게임은 최근 생성순으로 뒤에 이어집니다. 최대 50개까지 설정할 있어요.</div>
</div>
<button class="btn btn--primary" @click="props.saveFeaturedOrder">순서 저장</button>
</div>
<div class="featuredOrderPanel">
<div class="featuredOrderPanel__list">
<div class="section__title">상단 고정 목록</div>
<div v-if="!props.featuredGames.length" class="hint">아직 상단 고정 게임이 없어요.</div>
<div v-else :ref="props.featuredListRef" class="featuredList">
<article v-for="(game, index) in props.featuredGames" :key="game.id" class="featuredCard" :data-featured-id="game.id">
<div class="featuredCard__meta">
<span class="featuredCard__rank">{{ index + 1 }}</span>
<div>
<div class="featuredCard__title">{{ game.name }}</div>
<div class="featuredCard__id">{{ game.id }}</div>
</div>
</div>
<div class="featuredCard__actions">
<button class="btn btn--ghost btn--small" data-featured-handle>드래그</button>
<button class="btn btn--ghost btn--small" :disabled="index === 0" @click="props.moveFeaturedGame(game.id, -1)">위로</button>
<button class="btn btn--ghost btn--small" :disabled="index === props.featuredGames.length - 1" @click="props.moveFeaturedGame(game.id, 1)">아래로</button>
<button class="btn btn--danger btn--small" @click="props.removeFeaturedGame(game.id)">제외</button>
</div>
</article>
</div>
</div>
<div class="featuredOrderPanel__picker">
<div class="section__title">게임 추가</div>
<div class="featuredPickerList">
<button
v-for="game in props.availableGamesForFeatured"
:key="game.id"
class="featuredPickerItem"
:disabled="props.featuredGameIds.length >= 50"
@click="props.addFeaturedGame(game.id)"
>
<span>{{ game.name }}</span>
<span class="featuredPickerItem__id">{{ game.id }}</span>
</button>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,173 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
const props = defineProps({
activeTemplateRequest: { type: Object, default: null },
templateRequestSourceUrl: { type: Function, required: true },
stagedRequestDraftCount: { type: Number, required: true },
openGameCreateModal: { type: Function, required: true },
isGameLoading: { type: Boolean, required: true },
hasSelectedGame: { type: Boolean, required: true },
selectedGame: { type: Object, default: null },
itemFileInputRef: { type: Function, required: true },
onFile: { type: Function, required: true },
isItemDragOver: { type: Boolean, required: true },
onItemDragEnter: { type: Function, required: true },
onItemDragOver: { type: Function, required: true },
onItemDragLeave: { type: Function, required: true },
onItemDrop: { type: Function, required: true },
openItemFilePicker: { type: Function, required: true },
uploadItemDrafts: { type: Array, required: true },
clearItemFiles: { type: Function, required: true },
canAddItem: { type: Boolean, required: true },
uploadItem: { type: Function, required: true },
removeUploadDraft: { type: Function, required: true },
hasGameItemOrderChanges: { type: Boolean, required: true },
saveGameItemOrder: { type: Function, required: true },
gameItemListRef: { type: Function, required: true },
saveGameItemLabel: { type: Function, required: true },
removeGameItem: { type: Function, required: true },
selectedGameId: { type: String, default: '' },
})
</script>
<template>
<div v-if="props.activeTemplateRequest" class="panel requestWorkspace">
<div class="requestWorkspace__head">
<div>
<div class="panel__title">진행 중인 요청 작업</div>
<div class="requestWorkspace__title">{{ props.activeTemplateRequest.sourceTierListTitle || '템플릿 요청' }}</div>
<div class="hint hint--tight">
{{ props.activeTemplateRequest.type === 'create' ? '새 게임을 만든 뒤 필요한 아이템만 골라 저장하세요.' : '필요한 아이템만 남긴 뒤 기본 아이템 추가 버튼으로 반영하세요.' }}
</div>
</div>
<div class="requestWorkspace__stats">
<span class="pill pill--accent">{{ props.activeTemplateRequest.type === 'create' ? '신규 게임 요청' : '기존 게임 업데이트' }}</span>
<span class="pill">요청 아이템 {{ props.stagedRequestDraftCount }}</span>
</div>
</div>
<div class="requestWorkspace__actions">
<a
v-if="props.templateRequestSourceUrl(props.activeTemplateRequest)"
class="btn btn--ghost btn--small"
:href="props.templateRequestSourceUrl(props.activeTemplateRequest)"
target="_blank"
rel="noreferrer"
>
요청 티어표 보기
</a>
<button v-if="props.activeTemplateRequest.type === 'create'" class="btn btn--ghost btn--small" type="button" @click="props.openGameCreateModal">
게임 만들기
</button>
</div>
</div>
<div v-if="props.isGameLoading" class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임 정보를 불러오는 중이에요.</div>
<div class="emptyState__desc">선택한 게임의 썸네일과 기본 아이템을 표시합니다.</div>
</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>
</div>
</div>
<div class="section">
<section class="adminCard">
<div class="section__title">기본 아이템 추가</div>
<div class="itemComposer">
<div class="itemComposer__form">
<input :ref="props.itemFileInputRef" type="file" accept="image/*" multiple class="srOnlyInput" @change="props.onFile" />
<div
class="dropZone"
:class="{ 'dropZone--active': props.isItemDragOver }"
@dragenter="props.onItemDragEnter"
@dragover="props.onItemDragOver"
@dragleave="props.onItemDragLeave"
@drop="props.onItemDrop"
>
<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="props.openItemFilePicker">파일 선택</button>
<button class="btn btn--danger btn--small" type="button" :disabled="!props.uploadItemDrafts.length" @click="props.clearItemFiles">선택 비우기</button>
</div>
</div>
<button class="btn" :disabled="!props.canAddItem" @click="props.uploadItem">
아이템 {{ props.uploadItemDrafts.length || 0 }} 추가
</button>
</div>
<div class="itemPreviewCard">
<div v-if="props.uploadItemDrafts.length" class="itemDraftList">
<div
v-for="draft in props.uploadItemDrafts"
:key="draft.kind + ':' + (draft.itemId || draft.file?.name || draft.previewUrl)"
class="itemDraftRow"
>
<div class="itemDraftRow__preview">
<img class="itemPreviewImage" :src="draft.previewUrl" :alt="draft.sourceName || 'item preview'" />
</div>
<div class="itemDraftRow__body">
<input v-model="draft.label" class="input input--labelEdit input--dense" maxlength="60" placeholder="아이템 이름" />
<div class="hint hint--tight">{{ draft.sourceName }}</div>
<div class="itemDraftRow__meta">
<span class="pill" :class="draft.kind === 'request' ? 'pill--requestItem' : 'pill--directFile'">
{{ draft.kind === 'request' ? '요청 아이템' : '직접 추가 파일' }}
</span>
<button class="btn btn--danger btn--small" type="button" @click="props.removeUploadDraft(draft)">제외</button>
</div>
</div>
</div>
</div>
<div v-else class="itemPreviewEmpty">등록한 기본 아이템 미리보기가 여기에 표시됩니다.</div>
<div class="thumbLabel thumbLabel--preview">
{{ props.uploadItemDrafts.length ? `추가 예정 아이템 ${props.uploadItemDrafts.length}` : '아직 선택된 파일이 없어요.' }}
</div>
</div>
</div>
</section>
</div>
<div class="section">
<div class="sectionHeader">
<div>
<div class="section__title">현재 기본 아이템 목록</div>
<div class="hint hint--tight">드래그해서 기본 노출 순서를 바꿀 있어요. 저장 전까지는 화면에서만 순서가 바뀝니다.</div>
</div>
<button class="btn btn--primary btn--small" :disabled="!props.hasGameItemOrderChanges" @click="props.saveGameItemOrder">순서 저장</button>
</div>
<div v-if="!props.selectedGame?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="props.gameItemListRef" class="thumbGrid">
<div v-for="item in props.selectedGame.items" :key="item.id" class="thumbCard" :data-game-item-id="item.id">
<img class="thumb thumb--game" :src="toApiUrl(item.src)" :alt="item.label" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" />
<div class="thumbCard__actions">
<button
class="btn btn--ghost btn--small"
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
@click="props.saveGameItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" @click="props.removeGameItem(item.id)">아이템 삭제</button>
</div>
</div>
</div>
</div>
</div>
<div v-else class="panel panel--empty">
<div class="emptyState">
<div class="emptyState__title">게임을 선택해 주세요.</div>
<div v-if="props.activeTemplateRequest?.type === 'create'" class="hint hint--tight">진행 중인 신규 게임 요청이 있어요. 위의 ` 게임 만들기` 게임을 만든 아이템을 추가할 있습니다.</div>
<div v-if="props.selectedGameId" class="hint hint--tight">선택한 게임을 찾지 못했거나 로딩 오류가 발생했어요. 다시 선택해보세요.</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,31 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
const props = defineProps({
customItems: { type: Array, required: true },
openCustomItemModal: { type: Function, required: true },
customItemPage: { type: Number, required: true },
customItemPageCount: { type: Number, required: true },
customItemTotal: { type: Number, required: true },
moveCustomItemPage: { type: Function, required: true },
})
</script>
<template>
<div class="panel">
<div v-if="!props.customItems.length" class="hint">조건에 맞는 관리 대상 아이템이 없어요.</div>
<div v-else class="customItemGrid">
<button v-for="item in props.customItems" :key="item.id" type="button" class="customItemCard" @click="props.openCustomItemModal(item)">
<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>
</button>
</div>
<div class="pager">
<button class="btn btn--ghost" :disabled="props.customItemPage <= 1" @click="props.moveCustomItemPage(-1)">이전</button>
<div class="pager__info">{{ props.customItemPage }} / {{ props.customItemPageCount }} 페이지 · {{ props.customItemTotal }}</div>
<button class="btn btn--ghost" :disabled="props.customItemPage >= props.customItemPageCount" @click="props.moveCustomItemPage(1)">다음</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,175 @@
<script setup>
import { toApiUrl } from '../../lib/runtime'
const props = defineProps({
tierlistsMode: { type: String, required: true },
templateRequests: { type: Array, required: true },
openTemplateRequestPreview: { type: Function, required: true },
fmt: { type: Function, required: true },
templateRequestTargetLabel: { type: Function, required: true },
templateRequestStatusLabel: { type: Function, required: true },
templateRequestSourceUrl: { type: Function, required: true },
startTemplateRequestReview: { type: Function, required: true },
completeTemplateRequest: { type: Function, required: true },
adminTierLists: { type: Array, required: true },
tierListThumbUrl: { type: Function, required: true },
openAdminTierList: { type: Function, required: true },
tierListAuthorDisplayName: { type: Function, required: true },
tierListVisibilityLabel: { type: Function, required: true },
openTierListExtraItemModal: { type: Function, required: true },
openTierListImportModal: { type: Function, required: true },
adminTierListPage: { type: Number, required: true },
adminTierListPageCount: { type: Number, required: true },
adminTierListTotal: { type: Number, required: true },
moveAdminTierListPage: { type: Function, required: true },
})
</script>
<template>
<div v-if="props.tierlistsMode === 'requests'" class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">사용자 요청</div>
</div>
</div>
<div v-if="!props.templateRequests.length" class="hint">현재 처리 대기 중인 템플릿 요청이 없어요.</div>
<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" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
<div class="templateRequestCard__thumbMeta">
<template v-if="request.type === 'create'">
<label class="templateRequestField">
<span class="templateRequestField__label">게임 이름</span>
<input v-model="request.draftGameName" class="input" placeholder="새 게임 이름" />
</label>
<label class="templateRequestField">
<span class="templateRequestField__label">게임 ID</span>
<input v-model="request.draftGameId" class="input" placeholder="임시 게임 ID" />
</label>
</template>
<template v-else>
<div class="templateRequestCard__thumbLabel">게임 이름</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameName || request.sourceGameName || '-' }}</div>
<div class="templateRequestCard__thumbLabel">게임 ID</div>
<div class="templateRequestCard__thumbValue">{{ request.draftGameId || request.sourceGameId || '-' }}</div>
</template>
</div>
</div>
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
<div>
<span
class="pill templateRequestCard__cornerBadge"
:class="request.type === 'create' ? 'pill--create' : 'pill--owned'"
>
{{ request.type === 'create' ? '신규 템플릿' : '보유 템플릿' }}
</span>
<div class="tierAdminCard__title">{{ request.sourceTierListTitle }}</div>
<div v-if="request.sourceDescription" class="tierAdminCard__desc">{{ request.sourceDescription }}</div>
<div class="tierAdminCard__meta">
{{ request.requesterName }} · {{ props.fmt(request.createdAt) }}
</div>
<div class="tierAdminCard__meta">{{ props.templateRequestTargetLabel(request) }}</div>
</div>
</div>
<div class="tierAdminCard__stats">
<span class="pill">추가 아이템 {{ request.items?.length || 0 }}</span>
<span class="pill" :class="{ 'pill--accent': request.status === 'reviewing' }">{{ props.templateRequestStatusLabel(request) }}</span>
</div>
<div v-if="request.items?.length" class="tierAdminItemList templateRequestCard__items">
<button v-for="item in request.items" :key="item.id" class="tierAdminItem" type="button">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__title">{{ item.label }}</div>
</button>
</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__actions">
<button class="btn btn--primary" :disabled="request.isHandling" @click="props.startTemplateRequestReview(request)">
{{ request.isHandling ? '이동중...' : '확인하기' }}
</button>
<button class="btn btn--ghost" :disabled="request.isHandling || request.status !== 'reviewing'" @click="props.completeTemplateRequest(request)">처리 완료</button>
</div>
</div>
</div>
</article>
</div>
</div>
<div v-else class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">전체 티어표 관리</div>
</div>
</div>
<div v-if="!props.adminTierLists.length" class="hint">조건에 맞는 티어표가 없어요.</div>
<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" />
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
</button>
<div class="tierAdminCard__body">
<div class="tierAdminCard__head">
<div>
<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) }}
</div>
<div class="tierAdminCard__meta">{{ props.fmt(tierList.updatedAt) }}</div>
</div>
</div>
<div class="tierAdminCard__stats">
<span class="pill">전체 아이템 {{ tierList.itemCount }}</span>
<span class="pill" :class="{ 'pill--accent': tierList.extraItemCount > 0 }">추가 아이템 {{ tierList.extraItemCount }}</span>
</div>
<div v-if="tierList.extraItems?.length" class="tierAdminSection">
<div class="tierAdminSection__title">추가로 넣은 아이템</div>
<div class="tierAdminItemList">
<button v-for="item in tierList.extraItems" :key="item.id" class="tierAdminItem" @click="props.openTierListExtraItemModal(item, tierList)">
<img class="tierAdminItem__thumb" :src="toApiUrl(item.src)" :alt="item.label" />
<div class="tierAdminItem__title">{{ item.label }}</div>
</button>
</div>
<div class="tierAdminSection__actions">
<button class="btn btn--ghost btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">추가 아이템 전체 가져오기</button>
<button v-if="tierList.gameId === 'freeform'" class="btn btn--primary btn--small" @click="props.openTierListImportModal(tierList, tierList.extraItems)">
템플릿으로 가져오기
</button>
</div>
</div>
</div>
</article>
</div>
<div class="pager">
<button class="btn btn--ghost" :disabled="props.adminTierListPage <= 1" @click="props.moveAdminTierListPage(-1)">이전</button>
<div class="pager__info">{{ props.adminTierListPage }} / {{ props.adminTierListPageCount }} 페이지 · {{ props.adminTierListTotal }}</div>
<button class="btn btn--ghost" :disabled="props.adminTierListPage >= props.adminTierListPageCount" @click="props.moveAdminTierListPage(1)">다음</button>
</div>
</div>
</template>

View File

@@ -0,0 +1,125 @@
<script setup>
import { computed } from 'vue'
import SvgIcon from '../SvgIcon.vue'
const props = defineProps({
userQuery: { type: String, required: true },
userSort: { type: String, required: true },
userSortDirection: { type: String, required: true },
users: { type: Array, required: true },
submitUserFilters: { type: Function, required: true },
setUserAvatarInput: { type: Function, required: true },
onUserAvatarChange: { type: Function, required: true },
openUserAvatarPicker: { type: Function, required: true },
userAvatarUrl: { type: Function, required: true },
userDisplayName: { type: Function, required: true },
userAvatarFallback: { type: Function, required: true },
removeUserAvatar: { type: Function, required: true },
roleLabelOf: { type: Function, required: true },
fmt: { type: Function, required: true },
openUserPasswordModal: { type: Function, required: true },
openUserDeleteModal: { type: Function, required: true },
openUserEditModal: { type: Function, required: true },
lockResetIcon: { type: String, required: true },
deleteIcon: { type: String, required: true },
})
const emit = defineEmits(['update:userQuery', 'update:userSort', 'update:userSortDirection'])
const userQueryModel = computed({
get: () => props.userQuery,
set: (value) => emit('update:userQuery', value),
})
const userSortModel = computed({
get: () => props.userSort,
set: (value) => emit('update:userSort', value),
})
const userSortDirectionModel = computed({
get: () => props.userSortDirection,
set: (value) => emit('update:userSortDirection', value),
})
</script>
<template>
<div class="panel">
<div class="sectionHeader">
<div>
<div class="panel__title">회원 관리</div>
<div class="hint hint--tight">회원 프로필을 정리하고, 필요한 경우에만 권한 변경과 비밀번호 초기화를 진행할 있어요.</div>
</div>
</div>
<div class="toolbar toolbar--secondary">
<input v-model="userQueryModel" class="input toolbar__search" placeholder="이메일, 닉네임 검색" @keydown.enter.prevent="props.submitUserFilters" />
<select v-model="userSortModel" class="select toolbar__select" @change="props.submitUserFilters">
<option value="recent">최근 활동순</option>
<option value="created">가입순</option>
<option value="tierlists">작성 티어표 많은 </option>
</select>
<select v-model="userSortDirectionModel" class="select toolbar__select" @change="props.submitUserFilters">
<option value="desc">내림차순</option>
<option value="asc">오름차순</option>
</select>
<button class="btn btn--ghost toolbar__button" type="button" @click="props.submitUserFilters">조회</button>
</div>
<div v-if="!props.users.length" class="hint">아직 가입한 회원이 없어요.</div>
<div v-else class="userList">
<article v-for="user in props.users" :key="user.id" class="userCard">
<div class="userCard__head">
<div class="userCard__identity">
<input
:ref="(el) => props.setUserAvatarInput(user.id, el)"
type="file"
accept="image/*"
class="srOnlyInput"
@change="props.onUserAvatarChange(user, $event)"
/>
<div class="userAvatarWrap">
<button class="userAvatar userAvatarButton" type="button" :disabled="user.isAvatarBusy" @click="props.openUserAvatarPicker(user)">
<img v-if="props.userAvatarUrl(user)" class="userAvatar__image" :src="props.userAvatarUrl(user)" :alt="props.userDisplayName(user)" />
<span v-else class="userAvatar__fallback">{{ props.userAvatarFallback(user) }}</span>
<span class="userAvatarButton__overlay">{{ user.isAvatarBusy ? '업데이트중...' : '수정' }}</span>
</button>
<button
v-if="user?.avatarSrc"
class="userAvatarRemoveButton"
type="button"
title="회원 썸네일 삭제"
:disabled="user.isAvatarBusy"
@click.stop="props.removeUserAvatar(user)"
>
<SvgIcon class="userAvatarRemoveIcon" :src="props.deleteIcon" :size="12" />
</button>
</div>
<div class="userCard__identityMeta">
<div class="userCard__title">{{ props.userDisplayName(user) }}</div>
<div class="userCard__meta">{{ user.email }}</div>
</div>
</div>
</div>
<div v-if="user.isAdmin" class="roleBadge userCard__roleBadge">{{ props.roleLabelOf(user) }}</div>
<div class="userInfoList">
<div class="userInfoLine"><span>가입일</span><strong>{{ props.fmt(user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>작성 티어표</span><strong>{{ user.tierListCount }}</strong></div>
<div class="userInfoLine"><span>최근 활동</span><strong>{{ props.fmt(user.recentActivityAt || user.createdAt) }}</strong></div>
<div class="userInfoLine"><span>계정명</span><strong>{{ user.email }}</strong></div>
<div class="userInfoLine"><span>닉네임</span><strong>{{ user.nickname || '미설정' }}</strong></div>
<div class="userInfoLine"><span>권한</span><strong>{{ props.roleLabelOf(user) }}</strong></div>
</div>
<div class="userCard__actions userCard__actions--compact">
<button class="iconActionButton" type="button" title="비밀번호 초기화" @click="props.openUserPasswordModal(user)">
<SvgIcon class="iconActionButton__icon" :src="props.lockResetIcon" :size="18" />
</button>
<button class="iconActionButton iconActionButton--danger" type="button" title="회원 삭제" @click="props.openUserDeleteModal(user)">
<SvgIcon class="iconActionButton__icon" :src="props.deleteIcon" :size="18" />
</button>
<button class="btn btn--ghost userSaveButton" type="button" @click="props.openUserEditModal(user)">회원 정보 수정</button>
</div>
</article>
</div>
</div>
</template>

View File

@@ -0,0 +1,203 @@
import { nextTick } from 'vue'
export function useAdminCustomItems({
api,
toast,
customItems,
customItemPage,
customItemLimit,
customItemPageCount,
customItemQuery,
customItemOrphanOnly,
customItemModalOpen,
customItemDeleteModalOpen,
customItemModalHistoryActive,
modalTargetCustomItem,
customItemModalDraftLabel,
customItemModalLabelSaving,
customItemModalTargetGameId,
customItemModalGameQuery,
customItemModalGameSort,
games,
selectedGameId,
refreshCustomItems,
loadGame,
setTab,
selectAdminGame,
resetMessages,
success,
error,
}) {
function submitCustomItemSearch() {
customItemPage.value = 1
refreshCustomItems()
}
function toggleCustomItemOrphanOnly() {
customItemPage.value = 1
refreshCustomItems()
}
function changeCustomItemLimit(limit) {
customItemLimit.value = limit
customItemPage.value = 1
refreshCustomItems()
}
function moveCustomItemPage(direction) {
const nextPage = customItemPage.value + direction
if (nextPage < 1 || nextPage > customItemPageCount.value) return
customItemPage.value = nextPage
refreshCustomItems()
}
function pushCustomItemModalHistoryState() {
if (typeof window === 'undefined') return
window.history.pushState({ ...(window.history.state || {}), adminCustomItemModal: true }, '', window.location.href)
customItemModalHistoryActive.value = true
}
function openCustomItemModal(item) {
modalTargetCustomItem.value = item || null
customItemModalDraftLabel.value = item?.label || ''
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
customItemModalOpen.value = true
pushCustomItemModalHistoryState()
}
function closeCustomItemModal({ fromPopState = false } = {}) {
customItemModalOpen.value = false
customItemDeleteModalOpen.value = false
modalTargetCustomItem.value = null
customItemModalDraftLabel.value = ''
customItemModalLabelSaving.value = false
customItemModalTargetGameId.value = ''
customItemModalGameQuery.value = ''
customItemModalGameSort.value = 'recent'
if (fromPopState) {
customItemModalHistoryActive.value = false
return
}
if (customItemModalHistoryActive.value && typeof window !== 'undefined') {
customItemModalHistoryActive.value = false
window.history.back()
}
}
function openCustomItemDeleteModal(item) {
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
modalTargetCustomItem.value = item
customItemDeleteModalOpen.value = true
}
function closeCustomItemDeleteModal() {
customItemDeleteModalOpen.value = false
}
function jumpToGameAdmin(gameId) {
if (!gameId) return
closeCustomItemModal()
setTab('game-admin')
nextTick(() => {
selectAdminGame(gameId)
})
}
async function removeCustomItem(item = modalTargetCustomItem.value) {
resetMessages()
if (!item) return
if (item.sourceType === 'user' && (item.usageCount > 0 || item.linkedGames.length > 0)) {
error.value = '사용 중이거나 템플릿에 연결된 사용자 업로드 이미지는 먼저 참조를 정리해야 삭제할 수 있어요.'
return
}
try {
await api.deleteAdminCustomItem(item.id)
closeCustomItemDeleteModal()
closeCustomItemModal()
await refreshCustomItems()
success.value = item.sourceType === 'template' ? '선택한 템플릿 아이템을 제거했어요.' : '사용자 업로드 이미지를 삭제했어요.'
} catch (e) {
error.value = item.sourceType === 'template' ? '템플릿 아이템 제거에 실패했어요.' : '사용자 업로드 이미지 삭제에 실패했어요.'
}
}
async function removeUnusedCustomItems() {
resetMessages()
const ok = window.confirm('현재 검색 조건에 맞는 미사용 커스텀 이미지를 모두 삭제할까요?')
if (!ok) return
try {
const data = await api.deleteAdminUnusedCustomItems({ q: customItemQuery.value })
await refreshCustomItems()
success.value = `${data.deletedCount || 0}개의 미사용 사용자 업로드 이미지를 삭제했어요.`
} catch (e) {
error.value = '미사용 커스텀 이미지 일괄 삭제에 실패했어요.'
}
}
async function saveCustomItemModalLabel() {
const item = modalTargetCustomItem.value
const nextLabel = customItemModalDraftLabel.value.trim().slice(0, 60)
if (!item || !nextLabel || nextLabel === item.label || customItemModalLabelSaving.value) return
try {
customItemModalLabelSaving.value = true
const data = await api.updateAdminCustomItemLabel(item.id, { label: nextLabel, sourceType: item.sourceType })
item.label = data.item?.label || nextLabel
customItemModalDraftLabel.value = item.label
customItems.value = customItems.value.map((entry) => (entry.id === item.id ? { ...entry, label: item.label } : entry))
toast.success('아이템 이름을 변경했어요.')
} catch (e) {
error.value = '아이템 이름 변경에 실패했어요.'
} finally {
customItemModalLabelSaving.value = false
}
}
async function promoteCustomItem(item) {
resetMessages()
if (!customItemModalTargetGameId.value) {
error.value = '추가할 게임을 먼저 선택해주세요.'
return
}
try {
item.isPromoting = true
await api.promoteAdminCustomItem(item.id, { gameId: customItemModalTargetGameId.value })
const targetGameName = games.value.find((game) => game.id === customItemModalTargetGameId.value)?.name || customItemModalTargetGameId.value
if (selectedGameId.value === customItemModalTargetGameId.value) await loadGame()
closeCustomItemModal()
success.value = `"${item.label}" 이미지를 ${targetGameName} 템플릿으로 추가했어요.`
} catch (e) {
error.value = '선택한 이미지를 템플릿으로 추가하지 못했어요.'
} finally {
item.isPromoting = false
}
}
return {
submitCustomItemSearch,
toggleCustomItemOrphanOnly,
changeCustomItemLimit,
moveCustomItemPage,
pushCustomItemModalHistoryState,
openCustomItemModal,
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToGameAdmin,
removeCustomItem,
removeUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
}
}

View File

@@ -0,0 +1,93 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminFeaturedGames({
api,
featuredListEl,
featuredSortable,
featuredGameIds,
games,
resetMessages,
success,
error,
}) {
function destroyFeaturedSortable() {
if (featuredSortable.value) {
featuredSortable.value.destroy()
featuredSortable.value = null
}
}
async function syncFeaturedSortable() {
await nextTick()
destroyFeaturedSortable()
if (!featuredListEl.value) return
featuredSortable.value = Sortable.create(featuredListEl.value, {
animation: 160,
draggable: '[data-featured-id]',
handle: '[data-featured-handle]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
const nextIds = [...featuredGameIds.value]
const [moved] = nextIds.splice(evt.oldIndex, 1)
nextIds.splice(evt.newIndex, 0, moved)
featuredGameIds.value = nextIds
},
})
}
function addFeaturedGame(gameId) {
resetMessages()
if (!gameId || featuredGameIds.value.includes(gameId)) return
if (featuredGameIds.value.length >= 50) {
error.value = '상단 고정 게임은 최대 50개까지만 설정할 수 있어요.'
return
}
featuredGameIds.value = [...featuredGameIds.value, gameId]
syncFeaturedSortable()
}
function removeFeaturedGame(gameId) {
resetMessages()
featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId)
syncFeaturedSortable()
}
function moveFeaturedGame(gameId, direction) {
const currentIndex = featuredGameIds.value.indexOf(gameId)
const nextIndex = currentIndex + direction
if (currentIndex < 0 || nextIndex < 0 || nextIndex >= featuredGameIds.value.length) return
const nextIds = [...featuredGameIds.value]
const [moved] = nextIds.splice(currentIndex, 1)
nextIds.splice(nextIndex, 0, moved)
featuredGameIds.value = nextIds
syncFeaturedSortable()
}
async function saveFeaturedOrder() {
resetMessages()
try {
const data = await api.updateAdminGameDisplayOrder({ gameIds: featuredGameIds.value })
games.value = data.games || []
featuredGameIds.value = games.value
.filter((game) => game.displayRank != null)
.sort((a, b) => a.displayRank - b.displayRank)
.map((game) => game.id)
success.value = '홈 화면 게임 순서를 저장했어요.'
} catch (e) {
error.value = '게임 순서 저장에 실패했어요.'
}
}
return {
destroyFeaturedSortable,
syncFeaturedSortable,
addFeaturedGame,
removeFeaturedGame,
moveFeaturedGame,
saveFeaturedOrder,
}
}

View File

@@ -0,0 +1,295 @@
import { nextTick } from 'vue'
import Sortable from 'sortablejs'
export function useAdminGameManager({
api,
toApiUrl,
selectedGameId,
selectedGame,
uploadFiles,
uploadItemDrafts,
thumbFile,
itemPreviewUrls,
itemFileInput,
gameItemListEl,
gameItemSortable,
savedGameItemOrderIds,
isGameLoading,
activeTemplateRequest,
templateRequests,
customItemModalOpen,
customItemModalTargetGameId,
newGameId,
newGameName,
clearPreviewUrl,
resetFileInput,
resetUploadState,
refreshGames,
closeGameCreateModal,
resetMessages,
success,
error,
}) {
function requestItemFilename(item = {}) {
const src = typeof item.src === 'string' ? item.src : ''
return src.split('/').pop() || item.file?.name || 'item'
}
function destroyGameItemSortable() {
if (gameItemSortable.value) {
gameItemSortable.value.destroy()
gameItemSortable.value = null
}
}
async function syncGameItemSortable() {
await nextTick()
destroyGameItemSortable()
if (!gameItemListEl.value || !selectedGame.value?.items?.length) return
gameItemSortable.value = Sortable.create(gameItemListEl.value, {
animation: 160,
draggable: '[data-game-item-id]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onEnd: (evt) => {
if (evt.oldIndex == null || evt.newIndex == null || evt.oldIndex === evt.newIndex) return
const nextItems = [...(selectedGame.value?.items || [])]
const [moved] = nextItems.splice(evt.oldIndex, 1)
nextItems.splice(evt.newIndex, 0, moved)
selectedGame.value = {
...selectedGame.value,
items: nextItems,
}
},
})
}
function mergeRequestItemsIntoDrafts(request) {
const requestId = request?.id
if (!requestId) return
const existingKeys = new Set(uploadItemDrafts.value.map((draft) => `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}`))
const nextRequestDrafts = (request.items || [])
.filter((item) => item?.src)
.map((item) => ({
kind: 'request',
requestId,
itemId: item.id,
previewUrl: toApiUrl(item.src),
label: item.label || '',
sourceName: requestItemFilename(item),
src: item.src,
}))
.filter((draft) => !existingKeys.has(`${draft.kind}:${draft.requestId}:${draft.itemId}`))
if (nextRequestDrafts.length) {
uploadItemDrafts.value = [...uploadItemDrafts.value, ...nextRequestDrafts]
}
}
function removeUploadDraft(targetDraft) {
const targetKey = `${targetDraft.kind}:${targetDraft.requestId || ''}:${targetDraft.itemId || targetDraft.file?.name || ''}:${targetDraft.previewUrl || ''}`
uploadItemDrafts.value = uploadItemDrafts.value.filter((draft) => {
const currentKey = `${draft.kind}:${draft.requestId || ''}:${draft.itemId || draft.file?.name || ''}:${draft.previewUrl || ''}`
return currentKey !== targetKey
})
uploadFiles.value = uploadItemDrafts.value.filter((draft) => draft.kind === 'file').map((draft) => draft.file).filter(Boolean)
}
async function loadGame() {
resetMessages()
resetUploadState()
if (!selectedGameId.value) {
selectedGame.value = null
savedGameItemOrderIds.value = []
destroyGameItemSortable()
return
}
try {
isGameLoading.value = true
const data = await api.getGame(selectedGameId.value)
selectedGame.value = {
...data,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
} catch (e) {
console.error('[AdminView] loadGame failed', selectedGameId.value, e)
selectedGame.value = null
error.value = '게임 정보를 불러오지 못했어요.'
} finally {
isGameLoading.value = false
}
}
async function createGame() {
resetMessages()
try {
const res = await fetch(toApiUrl('/api/admin/games'), {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
id: newGameId.value.trim(),
name: newGameName.value.trim(),
thumbnailSrc: activeTemplateRequest.value?.type === 'create' ? (activeTemplateRequest.value?.thumbnailSrc || '') : '',
}),
})
if (!res.ok) throw new Error('failed')
const data = await res.json()
await refreshGames()
selectedGameId.value = data.game.id
if (customItemModalOpen.value) customItemModalTargetGameId.value = data.game.id
closeGameCreateModal()
await loadGame()
if (activeTemplateRequest.value?.id) {
const sourceRequest = templateRequests.value.find((entry) => entry.id === activeTemplateRequest.value.id) || activeTemplateRequest.value
mergeRequestItemsIntoDrafts(sourceRequest)
}
success.value = '게임이 생성됐어요. 이어서 썸네일과 기본 아이템을 관리할 수 있어요.'
} catch (e) {
error.value = '게임 생성 실패(관리자 권한/중복 ID 확인)'
}
}
function handleItemFiles(fileList) {
const files = Array.from(fileList || []).filter((file) => (file.type || '').startsWith('image/'))
const requestDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'request')
const previousFileDrafts = uploadItemDrafts.value.filter((draft) => draft.kind === 'file')
previousFileDrafts.forEach((draft) => {
if (draft.previewUrl) URL.revokeObjectURL(draft.previewUrl)
})
itemPreviewUrls.value = []
uploadFiles.value = files
uploadItemDrafts.value = requestDrafts
if (!files.length) {
resetFileInput('item')
return
}
itemPreviewUrls.value = files.map((file) => URL.createObjectURL(file))
const fileDrafts = files.map((file, index) => ({
kind: 'file',
file,
previewUrl: itemPreviewUrls.value[index],
label: (file.name || '').replace(/\.[^.]+$/, '').replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 60),
sourceName: file.name,
}))
uploadItemDrafts.value = [...requestDrafts, ...fileDrafts]
resetFileInput('item')
}
function onFile(event) {
handleItemFiles(event.target.files)
}
function openItemFilePicker() {
itemFileInput.value?.click()
}
function clearItemFiles() {
uploadFiles.value = []
uploadItemDrafts.value = []
itemPreviewUrls.value.forEach((url) => {
if (url) URL.revokeObjectURL(url)
})
itemPreviewUrls.value = []
resetFileInput('item')
}
async function uploadItem() {
resetMessages()
if (!uploadItemDrafts.value.length || !selectedGameId.value) {
error.value = '아이템 파일을 선택해주세요.'
return
}
try {
const fileDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'file')
const requestDrafts = uploadItemDrafts.value.filter((entry) => entry.kind === 'request')
let uploadCount = 0
if (fileDrafts.length) {
const fd = new FormData()
fileDrafts.forEach((entry) => {
fd.append('images', entry.file)
fd.append('labels', entry.label.trim())
})
const res = await fetch(toApiUrl(`/api/admin/games/${encodeURIComponent(selectedGameId.value)}/images`), {
method: 'POST',
credentials: 'include',
body: fd,
})
if (!res.ok) throw new Error('failed')
uploadCount += fileDrafts.length
}
if (requestDrafts.length) {
const requestIds = [...new Set(requestDrafts.map((entry) => entry.requestId).filter(Boolean))]
for (const requestId of requestIds) {
const draftsForRequest = requestDrafts.filter((entry) => entry.requestId === requestId)
await api.promoteAdminTemplateRequestItems(requestId, {
gameId: selectedGameId.value,
itemIds: draftsForRequest.map((entry) => entry.itemId).filter(Boolean),
itemLabels: draftsForRequest.reduce((acc, entry) => {
if (entry.itemId) acc[entry.itemId] = entry.label.trim()
return acc
}, {}),
})
uploadCount += draftsForRequest.length
}
}
resetUploadState()
await loadGame()
success.value = `게임 기본 아이템 ${uploadCount}개 추가를 완료했어요.`
} catch (e) {
error.value = '아이템 추가 실패(관리자 권한/파일 크기 확인)'
}
}
async function saveGameItemOrder() {
resetMessages()
if (!selectedGameId.value || !selectedGame.value?.items?.length) return
try {
const data = await api.updateAdminGameItemDisplayOrder(selectedGameId.value, {
itemIds: selectedGame.value.items.map((item) => item.id),
})
selectedGame.value = {
...selectedGame.value,
items: (data.items || []).map((item) => ({
...item,
draftLabel: item.label,
})),
}
savedGameItemOrderIds.value = (data.items || []).map((item) => item.id)
await syncGameItemSortable()
success.value = '기본 아이템 순서를 저장했어요.'
} catch (e) {
error.value = '기본 아이템 순서 저장에 실패했어요.'
}
}
return {
requestItemFilename,
destroyGameItemSortable,
syncGameItemSortable,
mergeRequestItemsIntoDrafts,
removeUploadDraft,
loadGame,
createGame,
handleItemFiles,
onFile,
openItemFilePicker,
clearItemFiles,
uploadItem,
saveGameItemOrder,
}
}

View File

@@ -0,0 +1,96 @@
export function useAdminTemplateRequests({
api,
activeTemplateRequest,
refreshTemplateRequests,
setTab,
openGameCreateModal,
newGameId,
newGameName,
selectAdminGame,
mergeRequestItemsIntoDrafts,
resetMessages,
success,
error,
}) {
function updateActiveTemplateRequest(request) {
if (!request?.id) return
activeTemplateRequest.value = {
id: request.id,
type: request.type,
status: request.status,
thumbnailSrc: request.thumbnailSrc || '',
draftGameId: request.draftGameId || '',
draftGameName: request.draftGameName || '',
sourceTierListId: request.sourceTierListId || '',
sourceGameId: request.sourceGameId || '',
sourceTierListTitle: request.sourceTierListTitle || '',
targetGameId: request.targetGameId || '',
requesterName: request.requesterName || '',
}
}
function templateRequestStatusLabel(request) {
return request.status === 'reviewing' ? '확인함' : '미확인'
}
function templateRequestSourceUrl(request) {
if (!request?.sourceGameId || !request?.sourceTierListId) return ''
return `/editor/${request.sourceGameId}/${request.sourceTierListId}?preview=1`
}
function templateRequestReviewHint(request) {
if (request.type === 'create') return '게임 생성 후 필요한 아이템만 골라 추가하고, 끝나면 여기서 처리 완료하세요.'
return '확인하기를 누르면 게임 관리 화면에 요청 아이템이 임시 추가되어 필요한 것만 선별 저장할 수 있어요.'
}
async function startTemplateRequestReview(request) {
resetMessages()
try {
request.isHandling = true
const data = await api.startAdminTemplateRequestReview(request.id)
request.status = data.request?.status || 'reviewing'
updateActiveTemplateRequest(request)
setTab('game-admin')
if (request.type === 'create') {
openGameCreateModal()
newGameId.value = (request.draftGameId || '').trim()
newGameName.value = (request.draftGameName || '').trim()
mergeRequestItemsIntoDrafts(request)
} else {
const nextGameId = request.targetGameId || request.sourceGameId || ''
if (nextGameId) await selectAdminGame(nextGameId)
mergeRequestItemsIntoDrafts(request)
}
success.value = '요청 아이템을 게임 관리 화면으로 가져왔어요. 필요한 항목만 골라서 추가해 주세요.'
} catch (e) {
error.value = '요청 확인 단계로 이동하지 못했어요.'
} finally {
request.isHandling = false
}
}
async function completeTemplateRequest(request) {
resetMessages()
try {
request.isHandling = true
await api.completeAdminTemplateRequest(request.id)
if (activeTemplateRequest.value?.id === request.id) activeTemplateRequest.value = null
await refreshTemplateRequests()
success.value = '요청 카드를 처리 완료로 정리했어요.'
} catch (e) {
error.value = '요청 완료 처리에 실패했어요.'
} finally {
request.isHandling = false
}
}
return {
updateActiveTemplateRequest,
templateRequestStatusLabel,
templateRequestSourceUrl,
templateRequestReviewHint,
startTemplateRequestReview,
completeTemplateRequest,
}
}

View File

@@ -0,0 +1,265 @@
import { computed } from 'vue'
export function useAdminUsers({
api,
auth,
users,
userQuery,
userSort,
userSortDirection,
userAvatarInputs,
modalTargetUser,
modalPasswordDraft,
modalRoleNextAdmin,
modalUserDraftEmail,
modalUserDraftNickname,
modalUserDraftIsAdmin,
userEditModalOpen,
userPasswordModalOpen,
userDeleteModalOpen,
userRoleModalOpen,
resetMessages,
refreshUsers,
success,
error,
}) {
function setUserAvatarInput(userId, el) {
if (!userId) return
if (!el) {
delete userAvatarInputs.value[userId]
return
}
userAvatarInputs.value[userId] = el
}
const canManageModalRole = computed(() => {
if (!auth.user?.isPrimaryAdmin) return false
if (!modalTargetUser.value) return false
return !modalTargetUser.value.isPrimaryAdmin
})
const isUserEditDirty = computed(() => {
if (!modalTargetUser.value) return false
return (
modalUserDraftEmail.value.trim() !== (modalTargetUser.value.email || '') ||
modalUserDraftNickname.value.trim() !== (modalTargetUser.value.nickname || '') ||
!!modalUserDraftIsAdmin.value !== !!modalTargetUser.value.isAdmin
)
})
function roleLabelOf(user) {
if (user?.isPrimaryAdmin) return '최고 관리자'
if (user?.isAdmin) return '운영자'
return '일반 회원'
}
function openUserAvatarPicker(user) {
userAvatarInputs.value[user?.id]?.click()
}
async function uploadUserAvatar(user, file, { remove = false } = {}) {
resetMessages()
if (!user?.id) return
try {
user.isAvatarBusy = true
const data = await api.updateAdminUserAvatar(user.id, { file, removeAvatar: remove })
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? {
...entry,
...updated,
isAvatarBusy: false,
}
: entry
)
if (modalTargetUser.value?.id === updated.id) {
modalTargetUser.value = { ...modalTargetUser.value, ...updated }
}
if (updated.id === auth.user?.id) await auth.refresh()
await refreshUsers()
success.value = remove ? '회원 썸네일을 삭제했어요.' : '회원 썸네일을 업데이트했어요.'
} catch (e) {
error.value = remove ? '회원 썸네일 삭제에 실패했어요.' : '회원 썸네일 변경에 실패했어요.'
} finally {
const target = users.value.find((entry) => entry.id === user.id)
if (target) target.isAvatarBusy = false
}
}
async function onUserAvatarChange(user, event) {
const file = event.target.files && event.target.files[0] ? event.target.files[0] : null
event.target.value = ''
if (!file) return
await uploadUserAvatar(user, file)
}
async function removeUserAvatar(user) {
if (!user?.avatarSrc) return
await uploadUserAvatar(user, null, { remove: true })
}
function openUserEditModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalUserDraftEmail.value = user?.email || ''
modalUserDraftNickname.value = user?.nickname || ''
modalUserDraftIsAdmin.value = !!user?.isAdmin
userEditModalOpen.value = true
}
function closeUserEditModal() {
userEditModalOpen.value = false
modalTargetUser.value = null
modalUserDraftEmail.value = ''
modalUserDraftNickname.value = ''
modalUserDraftIsAdmin.value = false
}
async function saveUserEdit() {
resetMessages()
if (!modalTargetUser.value?.id) return
try {
const data = await api.updateAdminUser(modalTargetUser.value.id, {
email: modalUserDraftEmail.value.trim(),
nickname: modalUserDraftNickname.value.trim(),
isAdmin: !!modalUserDraftIsAdmin.value,
})
const updated = data.user
users.value = users.value.map((entry) =>
entry.id === updated.id
? {
...entry,
...updated,
isAvatarBusy: entry.isAvatarBusy || false,
}
: entry
)
if (updated.id === auth.user?.id) await auth.refresh()
closeUserEditModal()
await refreshUsers()
success.value = '회원 정보를 저장했어요.'
} catch (e) {
error.value = '회원 정보 저장에 실패했어요.'
}
}
function openUserPasswordModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalPasswordDraft.value = ''
userPasswordModalOpen.value = true
}
function closeUserPasswordModal() {
userPasswordModalOpen.value = false
modalTargetUser.value = null
modalPasswordDraft.value = ''
}
function userDisplayName(user) {
return user?.nickname || user?.email?.split('@')[0] || '알 수 없음'
}
async function confirmUserPasswordReset() {
resetMessages()
if (!modalTargetUser.value?.id) return
const password = modalPasswordDraft.value.trim()
if (!password) {
error.value = '초기화할 비밀번호를 입력해주세요.'
return
}
try {
await api.updateAdminUserPassword(modalTargetUser.value.id, { password })
success.value = `${userDisplayName(modalTargetUser.value)} 계정 비밀번호를 초기화했어요.`
closeUserPasswordModal()
} catch (e) {
error.value = '비밀번호 초기화에 실패했어요.'
}
}
function openUserDeleteModal(user) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
userDeleteModalOpen.value = true
}
function closeUserDeleteModal() {
userDeleteModalOpen.value = false
modalTargetUser.value = null
}
async function confirmUserDelete() {
resetMessages()
if (!modalTargetUser.value?.id) return
try {
const deletingSelf = modalTargetUser.value.id === auth.user?.id
const deletedName = userDisplayName(modalTargetUser.value)
await api.deleteAdminUser(modalTargetUser.value.id)
users.value = users.value.filter((entry) => entry.id !== modalTargetUser.value.id)
closeUserDeleteModal()
success.value = `${deletedName} 계정을 삭제했어요.`
if (deletingSelf) await auth.refresh()
} catch (e) {
error.value = '회원 삭제에 실패했어요.'
}
}
function openUserRoleModal(user, nextIsAdmin = !modalUserDraftIsAdmin.value) {
resetMessages()
modalTargetUser.value = user ? { ...user } : null
modalRoleNextAdmin.value = !!nextIsAdmin
userRoleModalOpen.value = true
}
function closeUserRoleModal() {
userRoleModalOpen.value = false
if (!userEditModalOpen.value) modalTargetUser.value = null
modalRoleNextAdmin.value = false
}
function confirmUserRoleDraft() {
if (!modalTargetUser.value?.id) return
modalUserDraftIsAdmin.value = modalRoleNextAdmin.value
const targetLabel = modalRoleNextAdmin.value ? '운영자 권한을 저장 대기 상태로 반영했어요.' : '운영자 권한 해제를 저장 대기 상태로 반영했어요.'
closeUserRoleModal()
success.value = targetLabel
}
function submitUserFilters() {
refreshUsers({
q: userQuery.value,
sort: userSort.value,
direction: userSortDirection.value,
})
}
return {
setUserAvatarInput,
canManageModalRole,
isUserEditDirty,
roleLabelOf,
openUserAvatarPicker,
onUserAvatarChange,
removeUserAvatar,
openUserEditModal,
closeUserEditModal,
saveUserEdit,
openUserPasswordModal,
closeUserPasswordModal,
confirmUserPasswordReset,
openUserDeleteModal,
closeUserDeleteModal,
confirmUserDelete,
openUserRoleModal,
closeUserRoleModal,
confirmUserRoleDraft,
submitUserFilters,
userDisplayName,
}
}

View File

@@ -35,6 +35,8 @@ export const api = {
favoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'POST' }),
unfavoriteGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}/favorite`, { method: 'DELETE' }),
updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }),
updateAdminGameItemDisplayOrder: (gameId, payload) =>
request(`/api/admin/games/${encodeURIComponent(gameId)}/items/display-order`, { 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 } = {}) =>
@@ -61,6 +63,12 @@ export const api = {
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/promote-items`, { method: 'POST', body: payload }),
createAdminGameTemplateFromTierList: (tierListId, payload) =>
request(`/api/admin/tierlists/${encodeURIComponent(tierListId)}/create-game-template`, { method: 'POST', body: payload }),
startAdminTemplateRequestReview: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/review`, { method: 'POST', body: {} }),
promoteAdminTemplateRequestItems: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/promote-items`, { method: 'POST', body: payload }),
completeAdminTemplateRequest: (requestId) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/complete`, { method: 'POST', body: {} }),
approveAdminTemplateRequest: (requestId, payload) =>
request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/approve`, { method: 'POST', body: payload || {} }),
rejectAdminTemplateRequest: (requestId) => request(`/api/admin/template-requests/${encodeURIComponent(requestId)}/reject`, { method: 'POST', body: {} }),

View File

@@ -9,9 +9,10 @@ import FavoriteTierListsView from '../views/FavoriteTierListsView.vue'
import AdminView from '../views/AdminView.vue'
import ProfileView from '../views/ProfileView.vue'
import SearchResultsView from '../views/SearchResultsView.vue'
import { useAuthStore } from '../stores/auth'
export function createRouter() {
return _createRouter({
const router = _createRouter({
history: createWebHistory(),
routes: [
{ path: '/', name: 'home', component: HomeView },
@@ -22,8 +23,27 @@ export function createRouter() {
{ path: '/me', name: 'me', component: MyTierListsView },
{ path: '/favorites', name: 'favorites', component: FavoriteTierListsView },
{ path: '/search', name: 'search', component: SearchResultsView },
{ path: '/admin', name: 'admin', component: AdminView },
{ path: '/admin', redirect: '/admin/featured' },
{ path: '/admin/featured', name: 'adminFeatured', component: AdminView },
{ path: '/admin/games', name: 'adminGames', component: AdminView },
{ path: '/admin/items', name: 'adminItems', component: AdminView },
{ path: '/admin/tierlists', name: 'adminTierlists', component: AdminView },
{ path: '/admin/users', name: 'adminUsers', component: AdminView },
{ path: '/profile', name: 'profile', component: ProfileView },
],
})
router.beforeEach(async (to) => {
const routeName = String(to.name || '')
if (!routeName.startsWith('admin')) return true
const auth = useAuthStore()
if (!auth.hydrated) await auth.refresh()
if (!auth.user?.isAdmin) {
return { path: '/' }
}
return true
})
return router
}

File diff suppressed because it is too large Load Diff

View File

@@ -34,6 +34,7 @@ const pool = ref([])
const itemsById = ref({})
const title = ref('')
const persistedTierListId = ref('')
const thumbnailSrc = ref('')
const pendingThumbnailFile = ref(null)
const thumbnailPreviewUrl = ref('')
@@ -48,7 +49,6 @@ const isTemplateRequestModalOpen = ref(false)
const isTemplateUpdateModalOpen = ref(false)
const templateRequestDraftTitle = ref('')
const templateRequestDraftDescription = ref('')
const templateRequestSaveToMyTierList = ref(true)
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
@@ -94,10 +94,13 @@ const effectiveAuthorName = computed(() => {
if (currentEmail) return currentEmail.split('@')[0] || currentEmail
return (authorAccountName.value || '').trim() || 'unknown'
})
const autoGeneratedTitle = ref(createAutoTierListTitle())
const effectiveTitle = computed(() => {
const customTitle = (title.value || '').trim()
if (customTitle) return customTitle
return (gameName.value || gameId.value || 'Tier Maker').trim()
if (persistedTierListId.value) return persistedTierListId.value
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
return autoGeneratedTitle.value
})
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
const untitledWarning = computed(
@@ -118,13 +121,13 @@ const copiedFromLabel = computed(() => {
const customItems = computed(() =>
Object.values(itemsById.value)
.filter((item) => item?.origin === 'custom')
.sort((a, b) => (a.label || '').localeCompare(b.label || '', 'ko'))
)
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
const canRequestTemplateCreate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0
)
const canRequestTemplateUpdate = computed(
() => canEdit.value && !isNewTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
() => canEdit.value && hasSavedTierList.value && gameId.value !== 'freeform' && customItems.value.length > 0
)
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
@@ -136,6 +139,12 @@ watch(error, (message) => {
error.value = ''
})
function createAutoTierListTitle() {
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
return pick(10) + '-' + pick(10)
}
function formatTitleDate(ts) {
const date = new Date(ts)
const year = date.getFullYear()
@@ -652,9 +661,12 @@ function buildPayload(existingId) {
async function persistTierList({ showModal = false } = {}) {
await uploadPendingCustomItems()
await uploadPendingThumbnail()
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
const payload = buildPayload(currentTierListId || null)
const res = await api.saveTierList(payload)
const savedTierListId = res.tierList?.id || tierListId.value
const savedTierListId = res.tierList?.id || currentTierListId || tierListId.value
persistedTierListId.value = savedTierListId || ''
title.value = res.tierList?.title || payload.title
if (tierListId.value === 'new' && res.tierList?.id) {
await router.replace(`/editor/${gameId.value}/${res.tierList.id}`)
}
@@ -686,7 +698,6 @@ function closeSaveModal() {
function resetTemplateRequestDrafts() {
templateRequestDraftTitle.value = (title.value || '').trim()
templateRequestDraftDescription.value = (description.value || '').trim()
templateRequestSaveToMyTierList.value = true
}
function openTemplateRequestModal() {
@@ -764,52 +775,37 @@ async function toggleFavorite() {
async function requestTemplate(type) {
try {
isRequestingTemplate.value = true
await uploadPendingCustomItems()
const uploadedThumbnailSrc = await uploadPendingThumbnail()
const response = await api.requestTierListTemplate({
title.value = templateRequestDraftTitle.value.trim()
description.value = templateRequestDraftDescription.value.trim()
const saved = await persistTierList({ showModal: false })
const sourceId = saved.savedTierListId || persistedTierListId.value || ''
if (!sourceId) throw new Error('save_required')
await api.requestTierListTemplate({
type,
sourceTierListId: tierListId.value !== 'new' ? tierListId.value : '',
sourceTierListId: sourceId,
gameId: gameId.value,
requestTitle: templateRequestDraftTitle.value.trim(),
requestDescription: templateRequestDraftDescription.value.trim(),
thumbnailSrc: uploadedThumbnailSrc || thumbnailSrc.value || '',
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
saveToMyTierList: !!templateRequestSaveToMyTierList.value,
groups: buildGroupPayload(),
boardItems: Object.values(itemsById.value),
})
const savedTierList = response?.savedTierList
if (savedTierList) {
title.value = savedTierList.title || title.value
description.value = savedTierList.description || ''
updatedAt.value = Number(savedTierList.updatedAt || Date.now())
authorName.value = savedTierList.authorName || effectiveAuthorName.value
authorAccountName.value = savedTierList.authorAccountName || authorAccountName.value
favoriteCount.value = Number(savedTierList.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!savedTierList.isFavorited
if (tierListId.value === 'new' && savedTierList.id) {
await router.replace(`/editor/${gameId.value}/${savedTierList.id}`)
}
}
if (type === 'create') closeTemplateRequestModal()
if (type === 'update') closeTemplateUpdateModal()
toast.success(
type === 'create'
? templateRequestSaveToMyTierList.value
? '템플릿 등록 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 등록 요청을 보냈어요.'
: templateRequestSaveToMyTierList.value
? '템플릿 업데이트 요청과 내 티어표 저장을 함께 완료했어요.'
: '템플릿 업데이트 요청을 보냈어요.'
)
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
} catch (e) {
if (e?.message === 'custom_upload_failed') {
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
return
}
if (e?.message === 'save_required') {
toast.error('먼저 현재 티어표를 저장한 뒤 다시 요청해주세요.')
return
}
if (e?.status === 409) {
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
return
@@ -818,6 +814,10 @@ async function requestTemplate(type) {
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
return
}
if (e?.status === 400 && e?.data?.error === 'source_tierlist_required') {
toast.error('저장된 티어표에서만 템플릿 요청을 보낼 수 있어요.')
return
}
if (e?.status === 400 && e?.data?.error === 'bad_request') {
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
return
@@ -861,6 +861,7 @@ onMounted(() => {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
@@ -970,14 +971,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 요청 시점 스냅샷만 관리자에게 전달되고, 티어 리스트에는 별도로 남기지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
@@ -1009,14 +1002,6 @@ onUnmounted(() => {
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000</span>
</label>
<label class="toggleSwitch">
<input v-model="templateRequestSaveToMyTierList" type="checkbox" />
<span class="toggleSwitch__label"> 티어 리스트에도 저장</span>
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
</label>
<div class="templateRequestDraft__note">
저장을 끄면 관리자 확인용 요청 스냅샷만 남고, 현재 작업 중인 티어표는 따로 저장하지 않아요.
</div>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
@@ -1280,7 +1265,7 @@ onUnmounted(() => {
<div class="editorSidebar__label">커스텀 이름 정리</div>
<div class="customItemEditor customItemEditor--sidebar">
<div class="customItemEditor__desc">
아래에서 이름 정리해두면 관리자 요청 그대로 전달됩니다.
아래에서 이름 정리 저장하면, 템플릿 요청 그대로 전달됩니다.
</div>
<div class="customItemEditor__list">
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">

View File

@@ -1,9 +0,0 @@
# Update Log Entry Point
이 프로젝트의 상세 업데이트 로그는 [docs/update.md](/Users/bicute/Desktop/zenn.dev/tier-cursor/docs/update.md)에 계속 누적됩니다.
## 2026-03-30
- 루트 `package.json`에 공용 실행 스크립트(`dev:frontend`, `dev:backend`, `build`, `start`)를 추가했습니다.
- 루트에서도 바로 `npm run build` 같은 공용 명령을 사용할 수 있게 정리했습니다.
- 업데이트 로그 진입점을 루트 `update.md`로 추가해, 이후 작업 시 파일 위치를 바로 찾을 수 있게 했습니다.