|
|
|
|
@@ -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('')
|
|
|
|
|
@@ -74,6 +76,10 @@ const isFavorited = ref(false)
|
|
|
|
|
const isRequestingTemplate = ref(false)
|
|
|
|
|
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)
|
|
|
|
|
@@ -122,7 +128,7 @@ const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierLi
|
|
|
|
|
const copiedFromLabel = computed(() => {
|
|
|
|
|
if (!sourceTierListId.value) return ''
|
|
|
|
|
const parts = []
|
|
|
|
|
if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`)
|
|
|
|
|
if (sourceSnapshotTitle.value) parts.push(sourceSnapshotTitle.value)
|
|
|
|
|
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
|
|
|
|
|
return parts.join(' · ') || '복사해 온 티어표'
|
|
|
|
|
})
|
|
|
|
|
@@ -278,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)
|
|
|
|
|
@@ -287,6 +320,87 @@ function removeItemFromGroup(groupId, columnIndex, itemId) {
|
|
|
|
|
targetGroup.cells = nextCells
|
|
|
|
|
syncGroupItemIds(targetGroup)
|
|
|
|
|
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
|
|
|
|
|
if (selectedItemId.value === itemId) selectedItemId.value = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function shouldIgnoreItemClick() {
|
|
|
|
|
return Date.now() - recentDragFinishedAt.value < 180
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getItemLocation(itemId) {
|
|
|
|
|
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
|
|
|
|
|
|
|
|
|
const poolIndex = pool.value.findIndex((id) => id === itemId)
|
|
|
|
|
if (poolIndex >= 0) {
|
|
|
|
|
return { type: 'pool', groupId: '', columnIndex: -1, index: poolIndex }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for (const group of groups.value) {
|
|
|
|
|
for (let columnIndex = 0; columnIndex < columns.value.length; columnIndex += 1) {
|
|
|
|
|
const index = getGroupCellIds(group, columnIndex).findIndex((id) => id === itemId)
|
|
|
|
|
if (index >= 0) {
|
|
|
|
|
return { type: 'group', groupId: group.id, columnIndex, index }
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return { type: null, groupId: '', columnIndex: -1, index: -1 }
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function detachItemById(itemId) {
|
|
|
|
|
if (!itemId) return
|
|
|
|
|
pool.value = pool.value.filter((id) => id !== itemId)
|
|
|
|
|
groups.value.forEach((group) => {
|
|
|
|
|
group.cells = (group.cells || []).map((cell) => (cell || []).filter((id) => id !== itemId))
|
|
|
|
|
syncGroupItemIds(group)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function selectItemByClick(itemId) {
|
|
|
|
|
if (!canEdit.value || !itemId || shouldIgnoreItemClick()) return
|
|
|
|
|
selectedItemId.value = selectedItemId.value === itemId ? '' : itemId
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function placeSelectedItemInGroup(groupId, columnIndex) {
|
|
|
|
|
if (!canEdit.value || !selectedItemId.value || !groupId || !Number.isInteger(columnIndex)) return
|
|
|
|
|
if (shouldIgnoreItemClick()) return
|
|
|
|
|
|
|
|
|
|
const targetGroup = groups.value.find((group) => group.id === groupId)
|
|
|
|
|
if (!targetGroup) return
|
|
|
|
|
|
|
|
|
|
const selectedId = selectedItemId.value
|
|
|
|
|
const currentLocation = getItemLocation(selectedId)
|
|
|
|
|
const sameTarget =
|
|
|
|
|
currentLocation.type === 'group' &&
|
|
|
|
|
currentLocation.groupId === groupId &&
|
|
|
|
|
currentLocation.columnIndex === columnIndex
|
|
|
|
|
|
|
|
|
|
if (sameTarget) {
|
|
|
|
|
selectedItemId.value = ''
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
detachItemById(selectedId)
|
|
|
|
|
const nextCells = [...targetGroup.cells]
|
|
|
|
|
nextCells[columnIndex] = [...getGroupCellIds(targetGroup, columnIndex), selectedId]
|
|
|
|
|
targetGroup.cells = nextCells
|
|
|
|
|
syncGroupItemIds(targetGroup)
|
|
|
|
|
selectedItemId.value = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function moveSelectedItemToPool() {
|
|
|
|
|
if (!canEdit.value || !selectedItemId.value || shouldIgnoreItemClick()) return
|
|
|
|
|
|
|
|
|
|
const selectedId = selectedItemId.value
|
|
|
|
|
const currentLocation = getItemLocation(selectedId)
|
|
|
|
|
if (currentLocation.type === 'pool') {
|
|
|
|
|
selectedItemId.value = ''
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
detachItemById(selectedId)
|
|
|
|
|
pool.value = [selectedId, ...pool.value]
|
|
|
|
|
selectedItemId.value = ''
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setGroupDropEl(groupId, columnIndex, el) {
|
|
|
|
|
@@ -360,6 +474,12 @@ async function initSortables() {
|
|
|
|
|
draggable: '[data-item-id]',
|
|
|
|
|
ghostClass: 'ghost',
|
|
|
|
|
chosenClass: 'chosen',
|
|
|
|
|
onStart: () => {
|
|
|
|
|
selectedItemId.value = ''
|
|
|
|
|
},
|
|
|
|
|
onEnd: () => {
|
|
|
|
|
recentDragFinishedAt.value = Date.now()
|
|
|
|
|
},
|
|
|
|
|
onSort: () => normalizeSort(poolEl.value),
|
|
|
|
|
onAdd: () => normalizeSort(poolEl.value),
|
|
|
|
|
})
|
|
|
|
|
@@ -371,6 +491,12 @@ async function initSortables() {
|
|
|
|
|
draggable: '[data-item-id]',
|
|
|
|
|
ghostClass: 'ghost',
|
|
|
|
|
chosenClass: 'chosen',
|
|
|
|
|
onStart: () => {
|
|
|
|
|
selectedItemId.value = ''
|
|
|
|
|
},
|
|
|
|
|
onEnd: () => {
|
|
|
|
|
recentDragFinishedAt.value = Date.now()
|
|
|
|
|
},
|
|
|
|
|
onSort: () => normalizeSort(el),
|
|
|
|
|
onAdd: () => normalizeSort(el),
|
|
|
|
|
})
|
|
|
|
|
@@ -732,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 }
|
|
|
|
|
}
|
|
|
|
|
@@ -784,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
|
|
|
|
|
}
|
|
|
|
|
@@ -923,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)
|
|
|
|
|
@@ -1096,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>
|
|
|
|
|
@@ -1213,8 +1450,8 @@ onUnmounted(() => {
|
|
|
|
|
</template>
|
|
|
|
|
</div>
|
|
|
|
|
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
|
|
|
|
<span>복사본</span>
|
|
|
|
|
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
|
|
|
|
|
<span>원본</span>
|
|
|
|
|
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</section>
|
|
|
|
|
@@ -1294,10 +1531,19 @@ onUnmounted(() => {
|
|
|
|
|
:data-group-id="g.id"
|
|
|
|
|
:data-column-index="columnIndex"
|
|
|
|
|
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
|
|
|
|
|
:class="{ 'row__drop--clickTarget': canEdit && !!selectedItemId }"
|
|
|
|
|
@click="placeSelectedItemInGroup(g.id, columnIndex)"
|
|
|
|
|
>
|
|
|
|
|
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
|
|
|
|
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
|
|
|
|
|
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
|
|
|
|
|
<div
|
|
|
|
|
v-for="id in getGroupCellIds(g, columnIndex)"
|
|
|
|
|
:key="id"
|
|
|
|
|
class="cell"
|
|
|
|
|
:class="{ 'cell--selected': selectedItemId === id }"
|
|
|
|
|
:data-item-id="id"
|
|
|
|
|
@click.stop="selectItemByClick(id)"
|
|
|
|
|
>
|
|
|
|
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
|
|
|
|
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
|
|
|
|
<button
|
|
|
|
|
@@ -1351,7 +1597,7 @@ onUnmounted(() => {
|
|
|
|
|
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="sidebar__hint">
|
|
|
|
|
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
|
|
|
|
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
|
|
|
|
</div>
|
|
|
|
|
<input
|
|
|
|
|
v-model="poolSearchQuery"
|
|
|
|
|
@@ -1360,13 +1606,24 @@ onUnmounted(() => {
|
|
|
|
|
maxlength="60"
|
|
|
|
|
placeholder="아이템 이름 검색"
|
|
|
|
|
/>
|
|
|
|
|
<div ref="poolEl" class="pool" data-list-type="pool">
|
|
|
|
|
<div
|
|
|
|
|
ref="poolEl"
|
|
|
|
|
class="pool"
|
|
|
|
|
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
|
|
|
|
|
data-list-type="pool"
|
|
|
|
|
@click.self="moveSelectedItemToPool"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
v-for="id in pool"
|
|
|
|
|
:key="id"
|
|
|
|
|
class="poolItem"
|
|
|
|
|
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
|
|
|
|
|
:class="{
|
|
|
|
|
'poolItem--readonly': !canEdit,
|
|
|
|
|
'poolItem--hidden': !isPoolItemVisible(id),
|
|
|
|
|
'poolItem--selected': selectedItemId === id,
|
|
|
|
|
}"
|
|
|
|
|
:data-item-id="id"
|
|
|
|
|
@click.stop="selectItemByClick(id)"
|
|
|
|
|
>
|
|
|
|
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
|
|
|
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
|
|
|
|
@@ -2214,6 +2471,11 @@ onUnmounted(() => {
|
|
|
|
|
overflow: hidden;
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
.row__drop--clickTarget {
|
|
|
|
|
cursor: copy;
|
|
|
|
|
border-color: rgba(96, 165, 250, 0.42);
|
|
|
|
|
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.12);
|
|
|
|
|
}
|
|
|
|
|
.row__empty {
|
|
|
|
|
opacity: 0.6;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
@@ -2227,6 +2489,11 @@ onUnmounted(() => {
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
flex: 0 0 auto;
|
|
|
|
|
position: relative;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
}
|
|
|
|
|
.cell--selected {
|
|
|
|
|
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.itemNameOverlay {
|
|
|
|
|
position: absolute;
|
|
|
|
|
@@ -2606,6 +2873,9 @@ onUnmounted(() => {
|
|
|
|
|
gap: 10px;
|
|
|
|
|
align-content: start;
|
|
|
|
|
}
|
|
|
|
|
.pool--clickTarget {
|
|
|
|
|
cursor: copy;
|
|
|
|
|
}
|
|
|
|
|
.poolItem {
|
|
|
|
|
min-width: 0;
|
|
|
|
|
display: grid;
|
|
|
|
|
@@ -2617,11 +2887,17 @@ onUnmounted(() => {
|
|
|
|
|
border-radius: 16px;
|
|
|
|
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
|
|
|
|
background: var(--theme-pill-bg);
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
}
|
|
|
|
|
.poolItem--readonly {
|
|
|
|
|
cursor: default;
|
|
|
|
|
opacity: 0.58;
|
|
|
|
|
filter: grayscale(0.25) brightness(0.78);
|
|
|
|
|
}
|
|
|
|
|
.poolItem--selected {
|
|
|
|
|
border-color: rgba(96, 165, 250, 0.58);
|
|
|
|
|
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
|
|
|
|
|
}
|
|
|
|
|
.poolItem .thumb {
|
|
|
|
|
width: 100%;
|
|
|
|
|
max-width: var(--thumb-size, 80px);
|
|
|
|
|
|