Compare commits

...

3 Commits

7 changed files with 401 additions and 85 deletions

View File

@@ -1,5 +1,17 @@
# 의사결정 이력
## 2026-04-03 v1.4.44
- 공통 카피라이트 링크 색을 고정 민트색으로 두면 다크 모드에서는 잘 보이지만 라이트 모드에서 대비가 부족해질 수 있으므로, 테마 텍스트 색을 따라가게 하고 굵기로 링크 인지를 보완하는 편이 더 안정적이라고 판단했다.
## 2026-04-03 v1.4.43
- Vue Router에서 같은 컴포넌트가 유지된 채 `params/query`만 바뀌는 에디터 이동은 `onMounted()`만으로는 새 데이터를 다시 불러오지 못할 수 있으므로, 에디터 로딩을 라우트 값 watch 기반으로 옮기는 편이 맞다고 판단했다.
- 복사본에서 원본으로 이동하는 액션은 사용자가 편집 중이던 내용을 잃을 수 있으므로, 저장하지 않은 변경이 감지되는 경우에는 바로 이동하지 않고 확인 모달로 한 번 끊어주는 쪽이 안전하다고 정리했다.
## 2026-04-03 v1.4.42
- 홈 템플릿 목록은 관리자가 아직 수동 순서를 건드리지 않은 신규 템플릿까지 이름순으로 섞이면 “새로 만든 항목이 앞에 보인다”는 운영 기대와 어긋나므로, 수동 순서가 없는 항목은 최신 생성순을 우선하는 정렬이 맞다고 판단했다.
- 티어표 편집 조작은 드래그만으로도 충분하지만, 세밀한 이동이나 터치패드 환경에서는 클릭 선택 후 대상 셀 클릭 방식이 더 편할 수 있으므로 두 조작을 병행 지원하는 쪽으로 확장했다.
- 다만 드래그 직후 click 이벤트가 이어서 들어오면 의도치 않은 재선택이 생길 수 있으므로, 드래그 시작 시 선택을 비우고 드래그 종료 직후 짧은 클릭 잠금을 두는 방식으로 충돌을 줄였다.
## 2026-04-03 v1.4.41
- 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다.
- 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.

View File

@@ -45,7 +45,7 @@
- 좌우 레일의 주요 CTA는 스크롤되는 본문과 분리된 하단 `56px` 액션 영역에 배치한다.
- 하단 액션은 화면 바닥에 바로 붙지 않도록 푸터 내부에 추가 하단 여백을 둔다.
- 홈 화면 기준 우측 패널은 임시 정보 카드 여러 개보다 핵심 CTA 하나만 남겨, 시안처럼 단순한 보조 레일 역할을 우선 유지한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다.
- 광고 영역은 상단 헤더와 시각적으로 너무 붙지 않도록 `78px` 상단 여백을 두고, 하단 카피라이트는 중앙 정렬된 공통 footer로 표시한다. 카피라이트 링크는 다크/라이트 테마 모두에서 읽히도록 고정 민트색 대신 테마 텍스트 색과 굵기를 사용한다.
- 티어표 편집 화면
- 공통 우측 패널 대신 전용 로컬 편집 패널을 사용한다.
- 공통 `workspaceBody` 카드 컨테이너를 벗기고, 중앙 보드 영역은 메인 컬럼에, 우측 `320px` 편집 패널은 공통 셸의 세 번째 컬럼 aside에 배치한다.
@@ -184,6 +184,8 @@
- 비로그인 사용자도 뷰어 모드 우측 레일의 `공유하기` 버튼으로 현재 공유 링크를 복사할 수 있다.
- 로그인한 타인 티어표 열람자는 뷰어 모드 우측 레일에서 `내 티어표로 복사`를 사용할 수 있고, 작성자 본인은 `수정 모드로 전환`을 사용할 수 있다.
- 작성자 본인이 일반 편집 화면에서 저장된 본인 티어표를 보고 있을 때는 우측 패널의 `뷰어 모드로 보기`로 공유 화면 형태를 바로 확인할 수 있다.
- 같은 `TierEditorView` 안에서 `topicId / tierListId / preview` 라우트 값만 바뀌어도 화면이 이전 티어표 데이터에 남지 않도록, 라우트 변경마다 에디터 상태를 다시 로드한다.
- 복사한 티어표 상단의 `원본` 링크를 누르면 원본 티어표로 이동하며, 편집 모드에서 저장하지 않은 변경이 있으면 이동 전에 `저장 없이 이동` 확인 모달을 먼저 띄운다.
- 비공개 티어표라도 관리자는 편집 화면에서 완성본을 열람할 수 있다.
- 공개 티어표는 목록과 상세 화면에서 즐겨찾기 토글 및 개수를 함께 표시한다.
- 카드형 목록에서는 즐겨찾기 수/상태만 표시하고, 실제 토글은 상세 화면에서 처리한다.
@@ -193,6 +195,8 @@
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
@@ -216,6 +220,7 @@
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
## 업로드 제한 메모

View File

@@ -1,6 +1,12 @@
# 할 일 및 이슈
## 단기 확인
- `v1.4.44`에서 공통 카피라이트 `zenn` 링크를 테마 텍스트 색으로 바꿨으므로, 다크/라이트 모드 양쪽에서 하단 링크가 배경에 묻히지 않고 hover 상태도 자연스러운지 확인한다.
- `v1.4.43`에서 같은 `TierEditorView` 라우트 안에서도 `topicId / tierListId / preview`가 바뀌면 상태를 다시 불러오게 했으므로, 타인 티어표 복사 직후 화면이 내 복사본으로 바뀌는지와 상단 원본 링크 클릭 시 실제 원본 티어표 내용으로 전환되는지 확인한다.
- 편집 중 미저장 변경이 있는 상태에서 상단 원본 링크를 눌렀을 때는 경고 모달이 뜨고, `계속 편집`은 현재 화면 유지, `저장 없이 이동`은 원본으로 이동하면서 변경분을 버리는지 QA한다.
- `v1.4.42`에서 홈 템플릿 정렬을 `즐겨찾기 → 수동 순서 → 최신 생성순 → 이름순`으로 바꿨으므로, 관리자에서 아무 수동 정렬을 하지 않은 신규 템플릿이 가장 앞쪽에 보이고, 즐겨찾기/수동 고정 항목은 기존 우선순위를 유지하는지 확인한다.
- 티어표 편집기의 클릭 배치를 추가했으므로, 풀 아이템 클릭→빈 셀 클릭, 셀 아이템 클릭→다른 셀 클릭, 셀 아이템 클릭→풀 빈 영역 클릭, 같은 아이템 재클릭 선택 해제, 드래그 직후 의도치 않은 재선택 방지까지 한 번씩 QA한다.
- 클릭 배치에서 이미 아이템이 들어 있는 셀 안의 빈 영역을 눌렀을 때는 해당 셀 끝에 추가되고, 같은 셀을 다시 누르면 선택만 해제되는지 확인한다.
- `v1.4.41`에서 관리자 템플릿 기본 아이템 다중 업로드를 `100개/파일당 20MB`와 Nginx `client_max_body_size 1024m`으로 올렸으므로, 운영 NAS 앞단 리버스 프록시에도 별도 본문 크기 제한이 있으면 같은 수준으로 맞춰야 하는지 확인한다.
- 실제 QA에서는 10개 이상, 50개 이상, 100개 근처의 이미지 묶음을 한 번에 올렸을 때 브라우저/프런트 Nginx/백엔드 중 어느 단계에서도 `413`이나 업로드 실패가 나지 않는지 확인한다.
- `v1.4.40`에서 `preview=1` 공유 화면을 뷰어 모드로 정리했으므로, 비로그인/로그인한 타인/작성자 본인 세 경우에 드래그 편집이 막히고 오른쪽 레일 버튼이 각각 `공유하기`, `내 티어표로 복사`, `수정 모드로 전환` 조건대로 노출되는지 확인한다. 특히 비로그인/타인이 일반 편집 URL로 직접 들어왔을 때도 자동으로 `preview=1`로 바뀌는지 본다.

View File

@@ -1,5 +1,18 @@
# 업데이트 로그
## 2026-04-03 v1.4.44
- 오른쪽 레일 공통 카피라이트의 `zenn` 링크가 민트 단색이라 라이트 모드에서 배경과 충분히 분리되지 않을 수 있었으므로, 테마 텍스트 색 기반의 굵은 링크 스타일로 바꿔 다크/라이트 양쪽에서 읽히도록 조정했다.
## 2026-04-03 v1.4.43
- 다른 사람 티어표를 복사한 직후 URL은 복사본 ID로 바뀌었는데 화면 데이터가 기존 원본에 남아 있을 수 있었던 문제를 고치기 위해, `TierEditorView`가 같은 컴포넌트 안에서 `topicId / tierListId / preview` 라우트 값이 바뀔 때마다 편집기 상태를 다시 로드하도록 바꿨다.
- 복사한 티어표 상단의 원본 링크를 클릭했을 때도 주소만 바뀌고 화면이 그대로 남지 않도록, 원본 이동 버튼이 같은 재로딩 흐름을 타게 정리했다.
- 작성자 본인 편집 모드에서 저장하지 않은 수정 내용이 있는 상태로 원본 링크를 누르면, 현재 변경 내용이 사라진다는 확인 모달을 먼저 띄우고 `저장 없이 이동`을 선택한 경우에만 원본 티어표로 이동하도록 보강했다.
## 2026-04-03 v1.4.42
- 홈 주제 템플릿 목록 정렬에서 수동 고정 순서가 같은 항목끼리 이름순으로 다시 정렬되던 부분을 바꿔, 즐겨찾기 우선과 관리자 수동 순서를 유지하되 수동 순서가 없는 템플릿은 최신 생성순으로 먼저 보이도록 맞췄다.
- 티어표 편집기에서 아이템을 클릭으로도 옮길 수 있게 해, 아이템을 한 번 클릭하면 선택 포커스가 표시되고 원하는 티어 셀이나 아이템 풀 빈 영역을 클릭하면 해당 위치로 이동하도록 보강했다.
- 클릭 배치와 기존 드래그 배치가 충돌하지 않도록 드래그 시작 시 선택 상태를 해제하고, 드래그 직후 짧은 시간 동안 아이템 클릭 선택을 무시하는 보호를 추가했다.
## 2026-04-03 v1.4.41
- 관리자 템플릿 기본 아이템 다중 업로드 제한을 한 번에 `100개`, 파일당 `20MB`까지 받을 수 있도록 백엔드 `multer` 설정과 업로드 라우트 배열 제한을 함께 상향했다.
- 프런트 Nginx 프록시에도 `client_max_body_size 1024m`을 추가해, 여러 이미지를 한 번의 `FormData` 요청으로 올릴 때 합산 본문 크기 제한 때문에 먼저 `413`으로 막히는 상황을 줄였다.

View File

@@ -1318,12 +1318,13 @@ function reloadApp() {
}
.rightRail__footer a {
color: #00ffff;
color: var(--theme-text-strong);
font-weight: 700;
text-decoration: none;
}
.rightRail__footer a:hover {
color: #00ffff;
color: var(--theme-text);
text-decoration: underline;
}

View File

@@ -30,6 +30,9 @@ const templates = computed(() => {
const rankA = a.displayRank == null ? Number.MAX_SAFE_INTEGER : a.displayRank
const rankB = b.displayRank == null ? Number.MAX_SAFE_INTEGER : b.displayRank
if (rankA !== rankB) return rankA - rankB
if (Number(a.createdAt || 0) !== Number(b.createdAt || 0)) {
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
}
return (a.name || '').localeCompare(b.name || '', 'ko')
})
})

View File

@@ -1,5 +1,5 @@
<script setup>
import { Teleport, computed, inject, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import { Teleport, computed, inject, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import Sortable from 'sortablejs'
import * as htmlToImage from 'html-to-image'
@@ -56,8 +56,10 @@ const templateRequestDraftDescription = ref('')
const isDeleteModalOpen = ref(false)
const isGroupDeleteModalOpen = ref(false)
const isColumnDeleteModalOpen = ref(false)
const isNavigationConfirmModalOpen = ref(false)
const pendingRemoveGroupId = ref('')
const pendingRemoveColumnIndex = ref(-1)
const pendingNavigationPath = ref('')
const ownerId = ref('')
const authorName = ref('')
const authorAccountName = ref('')
@@ -74,6 +76,10 @@ const isFavorited = ref(false)
const isRequestingTemplate = ref(false)
const isDeleting = ref(false)
const poolSearchQuery = ref('')
const selectedItemId = ref('')
const recentDragFinishedAt = ref(0)
const savedEditorSnapshot = ref('')
let editorLoadToken = 0
const boardEl = ref(null)
const exportBoardEl = ref(null)
@@ -122,7 +128,7 @@ const canSwitchToEditMode = computed(() => isOwnTierList.value && hasSavedTierLi
const copiedFromLabel = computed(() => {
if (!sourceTierListId.value) return ''
const parts = []
if (sourceSnapshotTitle.value) parts.push(`원본 ${sourceSnapshotTitle.value}`)
if (sourceSnapshotTitle.value) parts.push(sourceSnapshotTitle.value)
if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value)
return parts.join(' · ') || '복사해 온 티어표'
})
@@ -278,6 +284,33 @@ function buildGroupPayload() {
}))
}
function createEditorSnapshot() {
return JSON.stringify({
title: (title.value || '').trim(),
description: description.value || '',
isPublic: !!isPublic.value,
showCharacterNames: !!showCharacterNames.value,
iconSize: Number(iconSize.value || 80),
columns: columns.value.map((column) => ({ id: column.id, name: column.name || '' })),
groups: buildGroupPayload(),
pool: pool.value.map((itemId) => {
const item = itemsById.value[itemId]
return {
id: item?.id || itemId,
src: item?.src || '',
label: item?.label || '',
origin: item?.origin || 'template',
}
}),
})
}
function syncSavedEditorSnapshot() {
savedEditorSnapshot.value = createEditorSnapshot()
}
const hasUnsavedChanges = computed(() => canEdit.value && savedEditorSnapshot.value && createEditorSnapshot() !== savedEditorSnapshot.value)
function removeItemFromGroup(groupId, columnIndex, itemId) {
if (!canEdit.value || !groupId || columnIndex == null || !itemId) return
const targetGroup = groups.value.find((group) => group.id === groupId)
@@ -287,6 +320,87 @@ function removeItemFromGroup(groupId, columnIndex, itemId) {
targetGroup.cells = nextCells
syncGroupItemIds(targetGroup)
pool.value = [itemId, ...pool.value.filter((id) => id !== itemId)]
if (selectedItemId.value === itemId) selectedItemId.value = ''
}
function shouldIgnoreItemClick() {
return Date.now() - recentDragFinishedAt.value < 180
}
function getItemLocation(itemId) {
if (!itemId) return { type: null, groupId: '', columnIndex: -1, index: -1 }
const poolIndex = pool.value.findIndex((id) => id === itemId)
if (poolIndex >= 0) {
return { type: 'pool', groupId: '', columnIndex: -1, index: poolIndex }
}
for (const group of groups.value) {
for (let columnIndex = 0; columnIndex < columns.value.length; columnIndex += 1) {
const index = getGroupCellIds(group, columnIndex).findIndex((id) => id === itemId)
if (index >= 0) {
return { type: 'group', groupId: group.id, columnIndex, index }
}
}
}
return { type: null, groupId: '', columnIndex: -1, index: -1 }
}
function detachItemById(itemId) {
if (!itemId) return
pool.value = pool.value.filter((id) => id !== itemId)
groups.value.forEach((group) => {
group.cells = (group.cells || []).map((cell) => (cell || []).filter((id) => id !== itemId))
syncGroupItemIds(group)
})
}
function selectItemByClick(itemId) {
if (!canEdit.value || !itemId || shouldIgnoreItemClick()) return
selectedItemId.value = selectedItemId.value === itemId ? '' : itemId
}
function placeSelectedItemInGroup(groupId, columnIndex) {
if (!canEdit.value || !selectedItemId.value || !groupId || !Number.isInteger(columnIndex)) return
if (shouldIgnoreItemClick()) return
const targetGroup = groups.value.find((group) => group.id === groupId)
if (!targetGroup) return
const selectedId = selectedItemId.value
const currentLocation = getItemLocation(selectedId)
const sameTarget =
currentLocation.type === 'group' &&
currentLocation.groupId === groupId &&
currentLocation.columnIndex === columnIndex
if (sameTarget) {
selectedItemId.value = ''
return
}
detachItemById(selectedId)
const nextCells = [...targetGroup.cells]
nextCells[columnIndex] = [...getGroupCellIds(targetGroup, columnIndex), selectedId]
targetGroup.cells = nextCells
syncGroupItemIds(targetGroup)
selectedItemId.value = ''
}
function moveSelectedItemToPool() {
if (!canEdit.value || !selectedItemId.value || shouldIgnoreItemClick()) return
const selectedId = selectedItemId.value
const currentLocation = getItemLocation(selectedId)
if (currentLocation.type === 'pool') {
selectedItemId.value = ''
return
}
detachItemById(selectedId)
pool.value = [selectedId, ...pool.value]
selectedItemId.value = ''
}
function setGroupDropEl(groupId, columnIndex, el) {
@@ -360,6 +474,12 @@ async function initSortables() {
draggable: '[data-item-id]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onStart: () => {
selectedItemId.value = ''
},
onEnd: () => {
recentDragFinishedAt.value = Date.now()
},
onSort: () => normalizeSort(poolEl.value),
onAdd: () => normalizeSort(poolEl.value),
})
@@ -371,6 +491,12 @@ async function initSortables() {
draggable: '[data-item-id]',
ghostClass: 'ghost',
chosenClass: 'chosen',
onStart: () => {
selectedItemId.value = ''
},
onEnd: () => {
recentDragFinishedAt.value = Date.now()
},
onSort: () => normalizeSort(el),
onAdd: () => normalizeSort(el),
})
@@ -732,6 +858,8 @@ async function persistTierList({ showModal = false } = {}) {
authorAccountName.value = res.tierList?.authorAccountName || authorAccountName.value
favoriteCount.value = Number(res.tierList?.favoriteCount || favoriteCount.value || 0)
isFavorited.value = !!res.tierList?.isFavorited
await nextTick()
syncSavedEditorSnapshot()
if (showModal) isSaveModalOpen.value = true
return { ...res, savedTierListId }
}
@@ -784,6 +912,34 @@ function openEditMode() {
router.push(editorPath(templateId.value, persistedTierListId.value || tierListId.value))
}
function closeNavigationConfirmModal() {
isNavigationConfirmModalOpen.value = false
pendingNavigationPath.value = ''
}
function requestEditorNavigation(path) {
if (!path) return
if (hasUnsavedChanges.value) {
pendingNavigationPath.value = path
isNavigationConfirmModalOpen.value = true
return
}
router.push(path)
}
function confirmNavigationDiscard() {
const nextPath = pendingNavigationPath.value
closeNavigationConfirmModal()
if (!nextPath) return
savedEditorSnapshot.value = ''
router.push(nextPath)
}
function openSourceTierList() {
if (!sourceTierListId.value) return
requestEditorNavigation(editorPath(templateId.value, sourceTierListId.value))
}
function closeSaveModal() {
isSaveModalOpen.value = false
}
@@ -923,88 +1079,156 @@ async function requestTemplate(type) {
}
}
onMounted(() => {
;(async () => {
await auth.refresh()
authorName.value = (auth.user?.nickname || '').trim()
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
function resetEditorStateForRoute() {
destroySortables()
if (thumbnailPreviewUrl.value) {
URL.revokeObjectURL(thumbnailPreviewUrl.value)
thumbnailPreviewUrl.value = ''
}
columns.value = [{ id: 'col-1', name: '' }]
groups.value = normalizeLoadedGroups([], columns.value)
pool.value = []
itemsById.value = {}
title.value = ''
persistedTierListId.value = ''
thumbnailSrc.value = ''
pendingThumbnailFile.value = null
description.value = ''
isPublic.value = true
showCharacterNames.value = false
isSaveModalOpen.value = false
isTemplateRequestModalOpen.value = false
isTemplateUpdateModalOpen.value = false
isDeleteModalOpen.value = false
isGroupDeleteModalOpen.value = false
isColumnDeleteModalOpen.value = false
isNavigationConfirmModalOpen.value = false
pendingRemoveGroupId.value = ''
pendingRemoveColumnIndex.value = -1
pendingNavigationPath.value = ''
ownerId.value = ''
authorName.value = ''
authorAccountName.value = ''
updatedAt.value = 0
sourceTierListId.value = ''
sourceSnapshotTitle.value = ''
sourceSnapshotAuthor.value = ''
isDragActive.value = false
isThumbnailDragActive.value = false
iconSize.value = 80
isFavoriteBusy.value = false
favoriteCount.value = 0
isFavorited.value = false
isRequestingTemplate.value = false
isDeleting.value = false
poolSearchQuery.value = ''
selectedItemId.value = ''
recentDragFinishedAt.value = 0
savedEditorSnapshot.value = ''
resetTemplateRequestDrafts()
}
if (isNewTierList.value && !auth.user) {
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
async function loadEditorState() {
const loadToken = ++editorLoadToken
resetEditorStateForRoute()
await auth.refresh()
if (loadToken !== editorLoadToken) return
let currentTemplateItems = []
authorName.value = (auth.user?.nickname || '').trim()
authorAccountName.value = ((auth.user?.email || '').trim().split('@')[0] || '').trim()
if (isNewTierList.value && !auth.user) {
router.replace(loginPath(editorNewPath(templateId.value)))
return
}
let currentTemplateItems = []
try {
const topicRes = await api.getTopic(templateId.value)
if (loadToken !== editorLoadToken) return
templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'template',
}))
currentTemplateItems = base
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
if (loadToken !== editorLoadToken) return
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
}
if (tierListId.value && tierListId.value !== 'new') {
try {
const topicRes = await api.getTopic(templateId.value)
templateName.value = topicRes.topic?.name || templateId.value
const base = (topicRes.items || []).map((img) => ({
id: img.id,
src: img.src,
label: img.label,
origin: 'template',
}))
currentTemplateItems = base
const map = {}
base.forEach((it) => (map[it.id] = it))
itemsById.value = map
pool.value = base.map((it) => it.id)
} catch (e) {
error.value = '기본 템플릿 이미지를 불러오지 못했어요.'
}
const res = await api.getTierList(tierListId.value)
if (loadToken !== editorLoadToken) return
if (tierListId.value && tierListId.value !== 'new') {
try {
const res = await api.getTierList(tierListId.value)
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
sourceTierListId.value = t.sourceTierListId || ''
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
const t = res.tierList
ownerId.value = t.authorId
persistedTierListId.value = t.id || ''
title.value = t.title
thumbnailSrc.value = t.thumbnailSrc || ''
description.value = t.description || ''
isPublic.value = !!t.isPublic
showCharacterNames.value = !!t.showCharacterNames
iconSize.value = Number(t.iconSize || 80)
authorName.value = t.authorName || ''
authorAccountName.value = t.authorAccountName || ''
updatedAt.value = Number(t.updatedAt || 0)
sourceTierListId.value = t.sourceTierListId || ''
sourceSnapshotTitle.value = t.sourceSnapshotTitle || ''
sourceSnapshotAuthor.value = t.sourceSnapshotAuthor || ''
favoriteCount.value = Number(t.favoriteCount || 0)
isFavorited.value = !!t.isFavorited
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
return
}
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
const grouped = new Set()
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
const merged = mergeLatestTemplateItemsIntoPool(
map,
Object.keys(map).filter((id) => !grouped.has(id)),
currentTemplateItems,
grouped,
canEdit.value && !previewMode.value
)
itemsById.value = merged.nextMap
pool.value = merged.nextPoolIds
} catch (e) {
error.value = '티어표를 불러오지 못했어요.'
if (!previewMode.value && !canEdit.value) {
router.replace(editorPath(templateId.value, t.id, { preview: true }))
return
}
}
await nextTick()
if (canEdit.value) {
await initSortables()
columns.value = normalizeLoadedColumns(t.groups)
groups.value = normalizeLoadedGroups(t.groups, columns.value)
const map = {}
;(t.pool || []).forEach((it) => (map[it.id] = it))
const grouped = new Set()
groups.value.forEach((group) => group.itemIds.forEach((id) => grouped.add(id)))
const merged = mergeLatestTemplateItemsIntoPool(
map,
Object.keys(map).filter((id) => !grouped.has(id)),
currentTemplateItems,
grouped,
canEdit.value && !previewMode.value
)
itemsById.value = merged.nextMap
pool.value = merged.nextPoolIds
} catch (e) {
if (loadToken !== editorLoadToken) return
error.value = '티어표를 불러오지 못했어요.'
}
})()
})
}
await nextTick()
if (loadToken !== editorLoadToken) return
syncSavedEditorSnapshot()
if (canEdit.value) {
await initSortables()
}
}
watch(
() => [route.params.topicId, route.params.tierListId, route.query.preview],
() => {
loadEditorState()
},
{ immediate: true }
)
onUnmounted(() => {
if (thumbnailPreviewUrl.value) URL.revokeObjectURL(thumbnailPreviewUrl.value)
@@ -1096,6 +1320,19 @@ onUnmounted(() => {
</div>
</div>
<div v-if="isNavigationConfirmModalOpen" class="modalOverlay" @click.self="closeNavigationConfirmModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="navigationConfirmTitle">
<div id="navigationConfirmTitle" class="modalCard__title">원본 티어표로 이동</div>
<div class="modalCard__desc">
아직 저장하지 않은 수정 내용이 있어요. 이대로 이동하면 현재 변경 내용은 사라집니다.
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" type="button" @click="closeNavigationConfirmModal">계속 편집</button>
<button class="btn btn--danger" type="button" @click="confirmNavigationDiscard">저장 없이 이동</button>
</div>
</div>
</div>
<div v-if="isTemplateRequestModalOpen" class="modalOverlay" @click.self="closeTemplateRequestModal">
<div class="modalCard" role="dialog" aria-modal="true" aria-labelledby="templateRequestTitle">
<div id="templateRequestTitle" class="modalCard__title">템플릿 등록 요청</div>
@@ -1213,8 +1450,8 @@ onUnmounted(() => {
</template>
</div>
<div v-if="sourceTierListId" class="editorMain__sourceNote">
<span>복사</span>
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
<span></span>
<button class="editorMain__sourceLink" type="button" @click="openSourceTierList">{{ copiedFromLabel }}</button>
</div>
</div>
</section>
@@ -1294,10 +1531,19 @@ onUnmounted(() => {
:data-group-id="g.id"
:data-column-index="columnIndex"
:ref="(el) => setGroupDropEl(g.id, columnIndex, el)"
:class="{ 'row__drop--clickTarget': canEdit && !!selectedItemId }"
@click="placeSelectedItemInGroup(g.id, columnIndex)"
>
<div v-if="columns.length > 1" class="row__columnBadge">{{ column.name || ' ' + (columnIndex + 1) }}</div>
<div v-if="!isExporting" class="row__empty" v-show="getGroupCellIds(g, columnIndex).length === 0">여기로 드래그해서 배치</div>
<div v-for="id in getGroupCellIds(g, columnIndex)" :key="id" class="cell" :data-item-id="id">
<div
v-for="id in getGroupCellIds(g, columnIndex)"
:key="id"
class="cell"
:class="{ 'cell--selected': selectedItemId === id }"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
<button
@@ -1351,7 +1597,7 @@ onUnmounted(() => {
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
</div>
<div class="sidebar__hint">
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
</div>
<input
v-model="poolSearchQuery"
@@ -1360,13 +1606,24 @@ onUnmounted(() => {
maxlength="60"
placeholder="아이템 이름 검색"
/>
<div ref="poolEl" class="pool" data-list-type="pool">
<div
ref="poolEl"
class="pool"
:class="{ 'pool--clickTarget': canEdit && !!selectedItemId }"
data-list-type="pool"
@click.self="moveSelectedItemToPool"
>
<div
v-for="id in pool"
:key="id"
class="poolItem"
:class="{ 'poolItem--readonly': !canEdit, 'poolItem--hidden': !isPoolItemVisible(id) }"
:class="{
'poolItem--readonly': !canEdit,
'poolItem--hidden': !isPoolItemVisible(id),
'poolItem--selected': selectedItemId === id,
}"
:data-item-id="id"
@click.stop="selectItemByClick(id)"
>
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
@@ -2214,6 +2471,11 @@ onUnmounted(() => {
overflow: hidden;
position: relative;
}
.row__drop--clickTarget {
cursor: copy;
border-color: rgba(96, 165, 250, 0.42);
box-shadow: inset 0 0 0 1px rgba(96, 165, 250, 0.12);
}
.row__empty {
opacity: 0.6;
font-size: 13px;
@@ -2227,6 +2489,11 @@ onUnmounted(() => {
display: inline-flex;
flex: 0 0 auto;
position: relative;
cursor: pointer;
border-radius: 12px;
}
.cell--selected {
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
}
.itemNameOverlay {
position: absolute;
@@ -2606,6 +2873,9 @@ onUnmounted(() => {
gap: 10px;
align-content: start;
}
.pool--clickTarget {
cursor: copy;
}
.poolItem {
min-width: 0;
display: grid;
@@ -2617,11 +2887,17 @@ onUnmounted(() => {
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.10);
background: var(--theme-pill-bg);
cursor: pointer;
}
.poolItem--readonly {
cursor: default;
opacity: 0.58;
filter: grayscale(0.25) brightness(0.78);
}
.poolItem--selected {
border-color: rgba(96, 165, 250, 0.58);
box-shadow: 0 0 0 2px rgba(96, 165, 250, 0.92), 0 0 0 6px rgba(96, 165, 250, 0.18);
}
.poolItem .thumb {
width: 100%;
max-width: var(--thumb-size, 80px);