릴리스: v1.4.43 에디터 라우트 재로딩 및 원본 이동 확인 보강

This commit is contained in:
2026-04-03 10:49:41 +09:00
parent 04e9a0420a
commit fcf4228bff
5 changed files with 231 additions and 76 deletions

View File

@@ -1,5 +1,5 @@
<script setup>
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
@@ -56,8 +56,10 @@ const templateRequestDraftDescription = ref('')
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
const isNavigationConfirmModalOpen = ref(false)
const pendingRemoveGroupId = ref('')
const pendingRemoveColumnIndex = ref(-1)
const pendingNavigationPath = ref('')
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
@@ -76,6 +78,8 @@ const isDeleting = ref(false)
const poolSearchQuery = ref('')
const selectedItemId = ref('')
const recentDragFinishedAt = ref(0)
const savedEditorSnapshot = ref('')
let editorLoadToken = 0
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -280,6 +284,33 @@ function buildGroupPayload() {
}))
}
function createEditorSnapshot() {
return JSON.stringify({
title: (title.value || '').trim(),
description: description.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
iconSize: Number(iconSize.value || 80),
columns: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
groups: buildGroupPayload(),
pool: pool.value.map((itemId) => {
const item = itemsById.value[itemId]
return {
id: item?.id || itemId,
src: item?.src || '',
label: item?.label || '',
origin: item?.origin || 'template',
}
}),
})
}
function syncSavedEditorSnapshot() {
savedEditorSnapshot.value = createEditorSnapshot()
}
const hasUnsavedChanges = computed(() => canEdit.value && savedEditorSnapshot.value && createEditorSnapshot() !== savedEditorSnapshot.value)
function removeItemFromGroup(groupId, columnIndex, itemId) {
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
const targetGroup = groups.value.find((group) => group.id === groupId)
@@ -827,6 +858,8 @@ async function persistTierList({ showModal = false } = {}) {
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!res.tierList?.isFavorited
await nextTick()
syncSavedEditorSnapshot()
if (showModal) isSaveModalOpen.value = true
return { ...res, savedTierListId }
}
@@ -879,6 +912,34 @@ function openEditMode() {
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
}
function closeNavigationConfirmModal() {
isNavigationConfirmModalOpen.value = false
pendingNavigationPath.value = ''
}
function requestEditorNavigation(path) {
if (!path) return
if (hasUnsavedChanges.value) {
pendingNavigationPath.value = path
isNavigationConfirmModalOpen.value = true
return
}
router.push(path)
}
function confirmNavigationDiscard() {
const nextPath = pendingNavigationPath.value
closeNavigationConfirmModal()
if (!nextPath) return
savedEditorSnapshot.value = ''
router.push(nextPath)
}
function openSourceTierList() {
if (!sourceTierListId.value) return
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
}
function closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -1018,88 +1079,156 @@ async function requestTemplate(type) {
}
}
onMounted(() => {
;(async () => {
await auth.refresh()
authorName.value = (auth.user?.nickname || '').trim()
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
function resetEditorStateForRoute() {
destroySortables()
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
columns.value = [{ id: 'col-1', name: '' }]
groups.value = normalizeLoadedGroups([], columns.value)
pool.value = []
itemsById.value = {}
title.value = ''
persistedTierListId.value = ''
thumbnailSrc.value = ''
pendingThumbnailFile.value = null
description.value = ''
isPublic.value = true
showCharacterNames.value = false
isSaveModalOpen.value = false
isTemplateRequestModalOpen.value = false
isTemplateUpdateModalOpen.value = false
isDeleteModalOpen.value = false
isGroupDeleteModalOpen.value = false
isColumnDeleteModalOpen.value = false
isNavigationConfirmModalOpen.value = false
pendingRemoveGroupId.value = ''
pendingRemoveColumnIndex.value = -1
pendingNavigationPath.value = ''
ownerId.value = ''
authorName.value = ''
authorAccountName.value = ''
updatedAt.value = 0
sourceTierListId.value = ''
sourceSnapshotTitle.value = ''
sourceSnapshotAuthor.value = ''
isDragActive.value = false
isThumbnailDragActive.value = false
iconSize.value = 80
isFavoriteBusy.value = false
favoriteCount.value = 0
isFavorited.value = false
isRequestingTemplate.value = false
isDeleting.value = false
poolSearchQuery.value = ''
selectedItemId.value = ''
recentDragFinishedAt.value = 0
savedEditorSnapshot.value = ''
resetTemplateRequestDrafts()
}
if (isNewTierList.value && !auth.user) {
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
async function loadEditorState() {
const loadToken = ++editorLoadToken
resetEditorStateForRoute()
await auth.refresh()
if (loadToken !== editorLoadToken) return
let currentTemplateItems = []
authorName.value = (auth.user?.nickname || '').trim()
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
if (isNewTierList.value && !auth.user) {
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
let currentTemplateItems = []
try {
const topicRes = await api.getTopic(templateId.value)
if (loadToken !== editorLoadToken) return
templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'template',
}))
currentTemplateItems = base
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
if (loadToken !== editorLoadToken) return
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
}
if (tierListId.value && tierListId.value !== 'new') {
try {
const topicRes = await api.getTopic(templateId.value)
templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'template',
}))
currentTemplateItems = base
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
}
const res = await api.getTierList(tierListId.value)
if (loadToken !== editorLoadToken) return
if (tierListId.value && tierListId.value !== 'new') {
try {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
sourceTierListId.value = t.sourceTierListId || ''
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
sourceTierListId.value = t.sourceTierListId || ''
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
return
}
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
const grouped = new Set()
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
const merged = mergeLatestTemplateItemsIntoPool(
map,
Object.keys(map).filter((id) => !grouped.has(id)),
currentTemplateItems,
grouped,
canEdit.value && !previewMode.value
)
itemsById.value = merged.nextMap
pool.value = merged.nextPoolIds
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
return
}
}
await nextTick()
if (canEdit.value) {
await initSortables()
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
const grouped = new Set()
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
const merged = mergeLatestTemplateItemsIntoPool(
map,
Object.keys(map).filter((id) => !grouped.has(id)),
currentTemplateItems,
grouped,
canEdit.value && !previewMode.value
)
itemsById.value = merged.nextMap
pool.value = merged.nextPoolIds
} catch (e) {
if (loadToken !== editorLoadToken) return
error.value = '티어표를 불러오지 못했어요.'
}
})()
})
}
await nextTick()
if (loadToken !== editorLoadToken) return
syncSavedEditorSnapshot()
if (canEdit.value) {
await initSortables()
}
}
watch(
() => [route.params.topicId, route.params.tierListId, route.query.preview],
() => {
loadEditorState()
},
{ immediate: true }
)
onUnmounted(() => {
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
@@ -1191,6 +1320,19 @@ onUnmounted(() => {
</div>
</div>
<div v-if="isNavigationConfirmModalOpen" class="modalOverlay" @click.self="closeNavigationConfirmModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="navigationConfirmTitle">
<div id="navigationConfirmTitle" class="modalCard__title">원본 티어표로 이동</div>
<div class="modalCard__desc">
아직 저장하지 않은 수정 내용이 있어요. 이대로 이동하면 현재 변경 내용은 사라집니다.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" type="button" @click="closeNavigationConfirmModal">계속 편집</button>
<button class="btn btn--danger" type="button" @click="confirmNavigationDiscard">저장 없이 이동</button>
</div>
</div>
</div>
<div v-if="isTemplateRequestModalOpen" class="modalOverlay" @click.self="closeTemplateRequestModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
@@ -1309,7 +1451,7 @@ onUnmounted(() => {
</div>
<div v-if="sourceTierListId" class="editorMain__sourceNote">
<span>원본</span>
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
</div>
</div>
</section>