From d089ba99e9acc4c1736731c4bcdff46aa3bbbef5 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 2 Apr 2026 20:15:17 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A6=B4=EB=A6=AC=EC=8A=A4:=20v1.4.19=20?= =?UTF-8?q?=ED=85=9C=ED=94=8C=EB=A6=BF=20=EC=95=84=EC=9D=B4=ED=85=9C=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EC=98=81=ED=96=A5=20=EA=B2=BD=EA=B3=A0=20?= =?UTF-8?q?=EB=B0=8F=20=EB=B3=B4=EC=A1=B4=20=EC=A0=95=EC=B1=85=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/db.js | 57 ++++++++++--------- backend/src/routes/admin.js | 10 ++++ docs/history.md | 4 ++ docs/todo.md | 3 + docs/update.md | 5 ++ .../components/admin/AdminGamesSection.vue | 2 +- frontend/src/views/AdminView.vue | 22 ++++++- 7 files changed, 73 insertions(+), 30 deletions(-) diff --git a/backend/src/db.js b/backend/src/db.js index 47ed5df..8fac2c3 100644 --- a/backend/src/db.js +++ b/backend/src/db.js @@ -1413,35 +1413,35 @@ async function updateImageAssetLabel(assetId, label) { return mapImageAssetRow(rows[0]) } +async function countTierListsUsingGameItem(itemId) { + if (!itemId) return { totalCount: 0, publicCount: 0, privateCount: 0 } + + const rows = await query( + ` + SELECT id, is_public, groups_json, pool_json + FROM tierlists + ` + ) + + let totalCount = 0 + let publicCount = 0 + let privateCount = 0 + + rows.forEach((row) => { + const groups = parseJson(row.groups_json, []) + const pool = parseJson(row.pool_json, []) + const inGroups = groups.some((group) => (group?.itemIds || []).includes(itemId)) + const inPool = pool.some((item) => item?.id === itemId) + if (!inGroups && !inPool) return + totalCount += 1 + if (row.is_public) publicCount += 1 + else privateCount += 1 + }) + + return { totalCount, publicCount, privateCount } +} + async function deleteGameItem(itemId) { - const gameItemRows = await query('SELECT topic_id FROM topic_items WHERE id = ? LIMIT 1', [itemId]) - const gameId = gameItemRows[0]?.topic_id - - if (gameId) { - const tierListRows = await query( - ` - SELECT id, author_id, topic_id, title, description, is_public, groups_json, pool_json, created_at, updated_at - FROM tierlists - WHERE topic_id = ? - `, - [gameId] - ) - - for (const row of tierListRows) { - const tierList = mapTierListRow(row) - const nextGroups = (tierList.groups || []).map((group) => ({ - ...group, - itemIds: (group.itemIds || []).filter((id) => id !== itemId), - })) - const nextPool = (tierList.pool || []).filter((item) => item.id !== itemId) - - await query( - 'UPDATE tierlists SET groups_json = ?, pool_json = ?, updated_at = ? WHERE id = ?', - [serializeJson(nextGroups), serializeJson(nextPool), now(), tierList.id] - ) - } - } - await query('DELETE FROM topic_items WHERE id = ?', [itemId]) } @@ -2560,6 +2560,7 @@ module.exports = { createGameItem, updateGameItemLabel, updateGameItemDisplayOrder, + countTierListsUsingGameItem, updateCustomItemLabel, updateImageAssetLabel, deleteGameItem, diff --git a/backend/src/routes/admin.js b/backend/src/routes/admin.js index 49d52a5..892b0a3 100644 --- a/backend/src/routes/admin.js +++ b/backend/src/routes/admin.js @@ -18,6 +18,7 @@ const { createGameItem, updateGameItemLabel, updateGameItemDisplayOrder, + countTierListsUsingGameItem, updateCustomItemLabel, updateImageAssetLabel, deleteGameItem, @@ -239,6 +240,15 @@ router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:it res.json({ ok: true }) }) +router.get(['/games/:gameId/items/:itemId/usage', '/templates/:templateId/items/:itemId/usage'], requireAdmin, async (req, res) => { + const game = await findGameById(getTemplateIdParam(req)) + if (!game) return res.status(404).json({ error: 'not_found' }) + const item = await findGameItemById(req.params.itemId) + if (!item || item.gameId !== game.id) return res.status(404).json({ error: 'not_found' }) + const usage = await countTierListsUsingGameItem(req.params.itemId) + res.json({ usage }) +}) + router.patch(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:itemId'], requireAdmin, async (req, res) => { const schema = z.object({ label: z.string().trim().min(1).max(60) }) const parsed = schema.safeParse(req.body) diff --git a/docs/history.md b/docs/history.md index 83edb90..42d0efd 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.4.19 +- 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다. +- 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다. + ## 2026-04-02 v1.4.18 - 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다. diff --git a/docs/todo.md b/docs/todo.md index a838387..e7b784d 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -1,6 +1,9 @@ # 할 일 및 이슈 ## 단기 확인 +- `v1.4.19`에서 템플릿 기본 아이템 삭제는 기존 저장 티어표를 보존하도록 정책이 바뀌었으므로, 실제 운영 데이터에서 삭제 후 예전 티어표의 배치/대기풀이 그대로 유지되는지와 새 티어표 생성 시에만 아이템이 빠지는지 한 번 더 QA한다. +- `v1.4.19`에서 삭제 전 영향 개수 경고를 붙였으므로, 공개/비공개 티어표가 섞인 템플릿에서 숫자가 기대대로 보이는지와 삭제 취소/확정 후 스크롤 위치가 안정적으로 유지되는지 한 번 더 QA한다. +- `v1.4.19`에서 템플릿 썸네일 등록 아이콘은 썸네일이 있을 때 숨기도록 정리했으므로, 썸네일 있음/없음 상태 전환과 드래그 오버 활성 상태에서 안내 문구가 겹치지 않는지 한 번 더 QA한다. - `v1.4.18`에서 관리자 템플릿 요청 카드 썸네일 클릭을 브라우저 기본 새 창 열기로 정리했으므로, 요청 썸네일 클릭 시 오류 없이 새 탭이 열리고 `전체 티어표 관리` 썸네일 모달 동작과도 섞이지 않는지 한 번 더 QA한다. - `v1.4.17`에서 주제 컬렉션 카드 클릭 시 에디터 진입 무한 루프를 끊었으므로, 새 티어표 만들기/기존 티어표 열기/공유 링크 열기 세 흐름이 모두 정상 진입하는지 한 번 더 QA한다. - `v1.4.16`에서 장애 전용 안내 화면을 붙였으므로, 실제로 `db_init_failed`와 네트워크 차단 상황에서 각각 `서비스 점검 중`, `서버 연결 확인 중` 화면이 기대대로 분기되는지 한 번 더 QA한다. diff --git a/docs/update.md b/docs/update.md index e8df525..f49ae0b 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.4.19 +- 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다. +- 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다. +- 템플릿 썸네일이 이미 등록된 상태에서는 등록 아이콘이 겹쳐 보이지 않도록 정리했고, 기본 아이템 삭제 후 템플릿을 다시 불러와도 페이지가 맨 위로 튀지 않게 스크롤 위치를 복원하도록 보강했다. + ## 2026-04-02 v1.4.18 - 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다. diff --git a/frontend/src/components/admin/AdminGamesSection.vue b/frontend/src/components/admin/AdminGamesSection.vue index 93bd8f7..4644d5a 100644 --- a/frontend/src/components/admin/AdminGamesSection.vue +++ b/frontend/src/components/admin/AdminGamesSection.vue @@ -125,7 +125,7 @@ function setThumbFileElement(el) {
대표 썸네일
-
+
{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}
diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index ef5673e..194f06b 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -1,5 +1,5 @@