Compare commits

..

3 Commits

Author SHA1 Message Date
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
fd3f61ca2b admin: refine template settings actions 2026-04-06 12:18:25 +09:00
5 changed files with 310 additions and 31 deletions

View File

@@ -1,5 +1,18 @@
# 의사결정 이력
## 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`는 버튼 문구가 비교적 길고 썸네일/폼/토글이 함께 들어가는 카드라서, 좁은 폭에서 각 블록의 최소 너비를 풀어 주지 않으면 카드 밖으로 밀려나기 쉬우므로 입력 필드와 액션 버튼 모두 카드 내부에서 줄어들고 줄바꿈되게 하는 쪽이 맞다고 판단했다.
- 템플릿 기본 아이템 카드도 작은 썸네일 위에 버튼 두 개를 계속 노출하면 카드 높이가 불필요하게 커지고 반복 조작 밀도가 떨어지므로, 저장은 입력 후 `Enter`, 삭제는 우상단 `X`처럼 더 직접적인 마이크로 인터랙션으로 옮기는 편이 낫다고 정리했다.
## 2026-04-06 v1.4.88
- 같은 이미지를 사용자 항목, 템플릿 항목, 관리자 자산으로 각각 따로 카드에 늘어놓으면 운영자가 실제로 보고 싶은 “이미지 단위 상태”보다 내부 저장 단위가 더 크게 드러나므로, 관리자 목록과 검색은 기본적으로 같은 `src`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다.
- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다.

View File

@@ -1,5 +1,23 @@
# 업데이트 로그
## 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 저장만 담당한다.
- 대신 현재 템플릿의 기본 아이템 전체에 같은 태그를 한 번에 추가하는 `기본 아이템 공통 태그` 기능을 추가했다. 배지형 입력으로 태그를 넣고 적용하면, 같은 태그는 중복 없이 각 아이템에 합쳐 저장된다.
- 운영 문구도 `메타`보다 실제 의미가 분명한 `태그` 기준으로 맞췄다.
- `adminCard templateSettingsCard`는 화면이 좁아질 때 내부 액션 버튼과 입력 필드가 카드 밖으로 밀려나지 않도록 최소 너비와 버튼 줄바꿈 규칙을 보정했다.
- 템플릿 기본 아이템 카드(`thumbCard`)는 `이름 저장`/`아이템 삭제` 버튼을 걷어내고, 이름 입력 후 `Enter`로 바로 저장되게 바꿨다. 삭제는 티어표 편집기처럼 우상단 `X` 버튼으로 옮겨 카드 높이를 더 작게 유지한다.
- 확인: `npm run build`
## 2026-04-06 v1.4.88
- 관리자 아이템 목록과 개별 아이템 검색에서는 같은 이미지 `src`를 공유하는 항목을 하나로 묶어 보여주도록 조정했다. 사용자 아이템, 템플릿 아이템, 관리자 자산이 같은 이미지를 가리키는 경우 카드가 반복해서 보이던 문제를 줄였다.
- 아이템 모달의 `이 이미지를 사용하는 템플릿` 배지는 더 이상 단순 이동 버튼이 아니고, 각 배지의 `X` 버튼으로 해당 템플릿에서 이미지를 바로 제외할 수 있게 바꿨다.

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

@@ -11,6 +11,7 @@ const props = defineProps({
openTemplateCreateModal: { type: Function, required: true },
openTemplateSourceImportModal: { type: Function, required: true },
openTemplateLibraryItemModal: { type: Function, required: true },
openTemplateBulkTagModal: { type: Function, required: true },
isTemplateLoading: { type: Boolean, required: true },
hasSelectedTemplate: { type: Boolean, required: true },
selectedTemplate: { type: Object, default: null },
@@ -20,6 +21,7 @@ const props = defineProps({
templateMetaSaving: { type: Boolean, required: true },
canSaveTemplateMeta: { type: Boolean, required: true },
saveTemplateMeta: { type: Function, required: true },
canBulkTagTemplateItems: { type: Boolean, required: true },
canApplyThumbnail: { type: Boolean, required: true },
templateVisibilitySaving: { type: Boolean, required: true },
thumbFileInputRef: { type: Function, required: true },
@@ -172,8 +174,9 @@ function setThumbFileElement(el) {
</label>
<div class="templateSettingsCard__actions">
<button class="btn btn--ghost" :disabled="!props.canSaveTemplateMeta || props.templateMetaSaving" @click="props.saveTemplateMeta">
{{ props.templateMetaSaving ? '저장중...' : '템플릿 메타 저장' }}
{{ props.templateMetaSaving ? '저장중...' : '이름/주소 저장' }}
</button>
<button class="btn btn--ghost" :disabled="!props.canBulkTagTemplateItems" @click="props.openTemplateBulkTagModal">기본 아이템 공통 태그</button>
<button class="btn btn--ghost" @click="props.openTemplateLibraryItemModal">개별 아이템 검색</button>
<button class="btn btn--ghost" @click="props.openTemplateSourceImportModal">기존 템플릿 가져오기</button>
<button class="btn" :disabled="!props.canApplyThumbnail" @click="props.uploadThumbnail">썸네일 적용</button>
@@ -252,19 +255,17 @@ function setThumbFileElement(el) {
<div v-if="!props.selectedTemplate?.items?.length" class="hint">아직 등록된 기본 아이템이 없어요.</div>
<div v-else :ref="setTemplateItemListElement" class="thumbGrid">
<div v-for="item in props.selectedTemplate.items" :key="item.id" class="thumbCard" :data-template-item-id="item.id">
<img class="thumb thumb--template" :src="toApiUrl(item.src)" :alt="item.label" draggable="false" />
<input v-model="item.draftLabel" class="input input--labelEdit" placeholder="아이템 이름" data-no-drag />
<div class="thumbCard__actions">
<button
class="btn btn--ghost btn--small"
data-no-drag
:disabled="item.isSavingLabel || !item.draftLabel?.trim() || item.draftLabel.trim() === item.label"
@click="props.saveTemplateItemLabel(item)"
>
{{ item.isSavingLabel ? '저장중...' : '이름 저장' }}
</button>
<button class="btn btn--danger btn--small" data-no-drag @click="props.removeTemplateItem(item.id)">아이템 삭제</button>
<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)">X</button>
</div>
<input
v-model="item.draftLabel"
class="input input--labelEdit"
placeholder="아이템 이름"
data-no-drag
@keydown.enter.prevent="props.saveTemplateItemLabel(item)"
/>
</div>
</div>
</div>

View File

@@ -79,6 +79,9 @@ const templateLibraryItemQuery = ref('')
const templateLibraryItemResults = ref([])
const templateLibraryItemSelectedIds = ref([])
const templateLibraryItemLoading = ref(false)
const templateBulkTagModalOpen = ref(false)
const templateBulkTagDrafts = ref([])
const templateBulkTagSaving = ref(false)
const previewModalOpen = ref(false)
const previewTierList = ref(null)
const adminTierListManageModalOpen = ref(false)
@@ -89,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('')
@@ -97,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)
@@ -219,6 +225,7 @@ const canSaveTemplateMeta = computed(() => {
)
})
const canApplyThumbnail = computed(() => !!thumbFile.value && !!selectedTemplateId.value)
const canBulkTagTemplateItems = computed(() => !!selectedTemplate.value?.items?.length)
const canAddItem = computed(() => uploadItemDrafts.value.length > 0 && uploadItemDrafts.value.every((item) => !!item.label.trim()) && !!selectedTemplateId.value)
const stagedRequestDraftCount = computed(() => uploadItemDrafts.value.filter((item) => item.kind === 'request').length)
const appliedRequestItemCount = computed(() => {
@@ -365,9 +372,11 @@ const isAnyModalOpen = computed(
importModalOpen.value ||
templateSourceImportModalOpen.value ||
templateLibraryItemModalOpen.value ||
templateBulkTagModalOpen.value ||
templatePickerModalOpen.value ||
customItemModalOpen.value ||
customItemDeleteModalOpen.value ||
templateItemDeleteModalOpen.value ||
adminTierListManageModalOpen.value ||
imageResetModalOpen.value ||
previewModalOpen.value
@@ -410,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()
@@ -420,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
}
}
@@ -1319,7 +1394,7 @@ async function saveTemplateMeta() {
templateMetaDraftName.value = nextTemplate.name || selectedTemplate.value.template.name || ''
templateMetaDraftSlug.value = nextTemplate.slug || selectedTemplate.value.template.slug || selectedTemplate.value.template.id || ''
await refreshTemplates()
success.value = '템플릿 메타를 저장했어요.'
success.value = '템플릿 이름과 주소를 저장했어요.'
} catch (e) {
const errorCode = e?.data?.error || ''
if (errorCode === 'topic_slug_taken') {
@@ -1330,12 +1405,69 @@ async function saveTemplateMeta() {
error.value = 'slug는 영문 소문자, 숫자, 하이픈만 사용할 수 있어요.'
return
}
error.value = '템플릿 메타를 저장하지 못했어요.'
error.value = '템플릿 이름과 주소를 저장하지 못했어요.'
} finally {
templateMetaSaving.value = false
}
}
function openTemplateBulkTagModal() {
resetMessages()
if (!selectedTemplate.value?.items?.length) {
error.value = '태그를 추가할 기본 아이템이 없어요.'
return
}
templateBulkTagDrafts.value = []
templateBulkTagSaving.value = false
templateBulkTagModalOpen.value = true
}
function closeTemplateBulkTagModal() {
templateBulkTagModalOpen.value = false
templateBulkTagDrafts.value = []
templateBulkTagSaving.value = false
}
async function applyTemplateBulkTags() {
resetMessages()
if (!selectedTemplateId.value || !selectedTemplate.value?.items?.length) {
error.value = '태그를 적용할 템플릿을 먼저 선택해주세요.'
return
}
const nextTags = parseAdminTagsText(templateBulkTagDrafts.value)
if (!nextTags.length) {
error.value = '추가할 태그를 하나 이상 입력해주세요.'
return
}
try {
templateBulkTagSaving.value = true
const items = selectedTemplate.value.items || []
let updatedCount = 0
for (const item of items) {
const mergedTags = Array.from(new Set([...(Array.isArray(item.tags) ? item.tags : []), ...nextTags]))
if (JSON.stringify(mergedTags) === JSON.stringify(Array.isArray(item.tags) ? item.tags : [])) continue
const data = await api.updateAdminTemplateItem(selectedTemplateId.value, item.id, {
label: item.label,
tags: mergedTags,
})
item.tags = Array.isArray(data.item?.tags) ? data.item.tags : mergedTags
updatedCount += 1
}
closeTemplateBulkTagModal()
success.value = updatedCount
? `기본 아이템 ${updatedCount}개에 공통 태그를 추가했어요.`
: '이미 같은 태그가 들어 있어서 바뀐 항목이 없었어요.'
} catch (e) {
error.value = '기본 아이템 공통 태그 추가에 실패했어요.'
} finally {
templateBulkTagSaving.value = false
}
}
async function toggleSelectedTemplateVisibility(nextValue) {
if (!selectedTemplate.value?.template?.id || templateVisibilitySaving.value) return
const previous = !!selectedTemplate.value.template.isPublic
@@ -1358,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',
}
@@ -1371,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',
@@ -1388,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' })
@@ -2033,6 +2186,7 @@ function openUserProfile(user) {
:open-template-create-modal="openTemplateCreateModal"
:open-template-source-import-modal="openTemplateSourceImportModal"
:open-template-library-item-modal="openTemplateLibraryItemModal"
:open-template-bulk-tag-modal="openTemplateBulkTagModal"
:is-template-loading="isTemplateLoading"
:has-selected-template="hasSelectedTemplate"
:selected-template="selectedTemplate"
@@ -2042,6 +2196,7 @@ function openUserProfile(user) {
:template-meta-saving="templateMetaSaving"
:can-save-template-meta="canSaveTemplateMeta"
:save-template-meta="saveTemplateMeta"
:can-bulk-tag-template-items="canBulkTagTemplateItems"
:can-apply-thumbnail="canApplyThumbnail"
:template-visibility-saving="templateVisibilitySaving"
:thumb-file-input-ref="setThumbFileInputRef"
@@ -2362,6 +2517,27 @@ function openUserProfile(user) {
</div>
</div>
<div v-if="templateBulkTagModalOpen" class="modalOverlay" @click.self="closeTemplateBulkTagModal">
<div class="modalCard" role="dialog" aria-modal="true">
<div class="modalCard__title">기본 아이템 공통 태그</div>
<div class="modalCard__desc">
현재 템플릿의 기본 아이템 전체에 같은 태그를 번에 추가합니다. 이미 있는 태그는 중복 저장하지 않아요.
</div>
<div class="modalCard__form">
<label class="field">
<span class="field__label">추가할 태그</span>
<TagBadgeInput v-model="templateBulkTagDrafts" placeholder="태그 입력 후 Enter" :disabled="templateBulkTagSaving" />
</label>
</div>
<div class="modalCard__actions">
<button class="btn btn--ghost" :disabled="templateBulkTagSaving" @click="closeTemplateBulkTagModal">취소</button>
<button class="btn btn--primary" :disabled="templateBulkTagSaving || !templateBulkTagDrafts.length" @click="applyTemplateBulkTags">
{{ templateBulkTagSaving ? '적용중...' : '공통 태그 추가' }}
</button>
</div>
</div>
</div>
<div v-if="customItemModalOpen" class="modalOverlay" @click.self="closeCustomItemModal">
<div class="modalCard modalCard--customItem" role="dialog" aria-modal="true">
<div v-if="modalTargetCustomItem" class="customItemModal">
@@ -2564,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>
@@ -3571,6 +3761,7 @@ function openUserProfile(user) {
grid-template-columns: minmax(220px, 300px) minmax(0, 1fr);
gap: 18px;
align-items: center;
min-width: 0;
}
.adminUiScope .templateSettingsCard__media {
min-width: 0;
@@ -3579,6 +3770,7 @@ function openUserProfile(user) {
display: grid;
gap: 14px;
align-content: center;
min-width: 0;
}
.adminUiScope .templateSettingsCard__meta {
color: var(--theme-text-soft);
@@ -3592,9 +3784,26 @@ function openUserProfile(user) {
align-items: center;
gap: 10px;
flex-wrap: wrap;
min-width: 0;
}
.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;
}
.adminUiScope .templateMetaForm,
.adminUiScope .templateMetaField {
min-width: 0;
}
.adminUiScope .templateMetaField .input {
width: 100%;
min-width: 0;
}
.adminUiScope .selectedThumb {
width: min(100%, 256px);
@@ -3822,6 +4031,7 @@ function openUserProfile(user) {
gap: 12px;
}
.adminUiScope .thumbCard {
position: relative;
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 16px;
background: var(--theme-surface-soft);
@@ -3832,6 +4042,9 @@ function openUserProfile(user) {
-webkit-user-drag: none;
touch-action: none;
}
.adminUiScope .thumbCard__media {
position: relative;
}
.adminUiScope .thumbCard:active {
cursor: grabbing;
}
@@ -3854,10 +4067,25 @@ function openUserProfile(user) {
opacity: 0.9;
word-break: break-word;
}
.adminUiScope .thumbCard__actions {
margin-top: 10px;
.adminUiScope .thumbCard__deleteBtn {
position: absolute;
top: -24px;
right: -24px;
width: 28px;
height: 28px;
display: grid;
gap: 8px;
place-items: center;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.14);
background: rgba(9, 13, 22, 0.82);
color: var(--theme-text);
font-size: 11px;
font-weight: 900;
cursor: pointer;
z-index: 2;
}
.adminUiScope .thumbCard__deleteBtn:hover {
background: rgba(190, 24, 24, 0.9);
}
.adminUiScope .thumbLabel--preview {
text-align: center;
@@ -5050,6 +5278,10 @@ function openUserProfile(user) {
.adminUiScope .modalCard__form--search {
grid-template-columns: 1fr;
}
.adminUiScope .templateSettingsCard__actions > .btn,
.adminUiScope .templateSettingsCard__actions > a.btn {
width: 100%;
}
.adminUiScope.adminSidebar {
display: none;
}