1151 lines
31 KiB
Vue
1151 lines
31 KiB
Vue
<script setup>
|
|
import { computed, nextTick, onMounted, onUnmounted, ref } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import Sortable from 'sortablejs'
|
|
import * as htmlToImage from 'html-to-image'
|
|
import { api } from '../lib/api'
|
|
import { toApiUrl } from '../lib/runtime'
|
|
import { useAuthStore } from '../stores/auth'
|
|
|
|
const route = useRoute()
|
|
const router = useRouter()
|
|
const auth = useAuthStore()
|
|
const gameId = computed(() => route.params.gameId)
|
|
const tierListId = computed(() => route.params.tierListId)
|
|
const gameName = ref('')
|
|
|
|
const groups = ref([
|
|
{ id: 'gS', name: 'S', itemIds: [] },
|
|
{ id: 'gA', name: 'A', itemIds: [] },
|
|
{ id: 'gB', name: 'B', itemIds: [] },
|
|
{ id: 'gC', name: 'C', itemIds: [] },
|
|
{ id: 'gD', name: 'D', itemIds: [] },
|
|
])
|
|
|
|
const pool = ref([])
|
|
const itemsById = ref({})
|
|
|
|
const title = ref('')
|
|
const thumbnailSrc = ref('')
|
|
const pendingThumbnailFile = ref(null)
|
|
const thumbnailPreviewUrl = ref('')
|
|
const description = ref('')
|
|
const isPublic = ref(true)
|
|
const error = ref('')
|
|
const isSaving = ref(false)
|
|
const isExporting = ref(false)
|
|
const isSaveModalOpen = ref(false)
|
|
const ownerId = ref('')
|
|
const authorName = ref('')
|
|
const authorAccountName = ref('')
|
|
const updatedAt = ref(0)
|
|
const isDragActive = ref(false)
|
|
const iconSize = ref(80)
|
|
|
|
const boardEl = ref(null)
|
|
const exportBoardEl = ref(null)
|
|
const groupListEl = ref(null)
|
|
const poolEl = 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 isNewTierList = computed(() => tierListId.value === 'new')
|
|
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
|
const iconSizeOptions = [48, 64, 80, 96, 112]
|
|
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 effectiveTitle = computed(() => {
|
|
const customTitle = (title.value || '').trim()
|
|
if (customTitle) return customTitle
|
|
return (gameName.value || gameId.value || 'Tier Maker').trim()
|
|
})
|
|
const displayThumbnailUrl = computed(() => thumbnailPreviewUrl.value || (thumbnailSrc.value ? resolveItemSrc({ src: thumbnailSrc.value }) : ''))
|
|
const untitledWarning = computed(
|
|
() =>
|
|
canEdit.value &&
|
|
!hasCustomTitle.value &&
|
|
'제목 없이 저장된 티어표는 무분별한 도배 방지를 위해 관리자에 의해 임의 삭제될 수 있어요.'
|
|
)
|
|
|
|
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',
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})
|
|
}
|
|
|
|
function setIconSize(nextSize) {
|
|
iconSize.value = nextSize
|
|
}
|
|
|
|
function setGroupDropEl(groupId, el) {
|
|
if (!el) {
|
|
delete groupDropEls.value[groupId]
|
|
return
|
|
}
|
|
groupDropEls.value[groupId] = el
|
|
}
|
|
|
|
function getListByContainer(containerEl) {
|
|
if (!containerEl) return { type: null, groupId: null }
|
|
const t = containerEl.getAttribute('data-list-type')
|
|
if (t === 'pool') return { type: 'pool', groupId: null }
|
|
if (t === 'group') return { type: 'group', groupId: containerEl.getAttribute('data-group-id') }
|
|
return { type: null, groupId: 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
|
|
if (meta.type === 'group') {
|
|
const g = groups.value.find((x) => x.id === meta.groupId)
|
|
if (g) g.itemIds = ids
|
|
}
|
|
}
|
|
|
|
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, {
|
|
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, {
|
|
group: 'tier-items',
|
|
animation: 160,
|
|
draggable: '[data-item-id]',
|
|
ghostClass: 'ghost',
|
|
chosenClass: 'chosen',
|
|
onSort: () => normalizeSort(poolEl.value),
|
|
onAdd: () => normalizeSort(poolEl.value),
|
|
})
|
|
|
|
dropSortables.value = Object.entries(groupDropEls.value).map(([gid, el]) =>
|
|
Sortable.create(el, {
|
|
group: 'tier-items',
|
|
animation: 160,
|
|
draggable: '[data-item-id]',
|
|
ghostClass: 'ghost',
|
|
chosenClass: 'chosen',
|
|
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() {
|
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'
|
|
const index = groups.value.length
|
|
if (index < alphabet.length) return alphabet[index]
|
|
return `Tier ${index + 1}`
|
|
}
|
|
|
|
async function addGroup() {
|
|
groups.value = [
|
|
...groups.value,
|
|
{
|
|
id: `g-${Date.now()}-${Math.random().toString(16).slice(2, 8)}`,
|
|
name: createGroupName(),
|
|
itemIds: [],
|
|
},
|
|
]
|
|
await syncSortables()
|
|
}
|
|
|
|
async function removeGroup(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)
|
|
delete groupDropEls.value[groupId]
|
|
await syncSortables()
|
|
}
|
|
|
|
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: file.name || 'custom', origin: 'custom', pendingFile: file },
|
|
}
|
|
pool.value = [id, ...pool.value]
|
|
}
|
|
|
|
function openFile() {
|
|
if (!canEdit.value) return
|
|
fileEl.value?.click()
|
|
}
|
|
|
|
function openThumbnailFile() {
|
|
if (!canEdit.value) return
|
|
thumbnailFileEl.value?.click()
|
|
}
|
|
|
|
function onThumbnailChange(event) {
|
|
const file = event.target.files?.[0]
|
|
if (thumbnailPreviewUrl.value) {
|
|
URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
|
thumbnailPreviewUrl.value = ''
|
|
}
|
|
pendingThumbnailFile.value = file || null
|
|
if (file) thumbnailPreviewUrl.value = URL.createObjectURL(file)
|
|
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', 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) => ({
|
|
...group,
|
|
itemIds: group.itemIds.map((currentId) => (currentId === item.id ? uploaded.id : currentId)),
|
|
}))
|
|
}
|
|
}
|
|
|
|
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,
|
|
gameId: gameId.value,
|
|
title: finalTitle,
|
|
thumbnailSrc: thumbnailSrc.value || '',
|
|
description: (description.value || '').trim(),
|
|
isPublic: !!isPublic.value,
|
|
groups: groups.value.map((g) => ({ id: g.id, name: g.name, itemIds: g.itemIds })),
|
|
pool: Object.values(itemsById.value),
|
|
}
|
|
}
|
|
|
|
async function save() {
|
|
error.value = ''
|
|
isSaving.value = true
|
|
try {
|
|
await uploadPendingCustomItems()
|
|
await uploadPendingThumbnail()
|
|
const payload = buildPayload(tierListId.value && tierListId.value !== 'new' ? tierListId.value : null)
|
|
const res = await api.saveTierList(payload)
|
|
if (tierListId.value === 'new') history.replaceState({}, '', `/editor/${gameId.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
|
|
isSaveModalOpen.value = true
|
|
} catch (e) {
|
|
error.value = '저장 실패: 로그인 상태인지 확인해주세요.'
|
|
} finally {
|
|
isSaving.value = false
|
|
}
|
|
}
|
|
|
|
function closeSaveModal() {
|
|
isSaveModalOpen.value = false
|
|
}
|
|
|
|
async function removeTierList() {
|
|
if (!canEdit.value || isNewTierList.value) return
|
|
error.value = ''
|
|
try {
|
|
const ok = window.confirm(`"${title.value || gameName.value || '이 티어표'}"를 삭제할까요?`)
|
|
if (!ok) return
|
|
await api.deleteTierList(tierListId.value)
|
|
router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`)
|
|
} catch (e) {
|
|
error.value = '티어표 삭제에 실패했어요.'
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
;(async () => {
|
|
await auth.refresh()
|
|
authorName.value = (auth.user?.nickname || '').trim()
|
|
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
|
|
|
|
if (isNewTierList.value && !auth.user) {
|
|
router.replace(`/login?redirect=/editor/${gameId.value}/new`)
|
|
return
|
|
}
|
|
|
|
try {
|
|
const gameRes = await api.getGame(gameId.value)
|
|
gameName.value = gameRes.game?.name || gameId.value
|
|
const base = (gameRes.items || []).map((img) => ({
|
|
id: img.id,
|
|
src: img.src,
|
|
label: img.label,
|
|
origin: 'game',
|
|
}))
|
|
const map = {}
|
|
base.forEach((it) => (map[it.id] = it))
|
|
itemsById.value = map
|
|
pool.value = base.map((it) => it.id)
|
|
} catch (e) {
|
|
error.value = '게임 기본 이미지를 불러오지 못했어요.'
|
|
}
|
|
|
|
if (tierListId.value && tierListId.value !== 'new') {
|
|
try {
|
|
const res = await api.getTierList(tierListId.value)
|
|
const t = res.tierList
|
|
ownerId.value = t.authorId
|
|
title.value = t.title
|
|
thumbnailSrc.value = t.thumbnailSrc || ''
|
|
description.value = t.description || ''
|
|
isPublic.value = !!t.isPublic
|
|
authorName.value = t.authorName || ''
|
|
authorAccountName.value = t.authorAccountName || ''
|
|
updatedAt.value = Number(t.updatedAt || 0)
|
|
groups.value = t.groups
|
|
const map = {}
|
|
;(t.pool || []).forEach((it) => (map[it.id] = it))
|
|
itemsById.value = map
|
|
const grouped = new Set()
|
|
groups.value.forEach((g) => g.itemIds.forEach((id) => grouped.add(id)))
|
|
pool.value = Object.keys(itemsById.value).filter((id) => !grouped.has(id))
|
|
} catch (e) {
|
|
error.value = '티어표를 불러오지 못했어요.'
|
|
}
|
|
}
|
|
|
|
await nextTick()
|
|
if (canEdit.value) {
|
|
await initSortables()
|
|
}
|
|
})()
|
|
})
|
|
|
|
onUnmounted(() => {
|
|
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
|
|
destroySortables()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<section class="head">
|
|
<div class="heroCard">
|
|
<div class="heroCard__main">
|
|
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
|
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
|
<input
|
|
v-model="description"
|
|
class="descInput"
|
|
placeholder="설명(선택): 이 티어표의 기준/룰"
|
|
:readonly="!canEdit"
|
|
/>
|
|
<div class="hint">
|
|
<template v-if="canEdit">
|
|
그룹 이름/순서 변경과 아이템 드래그&드롭이 가능합니다. 저장하려면 <b>저장</b>을 누르세요.
|
|
</template>
|
|
<template v-else>
|
|
공개된 티어표를 보는 중입니다. 로그인한 작성자만 수정할 수 있어요.
|
|
</template>
|
|
</div>
|
|
</div>
|
|
<div class="heroCard__side">
|
|
<div class="thumbComposer">
|
|
<input ref="thumbnailFileEl" type="file" accept="image/*" class="hidden" @change="onThumbnailChange" />
|
|
<div class="thumbComposer__header">
|
|
<div class="thumbComposer__eyebrow">대표 썸네일</div>
|
|
<div class="thumbComposer__caption">목록 카드 상단에 표시됩니다.</div>
|
|
</div>
|
|
<div class="thumbComposer__preview">
|
|
<img v-if="displayThumbnailUrl" class="thumbComposer__image" :src="displayThumbnailUrl" alt="썸네일 미리보기" />
|
|
<div v-else class="thumbComposer__empty">썸네일 없음</div>
|
|
</div>
|
|
<div v-if="canEdit" class="thumbComposer__actions">
|
|
<button class="btn btn--ghost thumbComposer__button" @click="openThumbnailFile">썸네일 선택</button>
|
|
<button class="btn btn--danger thumbComposer__button" :disabled="!pendingThumbnailFile && !thumbnailSrc" @click="clearThumbnail">제거</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="actions">
|
|
<div class="actions__left">
|
|
<button class="btn btn--download" @click="downloadImage">이미지 다운로드</button>
|
|
<button v-if="canEdit && !isNewTierList" class="btn btn--danger" @click="removeTierList">삭제</button>
|
|
</div>
|
|
<div class="actions__right">
|
|
<label class="toggle" :class="{ 'toggle--disabled': !canEdit }">
|
|
<input v-model="isPublic" type="checkbox" :disabled="!canEdit" />
|
|
<span>{{ isPublic ? '공개 ON' : '공개 OFF' }}</span>
|
|
</label>
|
|
<button v-if="canEdit" class="btn btn--save" :disabled="isSaving" @click="save">{{ isSaving ? '저장중...' : '저장' }}</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div v-if="error" class="error">{{ error }}</div>
|
|
|
|
<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">티어표가 저장되었어요. 이어서 더 수정한 뒤 다시 저장할 수도 있어요.</div>
|
|
<div class="modalCard__actions">
|
|
<button class="btn btn--save" @click="closeSaveModal">확인</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="layout" :style="{ '--thumb-size': `${iconSize}px` }">
|
|
<div ref="boardEl" class="board">
|
|
<div v-if="canEdit && !isExporting" class="boardTools">
|
|
<div class="boardTools__left">
|
|
<button class="btn btn--ghost" @click="addGroup">티어 추가</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 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" :readonly="!canEdit" />
|
|
<button v-if="canEdit" class="rowRemoveBtn" :disabled="groups.length <= 1" @click="removeGroup(g.id)">삭제</button>
|
|
</template>
|
|
</div>
|
|
<div
|
|
class="row__drop"
|
|
:data-list-type="'group'"
|
|
:data-group-id="g.id"
|
|
:ref="(el) => setGroupDropEl(g.id, el)"
|
|
>
|
|
<div v-if="!isExporting" class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
|
<div v-for="id in g.itemIds" :key="id" class="cell" :data-item-id="id">
|
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div v-if="isExporting" class="exportBoard__footer">
|
|
<span>{{ effectiveAuthorName }}</span>
|
|
<span>{{ formatExportDate(fallbackTimestamp) }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="sidebar">
|
|
<div class="sidebar__title">아이템</div>
|
|
<div class="sidebar__hint">
|
|
{{ canEdit ? '게임별 기본 이미지와 커스텀 업로드를 여기에 모읍니다.' : '공개 티어표는 보기 전용입니다.' }}
|
|
</div>
|
|
<div ref="poolEl" class="pool" data-list-type="pool">
|
|
<div v-for="id in pool" :key="id" class="poolItem" :data-item-id="id">
|
|
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
v-if="canEdit"
|
|
class="dropzone"
|
|
:class="{ 'dropzone--active': isDragActive }"
|
|
@dragenter.prevent="onDragEnter"
|
|
@dragover.prevent="onDragEnter"
|
|
@dragleave="onDragLeave"
|
|
@drop.prevent="onDropFiles"
|
|
>
|
|
<div class="dropzone__title">커스텀 이미지 추가</div>
|
|
<div class="dropzone__desc">여러 이미지를 한 번에 드래그하거나 파일 선택으로 추가할 수 있어요.</div>
|
|
</div>
|
|
<input ref="fileEl" type="file" accept="image/*" multiple class="hidden" @change="onFileChange" />
|
|
<button v-if="canEdit" class="btn btn--ghost" @click="openFile">파일 선택</button>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.head {
|
|
display: grid;
|
|
gap: 14px;
|
|
padding: 6px 2px 14px;
|
|
}
|
|
.heroCard {
|
|
display: grid;
|
|
grid-template-columns: minmax(0, 1.5fr) minmax(280px, 360px);
|
|
gap: 18px;
|
|
align-items: stretch;
|
|
}
|
|
.heroCard__main,
|
|
.heroCard__side {
|
|
min-width: 0;
|
|
}
|
|
.heroCard__main {
|
|
display: grid;
|
|
gap: 12px;
|
|
}
|
|
.heroCard__side {
|
|
display: flex;
|
|
}
|
|
.titleInput {
|
|
width: 100%;
|
|
font-size: 22px;
|
|
font-weight: 800;
|
|
letter-spacing: -0.02em;
|
|
padding: 14px 16px;
|
|
border-radius: 18px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.04));
|
|
color: rgba(255, 255, 255, 0.92);
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
}
|
|
.descInput {
|
|
width: 100%;
|
|
min-height: 92px;
|
|
padding: 14px 16px;
|
|
border-radius: 18px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(0, 0, 0, 0.18);
|
|
color: rgba(255, 255, 255, 0.92);
|
|
outline: none;
|
|
box-sizing: border-box;
|
|
}
|
|
.hint {
|
|
opacity: 0.78;
|
|
font-size: 13px;
|
|
line-height: 1.6;
|
|
padding: 0 2px;
|
|
}
|
|
.titleNotice {
|
|
font-size: 13px;
|
|
line-height: 1.5;
|
|
color: rgba(251, 191, 36, 0.94);
|
|
padding: 0 2px;
|
|
}
|
|
.thumbComposer {
|
|
display: grid;
|
|
gap: 10px;
|
|
width: 100%;
|
|
padding: 16px;
|
|
border-radius: 22px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background:
|
|
radial-gradient(circle at top right, rgba(96, 165, 250, 0.12), transparent 46%),
|
|
rgba(255, 255, 255, 0.04);
|
|
box-sizing: border-box;
|
|
}
|
|
.thumbComposer__header {
|
|
display: grid;
|
|
gap: 4px;
|
|
}
|
|
.thumbComposer__eyebrow {
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
letter-spacing: 0.02em;
|
|
}
|
|
.thumbComposer__caption {
|
|
font-size: 12px;
|
|
opacity: 0.68;
|
|
}
|
|
.thumbComposer__preview {
|
|
width: 100%;
|
|
aspect-ratio: 16 / 9;
|
|
border-radius: 18px;
|
|
overflow: hidden;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(11, 18, 32, 0.78);
|
|
}
|
|
.thumbComposer__image {
|
|
width: 100%;
|
|
height: 100%;
|
|
object-fit: cover;
|
|
}
|
|
.thumbComposer__empty {
|
|
width: 100%;
|
|
height: 100%;
|
|
display: grid;
|
|
place-items: center;
|
|
color: rgba(255, 255, 255, 0.62);
|
|
font-size: 13px;
|
|
}
|
|
.thumbComposer__actions {
|
|
display: grid;
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
gap: 10px;
|
|
}
|
|
.thumbComposer__button {
|
|
width: 100%;
|
|
margin-top: 0;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 14px;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
flex-wrap: wrap;
|
|
}
|
|
.actions__left,
|
|
.actions__right {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.toggle {
|
|
display: inline-flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
padding: 8px 10px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(0, 0, 0, 0.12);
|
|
font-weight: 800;
|
|
cursor: pointer;
|
|
user-select: none;
|
|
}
|
|
.toggle input {
|
|
width: 16px;
|
|
height: 16px;
|
|
}
|
|
.toggle--disabled {
|
|
opacity: 0.55;
|
|
pointer-events: none;
|
|
}
|
|
.btn {
|
|
padding: 10px 12px;
|
|
border-radius: 12px;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
color: rgba(255, 255, 255, 0.92);
|
|
cursor: pointer;
|
|
font-weight: 700;
|
|
}
|
|
.btn:hover {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
}
|
|
.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: grid;
|
|
grid-template-columns: 1fr 320px;
|
|
gap: 14px;
|
|
align-items: start;
|
|
}
|
|
.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 {
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
border-radius: 16px;
|
|
padding: 20px;
|
|
align-self: start;
|
|
}
|
|
.modalOverlay {
|
|
position: fixed;
|
|
inset: 0;
|
|
z-index: 40;
|
|
display: grid;
|
|
place-items: center;
|
|
padding: 20px;
|
|
background: rgba(4, 8, 16, 0.68);
|
|
backdrop-filter: blur(4px);
|
|
}
|
|
.modalCard {
|
|
width: min(100%, 420px);
|
|
border-radius: 20px;
|
|
padding: 24px;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
background: linear-gradient(180deg, rgba(17, 24, 39, 0.96), rgba(11, 18, 32, 0.96));
|
|
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;
|
|
}
|
|
.boardTools {
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: center;
|
|
justify-content: flex-end;
|
|
margin-bottom: 14px;
|
|
flex-wrap: wrap;
|
|
}
|
|
.boardTools__left,
|
|
.boardTools__right {
|
|
display: flex;
|
|
gap: 10px;
|
|
align-items: center;
|
|
flex-wrap: wrap;
|
|
}
|
|
.boardTools__left {
|
|
margin-right: auto;
|
|
}
|
|
.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: 8px 10px;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
background: rgba(255, 255, 255, 0.05);
|
|
color: rgba(255, 255, 255, 0.92);
|
|
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%),
|
|
rgba(11, 18, 32, 0.98);
|
|
}
|
|
.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: 180px 1fr;
|
|
gap: 10px;
|
|
align-items: stretch;
|
|
}
|
|
.row__label {
|
|
border-radius: 14px;
|
|
background: rgba(255, 255, 255, 0.06);
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
display: flex;
|
|
gap: 8px;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 10px 8px;
|
|
font-weight: 900;
|
|
overflow: hidden;
|
|
}
|
|
.grab {
|
|
cursor: grab;
|
|
opacity: 0.85;
|
|
width: 26px;
|
|
height: 26px;
|
|
display: grid;
|
|
place-items: center;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
background: rgba(0, 0, 0, 0.16);
|
|
flex: 0 0 auto;
|
|
}
|
|
.groupName {
|
|
width: 100%;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
background: rgba(0, 0, 0, 0.12);
|
|
color: rgba(255, 255, 255, 0.92);
|
|
border-radius: 10px;
|
|
padding: 6px 8px;
|
|
font-weight: 900;
|
|
text-align: center;
|
|
outline: none;
|
|
min-width: 0;
|
|
}
|
|
.row__exportName {
|
|
width: 100%;
|
|
text-align: center;
|
|
font-weight: 900;
|
|
word-break: break-word;
|
|
}
|
|
.rowRemoveBtn {
|
|
padding: 6px 10px;
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(239, 68, 68, 0.28);
|
|
background: rgba(239, 68, 68, 0.12);
|
|
color: rgba(255, 255, 255, 0.92);
|
|
cursor: pointer;
|
|
font-weight: 800;
|
|
flex: 0 0 auto;
|
|
}
|
|
.rowRemoveBtn:disabled {
|
|
opacity: 0.45;
|
|
cursor: not-allowed;
|
|
}
|
|
.row__drop {
|
|
border-radius: 14px;
|
|
background: rgba(0, 0, 0, 0.18);
|
|
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__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;
|
|
}
|
|
.thumb {
|
|
width: var(--thumb-size, 80px);
|
|
height: var(--thumb-size, 80px);
|
|
border-radius: 10px;
|
|
border: 1px solid rgba(255, 255, 255, 0.14);
|
|
background: rgba(255, 255, 255, 0.06);
|
|
object-fit: cover;
|
|
}
|
|
.sidebar {
|
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
|
background: rgba(255, 255, 255, 0.04);
|
|
border-radius: 16px;
|
|
padding: 12px;
|
|
}
|
|
.sidebar__title {
|
|
font-weight: 900;
|
|
margin-bottom: 6px;
|
|
}
|
|
.sidebar__hint {
|
|
opacity: 0.78;
|
|
font-size: 13px;
|
|
margin-bottom: 10px;
|
|
}
|
|
.dropzone {
|
|
margin-top: 12px;
|
|
padding: 14px;
|
|
border-radius: 16px;
|
|
border: 1px dashed rgba(255, 255, 255, 0.18);
|
|
background: rgba(255, 255, 255, 0.03);
|
|
text-align: center;
|
|
}
|
|
.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;
|
|
}
|
|
.pool {
|
|
display: grid;
|
|
gap: 10px;
|
|
}
|
|
.poolItem {
|
|
display: grid;
|
|
grid-template-columns: var(--thumb-size, 80px) 1fr;
|
|
gap: 10px;
|
|
align-items: center;
|
|
padding: 10px;
|
|
border-radius: 14px;
|
|
border: 1px solid rgba(255, 255, 255, 0.10);
|
|
background: rgba(0, 0, 0, 0.16);
|
|
}
|
|
.poolItem__label {
|
|
font-weight: 800;
|
|
opacity: 0.9;
|
|
}
|
|
.hidden {
|
|
display: none;
|
|
}
|
|
.ghost {
|
|
opacity: 0.3;
|
|
}
|
|
.chosen {
|
|
outline: 2px solid rgba(110, 231, 183, 0.5);
|
|
border-radius: 14px;
|
|
}
|
|
@media (max-width: 980px) {
|
|
.heroCard {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.layout {
|
|
grid-template-columns: 1fr;
|
|
}
|
|
.actions {
|
|
justify-content: stretch;
|
|
}
|
|
.actions__left,
|
|
.actions__right {
|
|
width: 100%;
|
|
}
|
|
.actions__right {
|
|
justify-content: flex-end;
|
|
}
|
|
.row {
|
|
grid-template-columns: 150px 1fr;
|
|
}
|
|
.thumbComposer {
|
|
padding: 14px;
|
|
border-radius: 18px;
|
|
}
|
|
.titleInput,
|
|
.descInput {
|
|
border-radius: 16px;
|
|
}
|
|
}
|
|
</style>
|