Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f98524390b | |||
| da37fe9fc9 | |||
| d09cd7e508 |
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.69
|
||||||
|
- 아이템 검색 실패가 라벨 누락이나 이벤트 문제처럼 보일 수도 있지만, 코드상 필터링 조건 자체는 단순했으므로 한글 입력/저장 문자열의 유니코드 정규형 차이까지 먼저 흡수하는 편이 더 안전하다고 판단했다.
|
||||||
|
- 검색 시점에만 임시 보정하는 것보다, 검색어와 저장 라벨 비교를 같은 정규화 함수로 통일하고 커스텀 파일명 기반 기본 라벨 생성도 `NFC`로 맞춰 이후 신규 업로드 항목까지 같은 규칙을 타게 정리했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.68
|
||||||
|
- 우클릭 복제 UX는 카드 영역과 썸네일 이미지 중 어디를 눌러도 같은 동작이어야 하므로, 개별 카드의 버블링 이벤트만 믿기보다 전역 캡처 단계에서 아이템 우클릭을 먼저 가로채는 방식이 더 안전하다고 판단했다.
|
||||||
|
- 편집기에서는 아이템 이미지를 브라우저 기본 이미지처럼 드래그하거나 저장 메뉴로 여는 것보다 보드 조작이 우선이므로, 썸네일 이미지의 기본 드래그도 명시적으로 꺼두는 편이 맞다고 정리했다.
|
||||||
|
|
||||||
## 2026-04-03 v1.4.67
|
## 2026-04-03 v1.4.67
|
||||||
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
|
- 이미지 최적화는 해시 기반 중복 재사용을 하기 때문에, 프로필 아바타로 올린 이미지가 우연히 템플릿/사용자 아이템과 같은 `src`를 공유할 수 있다. 이때 자산 카드 쪽을 무조건 숨기면 “실제로는 프로필 이미지로 쓰이는데 관리자 필터에서 안 보이는 상태”가 생기므로, 아바타/썸네일 참조가 있는 `src`는 자산 카드도 유지하는 편이 맞다고 판단했다.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
# 할 일 및 이슈
|
# 할 일 및 이슈
|
||||||
|
|
||||||
## 단기 확인
|
## 단기 확인
|
||||||
|
- `v1.4.68`에서 아이템 우클릭 처리를 `window` 캡처 단계로 보강했으므로, 보드에 배치된 아이템/미사용 풀 아이템/아이템 썸네일 이미지 위에서 각각 우클릭했을 때 브라우저 기본 메뉴 대신 `아이템 복제` 메뉴가 바로 뜨는지 QA한다.
|
||||||
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
- `v1.4.67`에서 같은 `src`가 프로필 아바타와 템플릿/사용자 아이템으로 동시에 쓰여도 자산 카드를 유지하도록 바꿨으므로, 운영 관리자 화면의 `전체 이미지`와 `프로필 이미지` 필터에서 실제 아바타가 보이고 상세 모달의 공유 참조 목록도 자연스럽게 읽히는지 QA한다.
|
||||||
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
- 아이템 우클릭 복제 기능을 추가했으므로, 템플릿 아이템 복제/커스텀 아이템 복제/이미 보드에 배치된 아이템 복제 각각에서 복제본이 미사용 풀 맨 앞에 생기고 원본과 복제본을 서로 다른 칸에 동시에 둘 수 있는지 QA한다.
|
||||||
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
- 복제본은 `dup-...` 새 ID로 저장되므로, 저장 후 재진입/티어표 복사본 생성/뷰어 모드 열람에서도 복제본이 그대로 유지되는지와, 템플릿 업데이트 요청에 복제된 커스텀 아이템이 포함될 때 운영상 이상이 없는지 확인한다.
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 업데이트 로그
|
# 업데이트 로그
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.69
|
||||||
|
- 티어표 편집 화면의 아이템 검색에서 한글 아이템명이 검색어와 눈으로는 같아 보여도 내부 유니코드 정규형 차이 때문에 일부 항목이 매칭되지 않을 수 있던 문제를 보강했다.
|
||||||
|
- 검색어와 아이템 라벨을 비교하기 전에 `NFC`로 정규화하도록 바꾸고, 커스텀 이미지 파일명에서 기본 라벨을 만들 때도 같은 정규화를 거쳐 한글 조합형 차이로 검색이 빗나가는 상황을 줄였다.
|
||||||
|
- 프런트 프로덕션 빌드(`npm run build`)까지 통과하는 것을 확인했다.
|
||||||
|
|
||||||
|
## 2026-04-03 v1.4.68
|
||||||
|
- 티어표 편집 화면에서 아이템을 우클릭해도 브라우저 기본 컨텍스트 메뉴가 먼저 떠서 `아이템 복제` 메뉴를 누르기 어려울 수 있던 부분을 보강했다.
|
||||||
|
- 기존에는 각 아이템 카드의 `@contextmenu.prevent`에 주로 의존했지만, 이제는 `window` 캡처 단계에서 `[data-item-id]` 대상 우클릭을 먼저 잡아 기본 메뉴를 막고 커스텀 복제 메뉴를 열도록 바꿨다.
|
||||||
|
- 아이템 썸네일 이미지에도 `draggable="false"`를 명시해, 이미지 자체의 기본 드래그/컨텍스트 동작이 편집 조작보다 앞서는 상황을 줄였다.
|
||||||
|
|
||||||
## 2026-04-03 v1.4.67
|
## 2026-04-03 v1.4.67
|
||||||
- 관리자 아이템 관리에서 프로필 아바타가 `전체 이미지`와 `프로필 이미지` 필터에 보이지 않을 수 있던 문제를 수정했다.
|
- 관리자 아이템 관리에서 프로필 아바타가 `전체 이미지`와 `프로필 이미지` 필터에 보이지 않을 수 있던 문제를 수정했다.
|
||||||
- 원인은 `image_assets`의 같은 `src`가 템플릿 아이템이나 사용자 아이템에서도 쓰이는 경우, 자산 카드 생성 단계에서 해당 `src`를 무조건 제외하던 필터였다. 이제는 `users.avatar_src`나 각종 썸네일 참조로 실제 사용 중인 자산이면 같은 이미지가 다른 아이템에 재사용되더라도 자산 카드도 함께 유지한다.
|
- 원인은 `image_assets`의 같은 `src`가 템플릿 아이템이나 사용자 아이템에서도 쓰이는 경우, 자산 카드 생성 단계에서 해당 `src`를 무조건 제외하던 필터였다. 이제는 `users.avatar_src`나 각종 썸네일 참조로 실제 사용 중인 자산이면 같은 이미지가 다른 아이템에 재사용되더라도 자산 카드도 함께 유지한다.
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const copiedFromLabel = computed(() => {
|
|||||||
return parts.join(' · ') || '복사해 온 티어표'
|
return parts.join(' · ') || '복사해 온 티어표'
|
||||||
})
|
})
|
||||||
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
const customItems = computed(() => getOrderedItems().filter((item) => item?.origin === 'custom'))
|
||||||
const normalizedPoolSearchQuery = computed(() => poolSearchQuery.value.trim().toLowerCase())
|
const normalizedPoolSearchQuery = computed(() => normalizeSearchText(poolSearchQuery.value))
|
||||||
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
const hasSavedTierList = computed(() => !!(persistedTierListId.value || (tierListId.value && tierListId.value !== 'new')))
|
||||||
const canRequestTemplateCreate = computed(
|
const canRequestTemplateCreate = computed(
|
||||||
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
() => canEdit.value && hasSavedTierList.value && templateId.value === 'freeform' && customItems.value.length > 0
|
||||||
@@ -164,6 +164,13 @@ watch(error, (message) => {
|
|||||||
error.value = ''
|
error.value = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function normalizeSearchText(text) {
|
||||||
|
return String(text || '')
|
||||||
|
.normalize('NFC')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
function createAutoTierListTitle() {
|
function createAutoTierListTitle() {
|
||||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'
|
||||||
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
const pick = (size) => Array.from({ length: size }, () => chars[Math.floor(Math.random() * chars.length)]).join('')
|
||||||
@@ -229,7 +236,7 @@ function isPoolItemVisible(itemId) {
|
|||||||
const query = normalizedPoolSearchQuery.value
|
const query = normalizedPoolSearchQuery.value
|
||||||
if (!query) return true
|
if (!query) return true
|
||||||
const item = itemsById.value[itemId]
|
const item = itemsById.value[itemId]
|
||||||
const label = String(item?.label || itemId || '').toLowerCase()
|
const label = normalizeSearchText(item?.label || itemId || '')
|
||||||
return label.includes(query)
|
return label.includes(query)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -456,13 +463,26 @@ function duplicateItemToPool() {
|
|||||||
pool.value = [clonedId, ...pool.value]
|
pool.value = [clonedId, ...pool.value]
|
||||||
selectedItemId.value = clonedId
|
selectedItemId.value = clonedId
|
||||||
closeItemContextMenu()
|
closeItemContextMenu()
|
||||||
toast.success('복제본을 미사용 아이템 목록에 추가했어요.')
|
toast.success('아이템 추가 완료')
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleGlobalContextMenu(event) {
|
function handleGlobalContextMenu(event) {
|
||||||
if (!itemContextMenu.value.open) return
|
|
||||||
const target = event?.target
|
const target = event?.target
|
||||||
if (target?.closest?.('[data-item-context-menu]') || target?.closest?.('[data-item-id]')) return
|
if (target?.closest?.('[data-item-context-menu]')) {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
event?.stopPropagation?.()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const itemEl = target?.closest?.('[data-item-id]')
|
||||||
|
if (canEdit.value && itemEl?.dataset?.itemId) {
|
||||||
|
event?.preventDefault?.()
|
||||||
|
event?.stopPropagation?.()
|
||||||
|
openItemContextMenu(itemEl.dataset.itemId, event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!itemContextMenu.value.open) return
|
||||||
closeItemContextMenu()
|
closeItemContextMenu()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -605,6 +625,7 @@ function createColumnName(index = columns.value.length) {
|
|||||||
|
|
||||||
function createCustomItemLabel(fileName = '') {
|
function createCustomItemLabel(fileName = '') {
|
||||||
const normalized = String(fileName || '')
|
const normalized = String(fileName || '')
|
||||||
|
.normalize('NFC')
|
||||||
.replace(/\.[^.]+$/, '')
|
.replace(/\.[^.]+$/, '')
|
||||||
.replace(/[_-]+/g, ' ')
|
.replace(/[_-]+/g, ' ')
|
||||||
.replace(/\s+/g, ' ')
|
.replace(/\s+/g, ' ')
|
||||||
@@ -1316,7 +1337,7 @@ watch(
|
|||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (typeof window === 'undefined') return
|
if (typeof window === 'undefined') return
|
||||||
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
window.addEventListener('pointerdown', handleGlobalPointerDown)
|
||||||
window.addEventListener('contextmenu', handleGlobalContextMenu)
|
window.addEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
window.addEventListener('blur', closeItemContextMenu)
|
window.addEventListener('blur', closeItemContextMenu)
|
||||||
window.addEventListener('scroll', closeItemContextMenu, true)
|
window.addEventListener('scroll', closeItemContextMenu, true)
|
||||||
})
|
})
|
||||||
@@ -1324,7 +1345,7 @@ onMounted(() => {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
window.removeEventListener('pointerdown', handleGlobalPointerDown)
|
||||||
window.removeEventListener('contextmenu', handleGlobalContextMenu)
|
window.removeEventListener('contextmenu', handleGlobalContextMenu, true)
|
||||||
window.removeEventListener('blur', closeItemContextMenu)
|
window.removeEventListener('blur', closeItemContextMenu)
|
||||||
window.removeEventListener('scroll', closeItemContextMenu, true)
|
window.removeEventListener('scroll', closeItemContextMenu, true)
|
||||||
}
|
}
|
||||||
@@ -1643,9 +1664,13 @@ onUnmounted(() => {
|
|||||||
:class="{ 'cell--selected': selectedItemId === id }"
|
:class="{ 'cell--selected': selectedItemId === id }"
|
||||||
:data-item-id="id"
|
:data-item-id="id"
|
||||||
@click.stop="selectItemByClick(id)"
|
@click.stop="selectItemByClick(id)"
|
||||||
@contextmenu.prevent.stop="openItemContextMenu(id, $event)"
|
|
||||||
>
|
>
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img
|
||||||
|
:src="resolveItemSrc(itemsById[id])"
|
||||||
|
class="thumb"
|
||||||
|
:alt="itemsById[id]?.label || id"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
<div v-if="showCharacterNames" class="itemNameOverlay">{{ itemsById[id]?.label || id }}</div>
|
||||||
<button
|
<button
|
||||||
v-if="canEdit && !isExporting"
|
v-if="canEdit && !isExporting"
|
||||||
@@ -1725,9 +1750,13 @@ onUnmounted(() => {
|
|||||||
}"
|
}"
|
||||||
:data-item-id="id"
|
:data-item-id="id"
|
||||||
@click.stop="selectItemByClick(id)"
|
@click.stop="selectItemByClick(id)"
|
||||||
@contextmenu.prevent.stop="openItemContextMenu(id, $event)"
|
|
||||||
>
|
>
|
||||||
<img :src="resolveItemSrc(itemsById[id])" class="thumb" :alt="itemsById[id]?.label || id" />
|
<img
|
||||||
|
:src="resolveItemSrc(itemsById[id])"
|
||||||
|
class="thumb"
|
||||||
|
:alt="itemsById[id]?.label || id"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
<div class="poolItem__label">{{ itemsById[id]?.label || id }}</div>
|
||||||
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
<div v-if="!canEdit" class="poolItem__state">미배치</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -3050,7 +3079,7 @@ onUnmounted(() => {
|
|||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid var(--theme-border);
|
border: 1px solid var(--theme-border);
|
||||||
background: var(--theme-card-bg);
|
background: color-mix(in srgb, var(--theme-main-bg) 96%, transparent);
|
||||||
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
box-shadow: 0 24px 48px rgba(0, 0, 0, 0.28);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user