Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d089ba99e9 | |||
| 2923237813 | |||
| fd3e983cdc | |||
| 04ac5c6ede |
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-02 v1.4.19
|
||||
- 템플릿 기본 아이템 삭제가 과거에 저장된 티어표까지 바꿔 버리면 운영자 편집 의도보다 사용자 결과물 보존이 더 크게 흔들리므로, 이 삭제는 “앞으로의 템플릿 구성만 바꾸고 기존 저장본은 보존”하는 편이 맞다고 판단했다.
|
||||
- 다만 이미 여러 티어표에서 쓰인 아이템인지 모른 채 지우게 두는 것도 위험하므로, 삭제 자체를 막기보다는 저장된 티어표 사용 개수와 공개/비공개 범위를 먼저 보여주고 운영자가 맥락을 알고 결정하게 하는 쪽이 더 현실적이라고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.18
|
||||
- 새 창 열기처럼 브라우저 기본 앵커가 충분한 동작은 템플릿 안에서 `window.open`을 직접 부르기보다, 기본 링크 동작에 맡기는 편이 더 단순하고 안전하다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.17
|
||||
- `editor` 주소는 이전과 현재가 같은 URL 형태를 공유하므로, 여기까지 redirect를 두면 호환성이 아니라 자기 자신으로의 재해석만 반복하게 된다. 이 구간은 별도 레거시 레코드를 두지 않고 현재 라우트 하나로 수용하는 편이 맞다고 판단했다.
|
||||
|
||||
## 2026-04-02 v1.4.16
|
||||
- 백엔드/DB 장애 상황을 단순 연결 실패처럼 보여주면 사용자가 원인을 잘못 이해하게 되므로, 네트워크 단절과 서버 점검/초기화 실패를 전역 UI에서 분리해서 안내하는 편이 맞다고 판단했다.
|
||||
- 이런 장애 안내는 각 화면별 에러 문구를 따로 손보는 것보다 `api` 공통 계층에서 상태를 감지하고 `App` 셸이 한 번에 전환하는 구조가 재사용성과 유지보수 측면에서 더 안전하다고 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.15
|
||||
- 실제 운영 DB에서 마지막 500 원인을 먼저 재현해본 결과, 스키마 설계보다 MariaDB의 `SHOW ... LIKE ?` 플레이스홀더 비호환과 부분 마이그레이션 상태 재진입 이슈가 핵심이었으므로, 이 단계에선 구조 변경보다 기동 안정성을 먼저 회복하는 편이 맞다고 판단했다.
|
||||
- 마이그레이션 로직은 “처음 실행”뿐 아니라 “반쯤 적용된 상태에서 다시 실행”도 견뎌야 하므로, 컬럼 존재 확인과 조건 분기를 모두 공용 `information_schema` 검사로 모으는 편이 더 안전하다고 정리했다.
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `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한다.
|
||||
- `v1.4.15`에서 `ensureData()`가 실제 운영 DB 설정으로 `ok`까지 통과한 것은 확인했으므로, 이제는 브라우저에서 `/api/auth/me`, `/api/auth/meta`, `/api/topics` 500이 실제로 사라졌는지와 기존 세션 로그인 흐름이 복구됐는지 한 번 더 QA한다.
|
||||
- `v1.4.14`부터는 DB 마이그레이션이 rename 대신 복사 기반으로 바뀌었으므로, 실제 운영 DB에서 서버 재시작 후 `topics` 계열 테이블과 `tierlists.topic_id`, `template_requests.source_topic_id/target_topic_id`가 기대대로 채워지는지 먼저 확인한다.
|
||||
- 레거시 `/games/...`와 `/editor/:gameId/...`는 redirect로 남겼으므로, 오래된 북마크 진입 후 주소가 `/topics/...`, `/editor/:topicId/...`로 자연스럽게 정규화되는지 한 번 더 QA한다.
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-02 v1.4.19
|
||||
- 관리자 템플릿 기본 아이템 삭제는 이제 기존에 저장된 티어표의 그룹/대기풀 데이터를 건드리지 않고, 템플릿의 현재 기본 아이템 목록에서만 제거되도록 바꿨다. 그래서 이미 만들어진 티어표는 그대로 유지되고, 이후 새로 만드는 티어표에서만 해당 아이템이 빠진다.
|
||||
- 삭제 전에는 이 아이템이 이미 저장된 티어표 몇 개에서 사용 중인지(공개/비공개 포함) 확인 문구를 먼저 보여주도록 바꿔, 운영자가 영향 범위를 알고 삭제할 수 있게 했다.
|
||||
- 템플릿 썸네일이 이미 등록된 상태에서는 등록 아이콘이 겹쳐 보이지 않도록 정리했고, 기본 아이템 삭제 후 템플릿을 다시 불러와도 페이지가 맨 위로 튀지 않게 스크롤 위치를 복원하도록 보강했다.
|
||||
|
||||
## 2026-04-02 v1.4.18
|
||||
- 관리자 템플릿 요청 카드 썸네일 클릭은 `window.open(...)`을 템플릿 이벤트 안에서 직접 호출하던 구조 때문에 브라우저 새 창 열기 시 `Cannot read properties of undefined (reading 'open')`가 날 수 있었고, 이를 제거해 앵커의 기본 새 창 동작만 사용하도록 정리했다.
|
||||
|
||||
## 2026-04-02 v1.4.17
|
||||
- 주제 컬렉션에서 티어표 카드를 클릭할 때 `Maximum call stack size exceeded`가 나던 원인은 `editor` 레거시 redirect가 새 라우트와 동일한 URL 패턴을 다시 자기 자신에게 redirect하던 구조였고, 불필요한 `editor` redirect 레코드를 제거해 무한 라우팅 루프를 끊었다.
|
||||
|
||||
## 2026-04-02 v1.4.16
|
||||
- 백엔드나 DB 장애가 났을 때 일반 화면에서 계속 `연결할 수 없어요` 식으로 보이던 흐름을 정리하고, `api` 공통 요청 계층에서 `db_init_failed` 같은 500과 네트워크 실패를 감지해 앱 전체를 점검/연결 확인 화면으로 전환하도록 바꿨다.
|
||||
- 이제 데이터베이스 초기화 실패나 서버 내부 500은 `서비스 점검 중`, 네트워크 단절은 `서버 연결 확인 중`으로 구분되어 보이며, 사용자는 일반 페이지 대신 전용 안내 화면과 다시 시도 버튼을 보게 된다.
|
||||
|
||||
## 2026-04-02 v1.4.15
|
||||
- `db_init_failed`의 직접 원인은 MariaDB에서 `SHOW TABLES LIKE ?`, `SHOW COLUMNS ... LIKE ?` 플레이스홀더를 허용하지 않던 부분이었고, 이를 `information_schema` 조회 기반으로 바꿔 실제 운영 DB에서도 `ensureData()`가 정상 통과되게 고쳤다.
|
||||
- 중간 마이그레이션 상태에서 `template_requests.target_topic_id`가 이미 생긴 DB는 중복 컬럼 추가로 다시 실패할 수 있었으므로, 해당 확인도 `columnExists()` 기준으로 바꿔 부분 적용된 DB까지 안전하게 다시 기동되게 정리했다.
|
||||
|
||||
@@ -34,6 +34,8 @@ const isGuideModalOpen = ref(false)
|
||||
const themeMode = ref('dark')
|
||||
const guideStepIndex = ref(0)
|
||||
const viewportWidth = ref(typeof window !== 'undefined' ? window.innerWidth : 1440)
|
||||
const backendState = ref('online')
|
||||
const backendMessage = ref('')
|
||||
provide('rightRailOpen', rightRailOpen)
|
||||
provide('localRightRailTarget', '#local-right-rail-root')
|
||||
|
||||
@@ -138,6 +140,7 @@ const themeToggleLabel = computed(() => (isLightTheme.value ? '다크 모드' :
|
||||
const showSettingsThemePanel = computed(() => false && route.name === 'profile')
|
||||
const showTopicViewToggle = computed(() => route.name === 'topicHub')
|
||||
const topicViewMode = computed(() => (route.query.view === 'list' ? 'list' : 'grid'))
|
||||
const showBackendFallback = computed(() => !isPreviewMode.value && ['maintenance', 'offline'].includes(backendState.value))
|
||||
const leftBottomPrimaryAction = computed(() => {
|
||||
if (!authReady.value) return null
|
||||
if (route.name === 'home' && auth.user) {
|
||||
@@ -251,6 +254,13 @@ function syncViewportWidth() {
|
||||
viewportWidth.value = window.innerWidth
|
||||
}
|
||||
|
||||
function handleBackendStatus(event) {
|
||||
const state = event?.detail?.state
|
||||
if (!state) return
|
||||
backendState.value = state
|
||||
backendMessage.value = typeof event?.detail?.message === 'string' ? event.detail.message : ''
|
||||
}
|
||||
|
||||
function applyTheme(mode) {
|
||||
themeMode.value = mode === 'light' ? 'light' : 'dark'
|
||||
if (typeof document !== 'undefined') document.documentElement.dataset.theme = themeMode.value
|
||||
@@ -270,6 +280,7 @@ onMounted(async () => {
|
||||
await auth.refresh()
|
||||
if (typeof window !== 'undefined') {
|
||||
syncViewportWidth()
|
||||
window.addEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.addEventListener('resize', syncViewportWidth)
|
||||
window.addEventListener('keydown', handleGlobalKeydown)
|
||||
const leftSaved = window.localStorage.getItem('tier-maker:left-rail-collapsed')
|
||||
@@ -292,6 +303,7 @@ function handleGlobalKeydown(event) {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.removeEventListener('tier-maker:backend-status', handleBackendStatus)
|
||||
window.removeEventListener('resize', syncViewportWidth)
|
||||
window.removeEventListener('keydown', handleGlobalKeydown)
|
||||
}
|
||||
@@ -400,6 +412,11 @@ function submitGlobalSearch() {
|
||||
router.push(homePath(query))
|
||||
}
|
||||
|
||||
function reloadApp() {
|
||||
if (typeof window === 'undefined') return
|
||||
window.location.reload()
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
@@ -414,7 +431,26 @@ function submitGlobalSearch() {
|
||||
}"
|
||||
:style="shellStyle"
|
||||
>
|
||||
<template v-if="isPreviewMode">
|
||||
<template v-if="showBackendFallback">
|
||||
<main class="backendFallback">
|
||||
<section class="backendFallback__card">
|
||||
<div class="backendFallback__eyebrow">{{ backendState === 'maintenance' ? 'Maintenance' : 'Connection' }}</div>
|
||||
<h1 class="backendFallback__title">{{ backendState === 'maintenance' ? '서비스 점검 중' : '서버 연결 확인 중' }}</h1>
|
||||
<p class="backendFallback__desc">
|
||||
{{
|
||||
backendMessage ||
|
||||
(backendState === 'maintenance'
|
||||
? '백엔드 또는 데이터베이스 작업으로 인해 잠시 이용이 어렵습니다. 잠시 후 다시 시도해주세요.'
|
||||
: '네트워크 또는 서버 연결 상태를 확인한 뒤 다시 시도해주세요.')
|
||||
}}
|
||||
</p>
|
||||
<div class="backendFallback__actions">
|
||||
<button class="backendFallback__button" type="button" @click="reloadApp">다시 시도</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
<template v-else-if="isPreviewMode">
|
||||
<main class="appMain appMain--preview">
|
||||
<RouterView />
|
||||
</main>
|
||||
@@ -660,6 +696,65 @@ function submitGlobalSearch() {
|
||||
transition: grid-template-columns 220ms ease;
|
||||
}
|
||||
|
||||
.backendFallback {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 32px;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(86, 153, 255, 0.14), transparent 38%),
|
||||
var(--theme-shell-bg);
|
||||
}
|
||||
|
||||
.backendFallback__card {
|
||||
width: min(100%, 560px);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
padding: 28px;
|
||||
border-radius: 28px;
|
||||
border: 1px solid var(--theme-card-border);
|
||||
background: var(--theme-card-bg);
|
||||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||||
}
|
||||
|
||||
.backendFallback__eyebrow {
|
||||
color: var(--theme-accent-strong);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.backendFallback__title {
|
||||
margin: 0;
|
||||
font-size: clamp(28px, 4vw, 42px);
|
||||
line-height: 1.05;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.backendFallback__desc {
|
||||
margin: 0;
|
||||
color: var(--theme-text-muted);
|
||||
font-size: 15px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.backendFallback__actions {
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.backendFallback__button {
|
||||
min-width: 128px;
|
||||
padding: 12px 18px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(98, 170, 255, 0.32);
|
||||
background: rgba(98, 170, 255, 0.18);
|
||||
color: var(--theme-text-strong);
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.appShell--preview {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ function setThumbFileElement(el) {
|
||||
<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 class="thumbDropZone__copy">
|
||||
<div class="thumbDropZone__iconWrap">
|
||||
<div v-if="!props.displayThumbnailUrl" class="thumbDropZone__iconWrap">
|
||||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="thumbDropZone__icon" />
|
||||
</div>
|
||||
<div class="thumbDropZone__title">{{ props.displayThumbnailUrl ? '썸네일 변경' : '클릭 & 드래그' }}</div>
|
||||
|
||||
@@ -45,7 +45,6 @@ const props = defineProps({
|
||||
:target="props.templateRequestSourceUrl(request) ? '_blank' : undefined"
|
||||
:rel="props.templateRequestSourceUrl(request) ? 'noreferrer' : undefined"
|
||||
:aria-disabled="!props.templateRequestSourceUrl(request)"
|
||||
@click.prevent="props.templateRequestSourceUrl(request) && window.open(props.templateRequestSourceUrl(request), '_blank', 'noopener,noreferrer')"
|
||||
>
|
||||
<img v-if="request.thumbnailSrc" class="tierAdminCard__thumb" :src="toApiUrl(request.thumbnailSrc)" :alt="request.sourceTierListTitle" draggable="false" />
|
||||
<div v-else class="tierAdminCard__thumb tierAdminCard__thumb--empty"></div>
|
||||
|
||||
@@ -1,25 +1,54 @@
|
||||
import { toApiUrl } from './runtime'
|
||||
|
||||
function emitBackendStatus(detail) {
|
||||
if (typeof window === 'undefined') return
|
||||
window.dispatchEvent(new CustomEvent('tier-maker:backend-status', { detail }))
|
||||
}
|
||||
|
||||
async function request(path, { method = 'GET', body, headers } = {}) {
|
||||
const res = await fetch(toApiUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
let res
|
||||
try {
|
||||
res = await fetch(toApiUrl(path), {
|
||||
method,
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||
...(headers || {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
} catch (error) {
|
||||
emitBackendStatus({
|
||||
state: 'offline',
|
||||
message: '서버 연결을 확인할 수 없어 잠시 후 다시 시도해주세요.',
|
||||
path,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type') || ''
|
||||
const data = contentType.includes('application/json') ? await res.json() : await res.text()
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status >= 500 && data?.error === 'db_init_failed') {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 점검 중이거나 데이터베이스 초기화 중입니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
} else if (res.status >= 500) {
|
||||
emitBackendStatus({
|
||||
state: 'maintenance',
|
||||
message: '서비스 내부 점검이 필요합니다. 잠시 후 다시 이용해주세요.',
|
||||
path,
|
||||
})
|
||||
}
|
||||
const err = new Error('request_failed')
|
||||
err.status = res.status
|
||||
err.data = data
|
||||
throw err
|
||||
}
|
||||
emitBackendStatus({ state: 'online', path })
|
||||
return data
|
||||
}
|
||||
|
||||
|
||||
@@ -18,12 +18,7 @@ export function createRouter() {
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/games/:gameId', redirect: (to) => `/topics/${encodeURIComponent(String(to.params.gameId || ''))}` },
|
||||
{ path: '/topics/:topicId', name: 'topicHub', component: GameHubView },
|
||||
{ path: '/editor/:gameId/new', redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/new` },
|
||||
{ path: '/editor/:topicId/new', name: 'newEditor', component: TierEditorView },
|
||||
{
|
||||
path: '/editor/:gameId/:tierListId',
|
||||
redirect: (to) => `/editor/${encodeURIComponent(String(to.params.gameId || ''))}/${encodeURIComponent(String(to.params.tierListId || ''))}`,
|
||||
},
|
||||
{ path: '/editor/:topicId/:tierListId', name: 'editEditor', component: TierEditorView },
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
{ path: '/me', name: 'me', component: MyTierListsView },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 { api } from '../lib/api'
|
||||
import { editorPath } from '../lib/paths'
|
||||
@@ -1215,7 +1215,25 @@ async function toggleSelectedTemplateVisibility(nextValue) {
|
||||
|
||||
async function removeTemplateItem(itemId) {
|
||||
resetMessages()
|
||||
if (!selectedTemplateId.value) return
|
||||
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(
|
||||
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')
|
||||
|
||||
await loadTemplate()
|
||||
await nextTick()
|
||||
window.scrollTo({ top: previousScrollY, behavior: 'auto' })
|
||||
success.value = '템플릿 기본 아이템을 삭제했어요.'
|
||||
} catch (e) {
|
||||
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
|
||||
|
||||
Reference in New Issue
Block a user