diff --git a/docs/history.md b/docs/history.md index 167b988..6e8f8c0 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,10 @@ # 의사결정 이력 +## 2026-04-03 v1.4.42 +- 홈 템플릿 목록은 관리자가 아직 수동 순서를 건드리지 않은 신규 템플릿까지 이름순으로 섞이면 “새로 만든 항목이 앞에 보인다”는 운영 기대와 어긋나므로, 수동 순서가 없는 항목은 최신 생성순을 우선하는 정렬이 맞다고 판단했다. +- 티어표 편집 조작은 드래그만으로도 충분하지만, 세밀한 이동이나 터치패드 환경에서는 클릭 선택 후 대상 셀 클릭 방식이 더 편할 수 있으므로 두 조작을 병행 지원하는 쪽으로 확장했다. +- 다만 드래그 직후 click 이벤트가 이어서 들어오면 의도치 않은 재선택이 생길 수 있으므로, 드래그 시작 시 선택을 비우고 드래그 종료 직후 짧은 클릭 잠금을 두는 방식으로 충돌을 줄였다. + ## 2026-04-03 v1.4.41 - 관리자 기본 아이템 업로드는 운영자가 한 번에 많은 캐릭터 이미지를 정리하는 작업이 잦으므로, 서버 개별 파일 제한뿐 아니라 한 요청당 업로드 개수와 프록시 본문 크기 제한도 같이 넉넉하게 올려두는 편이 맞다고 판단했다. - 다중 업로드가 프런트에서 한 번의 `FormData` 요청으로 묶여 나가는 구조라면, 백엔드 `multer`만 올리고 Nginx `client_max_body_size`를 그대로 두면 병목이 남을 수 있으므로 프런트 프록시 제한도 함께 상향하는 쪽으로 정리했다. diff --git a/docs/spec.md b/docs/spec.md index 71e88ef..3b86adf 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -193,6 +193,8 @@ - 사용자가 직접 추가한 커스텀 아이템 이름은 편집 화면 우측 목록에서 정리할 수 있고, 저장 시 원본 커스텀 아이템 라벨과 함께 동기화된다. - 티어 행은 기본 5단으로 시작하지만, 사용자가 직접 추가하거나 제거할 수 있다. - 티어 행에 넣은 아이템은 작은 제거 버튼으로 다시 우측 아이템 풀로 되돌릴 수 있다. +- 편집 가능한 티어표에서는 아이템을 드래그로 옮길 수 있고, 아이템을 클릭해 선택한 뒤 원하는 셀이나 아이템 풀 빈 영역을 클릭하는 방식으로도 위치를 바꿀 수 있다. +- 클릭 배치 모드에서 선택된 아이템은 파란 포커스 테두리로 표시하며, 드래그를 시작하면 선택 상태를 해제하고 드래그 직후 짧은 클릭 입력은 무시해 드래그/클릭 조작이 섞이지 않게 한다. - 기존 저장 티어표/복사본을 수정 가능한 상태로 다시 열 때는 저장본의 그룹/풀을 먼저 복원하고, 이후 현재 템플릿에 새로 추가된 기본 아이템만 미사용 풀 끝에 병합한다. - 관리자 템플릿에서 삭제된 기본 아이템이라도 이미 저장된 티어표의 그룹/풀에 포함되어 있던 항목은 자동 제거하지 않고 그대로 보존한다. - 신규 티어표의 공개 여부 기본값은 `ON`이며, 기존 티어표는 편집 화면과 `내 티어표` 목록에서 직접 삭제할 수 있다. @@ -216,6 +218,7 @@ - 저장/삭제/가져오기 같은 사용자 행동 피드백은 전역 우측 상단 토스트로 표시한다. - 전역 토스트는 동일 메시지/타입이 연속 발생하면 하나로 합쳐 카운트를 올리고, 종료 시 짧은 페이드아웃 애니메이션을 사용한다. - 홈 게임 목록은 관리자 상단 고정 순서가 있으면 그 순서를 먼저 적용하고, 그 외 게임은 최근 생성순으로 뒤에 이어진다. +- 홈 주제 템플릿 목록의 실제 정렬 우선순위는 `즐겨찾기 여부 → 관리자 수동 순서(displayRank) → 최신 생성순(createdAt DESC) → 이름순`이다. - `커스텀 티어표 만들기`는 카드가 아니라 홈 우측 상단 버튼으로 진입한다. ## 업로드 제한 메모 diff --git a/docs/todo.md b/docs/todo.md index d87dafc..f3aabab 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -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`로 바뀌는지 본다. diff --git a/docs/update.md b/docs/update.md index a1fff00..4fbc5d7 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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`으로 막히는 상황을 줄였다. diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index 895049b..cf54e62 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -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') }) }) diff --git a/frontend/src/views/TierEditorView.vue b/frontend/src/views/TierEditorView.vue index 3f1b47e..88f225c 100644 --- a/frontend/src/views/TierEditorView.vue +++ b/frontend/src/views/TierEditorView.vue @@ -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(() => {
- 복사본 + 원본
@@ -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)" >
{{ column.name || '열 ' + (columnIndex + 1) }}
여기로 드래그해서 배치
-
+
{{ itemsById[id]?.label || id }}
{ maxlength="60" placeholder="아이템 이름 검색" /> -
+
{{ itemsById[id]?.label || id }}
@@ -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);