|
|
|
|
@@ -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>
|
|
|
|
|
|