Compare commits

..

5 Commits

Author SHA1 Message Date
ba9ba8013b ui: constrain tier editor item scroll 2026-04-06 13:28:31 +09:00
da35351747 ui: add editor tips panel 2026-04-06 12:41:46 +09:00
305160663d ui: restore rail menu spacing 2026-04-06 12:32:28 +09:00
58b8df51ab ui: improve modal escape and rail wrapping 2026-04-06 12:30:49 +09:00
bdc7ee42e2 admin: polish template item actions 2026-04-06 12:26:22 +09:00
6 changed files with 224 additions and 16 deletions

View File

@@ -1,5 +1,22 @@
# 의사결정 이력
## 2026-04-06 v1.4.94
- 아이템 수가 많을 때 오른쪽 풀 때문에 페이지 전체가 길어지면 왼쪽 티어표까지 함께 움직여 방송/녹화 환경에서 기준 화면이 흔들릴 수 있다. 그래서 데스크톱에서는 오른쪽 사이드의 실제 화면 시작 위치를 감안해 높이를 제한하되, 제목과 검색창은 유지하고 아이템 그리드만 스크롤되게 하는 편이 더 적절하다고 정리했다. 모바일에서는 기존처럼 문서 흐름을 유지한다.
## 2026-04-06 v1.4.93
- 티어표 편집기의 커스텀 이미지 추가 영역 아래는 아이템 수가 적을 때 비어 보이기 쉬우므로, 이 공간에는 큰 기능보다 방해되지 않는 작은 작업 팁을 두는 편이 자연스럽다고 정리했다. 특히 우클릭 복제, 미사용 아이템 처리, 브라우저 확대/축소처럼 초반 시행착오를 줄여 주는 내용이 효과적이라고 판단했다.
## 2026-04-06 v1.4.92
- 모바일 왼쪽 레일은 사용자 카드, 검색, 메뉴가 세로로 붙는 구조라 기본 `gap`이 빠지면 브라우저별 렌더링 차이에 따라 훨씬 답답하게 보일 수 있으므로, 이 영역 간격은 명시적으로 주는 편이 안전하다고 정리했다.
## 2026-04-06 v1.4.91
- 관리자 화면 모달이 많아질수록 `Esc` 동작이 일부 모달에서만 먹으면 예측 가능성이 떨어지므로, 열려 있는 공통 모달은 모두 `Esc = 취소` 규칙으로 맞추는 편이 더 자연스럽다고 정리했다.
- 왼쪽 레일 사용자 카드의 두 번째 줄은 로그인된 상태에선 이메일이라 말줄임이 맞지만, 로그인 전/확인 중 메시지는 설명 성격이 강하므로 같은 `nowrap` 규칙을 쓰면 가로 스크롤을 유발할 수 있다. 그래서 이메일과 설명 문구의 줄 처리 정책을 분리하는 쪽이 맞다고 판단했다.
## 2026-04-06 v1.4.90
- `templateSettingsCard__actions`는 카드 안에서 버튼이 줄바꿈될 수 있어야 하지만, 공통 버튼 스타일의 높이 100% 규칙까지 그대로 받으면 줄바꿈된 행이 비정상적으로 늘어날 수 있으므로 이 영역의 버튼만 높이를 자동으로 되돌리는 편이 맞다고 정리했다.
- 또 템플릿 기본 아이템 삭제는 “기존 저장 티어표에는 영향 없음”이라는 정책 설명이 중요하므로, 브라우저 기본 확인창보다 관리자 공통 모달로 통일해 같은 톤과 문구 체계 안에서 보여주는 쪽이 더 낫다고 판단했다.
## 2026-04-06 v1.4.89
- 템플릿 화면에서 이름/slug 저장과 아이템 태그 일괄 추가는 성격이 다르므로, 기존처럼 하나의 `메타` 개념으로 묶기보다 `이름/주소 저장``공통 태그 추가`를 분리해 보여주는 편이 운영자가 이해하기 쉽다고 정리했다.
-`templateSettingsCard`는 버튼 문구가 비교적 길고 썸네일/폼/토글이 함께 들어가는 카드라서, 좁은 폭에서 각 블록의 최소 너비를 풀어 주지 않으면 카드 밖으로 밀려나기 쉬우므로 입력 필드와 액션 버튼 모두 카드 내부에서 줄어들고 줄바꿈되게 하는 쪽이 맞다고 판단했다.

View File

@@ -1,5 +1,26 @@
# 업데이트 로그
## 2026-04-06 v1.4.94
- 티어표 편집 화면에서 아이템이 많을 때 오른쪽 아이템 사이드가 문서 높이를 밀어 왼쪽 티어표까지 함께 움직이던 흐름을 줄였다. 데스크톱에서는 오른쪽 사이드의 실제 화면 시작 위치를 감안해 높이를 제한하고, 아이템 그리드만 내부 스크롤되게 해 검색창은 위에 유지하면서 필요한 아이템을 찾아 가져올 수 있게 했다.
- 확인: `npm run build`
## 2026-04-06 v1.4.93
- 티어표 편집 화면의 커스텀 이미지 추가 영역 아래에는 작은 `작업 팁` 안내를 추가했다. 복수 사용, 미사용 아이템 미리보기/저장 제외, 브라우저 확대/축소 활용 같은 자주 묻는 흐름을 바로 확인할 수 있다.
- 확인: `npm run build`
## 2026-04-06 v1.4.92
- 모바일 왼쪽 사이드 메뉴(`leftRail__mobileMenu`)에 `gap`이 빠져 일부 브라우저에서 사용자 카드와 검색창/메뉴가 더 붙어 보일 수 있던 간격을 다시 정리했다.
## 2026-04-06 v1.4.91
- 관리자 화면의 각종 모달은 이제 `Esc` 키를 누르면 현재 열려 있는 모달이 바로 닫히도록 정리했다. 브라우저 기본 동작 대신 공통 `취소` 흐름으로 맞췄다.
- 왼쪽 사이드에서 일부 브라우저 환경에 가로 스크롤이 생기던 문제를 보정했다. 사용자 카드와 검색창에 `min-width: 0`을 더 명확히 주고, 이메일은 계속 말줄임 처리하되 로그인 전 안내 문구처럼 긴 설명은 자연스럽게 줄바꿈되도록 분리했다.
- 확인: `npm run build`
## 2026-04-06 v1.4.90
- `templateSettingsCard__actions` 내부 버튼은 좁은 화면에서 과하게 세로로 늘어나지 않도록 버튼 높이를 자동으로 풀고, 카드 너비 안에서 자연스럽게 줄바꿈되도록 다시 보정했다.
- 템플릿 기본 아이템 삭제 확인은 브라우저 기본 `confirm` 대신 관리자 공통 모달로 바꿨다. 저장된 다른 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외된다는 안내도 모달 안에서 함께 보여준다.
- 확인: `npm run build`
## 2026-04-06 v1.4.89
- 템플릿 관리의 `템플릿 메타 저장` 버튼은 실제 역할에 맞춰 `이름/주소 저장`으로 바꿨다. 이제 이 버튼은 템플릿 이름과 slug 저장만 담당한다.
- 대신 현재 템플릿의 기본 아이템 전체에 같은 태그를 한 번에 추가하는 `기본 아이템 공통 태그` 기능을 추가했다. 배지형 입력으로 태그를 넣고 적용하면, 같은 태그는 중복 없이 각 아이템에 합쳐 저장된다.

View File

@@ -62,6 +62,7 @@ const accountEmail = computed(() => {
if (!authReady.value) return '계정 상태를 확인하고 있어요.'
return (auth.user?.email || '').trim() || '로그인 후 개인 메뉴를 사용할 수 있어요.'
})
const isAccountEmailHint = computed(() => !auth.user)
const shellStyle = computed(() => ({
'--left-rail-width': leftRailCollapsed.value ? '76px' : '248px',
'--right-rail-width': !isRightRailOverlay.value && rightRailOpen.value ? '325px' : '0px',
@@ -529,7 +530,7 @@ function reloadApp() {
<div v-else class="appUserCard__avatar appUserCard__avatar--fallback">{{ accountName[0]?.toUpperCase() || 'U' }}</div>
<div class="appUserCard__meta">
<div class="appUserCard__name">{{ accountName }}</div>
<div class="appUserCard__email">{{ accountEmail }}</div>
<div class="appUserCard__email" :class="{ 'appUserCard__email--hint': isAccountEmailHint }">{{ accountEmail }}</div>
</div>
<button
v-if="isMobileLayout"
@@ -982,6 +983,7 @@ function reloadApp() {
.appUserCard__button,
.appUserCard__guest {
width: 100%;
min-width: 0;
display: flex;
gap: 12px;
align-items: center;
@@ -1022,6 +1024,7 @@ function reloadApp() {
.leftRail__mobileMenu {
display: grid;
min-width: 0;
}
.appUserCard__navToggle {
@@ -1042,18 +1045,30 @@ function reloadApp() {
.appUserCard__name {
font-size: 14px;
font-weight: 800;
min-width: 0;
overflow-wrap: anywhere;
}
.appUserCard__email {
font-size: 12px;
color: var(--theme-text-muted);
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.appUserCard__email--hint {
white-space: normal;
overflow: visible;
text-overflow: unset;
overflow-wrap: anywhere;
word-break: keep-all;
}
.searchStub {
width: 100%;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;

View File

@@ -257,7 +257,7 @@ function setThumbFileElement(el) {
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<div class="thumbCard__media">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<button class="thumbCard__deleteBtn" type="button" data-no-drag @click="props.removeTemplateItem(item.id)">X</button>
<button class="thumbCard__deleteBtn" type="button" data-no-drag @click="props.removeTemplateItem(item)">X</button>
</div>
<input
v-model="item.draftLabel"

View File

@@ -92,6 +92,7 @@ const userDeleteModalOpen = ref(false)
const userRoleModalOpen = ref(false)
const customItemModalOpen = ref(false)
const customItemDeleteModalOpen = ref(false)
const templateItemDeleteModalOpen = ref(false)
const customItemModalHistoryActive = ref(false)
const modalTargetUser = ref(null)
const modalPasswordDraft = ref('')
@@ -100,6 +101,8 @@ const modalUserDraftEmail = ref('')
const modalUserDraftNickname = ref('')
const modalUserDraftIsAdmin = ref(false)
const modalTargetCustomItem = ref(null)
const modalTargetTemplateItem = ref(null)
const modalTargetTemplateItemUsage = ref({ totalCount: 0, publicCount: 0, privateCount: 0 })
const customItemModalDraftLabel = ref('')
const customItemModalDraftTags = ref([])
const customItemModalLabelSaving = ref(false)
@@ -373,6 +376,7 @@ const isAnyModalOpen = computed(
templatePickerModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
templateItemDeleteModalOpen.value ||
adminTierListManageModalOpen.value ||
imageResetModalOpen.value ||
previewModalOpen.value
@@ -415,6 +419,26 @@ function handleAdminPopState() {
function handleAdminKeydown(event) {
if (event.key !== 'Escape') return
if (previewModalOpen.value) {
event.preventDefault()
closePreviewModal()
return
}
if (imageResetModalOpen.value) {
event.preventDefault()
closeImageResetModal()
return
}
if (adminTierListManageModalOpen.value) {
event.preventDefault()
closeAdminTierListManageModal()
return
}
if (templateItemDeleteModalOpen.value) {
event.preventDefault()
closeTemplateItemDeleteModal()
return
}
if (customItemDeleteModalOpen.value) {
event.preventDefault()
closeCustomItemDeleteModal()
@@ -425,9 +449,55 @@ function handleAdminKeydown(event) {
closeCustomItemModal()
return
}
if (previewModalOpen.value) {
if (templatePickerModalOpen.value) {
event.preventDefault()
closePreviewModal()
closeTemplatePickerModal()
return
}
if (templateBulkTagModalOpen.value) {
event.preventDefault()
closeTemplateBulkTagModal()
return
}
if (templateLibraryItemModalOpen.value) {
event.preventDefault()
closeTemplateLibraryItemModal()
return
}
if (templateSourceImportModalOpen.value) {
event.preventDefault()
closeTemplateSourceImportModal()
return
}
if (importModalOpen.value) {
event.preventDefault()
closeTierListImportModal()
return
}
if (userRoleModalOpen.value) {
event.preventDefault()
closeUserRoleModal()
return
}
if (userDeleteModalOpen.value) {
event.preventDefault()
closeUserDeleteModal()
return
}
if (userPasswordModalOpen.value) {
event.preventDefault()
closeUserPasswordModal()
return
}
if (userEditModalOpen.value) {
event.preventDefault()
closeUserEditModal()
return
}
if (templateCreateModalOpen.value) {
event.preventDefault()
closeTemplateCreateModal()
return
}
}
@@ -1420,12 +1490,26 @@ async function toggleSelectedTemplateVisibility(nextValue) {
}
}
async function removeTemplateItem(itemId) {
function closeTemplateItemDeleteModal() {
templateItemDeleteModalOpen.value = false
modalTargetTemplateItem.value = null
modalTargetTemplateItemUsage.value = { totalCount: 0, publicCount: 0, privateCount: 0 }
}
function templateItemDeleteImpactText() {
const usage = modalTargetTemplateItemUsage.value || { totalCount: 0, publicCount: 0, privateCount: 0 }
if (usage.totalCount) {
return `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요. 기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.`
}
return '이 기본 아이템을 삭제할까요? 기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
}
async function removeTemplateItem(item) {
resetMessages()
if (!selectedTemplateId.value) return
if (!selectedTemplateId.value || !item?.id) return
try {
const usageRes = await fetch(
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}/usage`),
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(item.id)}/usage`),
{
credentials: 'include',
}
@@ -1433,16 +1517,22 @@ async function removeTemplateItem(itemId) {
if (!usageRes.ok) throw new Error('usage_failed')
const usageData = await usageRes.json()
const usage = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
const impactMessage = usage.totalCount
? `이 아이템은 이미 저장된 티어표 ${usage.totalCount}개(공개 ${usage.publicCount}개, 비공개 ${usage.privateCount}개)에서 사용 중이에요.\n기존 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.\n정말 삭제할까요?`
: '이 기본 아이템을 삭제할까요?\n기존 저장 티어표에는 영향을 주지 않고, 이후 새로 만드는 티어표에서만 제외됩니다.'
const ok = window.confirm(impactMessage)
if (!ok) return
modalTargetTemplateItem.value = item
modalTargetTemplateItemUsage.value = usageData?.usage || { totalCount: 0, publicCount: 0, privateCount: 0 }
templateItemDeleteModalOpen.value = true
} catch (e) {
error.value = '템플릿 기본 아이템 삭제에 실패했어요.'
}
}
async function confirmTemplateItemDelete() {
resetMessages()
if (!selectedTemplateId.value || !modalTargetTemplateItem.value?.id) return
try {
const previousScrollY = window.scrollY
const res = await fetch(
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(itemId)}`),
toApiUrl(`/api/admin/templates/${encodeURIComponent(selectedTemplateId.value)}/items/${encodeURIComponent(modalTargetTemplateItem.value.id)}`),
{
method: 'DELETE',
credentials: 'include',
@@ -1450,6 +1540,7 @@ async function removeTemplateItem(itemId) {
)
if (!res.ok) throw new Error('failed')
closeTemplateItemDeleteModal()
await loadTemplate()
await nextTick()
window.scrollTo({ top: previousScrollY, behavior: 'auto' })
@@ -2649,6 +2740,20 @@ function openUserProfile(user) {
</div>
</div>
<div v-if="templateItemDeleteModalOpen" class="modalOverlay" @click.self="closeTemplateItemDeleteModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">기본 아이템 삭제</div>
<div class="modalCard__desc">
<strong>{{ modalTargetTemplateItem?.label || '선택한 아이템' }}</strong>
{{ ' ' }}{{ templateItemDeleteImpactText() }}
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" @click="closeTemplateItemDeleteModal">취소</button>
<button class="btn btn--danger" @click="confirmTemplateItemDelete">삭제</button>
</div>
</div>
</div>
<div v-if="adminTierListManageModalOpen" class="modalOverlay" @click.self="closeAdminTierListManageModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">티어표 관리</div>
@@ -3683,6 +3788,12 @@ function openUserProfile(user) {
}
.adminUiScope .templateSettingsCard__actions > .btn {
flex: 0 0 auto;
height: auto;
max-width: 100%;
white-space: normal;
}
.adminUiScope .templateSettingsCard__actions > a.btn {
height: auto;
max-width: 100%;
white-space: normal;
}
@@ -3958,8 +4069,8 @@ function openUserProfile(user) {
}
.adminUiScope .thumbCard__deleteBtn {
position: absolute;
top: 8px;
right: 8px;
top: -24px;
right: -24px;
width: 28px;
height: 28px;
display: grid;

View File

@@ -1746,6 +1746,14 @@ onUnmounted(() => {
<button class="btn btn--ghost btn--small dropzone__button" @click="openFile">파일 선택</button>
</div>
</div>
<div class="editorTips">
<div class="editorTips__title">작업 </div>
<ul class="editorTips__list">
<li>마우스 오른쪽 클릭으로 아이템을 복수 사용하거나 커스텀 이미지를 빠르게 정리할 있어요.</li>
<li>미사용 아이템은 미리보기와 이미지 저장 결과에 표시되지 않으니, 필요한 것만 골라 배치해도 괜찮아요.</li>
<li>아이템이 많아 번에 보기 어렵다면 브라우저 확대/축소(`Ctrl +`, `Ctrl -`) 화면 밀도를 조절해보세요.</li>
</ul>
</div>
</div>
<div class="sidebar">
@@ -2735,7 +2743,10 @@ onUnmounted(() => {
object-fit: cover;
}
.sidebar {
--editor-sidebar-visible-offset: 136px;
min-width: 0;
display: flex;
flex-direction: column;
border: 1px solid var(--theme-border);
background: linear-gradient(180deg, color-mix(in srgb, var(--theme-card-bg) 94%, transparent), color-mix(in srgb, var(--theme-card-bg-hover) 88%, transparent));
border-radius: 22px;
@@ -2743,6 +2754,9 @@ onUnmounted(() => {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04);
position: sticky;
top: 14px;
max-height: calc(100dvh - var(--editor-sidebar-visible-offset));
overflow: hidden;
overscroll-behavior: contain;
}
.dropzone--board {
@@ -3057,6 +3071,9 @@ onUnmounted(() => {
color: var(--theme-text-faint);
}
.pool {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(96px, 1fr));
gap: 10px;
@@ -3065,6 +3082,30 @@ onUnmounted(() => {
.pool--clickTarget {
cursor: copy;
}
.editorTips {
margin-top: 14px;
padding: 12px 14px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: color-mix(in srgb, var(--theme-card-bg) 82%, transparent);
}
.editorTips__title {
font-size: 11px;
font-weight: 900;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--theme-text-soft);
}
.editorTips__list {
margin: 8px 0 0;
padding-left: 16px;
color: var(--theme-text-faint);
font-size: 12px;
line-height: 1.55;
}
.editorTips__list li + li {
margin-top: 6px;
}
.poolItem {
position: relative;
min-width: 0;
@@ -3227,8 +3268,11 @@ onUnmounted(() => {
}
.sidebar {
position: static;
max-height: none;
overflow: visible;
}
.pool {
overflow: visible;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 8px;
}