Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d089ba99e9 |
@@ -1413,35 +1413,35 @@ async function updateImageAssetLabel(assetId, label) {
|
|||||||
return mapImageAssetRow(rows[0])
|
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) {
|
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])
|
await query('DELETE FROM topic_items WHERE id = ?', [itemId])
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2560,6 +2560,7 @@ module.exports = {
|
|||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
updateGameItemDisplayOrder,
|
updateGameItemDisplayOrder,
|
||||||
|
countTierListsUsingGameItem,
|
||||||
updateCustomItemLabel,
|
updateCustomItemLabel,
|
||||||
updateImageAssetLabel,
|
updateImageAssetLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const {
|
|||||||
createGameItem,
|
createGameItem,
|
||||||
updateGameItemLabel,
|
updateGameItemLabel,
|
||||||
updateGameItemDisplayOrder,
|
updateGameItemDisplayOrder,
|
||||||
|
countTierListsUsingGameItem,
|
||||||
updateCustomItemLabel,
|
updateCustomItemLabel,
|
||||||
updateImageAssetLabel,
|
updateImageAssetLabel,
|
||||||
deleteGameItem,
|
deleteGameItem,
|
||||||
@@ -239,6 +240,15 @@ router.delete(['/games/:gameId/items/:itemId', '/templates/:templateId/items/:it
|
|||||||
res.json({ ok: true })
|
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) => {
|
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 schema = z.object({ label: z.string().trim().min(1).max(60) })
|
||||||
const parsed = schema.safeParse(req.body)
|
const parsed = schema.safeParse(req.body)
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.19
|
||||||
|
- 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다.
|
||||||
|
- 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.4.18
|
## 2026-04-02 v1.4.18
|
||||||
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
|
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.4.19`에서 템플릿 기본 아이템 삭제는 기존 저장 티어표를 보존하도록 정책이 바뀌었으므로, 실제 운영 데이터에서 삭제 후 예전 티어표의 배치/대기풀이 그대로 유지되는지와 새 티어표 생성 시에만 아이템이 빠지는지 한 번 더 QA한다.
|
||||||
|
- `v1.4.19`에서 삭제 전 영향 개수 경고를 붙였으므로, 공개/비공개 티어표가 섞인 템플릿에서 숫자가 기대대로 보이는지와 삭제 취소/확정 후 스크롤 위치가 안정적으로 유지되는지 한 번 더 QA한다.
|
||||||
|
- `v1.4.19`에서 템플릿 썸네일 등록 아이콘은 썸네일이 있을 때 숨기도록 정리했으므로, 썸네일 있음/없음 상태 전환과 드래그 오버 활성 상태에서 안내 문구가 겹치지 않는지 한 번 더 QA한다.
|
||||||
- `v1.4.18`에서 관리자 템플릿 요청 카드 썸네일 클릭을 브라우저 기본 새 창 열기로 정리했으므로, 요청 썸네일 클릭 시 오류 없이 새 탭이 열리고 `전체 티어표 관리` 썸네일 모달 동작과도 섞이지 않는지 한 번 더 QA한다.
|
- `v1.4.18`에서 관리자 템플릿 요청 카드 썸네일 클릭을 브라우저 기본 새 창 열기로 정리했으므로, 요청 썸네일 클릭 시 오류 없이 새 탭이 열리고 `전체 티어표 관리` 썸네일 모달 동작과도 섞이지 않는지 한 번 더 QA한다.
|
||||||
- `v1.4.17`에서 주제 컬렉션 카드 클릭 시 에디터 진입 무한 루프를 끊었으므로, 새 티어표 만들기/기존 티어표 열기/공유 링크 열기 세 흐름이 모두 정상 진입하는지 한 번 더 QA한다.
|
- `v1.4.17`에서 주제 컬렉션 카드 클릭 시 에디터 진입 무한 루프를 끊었으므로, 새 티어표 만들기/기존 티어표 열기/공유 링크 열기 세 흐름이 모두 정상 진입하는지 한 번 더 QA한다.
|
||||||
- `v1.4.16`에서 장애 전용 안내 화면을 붙였으므로, 실제로 `db_init_failed`와 네트워크 차단 상황에서 각각 `서비스 점검 중`, `서버 연결 확인 중` 화면이 기대대로 분기되는지 한 번 더 QA한다.
|
- `v1.4.16`에서 장애 전용 안내 화면을 붙였으므로, 실제로 `db_init_failed`와 네트워크 차단 상황에서 각각 `서비스 점검 중`, `서버 연결 확인 중` 화면이 기대대로 분기되는지 한 번 더 QA한다.
|
||||||
|
|||||||
@@ -1,5 +1,10 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-02 v1.4.19
|
||||||
|
- 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다.
|
||||||
|
- 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다.
|
||||||
|
- 템플릿 썸네일이 이미 등록된 상태에서는 등록 아이콘이 겹쳐 보이지 않도록 정리했고, 기본 아이템 삭제 후 템플릿을 다시 불러와도 페이지가 맨 위로 튀지 않게 스크롤 위치를 복원하도록 보강했다.
|
||||||
|
|
||||||
## 2026-04-02 v1.4.18
|
## 2026-04-02 v1.4.18
|
||||||
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
|
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
|
||||||
|
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ function setThumbFileElement(el) {
|
|||||||
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
|
<img v-if="props.displayThumbnailUrl" class="selectedThumb selectedThumb--sidebar" :src="props.displayThumbnailUrl" :alt="props.selectedTemplate.game.name" />
|
||||||
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
<div v-else class="selectedThumb selectedThumb--empty selectedThumb--sidebar">대표 썸네일</div>
|
||||||
<div class="thumbDropZone__copy">
|
<div class="thumbDropZone__copy">
|
||||||
<div class="thumbDropZone__iconWrap">
|
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
|
||||||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
|
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
|
||||||
</div>
|
</div>
|
||||||
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { Teleport, computed, inject, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { api } from '../lib/api'
|
import { api } from '../lib/api'
|
||||||
import { editorPath } from '../lib/paths'
|
import { editorPath } from '../lib/paths'
|
||||||
@@ -1215,7 +1215,25 @@ async function toggleSelectedTemplateVisibility(nextValue) {
|
|||||||
|
|
||||||
async function removeTemplateItem(itemId) {
|
async function removeTemplateItem(itemId) {
|
||||||
resetMessages()
|
resetMessages()
|
||||||
|
if (!selectedTemplateId.value) return
|
||||||
try {
|
try {
|
||||||
|
const usageRes = await fetch(
|
||||||
|
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}/usage`),
|
||||||
|
{
|
||||||
|
credentials: 'include',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if (!usageRes.ok) throw new Error('usage_failed')
|
||||||
|
|
||||||
|
const usageData = await usageRes.json()
|
||||||
|
const usage = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
|
||||||
|
const impactMessage = usage.totalCount
|
||||||
|
? `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요.\n기존 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.\n정말 삭제할까요?`
|
||||||
|
: '이 기본 아이템을 삭제할까요?\n기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
|
||||||
|
const ok = window.confirm(impactMessage)
|
||||||
|
if (!ok) return
|
||||||
|
|
||||||
|
const previousScrollY = window.scrollY
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
|
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
|
||||||
{
|
{
|
||||||
@@ -1226,6 +1244,8 @@ async function removeTemplateItem(itemId) {
|
|||||||
if (!res.ok) throw new Error('failed')
|
if (!res.ok) throw new Error('failed')
|
||||||
|
|
||||||
await loadTemplate()
|
await loadTemplate()
|
||||||
|
await nextTick()
|
||||||
|
window.scrollTo({ top: previousScrollY, behavior: 'auto' })
|
||||||
success.value = '템플릿 기본 아이템을 삭제했어요.'
|
success.value = '템플릿 기본 아이템을 삭제했어요.'
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
|
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
|
||||||
|
|||||||
Reference in New Issue
Block a user