From c1575783f032f9b8ed84ce80538e776bfa758e2f Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 26 Mar 2026 14:59:50 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v0.1.23=20?= =?UTF-8?q?=ED=99=88=20=EA=B2=8C=EC=9E=84=20=EC=A0=95=EB=A0=AC=EA=B3=BC=20?= =?UTF-8?q?=EA=B4=80=EB=A6=AC=EC=9E=90=20=EC=88=9C=EC=84=9C=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 43 ++++++- backend/src/routes/admin.js | 16 +++ docs/history.md | 5 + docs/spec.md | 3 + docs/todo.md | 1 - docs/update.md | 5 + frontend/src/lib/api.js | 1 + frontend/src/views/AdminView.vue | 202 ++++++++++++++++++++++++++++++- frontend/src/views/HomeView.vue | 73 +++++------ 9 files changed, 304 insertions(+), 45 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 55281c2..987d6e6 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -46,6 +46,7 @@ function mapGameRow(row) { id: row.id, name: row.name, thumbnailSrc: row.thumbnail_src || '', + displayRank: row.display_rank == null ? null : Number(row.display_rank), createdAt: Number(row.created_at), } } @@ -154,10 +155,16 @@ async function ensureSchema() { id VARCHAR(120) PRIMARY KEY, name VARCHAR(120) NOT NULL, thumbnail_src VARCHAR(255) NOT NULL DEFAULT '', + display_rank INT NULL DEFAULT NULL, created_at BIGINT NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 `) + const displayRankColumns = await query("SHOW COLUMNS FROM games LIKE 'display_rank'") + if (!displayRankColumns.length) { + await query('ALTER TABLE games ADD COLUMN display_rank INT NULL DEFAULT NULL AFTER thumbnail_src') + } + await query(` CREATE TABLE IF NOT EXISTS game_items ( id VARCHAR(64) PRIMARY KEY, @@ -326,14 +333,23 @@ async function adminDeleteUser(id) { async function listGames() { const rows = await query( - 'SELECT id, name, thumbnail_src, created_at FROM games WHERE id <> ? ORDER BY created_at ASC, name ASC', + ` + SELECT id, name, thumbnail_src, display_rank, created_at + FROM games + WHERE id <> ? + ORDER BY + CASE WHEN display_rank IS NULL THEN 1 ELSE 0 END ASC, + display_rank ASC, + created_at DESC, + name ASC + `, [FREEFORM_GAME_ID] ) return rows.map(mapGameRow) } async function findGameById(id) { - const rows = await query('SELECT id, name, thumbnail_src, created_at FROM games WHERE id = ? LIMIT 1', [id]) + const rows = await query('SELECT id, name, thumbnail_src, display_rank, created_at FROM games WHERE id = ? LIMIT 1', [id]) return mapGameRow(rows[0]) } @@ -353,7 +369,13 @@ async function getGameDetail(gameId) { } async function createGame({ id, name }) { - await query('INSERT INTO games (id, name, thumbnail_src, created_at) VALUES (?, ?, ?, ?)', [id, name, '', now()]) + await query('INSERT INTO games (id, name, thumbnail_src, display_rank, created_at) VALUES (?, ?, ?, ?, ?)', [ + id, + name, + '', + null, + now(), + ]) return findGameById(id) } @@ -411,6 +433,20 @@ async function deleteGame(gameId) { await query('DELETE FROM games WHERE id = ?', [gameId]) } +async function updateGameDisplayOrder(gameIds) { + const normalizedIds = Array.from(new Set((gameIds || []).filter((id) => id && id !== FREEFORM_GAME_ID))).slice(0, 50) + + await query('UPDATE games SET display_rank = NULL WHERE id <> ?', [FREEFORM_GAME_ID]) + + await Promise.all( + normalizedIds.map((gameId, index) => + query('UPDATE games SET display_rank = ? WHERE id = ? AND id <> ?', [index + 1, gameId, FREEFORM_GAME_ID]) + ) + ) + + return listGames() +} + async function createCustomItem({ id, ownerId, src, label }) { const createdAt = now() await query('INSERT INTO custom_items (id, owner_id, src, label, created_at) VALUES (?, ?, ?, ?, ?)', [ @@ -721,6 +757,7 @@ module.exports = { createGameItem, deleteGameItem, deleteGame, + updateGameDisplayOrder, createCustomItem, listCustomItems, findUnusedCustomItems, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 047fb06..8040fba 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -9,10 +9,12 @@ const { findUserById, findGameById, createGame, + listGames, updateGameThumbnail, createGameItem, deleteGameItem, deleteGame, + updateGameDisplayOrder, listCustomItems, findUnusedCustomItems, findCustomItemsByIds, @@ -50,6 +52,20 @@ router.post('/games', requireAdmin, async (req, res) => { res.json({ game }) }) +router.patch('/games/display-order', requireAdmin, async (req, res) => { + const schema = z.object({ + gameIds: z.array(z.string().min(1)).max(50), + }) + const parsed = schema.safeParse(req.body) + if (!parsed.success) return res.status(400).json({ error: 'bad_request' }) + + const games = await listGames() + const validGameIds = new Set(games.map((game) => game.id)) + const filteredIds = parsed.data.gameIds.filter((gameId) => validGameIds.has(gameId)) + const updatedGames = await updateGameDisplayOrder(filteredIds) + res.json({ games: updatedGames }) +}) + 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) diff --git a/docs/history.md b/docs/history.md index 0a897ef..51d95aa 100644 --- a/docs/history.md +++ b/docs/history.md @@ -82,3 +82,8 @@ - 무제목 저장은 게임 이름 기본값보다 `이름 없음 + 날짜`가 더 명확하다고 판단해 자동 제목 규칙을 변경했다. - 제목이 비어 있는 티어표는 품질 관리 대상이 될 수 있으므로, 작성 중 단계에서 관리자 임의 삭제 가능성을 미리 안내하기로 결정했다. - 다운로드 이미지에는 편집용 빈 칸 안내 문구를 제외하고, 더 넓은 보드 폭과 하단 작성자/날짜 메타를 포함해 공유용 완성도를 높이기로 결정했다. + +## 2026-03-26 v0.1.23 +- 홈 화면 정렬은 단순 생성순 하나보다 `관리자 상단 고정 순서 + 나머지 최신 생성순` 조합이 운영과 신규 노출을 함께 만족시킨다고 판단했다. +- 상단 고정은 전체 수동 정렬보다 최대 50개만 관리하는 방식이 운영 부담이 적으므로, 관리자에게는 제한된 상단 고정 목록만 직접 편집하게 하기로 결정했다. +- `커스텀 티어표 만들기`는 일반 게임 카드와 성격이 다르므로 카드형 목록에서 분리해 우측 상단 버튼으로 노출하기로 결정했다. diff --git a/docs/spec.md b/docs/spec.md index 1edd38c..12bb082 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -96,6 +96,7 @@ - 게임 기본 아이템 추가는 이름 입력, 파일 선택, 1:1 미리보기 확인 뒤 저장하는 흐름이다. - 아이템 미리보기는 반응형 환경에서도 최대 `192px` 크기 안에서 표시한다. - 게임 전환 또는 업로드 성공 뒤에는 파일 입력값을 초기화해 같은 파일도 다시 선택할 수 있다. +- 게임 관리 탭에서는 홈 화면 상단에 먼저 노출할 게임을 최대 50개까지 지정하고, 위/아래 순서를 저장할 수 있다. - 사용자 업로드 커스텀 아이템은 관리자 화면의 아이템 관리 탭에서 검색, 페이지네이션, 다운로드할 수 있다. - 커스텀 아이템은 사용 횟수(`usageCount`)를 표시하며, 미사용 항목만 필터링해 개별/일괄 삭제할 수 있다. - 관리자 화면에서는 회원 이메일/닉네임/권한 수정, 비밀번호 초기화, 계정 삭제가 가능하다. @@ -112,6 +113,8 @@ - 작성자 아바타 이미지가 없을 경우 목록 썸네일 fallback은 닉네임이 아니라 계정명 기준 첫 글자를 사용한다. - 티어표 목록 메타 정보는 최종 업데이트 시각만 간략하게 표시한다. - 저장 성공 시에는 에디터 안에서 반투명 오버레이 기반 확인 모달을 띄우고, PNG export 이미지는 약 `1600px` 폭과 외곽 여백, 작성자/날짜 하단 메타를 포함해 생성한다. +- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다. +- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다. ## 운영 환경 변수 - 프런트엔드 diff --git a/docs/todo.md b/docs/todo.md index 11dfac7..a8c36c4 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -16,6 +16,5 @@ - 게임/이미지/티어표 삭제 후 복구 또는 수정 이력 관리 기능을 추가한다. - 자동 테스트와 최소한의 배포 체크리스트를 만든다. - 관리자용 커스텀 아이템 승인/복제, 아이템 정렬 UI를 추가한다. -- 홈 화면 게임 카드와 `직접 티어표 만들기` 카드의 노출 순서를 관리자가 직접 조정할 수 있는 정렬 기능을 추가한다. - 회원 검색/필터, 일괄 권한 변경 같은 관리 보조 기능을 추가한다. - 티어 행 프리셋 저장, 색상 관리, 행 복제 같은 고급 편집 기능을 추가한다. diff --git a/docs/update.md b/docs/update.md index b58972b..c6f1c95 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-03-26 v0.1.23 +- **홈 게임 정렬 규칙 변경**: 일반 게임 목록은 `상단 고정 순서 → 나머지 최신 생성순`으로 정렬되도록 변경 +- **관리자 게임 순서 편집 추가**: 관리자 게임 관리 탭에서 최대 50개의 게임을 상단 고정 목록으로 선택하고 위/아래 순서를 저장할 수 있도록 추가 +- **커스텀 티어표 진입점 변경**: 홈 화면의 `직접 티어표 만들기` 카드를 제거하고 우측 상단 버튼형 진입점으로 변경 + ## 2026-03-26 v0.1.22 - **무제목 저장 규칙 변경**: 제목을 비워두고 저장하면 내부 저장 제목을 `이름 없음 + 날짜` 형식으로 생성하도록 변경 - **무제목 안내 문구 추가**: 제목 입력이 비어 있는 동안 관리자 임의 삭제 가능성을 알리는 경고 문구를 제목 입력 아래에 표시 diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js index 52c7135..4122ad0 100644 --- a/frontend/src/lib/api.js +++ b/frontend/src/lib/api.js @@ -32,6 +32,7 @@ export const api = { listGames: () => request('/api/games'), getGame: (gameId) => request(`/api/games/${encodeURIComponent(gameId)}`), + updateAdminGameDisplayOrder: (payload) => request('/api/admin/games/display-order', { method: 'PATCH', body: payload }), listAdminCustomItems: ({ q = '', page = 1, limit = 50, orphanOnly = false } = {}) => request( `/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&orphanOnly=${encodeURIComponent(orphanOnly)}` diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index d69d6bf..2b769cd 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -13,6 +13,7 @@ const gameMode = ref('existing') const games = ref([]) const selectedGameId = ref('') const selectedGame = ref(null) +const featuredGameIds = ref([]) const customItems = ref([]) const customItemQuery = ref('') @@ -41,6 +42,12 @@ const hasSelectedGame = computed(() => !!selectedGame.value?.game?.id) const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedGameId.value) const canAddItem = computed(() => !!uploadFile.value && !!selectedGameId.value) const customItemPageCount = computed(() => Math.max(1, Math.ceil(customItemTotal.value / customItemLimit.value))) +const featuredGames = computed(() => + featuredGameIds.value + .map((gameId) => games.value.find((game) => game.id === gameId)) + .filter(Boolean) +) +const availableGamesForFeatured = computed(() => games.value.filter((game) => !featuredGameIds.value.includes(game.id))) onMounted(async () => { await auth.refresh() @@ -66,6 +73,10 @@ async function refreshGames() { try { const data = await api.listGames() games.value = data.games || [] + featuredGameIds.value = games.value + .filter((game) => game.displayRank != null) + .sort((a, b) => a.displayRank - b.displayRank) + .map((game) => game.id) } catch (e) { error.value = '게임 목록을 불러오지 못했어요.' } @@ -416,13 +427,53 @@ function fmt(ts) { minute: '2-digit', }) } + +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] +} + +function removeFeaturedGame(gameId) { + resetMessages() + featuredGameIds.value = featuredGameIds.value.filter((id) => id !== gameId) +} + +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 +} + +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 = '게임 순서 저장에 실패했어요.' + } +}