3399 lines
105 KiB
Vue
3399 lines
105 KiB
Vue
<script setup>
|
||
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import Sortable from 'sortablejs'
|
||
import * as htmlToImage from 'html-to-image'
|
||
import SvgIcon from '../components/SvgIcon.vue'
|
||
import addColumnRightIcon from '../assets/icons/add_column_right.svg'
|
||
import addRowBelowIcon from '../assets/icons/add_row_below.svg'
|
||
import addPhotoAlternateIcon from '../assets/icons/add_photo_alternate.svg'
|
||
import shareIcon from '../assets/icons/share.svg'
|
||
import RightRailAd from '../components/RightRailAd.vue'
|
||
import { api } from '../lib/api'
|
||
import { editorNewPath, editorPath, loginPath, mePath, shareEditorPath, topicPath, userProfilePath } from '../lib/paths'
|
||
import { toApiUrl } from '../lib/runtime'
|
||
import { useAuthStore } from '../stores/auth'
|
||
import { useToast } from '../composables/useToast'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
const auth = useAuthStore()
|
||
const toast = useToast()
|
||
const globalRightRailOpen = inject('rightRailOpen', ref(true))
|
||
const localRightRailTarget = inject('localRightRailTarget', '#local-right-rail-root')
|
||
const templateId = computed(() => route.params.topicId)
|
||
const tierListId = computed(() => route.params.tierListId)
|
||
const previewMode = computed(() => route.query.preview === '1')
|
||
const templateName = ref('')
|
||
|
||
const columns = ref([{ id: 'col-1', name: '' }])
|
||
const groups = ref([
|
||
{ id: 'gS', name: 'S', itemIds: [], cells: [[]] },
|
||
{ id: 'gA', name: 'A', itemIds: [], cells: [[]] },
|
||
{ id: 'gB', name: 'B', itemIds: [], cells: [[]] },
|
||
{ id: 'gC', name: 'C', itemIds: [], cells: [[]] },
|
||
])
|
||
|
||
const pool = ref([])
|
||
const itemsById = ref({})
|
||
|
||
const title = ref('')
|
||
const persistedTierListId = ref('')
|
||
const thumbnailSrc = ref('')
|
||
const pendingThumbnailFile = ref(null)
|
||
const thumbnailPreviewUrl = ref('')
|
||
const description = ref('')
|
||
const isPublic = ref(true)
|
||
const showCharacterNames = ref(false)
|
||
const error = ref('')
|
||
const isSaving = ref(false)
|
||
const isExporting = ref(false)
|
||
const isSaveModalOpen = ref(false)
|
||
const isTemplateRequestModalOpen = ref(false)
|
||
const isTemplateUpdateModalOpen = ref(false)
|
||
const templateRequestDraftTitle = ref('')
|
||
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('')
|
||
const updatedAt = ref(0)
|
||
const sourceTierListId = ref('')
|
||
const sourceSnapshotTitle = ref('')
|
||
const sourceSnapshotAuthor = ref('')
|
||
const isDragActive = ref(false)
|
||
const isThumbnailDragActive = ref(false)
|
||
const iconSize = ref(80)
|
||
const isFavoriteBusy = ref(false)
|
||
const favoriteCount = ref(0)
|
||
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('')
|
||
const itemContextMenu = ref({
|
||
open: false,
|
||
x: 0,
|
||
y: 0,
|
||
itemId: '',
|
||
})
|
||
let editorLoadToken = 0
|
||
|
||
const boardEl = ref(null)
|
||
const exportBoardEl = ref(null)
|
||
const groupListEl = ref(null)
|
||
const sidebarEl = ref(null)
|
||
const poolEl = ref(null)
|
||
const poolSearchEl = ref(null)
|
||
const groupDropEls = ref({})
|
||
const fileEl = ref(null)
|
||
const thumbnailFileEl = ref(null)
|
||
const groupSortable = ref(null)
|
||
const poolSortable = ref(null)
|
||
const dropSortables = ref([])
|
||
const editorSidebarMaxHeight = ref('')
|
||
let editorSidebarMeasureFrame = 0
|
||
|
||
const isNewTierList = computed(() => tierListId.value === 'new')
|
||
const isOwnTierList = computed(() => !!auth.user && !!ownerId.value && ownerId.value === auth.user.id)
|
||
const canEdit = computed(() => !!auth.user && !previewMode.value && (!ownerId.value || ownerId.value === auth.user.id))
|
||
const iconSizeOptions = [48, 64, 80, 96, 112]
|
||
const touchSortableOptions = {
|
||
delayOnTouchOnly: true,
|
||
delay: 180,
|
||
touchStartThreshold: 8,
|
||
fallbackTolerance: 8,
|
||
}
|
||
const hasCustomTitle = computed(() => !!(title.value || '').trim())
|
||
const fallbackTimestamp = computed(() => (updatedAt.value ? updatedAt.value : Date.now()))
|
||
const effectiveAuthorName = computed(() => {
|
||
const currentNickname = (auth.user?.nickname || '').trim()
|
||
if (currentNickname) return currentNickname
|
||
if ((authorName.value || '').trim()) return authorName.value.trim()
|
||
const currentEmail = (auth.user?.email || '').trim()
|
||
if (currentEmail) return currentEmail.split('@')[0] || currentEmail
|
||
return (authorAccountName.value || '').trim() || 'unknown'
|
||
})
|
||
const autoGeneratedTitle = ref(createAutoTierListTitle())
|
||
const effectiveTitle = computed(() => {
|
||
const customTitle = (title.value || '').trim()
|
||
if (customTitle) return customTitle
|
||
if (persistedTierListId.value) return persistedTierListId.value
|
||
if (tierListId.value && tierListId.value !== 'new') return tierListId.value
|
||
return autoGeneratedTitle.value
|
||
})
|
||
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
|
||
const untitledWarning = computed(
|
||
() =>
|
||
canEdit.value &&
|
||
!hasCustomTitle.value &&
|
||
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
||
)
|
||
const canFavorite = computed(() => !!auth.user && !isNewTierList.value && !canEdit.value)
|
||
const canDuplicate = computed(() => !!auth.user && hasSavedTierList.value)
|
||
const canSwitchToViewerMode = computed(() => isOwnTierList.value && hasSavedTierList.value && !previewMode.value)
|
||
const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierList.value && previewMode.value)
|
||
const duplicateActionLabel = computed(() => (isOwnTierList.value ? '복사본 만들기' : '내 티어표로 복사'))
|
||
const canOpenAuthorProfile = computed(() => !!ownerId.value && hasSavedTierList.value)
|
||
const copiedFromLabel = computed(() => {
|
||
if (!sourceTierListId.value) return ''
|
||
const parts = []
|
||
if (sourceSnapshotTitle.value) parts.push(sourceSnapshotTitle.value)
|
||
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
|
||
return parts.join(' · ') || '복사해 온 티어표'
|
||
})
|
||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||
const normalizedPoolSearchQuery = computed(() => normalizeSearchText(poolSearchQuery.value))
|
||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||
const canRequestTemplateCreate = computed(
|
||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||
)
|
||
const canRequestTemplateUpdate = computed(
|
||
() => canEdit.value && hasSavedTierList.value && templateId.value !== 'freeform' && customItems.value.length > 0
|
||
)
|
||
const canSubmitTemplateCreateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||
const canSubmitTemplateUpdateRequest = computed(() => !!templateRequestDraftTitle.value.trim() && !!templateRequestDraftDescription.value.trim())
|
||
const templateRequestTargetLabel = computed(() => (templateId.value === 'freeform' ? '새로운 템플릿' : (templateName.value || templateId.value || '선택한 주제')))
|
||
const shareTierListUrl = computed(() => {
|
||
const savedTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||
if (!savedTierListId) return ''
|
||
const sharePath = shareEditorPath(templateId.value, savedTierListId)
|
||
if (typeof window === 'undefined') return sharePath
|
||
return new URL(sharePath, window.location.origin).toString()
|
||
})
|
||
watch(error, (message) => {
|
||
if (!message) return
|
||
toast.error(message)
|
||
error.value = ''
|
||
})
|
||
|
||
function normalizeSearchText(text) {
|
||
return String(text || '')
|
||
.normalize('NFC')
|
||
.trim()
|
||
.toLowerCase()
|
||
}
|
||
|
||
function createAutoTierListTitle() {
|
||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||
return pick(10) + '-' + pick(10)
|
||
}
|
||
|
||
function formatTitleDate(ts) {
|
||
const date = new Date(ts)
|
||
const year = date.getFullYear()
|
||
const month = String(date.getMonth() + 1).padStart(2, '0')
|
||
const day = String(date.getDate()).padStart(2, '0')
|
||
const hours = String(date.getHours()).padStart(2, '0')
|
||
const minutes = String(date.getMinutes()).padStart(2, '0')
|
||
return `${year}-${month}-${day} ${hours}:${minutes}`
|
||
}
|
||
|
||
function formatExportDate(ts) {
|
||
return new Date(ts).toLocaleString('ko-KR', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
})
|
||
}
|
||
|
||
function getOrderedItemIds() {
|
||
const orderedIds = []
|
||
const seen = new Set()
|
||
const pushId = (itemId) => {
|
||
if (!itemId || seen.has(itemId) || !itemsById.value[itemId]) return
|
||
seen.add(itemId)
|
||
orderedIds.push(itemId)
|
||
}
|
||
|
||
pool.value.forEach(pushId)
|
||
groups.value.forEach((group) => {
|
||
;(group.cells || []).forEach((cell) => {
|
||
;(cell || []).forEach(pushId)
|
||
})
|
||
})
|
||
Object.keys(itemsById.value).forEach(pushId)
|
||
return orderedIds
|
||
}
|
||
|
||
function getOrderedItems() {
|
||
return getOrderedItemIds().map((itemId) => itemsById.value[itemId]).filter(Boolean)
|
||
}
|
||
|
||
function mergeLatestTemplateItemsIntoPool(savedItemsMap, savedPoolIds, currentTemplateItems, groupedIds, editable) {
|
||
const nextMap = { ...(savedItemsMap || {}) }
|
||
const nextPoolIds = Array.isArray(savedPoolIds) ? [...savedPoolIds] : []
|
||
if (!editable) return { nextMap, nextPoolIds }
|
||
|
||
;(currentTemplateItems || []).forEach((item) => {
|
||
if (!item?.id || nextMap[item.id]) return
|
||
nextMap[item.id] = item
|
||
if (!groupedIds?.has(item.id)) nextPoolIds.push(item.id)
|
||
})
|
||
|
||
return { nextMap, nextPoolIds }
|
||
}
|
||
|
||
function isPoolItemVisible(itemId) {
|
||
const query = normalizedPoolSearchQuery.value
|
||
if (!query) return true
|
||
const item = itemsById.value[itemId]
|
||
const label = normalizeSearchText(item?.label || itemId || '')
|
||
return label.includes(query)
|
||
}
|
||
|
||
const visiblePoolCount = computed(() => pool.value.filter((itemId) => isPoolItemVisible(itemId)).length)
|
||
|
||
function setIconSize(nextSize) {
|
||
iconSize.value = nextSize
|
||
}
|
||
|
||
function getGroupCellIds(group, columnIndex) {
|
||
return Array.isArray(group?.cells?.[columnIndex]) ? group.cells[columnIndex] : []
|
||
}
|
||
|
||
function syncGroupItemIds(group) {
|
||
group.itemIds = (group.cells || []).flat()
|
||
}
|
||
|
||
function normalizeLoadedColumns(rawGroups) {
|
||
const fromGroup = Array.isArray(rawGroups) ? rawGroups.find((group) => Array.isArray(group?.columnNames) && group.columnNames.length) : null
|
||
const rawColumns = Array.isArray(fromGroup?.columnNames) ? fromGroup.columnNames : []
|
||
const cellCount = Math.max(1, ...(Array.isArray(rawGroups) ? rawGroups.map((group) => (Array.isArray(group?.cells) ? group.cells.length : 0)) : [0]))
|
||
const size = Math.max(rawColumns.length || 0, cellCount)
|
||
return Array.from({ length: size || 1 }, (_, index) => ({
|
||
id: rawColumns[index]?.id || `col-${index + 1}` ,
|
||
name: typeof rawColumns[index]?.name === 'string' ? rawColumns[index].name.slice(0, 16) : '',
|
||
}))
|
||
}
|
||
|
||
function normalizeLoadedGroups(rawGroups, nextColumns = columns.value) {
|
||
if (!Array.isArray(rawGroups) || !rawGroups.length) {
|
||
return [
|
||
{ id: 'gS', name: 'S', itemIds: [], cells: nextColumns.map(() => []) },
|
||
{ id: 'gA', name: 'A', itemIds: [], cells: nextColumns.map(() => []) },
|
||
{ id: 'gB', name: 'B', itemIds: [], cells: nextColumns.map(() => []) },
|
||
{ id: 'gC', name: 'C', itemIds: [], cells: nextColumns.map(() => []) },
|
||
]
|
||
}
|
||
return rawGroups.map((group, index) => {
|
||
const cells = Array.from({ length: nextColumns.length }, (_, cellIndex) => {
|
||
if (Array.isArray(group?.cells?.[cellIndex])) return [...group.cells[cellIndex]]
|
||
if (cellIndex === 0 && Array.isArray(group?.itemIds)) return [...group.itemIds]
|
||
return []
|
||
})
|
||
return {
|
||
id: typeof group?.id === 'string' && group.id ? group.id : `g-${index + 1}` ,
|
||
name: typeof group?.name === 'string' && group.name ? group.name.slice(0, 16) : 'Tier',
|
||
itemIds: cells.flat(),
|
||
cells,
|
||
}
|
||
})
|
||
}
|
||
|
||
function buildGroupPayload() {
|
||
return groups.value.map((group) => ({
|
||
id: group.id,
|
||
name: group.name,
|
||
itemIds: (group.cells || []).flat(),
|
||
cells: (group.cells || []).map((cell) => [...cell]),
|
||
columnNames: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
|
||
}))
|
||
}
|
||
|
||
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)
|
||
if (!targetGroup) return
|
||
const nextCells = [...targetGroup.cells]
|
||
nextCells[columnIndex] = getGroupCellIds(targetGroup, columnIndex).filter((id) => id !== 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 closeItemContextMenu() {
|
||
if (!itemContextMenu.value.open) return
|
||
itemContextMenu.value = {
|
||
open: false,
|
||
x: 0,
|
||
y: 0,
|
||
itemId: '',
|
||
}
|
||
}
|
||
|
||
function scrollWorkspaceBodyToTop() {
|
||
const workspaceBody = document.querySelector('.workspaceBody')
|
||
workspaceBody?.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
|
||
function updateEditorSidebarMaxHeight() {
|
||
if (typeof window === 'undefined' || !sidebarEl.value) return
|
||
const bottomGap = 14
|
||
const stickyTop = 14
|
||
const minHeight = 260
|
||
const sidebarTop = Math.max(sidebarEl.value.getBoundingClientRect().top, stickyTop)
|
||
const nextHeight = Math.max(minHeight, Math.floor(window.innerHeight - sidebarTop - bottomGap))
|
||
editorSidebarMaxHeight.value = `${nextHeight}px`
|
||
}
|
||
|
||
function scheduleEditorSidebarMeasure() {
|
||
if (typeof window === 'undefined') return
|
||
if (editorSidebarMeasureFrame) return
|
||
editorSidebarMeasureFrame = window.requestAnimationFrame(() => {
|
||
editorSidebarMeasureFrame = 0
|
||
updateEditorSidebarMaxHeight()
|
||
})
|
||
}
|
||
|
||
function focusPoolSearch() {
|
||
poolSearchEl.value?.focus()
|
||
poolSearchEl.value?.select()
|
||
}
|
||
|
||
function openItemContextMenu(itemId, event) {
|
||
if (!canEdit.value || !itemId || !itemsById.value[itemId] || shouldIgnoreItemClick()) return
|
||
selectedItemId.value = itemId
|
||
const viewportWidth = typeof window === 'undefined' ? 0 : window.innerWidth
|
||
const viewportHeight = typeof window === 'undefined' ? 0 : window.innerHeight
|
||
const menuX = Number(event?.clientX || 0)
|
||
const menuY = Number(event?.clientY || 0)
|
||
itemContextMenu.value = {
|
||
open: true,
|
||
x: viewportWidth ? Math.max(12, Math.min(menuX, viewportWidth - 180)) : menuX,
|
||
y: viewportHeight ? Math.max(12, Math.min(menuY, viewportHeight - 72)) : menuY,
|
||
itemId,
|
||
}
|
||
}
|
||
|
||
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 duplicateItemToPool() {
|
||
if (!canEdit.value || !itemContextMenu.value.itemId) return
|
||
|
||
const sourceItem = itemsById.value[itemContextMenu.value.itemId]
|
||
if (!sourceItem) {
|
||
closeItemContextMenu()
|
||
return
|
||
}
|
||
|
||
const clonedId = createClonedItemId()
|
||
itemsById.value = {
|
||
...itemsById.value,
|
||
[clonedId]: {
|
||
...sourceItem,
|
||
id: clonedId,
|
||
},
|
||
}
|
||
pool.value = [clonedId, ...pool.value]
|
||
selectedItemId.value = clonedId
|
||
closeItemContextMenu()
|
||
toast.success('아이템 추가 완료')
|
||
}
|
||
|
||
function canRemoveEditorItem(itemId) {
|
||
return canEdit.value && itemsById.value[itemId]?.origin === 'custom'
|
||
}
|
||
|
||
function deleteEditorItem(itemId) {
|
||
if (!canRemoveEditorItem(itemId)) return
|
||
const targetItem = itemsById.value[itemId]
|
||
detachItemById(itemId)
|
||
if (typeof targetItem?.src === 'string' && targetItem.src.startsWith('blob:')) {
|
||
URL.revokeObjectURL(targetItem.src)
|
||
}
|
||
const nextItems = { ...itemsById.value }
|
||
delete nextItems[itemId]
|
||
itemsById.value = nextItems
|
||
if (selectedItemId.value === itemId) selectedItemId.value = ''
|
||
if (itemContextMenu.value.itemId === itemId) closeItemContextMenu()
|
||
toast.success('커스텀 이미지를 현재 티어표에서 제거했어요.')
|
||
}
|
||
|
||
function handleGlobalContextMenu(event) {
|
||
const target = event?.target
|
||
if (target?.closest?.('[data-item-context-menu]')) {
|
||
event?.preventDefault?.()
|
||
event?.stopPropagation?.()
|
||
return
|
||
}
|
||
|
||
const itemEl = target?.closest?.('[data-item-id]')
|
||
if (canEdit.value && itemEl?.dataset?.itemId) {
|
||
event?.preventDefault?.()
|
||
event?.stopPropagation?.()
|
||
openItemContextMenu(itemEl.dataset.itemId, event)
|
||
return
|
||
}
|
||
|
||
if (!itemContextMenu.value.open) return
|
||
closeItemContextMenu()
|
||
}
|
||
|
||
function handleGlobalPointerDown(event) {
|
||
if (!itemContextMenu.value.open) return
|
||
const target = event?.target
|
||
if (target?.closest?.('[data-item-context-menu]')) return
|
||
closeItemContextMenu()
|
||
}
|
||
|
||
function setGroupDropEl(groupId, columnIndex, el) {
|
||
const key = `${groupId}::${columnIndex}`
|
||
if (!el) {
|
||
delete groupDropEls.value[key]
|
||
return
|
||
}
|
||
groupDropEls.value[key] = el
|
||
}
|
||
|
||
function getListByContainer(containerEl) {
|
||
if (!containerEl) return { type: null, groupId: null, columnIndex: null }
|
||
const t = containerEl.getAttribute('data-list-type')
|
||
if (t === 'pool') return { type: 'pool', groupId: null, columnIndex: null }
|
||
if (t === 'group') {
|
||
return {
|
||
type: 'group',
|
||
groupId: containerEl.getAttribute('data-group-id'),
|
||
columnIndex: Number(containerEl.getAttribute('data-column-index')),
|
||
}
|
||
}
|
||
return { type: null, groupId: null, columnIndex: null }
|
||
}
|
||
|
||
function normalizeSort(containerEl) {
|
||
const ids = Array.from(containerEl.querySelectorAll('[data-item-id]')).map((n) => n.getAttribute('data-item-id'))
|
||
const meta = getListByContainer(containerEl)
|
||
if (meta.type === 'pool') {
|
||
pool.value = ids
|
||
return
|
||
}
|
||
if (meta.type === 'group') {
|
||
const g = groups.value.find((x) => x.id === meta.groupId)
|
||
if (!g || !Number.isInteger(meta.columnIndex)) return
|
||
const nextCells = [...g.cells]
|
||
nextCells[meta.columnIndex] = ids
|
||
g.cells = nextCells
|
||
syncGroupItemIds(g)
|
||
}
|
||
}
|
||
|
||
function resolveItemSrc(item) {
|
||
const src = item?.src || ''
|
||
if (!src) return ''
|
||
if (src.startsWith('blob:')) return src
|
||
return toApiUrl(src)
|
||
}
|
||
|
||
async function initSortables() {
|
||
if (!poolEl.value || !groupListEl.value) return
|
||
|
||
destroySortables()
|
||
|
||
groupSortable.value = Sortable.create(groupListEl.value, {
|
||
...touchSortableOptions,
|
||
animation: 160,
|
||
handle: '[data-group-handle]',
|
||
ghostClass: 'ghost',
|
||
chosenClass: 'chosen',
|
||
onEnd: (evt) => {
|
||
const next = [...groups.value]
|
||
const [moved] = next.splice(evt.oldIndex, 1)
|
||
next.splice(evt.newIndex, 0, moved)
|
||
groups.value = next
|
||
},
|
||
})
|
||
|
||
poolSortable.value = Sortable.create(poolEl.value, {
|
||
...touchSortableOptions,
|
||
group: 'tier-items',
|
||
animation: 160,
|
||
draggable: '[data-item-id]',
|
||
ghostClass: 'ghost',
|
||
chosenClass: 'chosen',
|
||
onStart: () => {
|
||
selectedItemId.value = ''
|
||
},
|
||
onEnd: () => {
|
||
recentDragFinishedAt.value = Date.now()
|
||
},
|
||
onSort: () => normalizeSort(poolEl.value),
|
||
onAdd: () => normalizeSort(poolEl.value),
|
||
})
|
||
|
||
dropSortables.value = Object.entries(groupDropEls.value).map(([, el]) =>
|
||
Sortable.create(el, {
|
||
...touchSortableOptions,
|
||
group: 'tier-items',
|
||
animation: 160,
|
||
draggable: '[data-item-id]',
|
||
ghostClass: 'ghost',
|
||
chosenClass: 'chosen',
|
||
onStart: () => {
|
||
selectedItemId.value = ''
|
||
},
|
||
onEnd: () => {
|
||
recentDragFinishedAt.value = Date.now()
|
||
},
|
||
onSort: () => normalizeSort(el),
|
||
onAdd: () => normalizeSort(el),
|
||
})
|
||
)
|
||
}
|
||
|
||
function destroySortables() {
|
||
if (groupSortable.value) {
|
||
groupSortable.value.destroy()
|
||
groupSortable.value = null
|
||
}
|
||
if (poolSortable.value) {
|
||
poolSortable.value.destroy()
|
||
poolSortable.value = null
|
||
}
|
||
dropSortables.value.forEach((instance) => instance.destroy())
|
||
dropSortables.value = []
|
||
}
|
||
|
||
async function syncSortables() {
|
||
await nextTick()
|
||
if (canEdit.value) {
|
||
await initSortables()
|
||
}
|
||
}
|
||
|
||
function createGroupName(index = groups.value.length) {
|
||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
||
if (index < alphabet.length) return alphabet[index]
|
||
return `Tier ${index + 1}`
|
||
}
|
||
|
||
function createColumnName(index = columns.value.length) {
|
||
return `열 ${index + 1}`
|
||
}
|
||
|
||
function createCustomItemLabel(fileName = '') {
|
||
const normalized = String(fileName || '')
|
||
.normalize('NFC')
|
||
.replace(/\.[^.]+$/, '')
|
||
.replace(/[_-]+/g, ' ')
|
||
.replace(/\s+/g, ' ')
|
||
.trim()
|
||
return (normalized || 'custom').slice(0, 60)
|
||
}
|
||
|
||
function createClonedItemId() {
|
||
return `dup-${Date.now()}-${Math.random().toString(16).slice(2, 10)}`
|
||
}
|
||
|
||
async function addGroup() {
|
||
groups.value = [
|
||
...groups.value,
|
||
{
|
||
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}` ,
|
||
name: createGroupName(),
|
||
itemIds: [],
|
||
cells: columns.value.map(() => []),
|
||
},
|
||
]
|
||
await syncSortables()
|
||
}
|
||
|
||
async function addColumn() {
|
||
columns.value = [
|
||
...columns.value,
|
||
{ id: `col-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`, name: createColumnName() },
|
||
]
|
||
groups.value = groups.value.map((group) => ({
|
||
...group,
|
||
cells: [...group.cells, []],
|
||
itemIds: [...group.itemIds],
|
||
}))
|
||
await syncSortables()
|
||
}
|
||
|
||
async function removeColumn(columnIndex) {
|
||
if (!canEdit.value || columns.value.length <= 1) return
|
||
const nextColumns = columns.value.filter((_, index) => index !== columnIndex)
|
||
groups.value = groups.value.map((group) => {
|
||
const nextCells = group.cells.filter((_, index) => index !== columnIndex)
|
||
const removed = Array.isArray(group.cells[columnIndex]) ? group.cells[columnIndex] : []
|
||
if (nextCells[0] && removed.length) nextCells[0] = [...removed, ...nextCells[0]]
|
||
const nextGroup = { ...group, cells: nextCells }
|
||
syncGroupItemIds(nextGroup)
|
||
return nextGroup
|
||
})
|
||
Object.keys(groupDropEls.value).forEach((key) => {
|
||
if (key.endsWith(`::${columnIndex}`)) delete groupDropEls.value[key]
|
||
})
|
||
columns.value = nextColumns
|
||
await syncSortables()
|
||
}
|
||
|
||
function openColumnDeleteModal(columnIndex) {
|
||
if (!canEdit.value || columns.value.length <= 1) return
|
||
pendingRemoveColumnIndex.value = columnIndex
|
||
isColumnDeleteModalOpen.value = true
|
||
}
|
||
|
||
function closeColumnDeleteModal() {
|
||
isColumnDeleteModalOpen.value = false
|
||
pendingRemoveColumnIndex.value = -1
|
||
}
|
||
|
||
async function confirmRemoveColumn() {
|
||
const columnIndex = pendingRemoveColumnIndex.value
|
||
closeColumnDeleteModal()
|
||
if (columnIndex < 0) return
|
||
await removeColumn(columnIndex)
|
||
}
|
||
|
||
async function performRemoveGroup(groupId) {
|
||
if (groups.value.length <= 1) return
|
||
const target = groups.value.find((group) => group.id === groupId)
|
||
if (!target) return
|
||
pool.value = [...target.itemIds, ...pool.value]
|
||
groups.value = groups.value.filter((group) => group.id !== groupId)
|
||
Object.keys(groupDropEls.value).forEach((key) => {
|
||
if (key.startsWith(`${groupId}::`)) delete groupDropEls.value[key]
|
||
})
|
||
await syncSortables()
|
||
}
|
||
|
||
function openGroupDeleteModal(groupId) {
|
||
if (!canEdit.value || groups.value.length <= 1 || !groupId) return
|
||
pendingRemoveGroupId.value = groupId
|
||
isGroupDeleteModalOpen.value = true
|
||
}
|
||
|
||
function closeGroupDeleteModal() {
|
||
isGroupDeleteModalOpen.value = false
|
||
pendingRemoveGroupId.value = ''
|
||
}
|
||
|
||
async function confirmRemoveGroup() {
|
||
const groupId = pendingRemoveGroupId.value
|
||
closeGroupDeleteModal()
|
||
if (!groupId) return
|
||
await performRemoveGroup(groupId)
|
||
}
|
||
|
||
function addCustomImage(file) {
|
||
if (!file || !file.type.startsWith('image/')) return
|
||
const url = URL.createObjectURL(file)
|
||
const id = `c-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
||
itemsById.value = {
|
||
...itemsById.value,
|
||
[id]: { id, src: url, label: createCustomItemLabel(file.name), origin: 'custom', pendingFile: file },
|
||
}
|
||
pool.value = [id, ...pool.value]
|
||
}
|
||
|
||
function updateCustomItemLabel(itemId, nextLabel) {
|
||
const item = itemsById.value[itemId]
|
||
if (!item || item.origin !== 'custom') return
|
||
itemsById.value = {
|
||
...itemsById.value,
|
||
[itemId]: {
|
||
...item,
|
||
label: nextLabel.slice(0, 60),
|
||
},
|
||
}
|
||
}
|
||
|
||
function openFile() {
|
||
if (!canEdit.value) return
|
||
fileEl.value?.click()
|
||
}
|
||
|
||
function openThumbnailFile() {
|
||
if (!canEdit.value) return
|
||
thumbnailFileEl.value?.click()
|
||
}
|
||
|
||
function applyThumbnailFile(file) {
|
||
if (!file || !file.type.startsWith('image/')) return
|
||
if (thumbnailPreviewUrl.value) {
|
||
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||
thumbnailPreviewUrl.value = ''
|
||
}
|
||
pendingThumbnailFile.value = file
|
||
thumbnailPreviewUrl.value = URL.createObjectURL(file)
|
||
}
|
||
|
||
function onThumbnailDragEnter() {
|
||
if (!canEdit.value) return
|
||
isThumbnailDragActive.value = true
|
||
}
|
||
|
||
function onThumbnailDragLeave(event) {
|
||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||
isThumbnailDragActive.value = false
|
||
}
|
||
}
|
||
|
||
function onThumbnailDrop(event) {
|
||
if (!canEdit.value) return
|
||
isThumbnailDragActive.value = false
|
||
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||
if (!files.length) return
|
||
if (files.length > 1) {
|
||
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
|
||
}
|
||
applyThumbnailFile(files[0])
|
||
}
|
||
|
||
function onThumbnailChange(event) {
|
||
const files = Array.from(event.target.files || []).filter((file) => file.type.startsWith('image/'))
|
||
if (!files.length) {
|
||
event.target.value = ''
|
||
return
|
||
}
|
||
if (files.length > 1) {
|
||
toast.info('대표 썸네일은 하나만 설정할 수 있어요. 첫 번째 이미지를 사용할게요.')
|
||
}
|
||
applyThumbnailFile(files[0])
|
||
event.target.value = ''
|
||
}
|
||
|
||
function clearThumbnail() {
|
||
if (thumbnailPreviewUrl.value) {
|
||
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||
thumbnailPreviewUrl.value = ''
|
||
}
|
||
pendingThumbnailFile.value = null
|
||
thumbnailSrc.value = ''
|
||
}
|
||
|
||
function onFileChange(e) {
|
||
const files = Array.from(e.target.files || [])
|
||
if (!files.length) return
|
||
files.forEach(addCustomImage)
|
||
e.target.value = ''
|
||
}
|
||
|
||
function onDragEnter() {
|
||
if (!canEdit.value) return
|
||
isDragActive.value = true
|
||
}
|
||
|
||
function onDragLeave(event) {
|
||
if (!event.currentTarget.contains(event.relatedTarget)) {
|
||
isDragActive.value = false
|
||
}
|
||
}
|
||
|
||
function onDropFiles(event) {
|
||
if (!canEdit.value) return
|
||
isDragActive.value = false
|
||
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||
files.forEach(addCustomImage)
|
||
}
|
||
|
||
async function downloadImage() {
|
||
if (!boardEl.value) return
|
||
isExporting.value = true
|
||
await nextTick()
|
||
|
||
try {
|
||
const targetEl = exportBoardEl.value || boardEl.value
|
||
const blob = await htmlToImage.toBlob(targetEl, { pixelRatio: 1.5, backgroundColor: '#0b1220' })
|
||
if (!blob) throw new Error('image_export_failed')
|
||
|
||
const url = URL.createObjectURL(blob)
|
||
const a = document.createElement('a')
|
||
a.href = url
|
||
a.download = `${effectiveTitle.value.trim()}.png`
|
||
document.body.appendChild(a)
|
||
a.click()
|
||
a.remove()
|
||
URL.revokeObjectURL(url)
|
||
} finally {
|
||
isExporting.value = false
|
||
}
|
||
}
|
||
|
||
async function uploadPendingCustomItems() {
|
||
const entries = Object.values(itemsById.value).filter((item) => item?.origin === 'custom' && item?.pendingFile)
|
||
|
||
for (const item of entries) {
|
||
const fd = new FormData()
|
||
fd.append('label', createCustomItemLabel(item.label || 'custom'))
|
||
fd.append('image', item.pendingFile)
|
||
|
||
const res = await fetch(toApiUrl('/api/tierlists/custom-items'), {
|
||
method: 'POST',
|
||
credentials: 'include',
|
||
body: fd,
|
||
})
|
||
|
||
if (!res.ok) {
|
||
throw new Error('custom_upload_failed')
|
||
}
|
||
|
||
const data = await res.json()
|
||
const uploaded = data.item
|
||
const nextItemsById = { ...itemsById.value }
|
||
delete nextItemsById[item.id]
|
||
nextItemsById[uploaded.id] = {
|
||
id: uploaded.id,
|
||
src: uploaded.src,
|
||
label: uploaded.label,
|
||
origin: 'custom',
|
||
}
|
||
itemsById.value = nextItemsById
|
||
pool.value = pool.value.map((currentId) => (currentId === item.id ? uploaded.id : currentId))
|
||
groups.value = groups.value.map((group) => {
|
||
const nextGroup = {
|
||
...group,
|
||
cells: group.cells.map((cell) => cell.map((currentId) => (currentId === item.id ? uploaded.id : currentId))),
|
||
}
|
||
syncGroupItemIds(nextGroup)
|
||
return nextGroup
|
||
})
|
||
}
|
||
}
|
||
|
||
async function uploadPendingThumbnail() {
|
||
if (!pendingThumbnailFile.value) return thumbnailSrc.value || ''
|
||
const data = await api.uploadTierListThumbnail(pendingThumbnailFile.value)
|
||
if (thumbnailPreviewUrl.value) {
|
||
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||
thumbnailPreviewUrl.value = ''
|
||
}
|
||
pendingThumbnailFile.value = null
|
||
thumbnailSrc.value = data.thumbnailSrc || ''
|
||
return thumbnailSrc.value
|
||
}
|
||
|
||
function buildPayload(existingId) {
|
||
const finalTitle = effectiveTitle.value
|
||
return {
|
||
id: existingId || undefined,
|
||
topicId: templateId.value,
|
||
title: finalTitle,
|
||
thumbnailSrc: thumbnailSrc.value || '',
|
||
description: (description.value || '').trim(),
|
||
isPublic: !!isPublic.value,
|
||
showCharacterNames: !!showCharacterNames.value,
|
||
iconSize: Number(iconSize.value || 80),
|
||
sourceTierListId: sourceTierListId.value || '',
|
||
sourceSnapshotTitle: sourceSnapshotTitle.value || '',
|
||
sourceSnapshotAuthor: sourceSnapshotAuthor.value || '',
|
||
groups: buildGroupPayload(),
|
||
pool: getOrderedItems(),
|
||
}
|
||
}
|
||
|
||
async function persistTierList({ showModal = false } = {}) {
|
||
await uploadPendingCustomItems()
|
||
await uploadPendingThumbnail()
|
||
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||
const payload = buildPayload(currentTierListId || null)
|
||
const res = await api.saveTierList(payload)
|
||
const savedTierListId = res.tierList?.id || currentTierListId || tierListId.value
|
||
persistedTierListId.value = savedTierListId || ''
|
||
title.value = res.tierList?.title || payload.title
|
||
if (tierListId.value === 'new' && res.tierList?.id) {
|
||
await router.replace(editorPath(templateId.value, res.tierList.id))
|
||
}
|
||
updatedAt.value = Number(res.tierList?.updatedAt || Date.now())
|
||
authorName.value = res.tierList?.authorName || effectiveAuthorName.value
|
||
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 }
|
||
}
|
||
|
||
async function save() {
|
||
error.value = ''
|
||
isSaving.value = true
|
||
try {
|
||
await persistTierList({ showModal: true })
|
||
} catch (e) {
|
||
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
||
} finally {
|
||
isSaving.value = false
|
||
}
|
||
}
|
||
|
||
async function copyShareUrl() {
|
||
if (!shareTierListUrl.value) {
|
||
toast.error('먼저 티어표를 저장한 뒤 공유할 수 있어요.')
|
||
return
|
||
}
|
||
|
||
try {
|
||
if (navigator?.clipboard?.writeText) {
|
||
await navigator.clipboard.writeText(shareTierListUrl.value)
|
||
} else {
|
||
const helper = document.createElement('textarea')
|
||
helper.value = shareTierListUrl.value
|
||
helper.setAttribute('readonly', '')
|
||
helper.style.position = 'absolute'
|
||
helper.style.left = '-9999px'
|
||
document.body.appendChild(helper)
|
||
helper.select()
|
||
document.execCommand('copy')
|
||
helper.remove()
|
||
}
|
||
toast.success('공유 링크를 클립보드에 복사했어요.')
|
||
} catch (e) {
|
||
toast.error('공유 링크를 복사하지 못했어요.')
|
||
}
|
||
}
|
||
|
||
function openViewerMode() {
|
||
if (!canSwitchToViewerMode.value) return
|
||
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value, { preview: true }))
|
||
}
|
||
|
||
function openEditMode() {
|
||
if (!canSwitchToEditMode.value) return
|
||
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 openAuthorProfile() {
|
||
if (!canOpenAuthorProfile.value) return
|
||
router.push(userProfilePath(ownerId.value))
|
||
}
|
||
|
||
function closeSaveModal() {
|
||
isSaveModalOpen.value = false
|
||
}
|
||
|
||
function resetTemplateRequestDrafts() {
|
||
templateRequestDraftTitle.value = (title.value || '').trim()
|
||
templateRequestDraftDescription.value = (description.value || '').trim()
|
||
}
|
||
|
||
function openTemplateRequestModal() {
|
||
resetTemplateRequestDrafts()
|
||
isTemplateRequestModalOpen.value = true
|
||
}
|
||
|
||
function closeTemplateRequestModal() {
|
||
isTemplateRequestModalOpen.value = false
|
||
resetTemplateRequestDrafts()
|
||
}
|
||
|
||
function openTemplateUpdateModal() {
|
||
resetTemplateRequestDrafts()
|
||
isTemplateUpdateModalOpen.value = true
|
||
}
|
||
|
||
function closeTemplateUpdateModal() {
|
||
isTemplateUpdateModalOpen.value = false
|
||
resetTemplateRequestDrafts()
|
||
}
|
||
|
||
function openDeleteModal() {
|
||
if (!hasSavedTierList.value) return
|
||
isDeleteModalOpen.value = true
|
||
}
|
||
|
||
function closeDeleteModal() {
|
||
isDeleteModalOpen.value = false
|
||
}
|
||
|
||
async function confirmDeleteTierList() {
|
||
const currentTierListId = persistedTierListId.value || (tierListId.value && tierListId.value !== 'new' ? tierListId.value : '')
|
||
if (!canEdit.value || !currentTierListId || isDeleting.value) return
|
||
error.value = ''
|
||
try {
|
||
isDeleting.value = true
|
||
await api.deleteTierList(currentTierListId)
|
||
closeDeleteModal()
|
||
toast.success('티어표를 삭제했어요.')
|
||
router.push(templateId.value === 'freeform' ? mePath() : topicPath(templateId.value))
|
||
} catch (e) {
|
||
error.value = '티어표 삭제에 실패했어요.'
|
||
} finally {
|
||
isDeleting.value = false
|
||
}
|
||
}
|
||
|
||
async function duplicateCurrentTierList() {
|
||
if (!canDuplicate.value) return
|
||
try {
|
||
if (canEdit.value && hasUnsavedChanges.value) {
|
||
await persistTierList({ showModal: false })
|
||
}
|
||
const data = await api.duplicateTierList(tierListId.value)
|
||
const duplicatedId = data.tierList?.id
|
||
if (!duplicatedId) throw new Error('duplicate_failed')
|
||
toast.success('티어표를 복사해 내 작업으로 가져왔어요.')
|
||
router.push(editorPath(templateId.value, duplicatedId))
|
||
} catch (e) {
|
||
error.value = '티어표 복사에 실패했어요.'
|
||
}
|
||
}
|
||
|
||
async function toggleFavorite() {
|
||
if (!canFavorite.value || isFavoriteBusy.value) return
|
||
try {
|
||
isFavoriteBusy.value = true
|
||
const data = isFavorited.value ? await api.unfavoriteTierList(tierListId.value) : await api.favoriteTierList(tierListId.value)
|
||
favoriteCount.value = Number(data.tierList?.favoriteCount || 0)
|
||
isFavorited.value = !!data.tierList?.isFavorited
|
||
toast.success(isFavorited.value ? '즐겨찾기에 추가했어요.' : '즐겨찾기를 해제했어요.')
|
||
} catch (e) {
|
||
error.value = '즐겨찾기 처리에 실패했어요.'
|
||
} finally {
|
||
isFavoriteBusy.value = false
|
||
}
|
||
}
|
||
|
||
async function requestTemplate(type) {
|
||
try {
|
||
isRequestingTemplate.value = true
|
||
title.value = templateRequestDraftTitle.value.trim()
|
||
description.value = templateRequestDraftDescription.value.trim()
|
||
const saved = await persistTierList({ showModal: false })
|
||
const sourceId = saved.savedTierListId || persistedTierListId.value || ''
|
||
if (!sourceId) throw new Error('save_required')
|
||
|
||
await api.requestTierListTemplate({
|
||
type,
|
||
sourceTierListId: sourceId,
|
||
topicId: templateId.value,
|
||
requestTitle: templateRequestDraftTitle.value.trim(),
|
||
requestDescription: templateRequestDraftDescription.value.trim(),
|
||
thumbnailSrc: saved.tierList?.thumbnailSrc || thumbnailSrc.value || '',
|
||
isPublic: !!isPublic.value,
|
||
showCharacterNames: !!showCharacterNames.value,
|
||
groups: buildGroupPayload(),
|
||
boardItems: getOrderedItems(),
|
||
})
|
||
|
||
if (type === 'create') closeTemplateRequestModal()
|
||
if (type === 'update') closeTemplateUpdateModal()
|
||
toast.success(type === 'create' ? '템플릿 등록 요청을 보냈어요.' : '템플릿 업데이트 요청을 보냈어요.')
|
||
} catch (e) {
|
||
if (e?.message === 'custom_upload_failed') {
|
||
toast.error('커스텀 이미지 이름이 너무 길거나 업로드 조건에 맞지 않아 요청 전에 저장하지 못했어요. 아이템 이름을 60자 이하로 줄인 뒤 다시 시도해주세요.')
|
||
return
|
||
}
|
||
if (e?.message === 'save_required') {
|
||
toast.error('먼저 현재 티어표를 저장한 뒤 다시 요청해주세요.')
|
||
return
|
||
}
|
||
if (e?.status === 409) {
|
||
toast.error('이미 처리 대기 중인 같은 요청이 있어요.')
|
||
return
|
||
}
|
||
if (e?.status === 400 && e?.data?.error === 'custom_items_required') {
|
||
toast.error('먼저 커스텀 아이템을 추가한 뒤 요청해주세요.')
|
||
return
|
||
}
|
||
if (e?.status === 400 && e?.data?.error === 'source_tierlist_required') {
|
||
toast.error('저장된 티어표에서만 템플릿 요청을 보낼 수 있어요.')
|
||
return
|
||
}
|
||
if (e?.status === 400 && e?.data?.error === 'bad_request') {
|
||
toast.error('요청 제목, 설명, 아이템 이름 중 길이 제한을 넘긴 값이 없는지 확인해주세요.')
|
||
return
|
||
}
|
||
toast.error(type === 'create' ? '템플릿 등록 요청에 실패했어요.' : '템플릿 업데이트 요청에 실패했어요.')
|
||
} finally {
|
||
isRequestingTemplate.value = false
|
||
}
|
||
}
|
||
|
||
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 = ''
|
||
closeItemContextMenu()
|
||
resetTemplateRequestDrafts()
|
||
}
|
||
|
||
async function loadEditorState() {
|
||
const loadToken = ++editorLoadToken
|
||
resetEditorStateForRoute()
|
||
await auth.refresh()
|
||
if (loadToken !== editorLoadToken) return
|
||
|
||
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 res = await api.getTierList(tierListId.value)
|
||
if (loadToken !== editorLoadToken) return
|
||
|
||
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) {
|
||
if (loadToken !== editorLoadToken) return
|
||
error.value = '티어표를 불러오지 못했어요.'
|
||
}
|
||
}
|
||
|
||
await nextTick()
|
||
if (loadToken !== editorLoadToken) return
|
||
|
||
syncSavedEditorSnapshot()
|
||
scheduleEditorSidebarMeasure()
|
||
if (canEdit.value) {
|
||
await initSortables()
|
||
}
|
||
}
|
||
|
||
watch(
|
||
() => [route.params.topicId, route.params.tierListId, route.query.preview],
|
||
() => {
|
||
loadEditorState()
|
||
},
|
||
{ immediate: true }
|
||
)
|
||
|
||
onMounted(() => {
|
||
if (typeof window === 'undefined') return
|
||
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
||
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||
window.addEventListener('blur', closeItemContextMenu)
|
||
window.addEventListener('scroll', closeItemContextMenu, true)
|
||
window.addEventListener('resize', scheduleEditorSidebarMeasure)
|
||
window.addEventListener('scroll', scheduleEditorSidebarMeasure, true)
|
||
window.addEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
|
||
nextTick(() => scheduleEditorSidebarMeasure())
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (typeof window !== 'undefined') {
|
||
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
||
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||
window.removeEventListener('blur', closeItemContextMenu)
|
||
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||
window.removeEventListener('resize', scheduleEditorSidebarMeasure)
|
||
window.removeEventListener('scroll', scheduleEditorSidebarMeasure, true)
|
||
window.removeEventListener('tier-maker:focus-editor-item-search', focusPoolSearch)
|
||
if (editorSidebarMeasureFrame) {
|
||
window.cancelAnimationFrame(editorSidebarMeasureFrame)
|
||
editorSidebarMeasureFrame = 0
|
||
}
|
||
}
|
||
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
||
destroySortables()
|
||
})
|
||
</script>
|
||
|
||
<template>
|
||
<section v-if="previewMode" class="previewOnly" :style="{ '--thumb-size': `${iconSize}px` }">
|
||
<header class="pageHead">
|
||
<div class="pageHead__main">
|
||
<div class="pageHead__eyebrow">Preview</div>
|
||
<h1 class="pageHead__title">{{ effectiveTitle }}</h1>
|
||
<p v-if="description" class="pageHead__desc">{{ description }}</p>
|
||
</div>
|
||
</header>
|
||
<div class="previewOnly__sheet">
|
||
<div v-if="columns.length > 1" class="previewOnly__columns">
|
||
<div class="previewOnly__columnsSpacer" aria-hidden="true"></div>
|
||
<div class="previewOnly__columnsGrid" :style="{ '--column-count': columns.length }">
|
||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__columnHeader">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||
</div>
|
||
</div>
|
||
<div class="previewOnly__rows">
|
||
<div v-for="g in groups" :key="g.id" class="previewOnly__row">
|
||
<div class="previewOnly__label">{{ g.name }}</div>
|
||
<div class="previewOnly__dropGrid" :style="{ '--column-count': columns.length }">
|
||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="previewOnly__dropColumn">
|
||
<div class="previewOnly__drop">
|
||
<div v-if="columns.length > 1" class="previewOnly__columnBadge">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="previewOnly__cell">
|
||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="previewOnly__footer">
|
||
<span>{{ effectiveAuthorName }}</span>
|
||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<Teleport :to="localRightRailTarget">
|
||
<template v-if="globalRightRailOpen">
|
||
<RightRailAd />
|
||
|
||
<div class="viewerSidebar__section">
|
||
<div class="viewerSidebar__eyebrow">Viewer Mode</div>
|
||
<div class="viewerSidebar__title">공유 티어표 보기</div>
|
||
<p class="viewerSidebar__desc">
|
||
{{ canSwitchToEditMode ? '지금은 드래그 없는 뷰어 모드입니다. 원하면 바로 수정 모드로 돌아갈 수 있어요.' : '드래그나 편집 없이 완성본만 보는 뷰어 모드입니다.' }}
|
||
</p>
|
||
<div class="viewerSidebar__actions">
|
||
<button v-if="hasSavedTierList" class="btn btn--ghost viewerSidebar__button" type="button" @click="copyShareUrl">
|
||
공유하기
|
||
</button>
|
||
<button v-if="canDuplicate" class="btn btn--save viewerSidebar__button" type="button" @click="duplicateCurrentTierList">
|
||
{{ duplicateActionLabel }}
|
||
</button>
|
||
<button v-if="canSwitchToEditMode" class="btn btn--save viewerSidebar__button" type="button" @click="openEditMode">
|
||
수정 모드로 전환
|
||
</button>
|
||
<button v-if="canOpenAuthorProfile" class="btn btn--ghost viewerSidebar__button" type="button" @click="openAuthorProfile">
|
||
작성자 프로필 보기
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Teleport>
|
||
</section>
|
||
|
||
<template v-else>
|
||
|
||
<div v-if="isSaveModalOpen" class="modalOverlay" @click.self="closeSaveModal">
|
||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="saveModalTitle">
|
||
<div id="saveModalTitle" class="modalCard__title">저장 완료</div>
|
||
<div class="modalCard__desc">티어표가 저장되었어요.<br />이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
||
<div class="modalCard__actions">
|
||
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
||
</div>
|
||
</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>
|
||
<div class="modalCard__desc">
|
||
티어표에 적어둔 제목과 설명이 있다면 그대로 가져와 두었어요. 비어 있다면 여기서 바로 작성해도 괜찮아요.
|
||
</div>
|
||
<div class="requestChecklist__hint">
|
||
제목과 설명을 함께 적어두면 관리자가 어떤 신규 템플릿인지 훨씬 빠르게 파악할 수 있어요.
|
||
예시: 제목 `템플릿 등록 요청`, 설명 `여름 이벤트 한정 캐릭터 중심으로 새 주제 템플릿이 필요합니다.`
|
||
</div>
|
||
<div class="templateRequestDraft">
|
||
<label class="templateRequestDraft__field">
|
||
<span class="templateRequestDraft__label">티어표 제목</span>
|
||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 등록 요청" />
|
||
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120자</span>
|
||
</label>
|
||
<label class="templateRequestDraft__field">
|
||
<span class="templateRequestDraft__label">티어표 설명</span>
|
||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가용으로 신규 템플릿이 필요합니다." />
|
||
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000자</span>
|
||
</label>
|
||
</div>
|
||
<div class="modalCard__actions">
|
||
<button class="btn btn--ghost" @click="closeTemplateRequestModal">취소</button>
|
||
<button class="btn btn--save" :disabled="!canSubmitTemplateCreateRequest || isRequestingTemplate" @click="requestTemplate('create')">
|
||
{{ isRequestingTemplate ? '요청중...' : '등록 요청 보내기' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isTemplateUpdateModalOpen" class="modalOverlay" @click.self="closeTemplateUpdateModal">
|
||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateUpdateTitle">
|
||
<div id="templateUpdateTitle" class="modalCard__title">템플릿 요청하기</div>
|
||
<div class="modalCard__desc">
|
||
{{ templateRequestTargetLabel }}에 직접 추가한 아이템을 포함해 달라고 관리자에게 요청합니다. 현재 티어표 제목과 설명은 그대로 가져와 두었어요.
|
||
</div>
|
||
<div class="modalCard__note">
|
||
모두가 사용하는 기본 템플릿이니 개인적인 항목이 아닌 공통된 항목만 추가한 뒤 신청해주세요.
|
||
예시: 제목 `템플릿 업데이트 요청`, 설명 `여름 이벤트 한정 캐릭터 추가`
|
||
</div>
|
||
<div class="templateRequestDraft">
|
||
<label class="templateRequestDraft__field">
|
||
<span class="templateRequestDraft__label">티어표 제목</span>
|
||
<input v-model="templateRequestDraftTitle" class="templateRequestDraft__input" maxlength="120" placeholder="예: 템플릿 업데이트 요청" />
|
||
<span class="templateRequestDraft__hint">{{ templateRequestDraftTitle.length }}/120자</span>
|
||
</label>
|
||
<label class="templateRequestDraft__field">
|
||
<span class="templateRequestDraft__label">티어표 설명</span>
|
||
<textarea v-model="templateRequestDraftDescription" class="templateRequestDraft__input templateRequestDraft__textarea" maxlength="1000" placeholder="예: 여름 이벤트 한정 캐릭터 추가" />
|
||
<span class="templateRequestDraft__hint">{{ templateRequestDraftDescription.length }}/1000자</span>
|
||
</label>
|
||
</div>
|
||
<div class="modalCard__actions">
|
||
<button class="btn btn--ghost" @click="closeTemplateUpdateModal">요청 취소</button>
|
||
<button class="btn btn--save" :disabled="!canSubmitTemplateUpdateRequest || isRequestingTemplate" @click="requestTemplate('update')">
|
||
{{ isRequestingTemplate ? '요청중...' : '예, 요청할게요' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isDeleteModalOpen" class="modalOverlay" @click.self="closeDeleteModal">
|
||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteTierListTitle">
|
||
<div id="deleteTierListTitle" class="modalCard__title">티어표 삭제</div>
|
||
<div class="modalCard__desc">
|
||
"{{ title || templateName || '이 티어표' }}"를 삭제할까요? 삭제 후에는 복구할 수 없어요.
|
||
</div>
|
||
<div class="modalCard__actions">
|
||
<button class="btn btn--ghost" @click="closeDeleteModal">취소</button>
|
||
<button class="btn btn--danger" :disabled="isDeleting" @click="confirmDeleteTierList">
|
||
{{ isDeleting ? '삭제중...' : '삭제하기' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isGroupDeleteModalOpen" class="modalOverlay" @click.self="closeGroupDeleteModal">
|
||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteGroupTitle">
|
||
<div id="deleteGroupTitle" class="modalCard__title">티어 행 삭제</div>
|
||
<div class="modalCard__desc">
|
||
이 행을 삭제하면 현재 들어 있는 아이템은 모두 아래 아이템 영역으로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
|
||
</div>
|
||
<div class="modalCard__actions">
|
||
<button class="btn btn--ghost" @click="closeGroupDeleteModal">취소</button>
|
||
<button class="btn btn--danger" @click="confirmRemoveGroup">행 삭제</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="isColumnDeleteModalOpen" class="modalOverlay" @click.self="closeColumnDeleteModal">
|
||
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="deleteColumnTitle">
|
||
<div id="deleteColumnTitle" class="modalCard__title">티어 열 삭제</div>
|
||
<div class="modalCard__desc">
|
||
이 열을 삭제하면 현재 들어 있는 아이템은 모두 첫 번째 열로 이동합니다. 삭제 후에도 아이템 자체는 유지돼요.
|
||
</div>
|
||
<div class="modalCard__actions">
|
||
<button class="btn btn--ghost" @click="closeColumnDeleteModal">취소</button>
|
||
<button class="btn btn--danger" @click="confirmRemoveColumn">열 삭제</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
||
<div class="editorMain">
|
||
<section class="head">
|
||
<div class="editorMain__headCopy">
|
||
<button
|
||
class="editorMain__title editorMain__titleButton"
|
||
type="button"
|
||
title="본문을 화면 위로 이동"
|
||
@click="scrollWorkspaceBodyToTop"
|
||
@keydown.space.prevent="scrollWorkspaceBodyToTop"
|
||
>
|
||
{{ templateName || templateId }}
|
||
</button>
|
||
<div class="editorMain__subtitle">
|
||
<template v-if="canEdit">
|
||
행/열 이름과 순서를 바꾸고 아이템을 드래그해서 배치할 수 있어요.
|
||
</template>
|
||
<template v-else>
|
||
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
||
</template>
|
||
</div>
|
||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||
<span>원본</span>
|
||
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div class="editorCanvas">
|
||
<div ref="boardEl" class="board">
|
||
<div v-if="canEdit && !isExporting" class="boardTools">
|
||
<div class="boardTools__left">
|
||
<button class="boardActionIcon" type="button" title="행 추가" aria-label="행 추가" @click="addGroup">
|
||
<SvgIcon :src="addRowBelowIcon" :size="18" />
|
||
</button>
|
||
<button class="boardActionIcon" type="button" title="열 추가" aria-label="열 추가" @click="addColumn">
|
||
<SvgIcon :src="addColumnRightIcon" :size="18" />
|
||
</button>
|
||
</div>
|
||
<div class="boardTools__right">
|
||
<span class="boardTools__label">아이콘 크기</span>
|
||
<div class="sizePicker">
|
||
<button
|
||
v-for="size in iconSizeOptions"
|
||
:key="size"
|
||
class="sizePicker__button"
|
||
:class="{ 'sizePicker__button--active': iconSize === size }"
|
||
@click="setIconSize(size)"
|
||
>
|
||
{{ size }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
||
<div v-if="isExporting" class="exportBoard__title">{{ effectiveTitle }}</div>
|
||
<div v-if="isExporting && description" class="exportBoard__description">{{ description }}</div>
|
||
<div v-if="columns.length > 1" class="boardColumnsHeader" :class="{ 'boardColumnsHeader--export': isExporting }">
|
||
<div class="boardColumnsHeader__spacer" aria-hidden="true"></div>
|
||
<div class="boardColumnsHeader__grid" :style="{ '--column-count': columns.length }">
|
||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="boardColumnsHeader__cell">
|
||
<template v-if="isExporting">
|
||
<div class="boardColumnsHeader__name">{{ column.name || '열 ' + (columnIndex + 1) }}</div>
|
||
</template>
|
||
<template v-else>
|
||
<div class="columnHeader">
|
||
<input v-model="column.name" class="columnName" maxlength="16" placeholder="열 이름" />
|
||
<button class="columnRemoveText" type="button" title="열 삭제" aria-label="열 삭제" :disabled="columns.length <= 1" @click="openColumnDeleteModal(columnIndex)">×</button>
|
||
</div>
|
||
</template>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div ref="groupListEl" class="rows">
|
||
<div v-for="g in groups" :key="g.id" class="row">
|
||
<div class="row__label">
|
||
<template v-if="isExporting">
|
||
<div class="row__exportName">{{ g.name }}</div>
|
||
</template>
|
||
<template v-else>
|
||
<span class="grab" title="드래그로 순서 변경" data-group-handle>↕</span>
|
||
<input v-model="g.name" class="groupName" maxlength="16" :readonly="!canEdit" />
|
||
<button
|
||
v-if="canEdit"
|
||
class="rowRemoveText"
|
||
type="button"
|
||
title="행 삭제"
|
||
aria-label="행 삭제"
|
||
:disabled="groups.length <= 1"
|
||
@click="openGroupDeleteModal(g.id)"
|
||
>
|
||
×
|
||
</button>
|
||
</template>
|
||
</div>
|
||
<div class="row__content" :style="{ '--column-count': columns.length }">
|
||
<div v-for="(column, columnIndex) in columns" :key="column.id" class="row__column">
|
||
<div
|
||
class="row__drop"
|
||
:data-list-type="'group'"
|
||
: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"
|
||
: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"
|
||
draggable="false"
|
||
/>
|
||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||
<button
|
||
v-if="canEdit && !isExporting"
|
||
class="cellRemoveBtn"
|
||
type="button"
|
||
title="아이템 빼내기"
|
||
@pointerdown.stop
|
||
@click.stop="removeItemFromGroup(g.id, columnIndex, id)"
|
||
>
|
||
×
|
||
</button>
|
||
<button
|
||
v-if="canRemoveEditorItem(id) && !isExporting"
|
||
class="cellDeleteBtn"
|
||
type="button"
|
||
title="커스텀 이미지 제거"
|
||
@pointerdown.stop
|
||
@click.stop="deleteEditorItem(id)"
|
||
>
|
||
삭제
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div v-if="isExporting" class="exportBoard__footer">
|
||
<span>{{ effectiveAuthorName }}</span>
|
||
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="canEdit"
|
||
class="dropzone dropzone--board"
|
||
:class="{ 'dropzone--active': isDragActive }"
|
||
@dragenter.prevent="onDragEnter"
|
||
@dragover.prevent="onDragEnter"
|
||
@dragleave="onDragLeave"
|
||
@drop.prevent="onDropFiles"
|
||
>
|
||
<div>
|
||
<div class="dropzone__iconWrap">
|
||
<SvgIcon :src="addPhotoAlternateIcon" alt="" class="dropzone__icon" />
|
||
</div>
|
||
<div class="dropzone__title">커스텀 이미지 추가</div>
|
||
<div class="dropzone__desc">이곳으로 이미지를 드래그하거나 파일 선택으로 한 번에 추가할 수 있어요.</div>
|
||
</div>
|
||
<div class="dropzone__actions">
|
||
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
||
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
|
||
</div>
|
||
</div>
|
||
<div class="editorTips">
|
||
<div class="editorTips__title">작업 팁</div>
|
||
<ul class="editorTips__list">
|
||
<li>마우스 오른쪽 클릭으로 아이템을 복수 사용하거나 커스텀 이미지를 빠르게 정리할 수 있어요.</li>
|
||
<li>미사용 아이템은 미리보기와 이미지 저장 결과에 표시되지 않으니, 필요한 것만 골라 배치해도 괜찮아요.</li>
|
||
<li>아이템이 많아 한 번에 보기 어렵다면 브라우저 확대/축소(`Ctrl +`, `Ctrl -`)로 화면 밀도를 조절해보세요.</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="sidebarStickyFrame">
|
||
<div ref="sidebarEl" class="sidebar" :style="{ '--editor-sidebar-max-height': editorSidebarMaxHeight || undefined }">
|
||
<div class="sidebar__titleRow">
|
||
<div class="sidebar__title">아이템</div>
|
||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||
</div>
|
||
<div class="sidebar__hint">
|
||
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
||
</div>
|
||
<input
|
||
ref="poolSearchEl"
|
||
v-model="poolSearchQuery"
|
||
class="sidebar__search"
|
||
type="text"
|
||
maxlength="60"
|
||
placeholder="아이템 이름 검색"
|
||
/>
|
||
<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),
|
||
'poolItem--selected': selectedItemId === id,
|
||
}"
|
||
:data-item-id="id"
|
||
@click.stop="selectItemByClick(id)"
|
||
>
|
||
<img
|
||
:src="resolveItemSrc(itemsById[id])"
|
||
class="thumb"
|
||
:alt="itemsById[id]?.label || id"
|
||
draggable="false"
|
||
/>
|
||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||
<button
|
||
v-if="canRemoveEditorItem(id)"
|
||
class="poolItem__deleteBtn"
|
||
type="button"
|
||
title="커스텀 이미지 제거"
|
||
@pointerdown.stop
|
||
@click.stop="deleteEditorItem(id)"
|
||
>
|
||
삭제
|
||
</button>
|
||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
v-if="itemContextMenu.open && canEdit"
|
||
class="itemContextMenu"
|
||
:style="{ left: `${itemContextMenu.x}px`, top: `${itemContextMenu.y}px` }"
|
||
data-item-context-menu
|
||
@click.stop
|
||
@contextmenu.prevent.stop
|
||
>
|
||
<button class="itemContextMenu__action" type="button" @click="duplicateItemToPool">
|
||
아이템 복제
|
||
</button>
|
||
<button v-if="canRemoveEditorItem(itemContextMenu.itemId)" class="itemContextMenu__action itemContextMenu__action--danger" type="button" @click="deleteEditorItem(itemContextMenu.itemId)">
|
||
이 커스텀 이미지 제거
|
||
</button>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
</section>
|
||
|
||
<Teleport :to="localRightRailTarget">
|
||
<template v-if="globalRightRailOpen">
|
||
<div class="editorSidebar__section">
|
||
<div class="editorSidebar__label">Title</div>
|
||
<input v-model="title" class="editorSidebar__input" maxlength="120" placeholder="Title Text" :readonly="!canEdit" />
|
||
<div class="editorSidebar__hint">{{ title.length }}/120자</div>
|
||
<div v-if="untitledWarning" class="editorSidebar__hint editorSidebar__hint--warn">{{ untitledWarning }}</div>
|
||
</div>
|
||
|
||
<div class="editorSidebar__section">
|
||
<div class="editorSidebar__label">Desc</div>
|
||
<textarea
|
||
v-model="description"
|
||
class="editorSidebar__textarea"
|
||
placeholder="Description Text"
|
||
maxlength="1000"
|
||
:readonly="!canEdit"
|
||
></textarea>
|
||
<div class="editorSidebar__hint">{{ description.length }}/1000자</div>
|
||
</div>
|
||
|
||
<div class="editorSidebar__section">
|
||
<div class="editorSidebar__label">대표 썸네일</div>
|
||
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
||
<div
|
||
class="editorSidebar__thumbFrame"
|
||
:class="{ 'editorSidebar__thumbFrame--active': isThumbnailDragActive }"
|
||
:role="canEdit ? 'button' : undefined"
|
||
:tabindex="canEdit ? 0 : undefined"
|
||
@click="canEdit ? openThumbnailFile() : null"
|
||
@keydown.enter.prevent="canEdit ? openThumbnailFile() : null"
|
||
@keydown.space.prevent="canEdit ? openThumbnailFile() : null"
|
||
@dragenter.prevent="onThumbnailDragEnter"
|
||
@dragover.prevent="onThumbnailDragEnter"
|
||
@dragleave="onThumbnailDragLeave"
|
||
@drop.prevent="onThumbnailDrop"
|
||
>
|
||
<img v-if="displayThumbnailUrl" class="editorSidebar__thumbImage" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
||
<div v-else class="editorSidebar__thumbEmpty">대표 썸네일</div>
|
||
<div class="editorSidebar__thumbOverlay">드래그 또는 클릭으로 썸네일 추가</div>
|
||
</div>
|
||
<div v-if="pendingThumbnailFile" class="editorSidebar__fileName">{{ pendingThumbnailFile.name }}</div>
|
||
</div>
|
||
|
||
<div v-if="canFavorite" class="editorSidebar__section">
|
||
<button class="editorSidebar__favorite" :disabled="isFavoriteBusy" @click="toggleFavorite">
|
||
<span>♡ 즐겨찾기</span>
|
||
<span>{{ favoriteCount }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div v-if="canEdit && customItems.length" class="editorSidebar__section">
|
||
<div class="editorSidebar__label">커스텀 이름 정리</div>
|
||
<div class="customItemEditor customItemEditor--sidebar">
|
||
<div class="customItemEditor__desc">
|
||
아래에서 이름을 정리한 뒤 저장하면, 템플릿 요청 시 그대로 전달됩니다.
|
||
</div>
|
||
<div class="customItemEditor__list">
|
||
<label v-for="item in customItems" :key="item.id" class="customItemEditor__row">
|
||
<img class="customItemEditor__thumb" :src="resolveItemSrc(item)" :alt="item.label" />
|
||
<input
|
||
class="customItemEditor__input"
|
||
:value="item.label"
|
||
maxlength="60"
|
||
placeholder="아이템 이름"
|
||
@input="updateCustomItemLabel(item.id, $event.target.value)"
|
||
/>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="editorSidebar__section editorSidebar__section--footer">
|
||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
|
||
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
||
<span class="toggleSwitch__label">공개</span>
|
||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||
</label>
|
||
<label class="toggleSwitch" :class="{ 'toggleSwitch--disabled': !canEdit }">
|
||
<input v-model="showCharacterNames" type="checkbox" :disabled="!canEdit" />
|
||
<span class="toggleSwitch__label">캐릭터 이름 표시</span>
|
||
<span class="toggleSwitch__track"><span class="toggleSwitch__thumb"></span></span>
|
||
</label>
|
||
<div class="editorSidebar__actionGrid">
|
||
<button class="btn btn--ghost editorSidebar__button" @click="downloadImage">이미지 다운로드</button>
|
||
<button v-if="canEdit" class="btn btn--save editorSidebar__button" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
||
</div>
|
||
<div class="editorSidebar__utilityLinks">
|
||
<button v-if="canSwitchToViewerMode" class="editorSidebar__utilityLink" @click="openViewerMode">뷰어 모드로 보기</button>
|
||
<button v-if="hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--share" @click="copyShareUrl">
|
||
<SvgIcon :src="shareIcon" :size="16" />
|
||
<span>공유하기</span>
|
||
</button>
|
||
<button v-if="canOpenAuthorProfile" class="editorSidebar__utilityLink" @click="openAuthorProfile">작성자 프로필 보기</button>
|
||
<button v-if="canEdit && hasSavedTierList" class="editorSidebar__utilityLink editorSidebar__utilityLink--danger" @click="openDeleteModal">삭제하기</button>
|
||
<button v-if="canDuplicate" class="editorSidebar__utilityLink" @click="duplicateCurrentTierList">{{ duplicateActionLabel }}</button>
|
||
<button
|
||
v-if="canRequestTemplateCreate"
|
||
class="editorSidebar__utilityLink"
|
||
:disabled="isRequestingTemplate"
|
||
@click="openTemplateRequestModal"
|
||
>
|
||
템플릿 등록 요청
|
||
</button>
|
||
<button
|
||
v-if="canRequestTemplateUpdate"
|
||
class="editorSidebar__utilityLink"
|
||
:disabled="isRequestingTemplate"
|
||
@click="openTemplateUpdateModal"
|
||
>
|
||
{{ isRequestingTemplate ? '요청중...' : '템플릿 업데이트 요청' }}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
</Teleport>
|
||
</template>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.head {
|
||
display: grid;
|
||
gap: 8px;
|
||
padding: 2px 2px 8px;
|
||
}
|
||
.editorMain {
|
||
min-width: 0;
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
.editorCanvas {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, clamp(680px, 58vw, 960px)) minmax(280px, 1fr);
|
||
gap: 16px;
|
||
align-items: start;
|
||
padding-bottom: 14px;
|
||
}
|
||
.sidebarStickyFrame {
|
||
min-width: 0;
|
||
align-self: stretch;
|
||
box-sizing: border-box;
|
||
}
|
||
.editorMain__title {
|
||
font-size: 28px;
|
||
font-weight: 900;
|
||
letter-spacing: -0.04em;
|
||
}
|
||
.editorMain__titleButton {
|
||
width: fit-content;
|
||
max-width: 100%;
|
||
border: 0;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
.editorMain__titleButton:hover {
|
||
color: var(--theme-text-strong);
|
||
}
|
||
.editorMain__titleButton:focus-visible {
|
||
outline: 2px solid color-mix(in srgb, var(--theme-accent) 70%, white);
|
||
outline-offset: 4px;
|
||
border-radius: 8px;
|
||
}
|
||
.editorMain__subtitle {
|
||
color: var(--theme-text-soft);
|
||
font-size: 13px;
|
||
line-height: 1.5;
|
||
}
|
||
.editorMain__sourceNote {
|
||
margin-top: 4px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
font-size: 12px;
|
||
color: var(--theme-text-soft);
|
||
}
|
||
.editorMain__sourceLink {
|
||
border: 0;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: color-mix(in srgb, var(--theme-accent-bg) 78%, white);
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
.previewOnly {
|
||
display: grid;
|
||
gap: 18px;
|
||
}
|
||
.previewOnly__sheet {
|
||
display: grid;
|
||
gap: 16px;
|
||
width: 100%;
|
||
padding: 18px;
|
||
border-radius: 24px;
|
||
border: 1px solid var(--theme-card-border);
|
||
background: var(--theme-card-bg);
|
||
box-shadow: inset 0 1px 0 var(--theme-card-shadow);
|
||
}
|
||
.previewOnly__columns {
|
||
display: grid;
|
||
grid-template-columns: 132px 1fr;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.previewOnly__columnsGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.previewOnly__columnHeader {
|
||
min-height: 20px;
|
||
font-size: 12px;
|
||
font-weight: 800;
|
||
text-align: center;
|
||
opacity: 0.72;
|
||
}
|
||
.previewOnly__rows {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.previewOnly__row {
|
||
display: grid;
|
||
grid-template-columns: 132px 1fr;
|
||
gap: 10px;
|
||
}
|
||
.previewOnly__dropGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.previewOnly__dropColumn {
|
||
display: grid;
|
||
gap: 8px;
|
||
}
|
||
.previewOnly__label {
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 10px 12px;
|
||
text-align: center;
|
||
font-weight: 900;
|
||
border-radius: 14px;
|
||
background: var(--theme-surface-soft-2);
|
||
border: 1px solid var(--theme-border-strong);
|
||
}
|
||
.previewOnly__drop {
|
||
position: relative;
|
||
border-radius: 14px;
|
||
background: var(--theme-pill-bg);
|
||
border: 1px solid var(--theme-border);
|
||
min-height: calc(var(--thumb-size, 80px) + 24px);
|
||
padding: 10px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-content: flex-start;
|
||
}
|
||
.previewOnly__columnBadge,
|
||
.row__columnBadge {
|
||
display: none;
|
||
}
|
||
.previewOnly__cell {
|
||
display: inline-flex;
|
||
position: relative;
|
||
}
|
||
.previewOnly__pool {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding-top: 8px;
|
||
}
|
||
.previewOnly__poolTitle {
|
||
font-weight: 900;
|
||
opacity: 0.82;
|
||
}
|
||
.previewOnly__poolGrid {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.previewOnly__poolItem {
|
||
display: inline-flex;
|
||
position: relative;
|
||
}
|
||
.previewOnly__poolItem--inactive {
|
||
opacity: 0.52;
|
||
filter: grayscale(0.22) brightness(0.78);
|
||
}
|
||
.previewOnly__footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
padding-top: 8px;
|
||
color: var(--theme-text-soft);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.viewerSidebar__section {
|
||
margin-top: auto;
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 18px;
|
||
border-radius: 22px;
|
||
border: 1px solid var(--theme-border);
|
||
background: var(--theme-pill-bg);
|
||
}
|
||
|
||
.viewerSidebar__eyebrow {
|
||
color: var(--theme-text-faint);
|
||
font-size: 11px;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
.viewerSidebar__title {
|
||
font-size: 22px;
|
||
font-weight: 900;
|
||
letter-spacing: -0.04em;
|
||
color: var(--theme-text-strong);
|
||
}
|
||
|
||
.viewerSidebar__desc {
|
||
margin: 0;
|
||
color: var(--theme-text-muted);
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.viewerSidebar__actions {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
|
||
.viewerSidebar__button {
|
||
width: 100%;
|
||
min-height: 44px;
|
||
margin-top: 0;
|
||
}
|
||
.toggleSwitch {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--theme-border);
|
||
background: var(--theme-pill-bg);
|
||
cursor: pointer;
|
||
user-select: none;
|
||
}
|
||
.toggleSwitch input {
|
||
position: absolute;
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.toggleSwitch__track {
|
||
position: relative;
|
||
width: 42px;
|
||
height: 24px;
|
||
border-radius: 999px;
|
||
background: var(--theme-surface-soft-3);
|
||
border: 1px solid var(--theme-border-strong);
|
||
transition: background 180ms ease, border-color 180ms ease;
|
||
flex: 0 0 auto;
|
||
}
|
||
.toggleSwitch__thumb {
|
||
position: absolute;
|
||
top: 2px;
|
||
left: 2px;
|
||
width: 18px;
|
||
height: 18px;
|
||
border-radius: 999px;
|
||
background: var(--theme-text-strong);
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.24);
|
||
transition: transform 180ms ease;
|
||
}
|
||
.toggleSwitch__label {
|
||
font-weight: 800;
|
||
color: var(--theme-text);
|
||
}
|
||
.toggleSwitch input:checked ~ .toggleSwitch__track {
|
||
background: rgba(96, 165, 250, 0.34);
|
||
border-color: rgba(96, 165, 250, 0.42);
|
||
}
|
||
.toggleSwitch input:checked ~ .toggleSwitch__track .toggleSwitch__thumb {
|
||
transform: translateX(18px);
|
||
}
|
||
.toggleSwitch--disabled {
|
||
opacity: 0.55;
|
||
pointer-events: none;
|
||
}
|
||
.btn {
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--theme-border-strong);
|
||
background: var(--theme-surface-soft-2);
|
||
color: var(--theme-text);
|
||
cursor: pointer;
|
||
font-weight: 700;
|
||
}
|
||
.btn:hover {
|
||
background: var(--theme-surface-soft-3);
|
||
}
|
||
.btn--primary {
|
||
background: rgba(110, 231, 183, 0.18);
|
||
}
|
||
.btn--primary:hover {
|
||
background: rgba(110, 231, 183, 0.24);
|
||
}
|
||
.btn--download {
|
||
justify-self: flex-start;
|
||
}
|
||
.btn--save {
|
||
min-width: 112px;
|
||
padding: 12px 18px;
|
||
font-size: 15px;
|
||
font-weight: 900;
|
||
background: rgba(96, 165, 250, 0.22);
|
||
border-color: rgba(96, 165, 250, 0.36);
|
||
}
|
||
.btn--save:hover {
|
||
background: rgba(96, 165, 250, 0.3);
|
||
}
|
||
.btn--danger {
|
||
background: rgba(239, 68, 68, 0.14);
|
||
border-color: rgba(239, 68, 68, 0.28);
|
||
}
|
||
.btn--danger:hover {
|
||
background: rgba(239, 68, 68, 0.22);
|
||
}
|
||
.btn--ghost {
|
||
width: 100%;
|
||
margin-top: 0;
|
||
}
|
||
.layout {
|
||
display: block;
|
||
min-width: 0;
|
||
}
|
||
.error {
|
||
margin: 10px 0 14px;
|
||
padding: 10px 12px;
|
||
border-radius: 12px;
|
||
border: 1px solid rgba(239, 68, 68, 0.3);
|
||
background: rgba(239, 68, 68, 0.12);
|
||
}
|
||
.board {
|
||
width: min(100%, 960px);
|
||
border: 1px solid var(--theme-border);
|
||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 94%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent));
|
||
border-radius: 22px;
|
||
padding: 20px;
|
||
align-self: start;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||
}
|
||
.modalOverlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 40;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 20px;
|
||
background: color-mix(in srgb, var(--theme-body-bg) 76%, transparent);
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
.modalCard {
|
||
width: min(100%, 420px);
|
||
border-radius: 20px;
|
||
padding: 24px;
|
||
border: 1px solid var(--theme-border-strong);
|
||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-main-bg) 98%, transparent), color-mix(in srgb, var(--theme-shell-bg) 98%, transparent));
|
||
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.38);
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.modalCard__title {
|
||
font-size: 22px;
|
||
font-weight: 900;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.modalCard__desc {
|
||
line-height: 1.6;
|
||
opacity: 0.82;
|
||
}
|
||
.modalCard__actions {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
margin-top: 8px;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.modalCard__actions .btn {
|
||
width: auto;
|
||
min-width: 120px;
|
||
}
|
||
.requestChecklist {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.requestChecklist__item {
|
||
display: grid;
|
||
grid-template-columns: minmax(0, 1fr) auto;
|
||
gap: 10px;
|
||
align-items: center;
|
||
padding: 10px 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid rgba(251, 191, 36, 0.22);
|
||
background: rgba(251, 191, 36, 0.08);
|
||
line-height: 1.5;
|
||
}
|
||
.requestChecklist__item--passed {
|
||
border-color: rgba(52, 211, 153, 0.24);
|
||
background: rgba(52, 211, 153, 0.1);
|
||
}
|
||
.requestChecklist__label {
|
||
min-width: 0;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
.requestChecklist__icon {
|
||
flex: 0 0 auto;
|
||
min-width: 68px;
|
||
font-size: 12px;
|
||
font-weight: 900;
|
||
opacity: 0.9;
|
||
text-align: right;
|
||
white-space: nowrap;
|
||
}
|
||
.requestChecklist__hint {
|
||
font-size: 13px;
|
||
line-height: 1.6;
|
||
opacity: 0.78;
|
||
white-space: pre-line;
|
||
}
|
||
.templateRequestDraft {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.templateRequestDraft__field {
|
||
display: grid;
|
||
gap: 6px;
|
||
}
|
||
.templateRequestDraft__label {
|
||
font-size: 12px;
|
||
color: var(--theme-text-soft);
|
||
}
|
||
.templateRequestDraft__hint {
|
||
font-size: 12px;
|
||
color: var(--theme-text-faint);
|
||
}
|
||
.templateRequestDraft__note {
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
color: var(--theme-text-soft);
|
||
}
|
||
.templateRequestDraft__input {
|
||
width: 100%;
|
||
padding: 14px 0;
|
||
border: 0;
|
||
border-bottom: 1px solid var(--theme-border-strong);
|
||
background: transparent;
|
||
color: var(--theme-text-strong);
|
||
outline: none;
|
||
font-size: 18px;
|
||
line-height: 1.5;
|
||
letter-spacing: -0.02em;
|
||
resize: none;
|
||
}
|
||
.templateRequestDraft__input:focus {
|
||
border-bottom-color: rgba(96, 165, 250, 0.9);
|
||
}
|
||
.templateRequestDraft__input::placeholder {
|
||
color: var(--theme-text-faint);
|
||
}
|
||
.templateRequestDraft__textarea {
|
||
min-height: 92px;
|
||
resize: vertical;
|
||
}
|
||
.boardTools {
|
||
display: flex;
|
||
gap: 12px;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
margin-bottom: 16px;
|
||
flex-wrap: wrap;
|
||
padding: 10px 12px;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--theme-border);
|
||
background: var(--theme-pill-bg);
|
||
}
|
||
.boardTools__left,
|
||
.boardTools__right {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
flex-wrap: wrap;
|
||
}
|
||
.boardTools__left {
|
||
margin-right: auto;
|
||
}
|
||
.boardActionIcon {
|
||
width: 40px;
|
||
height: 40px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--theme-border-strong);
|
||
background: var(--theme-surface-soft);
|
||
color: var(--theme-text);
|
||
cursor: pointer;
|
||
transition: background 160ms ease, border-color 160ms ease, color 160ms ease;
|
||
}
|
||
.boardActionIcon:hover {
|
||
background: rgba(96, 165, 250, 0.12);
|
||
border-color: rgba(96, 165, 250, 0.28);
|
||
color: rgba(255, 255, 255, 0.98);
|
||
}
|
||
.boardColumnsHeader {
|
||
display: grid;
|
||
grid-template-columns: 132px 1fr;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.boardColumnsHeader__grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.boardColumnsHeader__cell {
|
||
min-width: 0;
|
||
position: relative;
|
||
}
|
||
.boardColumnsHeader__name {
|
||
min-height: 38px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 0 12px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
font-weight: 800;
|
||
opacity: 0.74;
|
||
}
|
||
.boardTools__label {
|
||
font-size: 13px;
|
||
opacity: 0.76;
|
||
font-weight: 800;
|
||
}
|
||
.sizePicker {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.sizePicker__button {
|
||
margin: 0;
|
||
min-width: 48px;
|
||
padding: 9px 10px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--theme-border-strong);
|
||
background: var(--theme-surface-soft);
|
||
color: var(--theme-text);
|
||
cursor: pointer;
|
||
font-weight: 800;
|
||
}
|
||
.sizePicker__button--active {
|
||
background: rgba(96, 165, 250, 0.24);
|
||
border-color: rgba(96, 165, 250, 0.38);
|
||
}
|
||
.exportBoard--active {
|
||
display: grid;
|
||
gap: 12px;
|
||
width: 960px;
|
||
max-width: none;
|
||
box-sizing: border-box;
|
||
padding: 44px 52px;
|
||
border-radius: 28px;
|
||
background:
|
||
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
||
var(--theme-shell-bg);
|
||
}
|
||
.exportBoard__title {
|
||
font-size: 28px;
|
||
font-weight: 900;
|
||
letter-spacing: -0.03em;
|
||
text-align: left;
|
||
}
|
||
.exportBoard__description {
|
||
margin-top: -4px;
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
opacity: 0.74;
|
||
text-align: left;
|
||
}
|
||
.exportBoard__footer {
|
||
margin-top: 12px;
|
||
padding-top: 18px;
|
||
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
font-size: 15px;
|
||
opacity: 0.8;
|
||
}
|
||
.rows {
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.row {
|
||
display: grid;
|
||
grid-template-columns: 132px 1fr;
|
||
gap: 10px;
|
||
align-items: stretch;
|
||
}
|
||
.row__label {
|
||
position: relative;
|
||
border-radius: 16px;
|
||
background: var(--theme-surface-soft-3);
|
||
border: 1px solid var(--theme-border-strong);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 14px 28px;
|
||
font-weight: 900;
|
||
overflow: hidden;
|
||
}
|
||
.row__content {
|
||
display: grid;
|
||
grid-template-columns: repeat(var(--column-count, 1), minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
.row__column {
|
||
display: grid;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
}
|
||
.columnHeader {
|
||
position: relative;
|
||
display: flex;
|
||
align-items: center;
|
||
min-height: 38px;
|
||
padding: 0 28px;
|
||
}
|
||
.columnName {
|
||
width: 100%;
|
||
border: 0;
|
||
border-bottom: 1px solid var(--theme-border-strong);
|
||
background: transparent;
|
||
color: var(--theme-text);
|
||
padding: 4px 0;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
line-height: 1.2;
|
||
font-weight: 800;
|
||
outline: none;
|
||
}
|
||
.columnName::placeholder {
|
||
color: var(--theme-text-faint);
|
||
}
|
||
.columnRemoveText {
|
||
position: absolute;
|
||
top: 50%;
|
||
right: 0;
|
||
transform: translateY(-50%);
|
||
width: 20px;
|
||
height: 20px;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--theme-text-soft);
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
}
|
||
.columnRemoveText:hover {
|
||
color: var(--theme-text);
|
||
background: var(--theme-surface-soft-2);
|
||
}
|
||
.columnRemoveText:disabled {
|
||
opacity: 0.32;
|
||
cursor: not-allowed;
|
||
}
|
||
.grab {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
cursor: grab;
|
||
opacity: 0.72;
|
||
width: 22px;
|
||
height: 22px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 8px;
|
||
border: 1px solid var(--theme-border);
|
||
background: rgba(0, 0, 0, 0.16);
|
||
font-size: 12px;
|
||
}
|
||
.groupName {
|
||
width: 100%;
|
||
border: 1px solid var(--theme-border-strong);
|
||
background: var(--theme-pill-bg);
|
||
color: var(--theme-text);
|
||
border-radius: 10px;
|
||
padding: 8px 10px;
|
||
font-weight: 900;
|
||
text-align: center;
|
||
outline: none;
|
||
min-width: 0;
|
||
}
|
||
.rowRemoveText {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
width: 20px;
|
||
height: 20px;
|
||
display: grid;
|
||
place-items: center;
|
||
padding: 0;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--theme-text-soft);
|
||
cursor: pointer;
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
font-weight: 800;
|
||
}
|
||
.rowRemoveText:hover {
|
||
color: var(--theme-text);
|
||
background: var(--theme-surface-soft-2);
|
||
}
|
||
.rowRemoveText:disabled {
|
||
opacity: 0.32;
|
||
cursor: not-allowed;
|
||
}
|
||
.row__exportName {
|
||
width: 100%;
|
||
text-align: center;
|
||
font-weight: 900;
|
||
word-break: break-word;
|
||
}
|
||
.row__drop {
|
||
border-radius: 16px;
|
||
background: var(--theme-pill-bg);
|
||
border: 1px solid rgba(255, 255, 255, 0.10);
|
||
min-height: calc(var(--thumb-size, 80px) + 24px);
|
||
padding: 10px;
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
align-content: flex-start;
|
||
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;
|
||
position: absolute;
|
||
inset: 0;
|
||
display: grid;
|
||
place-items: center;
|
||
pointer-events: none;
|
||
}
|
||
.cell {
|
||
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;
|
||
inset: auto 0 0 0;
|
||
min-height: 26px;
|
||
padding: 18px 8px 6px;
|
||
border-radius: 0 0 10px 10px;
|
||
background: linear-gradient(180deg, rgba(7, 10, 18, 0), rgba(7, 10, 18, 0.92));
|
||
color: rgba(255, 255, 255, 0.96);
|
||
font-size: 11px;
|
||
line-height: 1.2;
|
||
font-weight: 800;
|
||
text-align: center;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
display: flex;
|
||
align-items: flex-end;
|
||
justify-content: center;
|
||
pointer-events: none;
|
||
}
|
||
.cellRemoveBtn {
|
||
position: absolute;
|
||
top: -6px;
|
||
right: -6px;
|
||
width: 24px;
|
||
height: 24px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(239, 68, 68, 0.32);
|
||
background: rgba(11, 18, 32, 0.92);
|
||
color: var(--theme-text);
|
||
font-size: 16px;
|
||
line-height: 1;
|
||
font-weight: 900;
|
||
cursor: pointer;
|
||
z-index: 2;
|
||
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.24);
|
||
}
|
||
.cellRemoveBtn:hover {
|
||
background: rgba(239, 68, 68, 0.9);
|
||
}
|
||
.thumb {
|
||
width: var(--thumb-size, 80px);
|
||
height: var(--thumb-size, 80px);
|
||
border-radius: 10px;
|
||
border: 1px solid var(--theme-border-strong);
|
||
background: var(--theme-surface-soft-2);
|
||
object-fit: cover;
|
||
}
|
||
.sidebar {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border: 1px solid var(--theme-border);
|
||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 94%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent));
|
||
border-radius: 22px;
|
||
padding: 14px;
|
||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
|
||
position: sticky;
|
||
top: 14px;
|
||
max-height: var(--editor-sidebar-max-height, calc(100dvh - 136px));
|
||
overflow: hidden;
|
||
overscroll-behavior: contain;
|
||
}
|
||
|
||
.dropzone--board {
|
||
margin-top: 18px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 18px;
|
||
min-height: 180px;
|
||
padding: 28px 22px;
|
||
border-radius: 22px;
|
||
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
|
||
background:
|
||
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
|
||
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
|
||
text-align: center;
|
||
}
|
||
|
||
.dropzone--board.dropzone--active {
|
||
border-color: rgba(96, 165, 250, 0.56);
|
||
background:
|
||
linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg-hover) 90%, white 6%), color-mix(in srgb, var(--theme-card-bg) 84%, white 4%)),
|
||
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 18%, transparent), transparent 60%);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.dropzone__actions {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex: 0 0 auto;
|
||
justify-content: center;
|
||
}
|
||
|
||
.dropzone__button {
|
||
min-width: 124px;
|
||
min-height: 34px;
|
||
font-size: 11px;
|
||
}
|
||
|
||
/* .dropzone__iconWrap {
|
||
width: 52px;
|
||
height: 52px;
|
||
display: grid;
|
||
place-items: center;
|
||
border-radius: 16px;
|
||
background: color-mix(in srgb, var(--theme-text) 10%, transparent);
|
||
} */
|
||
|
||
.dropzone__icon {
|
||
width: 48px;
|
||
height: 48px;
|
||
opacity: 0.86;
|
||
}
|
||
|
||
.dropzone--board .dropzone__title {
|
||
font-size: 18px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.dropzone--board .dropzone__desc {
|
||
max-width: 520px;
|
||
color: var(--theme-text-soft);
|
||
line-height: 1.6;
|
||
}
|
||
.editorSidebar__section {
|
||
display: grid;
|
||
gap: 10px;
|
||
padding: 12px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||
background: rgba(255, 255, 255, 0.02);
|
||
}
|
||
.editorSidebar__label {
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
color: var(--theme-text-faint);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.12em;
|
||
}
|
||
.editorSidebar__input,
|
||
.editorSidebar__textarea {
|
||
width: 100%;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--theme-border);
|
||
background: var(--theme-surface-soft);
|
||
color: var(--theme-text);
|
||
padding: 11px 12px;
|
||
outline: none;
|
||
resize: vertical;
|
||
}
|
||
.editorSidebar__textarea {
|
||
min-height: 92px;
|
||
}
|
||
.editorSidebar__hint {
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
color: var(--theme-text-soft);
|
||
word-break: keep-all;
|
||
}
|
||
.editorSidebar__hint--warn {
|
||
color: rgba(251, 191, 36, 0.92);
|
||
}
|
||
.editorSidebar__thumbFrame {
|
||
position: relative;
|
||
width: 100%;
|
||
aspect-ratio: 16 / 9;
|
||
border-radius: 16px;
|
||
overflow: hidden;
|
||
border: 1px solid var(--theme-border);
|
||
background: var(--theme-thumb-fallback-bg);
|
||
}
|
||
|
||
.editorSidebar__thumbFrame--active {
|
||
border-color: rgba(96, 165, 250, 0.8);
|
||
box-shadow: 0 0 0 3px rgba(96, 165, 250, 0.18);
|
||
}
|
||
.editorSidebar__thumbImage {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
}
|
||
.editorSidebar__thumbEmpty {
|
||
width: 100%;
|
||
height: 100%;
|
||
display: grid;
|
||
place-items: center;
|
||
color: var(--theme-text-faint);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.editorSidebar__thumbOverlay {
|
||
position: absolute;
|
||
inset: auto 0 0 0;
|
||
padding: 10px 12px;
|
||
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.72));
|
||
color: rgba(255, 255, 255, 0.82);
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
}
|
||
.editorSidebar__button {
|
||
width: 100%;
|
||
margin-top: 0;
|
||
}
|
||
.editorSidebar__fileName {
|
||
font-size: 12px;
|
||
color: var(--theme-text-soft);
|
||
word-break: break-word;
|
||
}
|
||
.editorSidebar__favorite {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
width: 100%;
|
||
padding: 11px 12px;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--theme-border);
|
||
background: var(--theme-pill-bg);
|
||
color: var(--theme-text);
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
}
|
||
.editorSidebar__section--footer {
|
||
padding-top: 12px;
|
||
}
|
||
.editorSidebar__actionGrid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 10px;
|
||
}
|
||
|
||
.editorSidebar__utilityLinks {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
flex-wrap: wrap;
|
||
padding-top: 4px;
|
||
}
|
||
|
||
.editorSidebar__utilityLink {
|
||
border: 0;
|
||
padding: 0;
|
||
background: transparent;
|
||
color: var(--theme-text-muted);
|
||
font-size: 14px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.editorSidebar__utilityLink:disabled {
|
||
cursor: default;
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.editorSidebar__utilityLink--danger {
|
||
color: rgba(248, 113, 113, 0.96);
|
||
}
|
||
|
||
.editorSidebar__utilityLink--share {
|
||
color: var(--theme-text-soft);
|
||
}
|
||
.sidebar__title {
|
||
font-weight: 900;
|
||
margin-bottom: 8px;
|
||
font-size: 18px;
|
||
letter-spacing: -0.02em;
|
||
}
|
||
.sidebar__hint {
|
||
opacity: 0.78;
|
||
font-size: 13px;
|
||
margin-bottom: 12px;
|
||
line-height: 1.5;
|
||
word-break: keep-all;
|
||
}
|
||
.customItemEditor {
|
||
margin-top: 0;
|
||
padding: 0;
|
||
border-radius: 0;
|
||
border: 0;
|
||
background: transparent;
|
||
}
|
||
.customItemEditor--sidebar {
|
||
display: grid;
|
||
gap: 12px;
|
||
}
|
||
.customItemEditor__title {
|
||
font-weight: 900;
|
||
}
|
||
.customItemEditor__desc {
|
||
margin-top: 6px;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
opacity: 0.72;
|
||
}
|
||
.customItemEditor__list {
|
||
margin-top: 12px;
|
||
display: grid;
|
||
gap: 10px;
|
||
}
|
||
.customItemEditor__row {
|
||
display: grid;
|
||
grid-template-columns: 44px minmax(0, 1fr);
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
.customItemEditor__thumb {
|
||
width: 44px;
|
||
height: 44px;
|
||
border-radius: 10px;
|
||
object-fit: cover;
|
||
border: 1px solid var(--theme-border-strong);
|
||
}
|
||
.customItemEditor__input {
|
||
width: 100%;
|
||
min-width: 0;
|
||
padding: 9px 10px;
|
||
border-radius: 12px;
|
||
border: 1px solid var(--theme-border-strong);
|
||
background: var(--theme-pill-bg);
|
||
color: var(--theme-text);
|
||
outline: none;
|
||
box-sizing: border-box;
|
||
}
|
||
.dropzone {
|
||
margin-top: 12px;
|
||
padding: 28px 22px;
|
||
border-radius: 16px;
|
||
border: 2px dashed color-mix(in srgb, var(--theme-border-strong) 82%, rgba(255, 255, 255, 0.12));
|
||
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 88%, white 4%), color-mix(in srgb, var(--theme-card-bg-hover) 82%, white 6%)),
|
||
radial-gradient(circle at top, color-mix(in srgb, var(--theme-accent) 12%, transparent), transparent 60%);
|
||
}
|
||
.dropzone--active {
|
||
border-color: rgba(110, 231, 183, 0.6);
|
||
background: rgba(110, 231, 183, 0.08);
|
||
}
|
||
.dropzone__title {
|
||
font-weight: 900;
|
||
}
|
||
.dropzone__desc {
|
||
margin-top: 6px;
|
||
opacity: 0.74;
|
||
font-size: 13px;
|
||
line-height: 1.4;
|
||
}
|
||
.sidebar__titleRow {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
}
|
||
.sidebar__count {
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
color: var(--theme-text-soft);
|
||
}
|
||
.sidebar__search {
|
||
width: 100%;
|
||
margin-top: 12px;
|
||
margin-bottom: 12px;
|
||
padding: 11px 14px;
|
||
border-radius: 14px;
|
||
border: 1px solid var(--theme-border);
|
||
background: var(--theme-card-bg);
|
||
color: var(--theme-text);
|
||
}
|
||
.sidebar__search::placeholder {
|
||
color: var(--theme-text-faint);
|
||
}
|
||
.pool {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
overflow-y: auto;
|
||
scrollbar-width: none;
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
|
||
gap: 10px;
|
||
align-content: start;
|
||
}
|
||
.pool::-webkit-scrollbar {
|
||
display: none;
|
||
}
|
||
.pool--clickTarget {
|
||
cursor: copy;
|
||
}
|
||
.editorTips {
|
||
margin-top: 14px;
|
||
padding: 12px 14px;
|
||
border-radius: 16px;
|
||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||
background: color-mix(in srgb, var(--theme-card-bg) 82%, transparent);
|
||
}
|
||
.editorTips__title {
|
||
font-size: 11px;
|
||
font-weight: 900;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--theme-text-soft);
|
||
}
|
||
.editorTips__list {
|
||
margin: 8px 0 0;
|
||
padding-left: 16px;
|
||
color: var(--theme-text-faint);
|
||
font-size: 12px;
|
||
line-height: 1.55;
|
||
}
|
||
.editorTips__list li + li {
|
||
margin-top: 6px;
|
||
}
|
||
.poolItem {
|
||
position: relative;
|
||
min-width: 0;
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
justify-items: center;
|
||
align-content: start;
|
||
gap: 8px;
|
||
padding: 10px 8px;
|
||
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);
|
||
height: auto;
|
||
aspect-ratio: 1 / 1;
|
||
}
|
||
.poolItem__label {
|
||
width: 100%;
|
||
min-width: 0;
|
||
font-size: 11px;
|
||
line-height: 1.35;
|
||
font-weight: 800;
|
||
text-align: center;
|
||
opacity: 0.9;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.poolItem__state {
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
color: var(--theme-text-soft);
|
||
}
|
||
.poolItem__deleteBtn,
|
||
.cellDeleteBtn {
|
||
position: absolute;
|
||
right: 8px;
|
||
bottom: 8px;
|
||
border: 0;
|
||
border-radius: 999px;
|
||
padding: 6px 9px;
|
||
background: rgba(11, 18, 32, 0.84);
|
||
color: #fff;
|
||
font-size: 10px;
|
||
font-weight: 800;
|
||
cursor: pointer;
|
||
}
|
||
.cellDeleteBtn {
|
||
bottom: 34px;
|
||
}
|
||
.poolItem--hidden {
|
||
display: none;
|
||
}
|
||
|
||
.itemContextMenu {
|
||
position: fixed;
|
||
z-index: 50;
|
||
min-width: 150px;
|
||
padding: 8px;
|
||
border-radius: 16px;
|
||
border: 1px solid var(--theme-border);
|
||
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
||
}
|
||
|
||
.itemContextMenu__action {
|
||
width: 100%;
|
||
border: 0;
|
||
border-radius: 12px;
|
||
padding: 11px 12px;
|
||
background: transparent;
|
||
color: var(--theme-text);
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.itemContextMenu__action:hover {
|
||
background: var(--theme-pill-bg);
|
||
}
|
||
.itemContextMenu__action--danger {
|
||
color: #fca5a5;
|
||
}
|
||
|
||
.hidden {
|
||
display: none;
|
||
}
|
||
.ghost {
|
||
opacity: 0.3;
|
||
}
|
||
.chosen {
|
||
outline: 2px solid rgba(110, 231, 183, 0.5);
|
||
border-radius: 14px;
|
||
}
|
||
@media (max-width: 980px) {
|
||
.previewOnly__row,
|
||
.row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.previewOnly__columns,
|
||
.boardColumnsHeader {
|
||
display: none;
|
||
}
|
||
.previewOnly__columnsSpacer,
|
||
.boardColumnsHeader__spacer {
|
||
display: none;
|
||
}
|
||
.previewOnly__dropGrid,
|
||
.boardColumnsHeader__grid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.previewOnly__drop,
|
||
.row__drop {
|
||
padding-top: 40px;
|
||
}
|
||
.previewOnly__columnBadge,
|
||
.row__columnBadge {
|
||
position: absolute;
|
||
top: 10px;
|
||
left: 10px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
max-width: calc(100% - 20px);
|
||
padding: 5px 10px;
|
||
border-radius: 999px;
|
||
border: 1px solid rgba(255, 255, 255, 0.12);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: var(--theme-text-soft);
|
||
font-size: 11px;
|
||
font-weight: 800;
|
||
line-height: 1;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.heroCard {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.editorCanvas {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.row__content {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.sidebar {
|
||
position: static;
|
||
max-height: none;
|
||
overflow: visible;
|
||
}
|
||
.sidebarStickyFrame {
|
||
padding-bottom: 0;
|
||
}
|
||
.pool {
|
||
overflow: visible;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
gap: 8px;
|
||
}
|
||
.editorSidebar__actionGrid {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.editorSidebar__utilityLinks {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
}
|
||
.requestChecklist__item {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
.requestChecklist__icon {
|
||
text-align: left;
|
||
}
|
||
.modalCard__actions .btn {
|
||
width: 100%;
|
||
}
|
||
}
|
||
@media (max-width: 720px) {
|
||
.previewOnly {
|
||
padding: 14px;
|
||
}
|
||
.viewerSidebar__section {
|
||
margin-top: 0;
|
||
}
|
||
.pool {
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
}
|
||
}
|
||
@media (max-width: 560px) {
|
||
.pool {
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
}
|
||
}
|
||
</style>
|