diff --git a/docs/history.md b/docs/history.md index 5f33847..3d92881 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-04-02 v1.3.62 +- 커스텀 이미지가 많은 상태에서 저장할 때 사용자 체감 순서가 흔들리는 것은 업로드 성공보다 더 직접적인 UX 문제이므로, 내부 객체 키 순서가 아니라 현재 화면 배치 순서를 저장 기준으로 삼는 편이 맞다고 정리했다. +- 템플릿 요청이 저장본에서만 가능하다면 삭제도 같은 기준을 따르는 편이 흐름상 자연스러우므로, 저장되지 않은 초안에는 삭제 액션을 노출하지 않는 쪽으로 판단했다. + ## 2026-04-02 v1.3.61 - 업로드 드롭존은 기능만 같고 생김새가 다르면 운영자와 사용자 모두 맥락 전환 비용이 생기므로, 관리자와 에디터에서 같은 아이콘·점선 보더·버튼 문법으로 읽히게 맞추는 편이 낫다고 정리했다. - 썸네일 교체 영역은 일반 입력 필드처럼 보이면 클릭 가능성이 떨어지므로, 이미지 미리보기 위에서도 업로드 박스라는 인상이 유지되게 밝은 배경과 아이콘을 함께 쓰는 쪽으로 판단했다. diff --git a/docs/todo.md b/docs/todo.md index 0547164..b64e7fd 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -4,6 +4,7 @@ - 관리자 계정으로 `/admin/...`을 직접 새로고침했을 때 홈으로 튕기지 않고 그대로 유지되는지, 실제 세션이 살아 있는 브라우저와 만료된 브라우저 각각에서 한 번 더 QA한다. - 티어표 만들기 화면의 보드 드롭존은 점선/높이/중앙 정렬로 존재감이 커졌으므로, 데스크톱과 모바일에서 파일 선택 버튼과 안내 문구 밀도를 한 번 더 QA한다. - 관리자 썸네일 드롭존과 기본 아이템 추가 드롭존은 에디터 드롭존과 시각 문법을 맞췄으므로, 라이트모드와 좁은 화면에서 아이콘 대비와 배경 밝기가 과하지 않은지 한 번 더 QA한다. +- 커스텀 이미지가 많은 티어표를 저장할 때 커스텀 이름 정리 목록과 미배치 아이템 영역 순서가 실제로 안정적으로 유지되는지, 10장 이상 업로드한 상태로 한 번 더 QA한다. ## 중기 개선 - 신규 템플릿 요청은 현재 `첫 생성 1회 후 요청과 게임을 연결해 재사용` 단계까지 정리했으므로, 다음 단계에서는 정말로 게임을 DB에 만들기 전까지 임시 작업 상태로 유지할지 여부를 운영 흐름 기준으로 결정한다. diff --git a/docs/update.md b/docs/update.md index 4db47be..cc1e058 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 로그 +## 2026-04-02 v1.3.62 +- 티어표 저장과 템플릿 요청 전 커스텀 이미지 업로드에서는 더 이상 `itemsById` 객체 키 순서에 기대지 않고, 실제 화면에 보이는 `아이템 영역 + 보드 배치 순서` 기준으로 아이템 배열을 만들도록 바꿔 저장 중 이미지 목록이 흔들리던 현상을 줄임. +- 따라서 커스텀 아이템 이름 정리 목록, 저장 payload, 템플릿 요청 payload 모두 같은 순서 기준을 공유하게 되어, 이미지를 여러 장 올린 뒤 저장해도 사용자가 보고 있던 흐름이 덜 흔들리도록 정리함. +- 티어표 삭제 버튼은 이제 템플릿 요청과 같은 기준으로 `저장된 티어표`에서만 노출되며, 실제 삭제도 저장본 ID가 있을 때만 동작하도록 맞춰 저장 전 초안 상태의 어색한 삭제 액션을 제거함. + ## 2026-04-02 v1.3.61 - 관리자 게임 관리의 썸네일 드롭존, 관리자 기본 아이템 추가 드롭존, 티어표 에디터의 커스텀 이미지 드롭존에 `add_photo_alternate` 아이콘을 넣어 업로드 영역임을 더 빠르게 인식할 수 있게 정리함. - 관리자와 에디터 드롭존은 점선 보더 굵기, 라운드, 밝은 배경 톤, 활성화 상태 색 변화, 파일 선택 버튼 크기를 같은 계열로 맞춰 서로 다른 화면에서도 같은 업로드 컴포넌트처럼 읽히도록 통일함. diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 600cb89..fe4a623 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -119,10 +119,7 @@ const copiedFromLabel = computed(() => { if (sourceSnapshotAuthor.value) parts.push(sourceSnapshotAuthor.value) return parts.join(' · ') || '복사해 온 티어표' }) -const customItems = computed(() => - Object.values(itemsById.value) - .filter((item) => item?.origin === 'custom') -) +const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom')) const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new'))) const canRequestTemplateCreate = computed( () => canEdit.value && hasSavedTierList.value && gameId.value === 'freeform' && customItems.value.length > 0 @@ -166,6 +163,29 @@ function formatExportDate(ts) { }) } +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 setIconSize(nextSize) { iconSize.value = nextSize } @@ -655,7 +675,7 @@ function buildPayload(existingId) { sourceSnapshotTitle: sourceSnapshotTitle.value || '', sourceSnapshotAuthor: sourceSnapshotAuthor.value || '', groups: buildGroupPayload(), - pool: Object.values(itemsById.value), + pool: getOrderedItems(), } } @@ -722,6 +742,7 @@ function closeTemplateUpdateModal() { } function openDeleteModal() { + if (!hasSavedTierList.value) return isDeleteModalOpen.value = true } @@ -730,11 +751,12 @@ function closeDeleteModal() { } async function confirmDeleteTierList() { - if (!canEdit.value || isNewTierList.value || isDeleting.value) return + 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(tierListId.value) + await api.deleteTierList(currentTierListId) closeDeleteModal() toast.success('티어표를 삭제했어요.') router.push(gameId.value === 'freeform' ? '/me' : `/games/${gameId.value}`) @@ -792,7 +814,7 @@ async function requestTemplate(type) { isPublic: !!isPublic.value, showCharacterNames: !!showCharacterNames.value, groups: buildGroupPayload(), - boardItems: Object.values(itemsById.value), + boardItems: getOrderedItems(), }) if (type === 'create') closeTemplateRequestModal() @@ -1185,10 +1207,10 @@ onUnmounted(() => { @dragleave="onDragLeave" @drop.prevent="onDropFiles" > +