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) {