|
|
|
|
@@ -33,6 +33,9 @@ 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 boardEl = ref(null)
|
|
|
|
|
@@ -47,6 +50,47 @@ const dropSortables = ref([])
|
|
|
|
|
|
|
|
|
|
const isNewTierList = computed(() => tierListId.value === 'new')
|
|
|
|
|
const canEdit = computed(() => !!auth.user && (!ownerId.value || ownerId.value === auth.user.id))
|
|
|
|
|
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 `이름 없음 ${formatTitleDate(fallbackTimestamp.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 setGroupDropEl(groupId, el) {
|
|
|
|
|
if (!el) {
|
|
|
|
|
@@ -225,7 +269,7 @@ async function downloadImage() {
|
|
|
|
|
const url = URL.createObjectURL(blob)
|
|
|
|
|
const a = document.createElement('a')
|
|
|
|
|
a.href = url
|
|
|
|
|
a.download = `${(title.value || gameName.value || 'tierlist').trim()}.png`
|
|
|
|
|
a.download = `${effectiveTitle.value.trim()}.png`
|
|
|
|
|
document.body.appendChild(a)
|
|
|
|
|
a.click()
|
|
|
|
|
a.remove()
|
|
|
|
|
@@ -273,7 +317,7 @@ async function uploadPendingCustomItems() {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildPayload(existingId) {
|
|
|
|
|
const finalTitle = (title.value || '').trim() || `${(gameName.value || gameId.value).trim()} 티어표`
|
|
|
|
|
const finalTitle = effectiveTitle.value
|
|
|
|
|
return {
|
|
|
|
|
id: existingId || undefined,
|
|
|
|
|
gameId: gameId.value,
|
|
|
|
|
@@ -293,6 +337,9 @@ async function save() {
|
|
|
|
|
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 = '저장 실패: 로그인 상태인지 확인해주세요.'
|
|
|
|
|
@@ -321,6 +368,8 @@ async function removeTierList() {
|
|
|
|
|
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`)
|
|
|
|
|
@@ -352,6 +401,9 @@ onMounted(() => {
|
|
|
|
|
title.value = t.title
|
|
|
|
|
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))
|
|
|
|
|
@@ -381,6 +433,7 @@ onUnmounted(() => {
|
|
|
|
|
<div class="head__meta">
|
|
|
|
|
<div class="kicker">{{ gameName || gameId }}</div>
|
|
|
|
|
<input v-model="title" class="titleInput" placeholder="티어표 이름을 입력하세요" :readonly="!canEdit" />
|
|
|
|
|
<div v-if="untitledWarning" class="titleNotice">{{ untitledWarning }}</div>
|
|
|
|
|
<input
|
|
|
|
|
v-model="description"
|
|
|
|
|
class="descInput"
|
|
|
|
|
@@ -429,7 +482,7 @@ onUnmounted(() => {
|
|
|
|
|
<button class="btn btn--ghost" @click="addGroup">티어 추가</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div ref="exportBoardEl" class="exportBoard" :class="{ 'exportBoard--active': isExporting }">
|
|
|
|
|
<div v-if="isExporting" class="exportBoard__title">{{ title || gameName || gameId }}</div>
|
|
|
|
|
<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">
|
|
|
|
|
@@ -449,13 +502,17 @@ onUnmounted(() => {
|
|
|
|
|
:data-group-id="g.id"
|
|
|
|
|
:ref="(el) => setGroupDropEl(g.id, el)"
|
|
|
|
|
>
|
|
|
|
|
<div class="row__empty" v-show="g.itemIds.length === 0">여기로 드래그해서 배치</div>
|
|
|
|
|
<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>
|
|
|
|
|
|
|
|
|
|
@@ -528,6 +585,11 @@ onUnmounted(() => {
|
|
|
|
|
opacity: 0.78;
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
}
|
|
|
|
|
.titleNotice {
|
|
|
|
|
font-size: 13px;
|
|
|
|
|
line-height: 1.5;
|
|
|
|
|
color: rgba(251, 191, 36, 0.94);
|
|
|
|
|
}
|
|
|
|
|
.actions {
|
|
|
|
|
display: flex;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
@@ -667,8 +729,11 @@ onUnmounted(() => {
|
|
|
|
|
.exportBoard--active {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 12px;
|
|
|
|
|
padding: 28px;
|
|
|
|
|
border-radius: 24px;
|
|
|
|
|
width: 1600px;
|
|
|
|
|
max-width: none;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
padding: 48px 56px;
|
|
|
|
|
border-radius: 28px;
|
|
|
|
|
background:
|
|
|
|
|
radial-gradient(circle at top, rgba(96, 165, 250, 0.14), transparent 38%),
|
|
|
|
|
rgba(11, 18, 32, 0.98);
|
|
|
|
|
@@ -686,6 +751,16 @@ onUnmounted(() => {
|
|
|
|
|
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;
|
|
|
|
|
|