Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 04e9a0420a |
@@ -1,5 +1,10 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-04-03 v1.4.42
|
||||
- 홈 템플릿 목록은 관리자가 아직 수동 순서를 건드리지 않은 신규 템플릿까지 이름순으로 섞이면 “새로 만든 항목이 앞에 보인다”는 운영 기대와 어긋나므로, 수동 순서가 없는 항목은 최신 생성순을 우선하는 정렬이 맞다고 판단했다.
|
||||
- 티어표 편집 조작은 드래그만으로도 충분하지만, 세밀한 이동이나 터치패드 환경에서는 클릭 선택 후 대상 셀 클릭 방식이 더 편할 수 있으므로 두 조작을 병행 지원하는 쪽으로 확장했다.
|
||||
- 다만 드래그 직후 click 이벤트가 이어서 들어오면 의도치 않은 재선택이 생길 수 있으므로, 드래그 시작 시 선택을 비우고 드래그 종료 직후 짧은 클릭 잠금을 두는 방식으로 충돌을 줄였다.
|
||||
|
||||
## 2026-04-03 v1.4.41
|
||||
- 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다.
|
||||
- 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다.
|
||||
|
||||
@@ -193,6 +193,8 @@
|
||||
- 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다.
|
||||
- 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다.
|
||||
- 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다.
|
||||
- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다.
|
||||
- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다.
|
||||
- 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다.
|
||||
- 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다.
|
||||
- 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다.
|
||||
@@ -216,6 +218,7 @@
|
||||
- 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다.
|
||||
- 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다.
|
||||
- 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다.
|
||||
- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다.
|
||||
- `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다.
|
||||
|
||||
## 업로드 제한 메모
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
# 할 일 및 이슈
|
||||
|
||||
## 단기 확인
|
||||
- `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`로 바뀌는지 본다.
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 2026-04-03 v1.4.42
|
||||
- 홈 주제 템플릿 목록 정렬에서 수동 고정 순서가 같은 항목끼리 이름순으로 다시 정렬되던 부분을 바꿔, 즐겨찾기 우선과 관리자 수동 순서를 유지하되 수동 순서가 없는 템플릿은 최신 생성순으로 먼저 보이도록 맞췄다.
|
||||
- 티어표 편집기에서 아이템을 클릭으로도 옮길 수 있게 해, 아이템을 한 번 클릭하면 선택 포커스가 표시되고 원하는 티어 셀이나 아이템 풀 빈 영역을 클릭하면 해당 위치로 이동하도록 보강했다.
|
||||
- 클릭 배치와 기존 드래그 배치가 충돌하지 않도록 드래그 시작 시 선택 상태를 해제하고, 드래그 직후 짧은 시간 동안 아이템 클릭 선택을 무시하는 보호를 추가했다.
|
||||
|
||||
## 2026-04-03 v1.4.41
|
||||
- 관리자 템플릿 기본 아이템 다중 업로드 제한을 한 번에 `100개`, 파일당 `20MB`까지 받을 수 있도록 백엔드 `multer` 설정과 업로드 라우트 배열 제한을 함께 상향했다.
|
||||
- 프런트 Nginx 프록시에도 `client_max_body_size 1024m`을 추가해, 여러 이미지를 한 번의 `FormData` 요청으로 올릴 때 합산 본문 크기 제한 때문에 먼저 `413`으로 막히는 상황을 줄였다.
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -74,6 +74,8 @@ const isFavorited = ref(false)
|
||||
const isRequestingTemplate = ref(false)
|
||||
const isDeleting = ref(false)
|
||||
const poolSearchQuery = ref('')
|
||||
const selectedItemId = ref('')
|
||||
const recentDragFinishedAt = ref(0)
|
||||
|
||||
const boardEl = ref(null)
|
||||
const exportBoardEl = ref(null)
|
||||
@@ -122,7 +124,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(' · ') || '복사해 온 티어표'
|
||||
})
|
||||
@@ -287,6 +289,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 +443,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 +460,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),
|
||||
})
|
||||
@@ -1213,7 +1308,7 @@ onUnmounted(() => {
|
||||
</template>
|
||||
</div>
|
||||
<div v-if="sourceTierListId" class="editorMain__sourceNote">
|
||||
<span>복사본</span>
|
||||
<span>원본</span>
|
||||
<button class="editorMain__sourceLink" type="button" @click="router.push(editorPath(templateId, sourceTierListId))">{{ copiedFromLabel }}</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1294,10 +1389,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 +1455,7 @@ onUnmounted(() => {
|
||||
<div class="sidebar__count">{{ visiblePoolCount }} / {{ pool.length }}</div>
|
||||
</div>
|
||||
<div class="sidebar__hint">
|
||||
{{ canEdit ? '등록된 아이템 리스트입니다. 드래그해서 표에 넣을 수 있습니다.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
{{ canEdit ? '아이템을 드래그하거나, 클릭으로 선택한 뒤 원하는 셀/풀을 클릭해서 옮길 수 있어요.' : '공개 티어표는 보기 전용입니다.' }}
|
||||
</div>
|
||||
<input
|
||||
v-model="poolSearchQuery"
|
||||
@@ -1360,13 +1464,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 +2329,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 +2347,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 +2731,9 @@ onUnmounted(() => {
|
||||
gap: 10px;
|
||||
align-content: start;
|
||||
}
|
||||
.pool--clickTarget {
|
||||
cursor: copy;
|
||||
}
|
||||
.poolItem {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
@@ -2617,11 +2745,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);
|
||||
|
||||
Reference in New Issue
Block a user