From 147ff963abf2b781f02649c5d1dfb7bbf60e35e3 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 11:23:33 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=20=EB=B6=84=EB=A6=AC=20=EB=B0=8F=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?/=EC=95=84=EC=9D=B4=ED=85=9C=20=EA=B4=80=EB=A6=AC=20=ED=9D=90?= =?UTF-8?q?=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 64 +- backend/src/routes/admin.js | 85 +- docs/history.md | 10 + docs/todo.md | 4 + docs/update.md | 10 + frontend/src/App.vue | 7 +- .../components/admin/AdminFeaturedSection.vue | 64 ++ .../components/admin/AdminGamesSection.vue | 171 ++++ .../components/admin/AdminItemsSection.vue | 31 + .../admin/AdminTierlistsSection.vue | 174 ++++ .../components/admin/AdminUsersSection.vue | 125 +++ .../src/composables/useAdminGameManager.js | 291 ++++++ .../composables/useAdminTemplateRequests.js | 95 ++ frontend/src/lib/api.js | 8 + frontend/src/router/index.js | 24 +- frontend/src/views/AdminView.vue | 849 +++++++----------- frontend/src/views/TierEditorView.vue | 1 - 17 files changed, 1460 insertions(+), 553 deletions(-) create mode 100644 frontend/src/components/admin/AdminFeaturedSection.vue create mode 100644 frontend/src/components/admin/AdminGamesSection.vue create mode 100644 frontend/src/components/admin/AdminItemsSection.vue create mode 100644 frontend/src/components/admin/AdminTierlistsSection.vue create mode 100644 frontend/src/components/admin/AdminUsersSection.vue create mode 100644 frontend/src/composables/useAdminGameManager.js create mode 100644 frontend/src/composables/useAdminTemplateRequests.js diff --git a/backend/src/db.js b/backend/src/db.js index e844628..ec80daa 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -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, @@ -668,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]) } @@ -1077,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(` @@ -1916,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 @@ -1945,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) @@ -2111,6 +2156,7 @@ module.exports = { getImageAssetStats, createGameItem, updateGameItemLabel, + updateGameItemDisplayOrder, updateCustomItemLabel, updateImageAssetLabel, deleteGameItem, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 7cfb4b7..9ae03b9 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -15,6 +15,7 @@ const { updateGameThumbnail, createGameItem, updateGameItemLabel, + updateGameItemDisplayOrder, updateCustomItemLabel, updateImageAssetLabel, deleteGameItem, @@ -115,6 +116,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 +277,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 +444,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 +635,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' }) diff --git a/docs/history.md b/docs/history.md index e71c3f5..717d8c6 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,15 @@ # 의사결정 이력 +## 2026-04-02 v1.3.50 +- 템플릿 요청 카드는 게임 이름/ID만 남기기보다 대표 썸네일까지 유지하는 편이 운영자가 요청 대상을 훨씬 빨리 구분할 수 있다고 정리했다. +- 템플릿 요청의 `확인하기`는 단순히 해당 게임을 선택하는 동작이 아니라, 게임 관리 화면에서 요청 아이템 후보가 실제 작업 상태로 복원되어야 한다고 판단했다. +- 관리자 화면은 기능이 많아진 만큼 단일 `/admin` 상태보다 섹션별 경로를 갖는 편이 뒤로가기와 직접 진입 모두에서 더 안정적이라고 정리했다. +- 관리자 URL은 보이기만 막는 수준이 아니라, 라우터 단계에서 비로그인/비관리자 접근 자체를 차단하는 편이 맞다고 정리했다. +- 티어표 에디터의 커스텀 아이템 이름 편집 목록은 “사전순 재정렬”보다 입력 안정성이 더 중요하므로, 실시간 라벨 기준 정렬은 제거하는 쪽으로 결정했다. +- 게임 기본 아이템은 최신 추가 항목이 먼저 보이도록 하되, 관리자가 필요하면 직접 드래그해 기준 순서를 고정할 수 있어야 한다고 판단했다. +- 관리자 리팩터링은 한 번에 로직까지 갈아엎기보다, 먼저 각 관리 본문을 섹션 컴포넌트로 분리해 `AdminView.vue`의 책임을 줄이는 단계형 접근이 더 안전하다고 정리했다. +- 본문 템플릿 분리 다음 단계에서는 `게임 관리`와 `템플릿 요청`처럼 상태가 무거운 영역부터 composable로 옮겨, 뷰 파일과 업무 로직 파일의 경계를 먼저 세우는 편이 맞다고 판단했다. + ## 2026-04-01 v1.3.49 - 템플릿 요청은 임시 편집 상태에 기대기보다, 먼저 저장된 티어표를 기준으로 요청 스냅샷을 만드는 편이 훨씬 안정적이라고 정리했다. 그래서 요청 버튼은 저장본이 있을 때만 노출하고, 제목이 비어 있으면 사람이 쓰는 기본 문구 대신 고유한 랜덤 제목으로 먼저 저장본을 만든 뒤 요청을 이어가도록 했다. - 관리자 템플릿 요청 미리보기는 별도 요청 전용 보드 레이아웃을 유지하기보다, 일반 티어표 완성본과 같은 행·열·남은 아이템 문법을 그대로 재사용하는 편이 검수 체감이 가장 자연스럽다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 57b5d3c..ae017c3 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,10 @@ # 할 일 및 이슈 ## 중기 개선 +- 관리자 URL 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue` 단일 대형 파일을 섹션별 뷰/컴포저블로 쪼개고 직접 진입 시 선택 게임/요청 작업 상태 복원 범위도 함께 정리한다. +- 관리자 본문 컴포넌트 분리는 시작했으므로, 다음 단계에서는 `AdminView.vue`에 남아 있는 상태/액션도 `useAdmin*` composable 단위로 분리해 실제 로직 결합도를 줄인다. +- 관리자 게임/템플릿 요청 composable 분리는 시작했으므로, 다음 단계에서는 회원/아이템/목록 관리도 같은 기준으로 정리하고 공통 모달 상태를 어느 계층에서 소유할지 정리한다. +- 관리자 게임 아이템 순서 저장은 추가됐으므로, 다음 단계에서는 새 아이템 추가 직후 `자동 맨 앞 배치`와 `관리자 수동 고정 순서`의 우선순위를 실제 운영 흐름 기준으로 한 번 더 QA한다. - 관리자 템플릿 요청 미리보기는 실제 완성본 iframe 방식과의 체감 차이를 마지막으로 한 번 더 QA한다. - 라이트모드/다크모드 2차 보정까지 반영했으므로, 남은 작업은 전체 화면을 실제 사용 흐름으로 돌려 보며 대비·명도·아이콘 가독성을 미세하게 QA하는 최종 테마 점검 단계로 가져간다. - 관리자용 티어표 승인/숨김 처리, 아이템 정렬 UI를 추가한다. diff --git a/docs/update.md b/docs/update.md index 8cb0222..f5dd007 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,15 @@ # 업데이트 로그 +## 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 - 티어표 에디터의 템플릿 요청 흐름은 저장된 티어표를 기준으로만 요청을 보낼 수 있도록 다시 정리하고, 요청 모달의 `내 티어리스트에도 저장` 분기는 제거함. 제목이 비어 있는 상태에서 저장하면 `직접 티어표 만들기` 대신 랜덤한 고유 제목을 먼저 부여해 저장본을 만들고, 그 이후에만 템플릿 요청 버튼이 노출되도록 맞춤. - 관리자 `사용자 템플릿 요청` 카드는 `전체 티어표 관리`와 같은 카드 문법으로 유지하되, 썸네일은 상단에 고정된 클릭 진입점으로 다시 정리하고 카드 본문과 별도 입력 영역의 밀도를 맞춤. diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 3f78bba..0f5c359 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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() { 가이드 보기 - 관리자 메뉴 + 관리자 메뉴 로그인 diff --git a/frontend/src/components/admin/AdminFeaturedSection.vue b/frontend/src/components/admin/AdminFeaturedSection.vue new file mode 100644 index 0000000..4008245 --- /dev/null +++ b/frontend/src/components/admin/AdminFeaturedSection.vue @@ -0,0 +1,64 @@ + + + diff --git a/frontend/src/components/admin/AdminGamesSection.vue b/frontend/src/components/admin/AdminGamesSection.vue new file mode 100644 index 0000000..a6489c7 --- /dev/null +++ b/frontend/src/components/admin/AdminGamesSection.vue @@ -0,0 +1,171 @@ + + + diff --git a/frontend/src/components/admin/AdminItemsSection.vue b/frontend/src/components/admin/AdminItemsSection.vue new file mode 100644 index 0000000..ae9ae47 --- /dev/null +++ b/frontend/src/components/admin/AdminItemsSection.vue @@ -0,0 +1,31 @@ + + + diff --git a/frontend/src/components/admin/AdminTierlistsSection.vue b/frontend/src/components/admin/AdminTierlistsSection.vue new file mode 100644 index 0000000..a1600e5 --- /dev/null +++ b/frontend/src/components/admin/AdminTierlistsSection.vue @@ -0,0 +1,174 @@ + + + diff --git a/frontend/src/components/admin/AdminUsersSection.vue b/frontend/src/components/admin/AdminUsersSection.vue new file mode 100644 index 0000000..81789bf --- /dev/null +++ b/frontend/src/components/admin/AdminUsersSection.vue @@ -0,0 +1,125 @@ + + + diff --git a/frontend/src/composables/useAdminGameManager.js b/frontend/src/composables/useAdminGameManager.js new file mode 100644 index 0000000..75154d5 --- /dev/null +++ b/frontend/src/composables/useAdminGameManager.js @@ -0,0 +1,291 @@ +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() }), + }) + 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, + } +} diff --git a/frontend/src/composables/useAdminTemplateRequests.js b/frontend/src/composables/useAdminTemplateRequests.js new file mode 100644 index 0000000..4a81782 --- /dev/null +++ b/frontend/src/composables/useAdminTemplateRequests.js @@ -0,0 +1,95 @@ +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, + 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, + } +} diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 1f0d620..41ed2cf 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -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: {} }), diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e446528..be9a62e 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -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 } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 5a1a9c8..298ec47 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,16 +1,26 @@