|
|
|
|
@@ -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
|
|
|
|
|
@@ -1319,7 +1328,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 +1339,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 +1424,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 +1451,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 +1474,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 +2120,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 +2130,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 +2451,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 +2674,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 +3695,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 +3704,7 @@ function openUserProfile(user) {
|
|
|
|
|
display: grid;
|
|
|
|
|
gap: 14px;
|
|
|
|
|
align-content: center;
|
|
|
|
|
min-width: 0;
|
|
|
|
|
}
|
|
|
|
|
.adminUiScope .templateSettingsCard__meta {
|
|
|
|
|
color: var(--theme-text-soft);
|
|
|
|
|
@@ -3592,9 +3718,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 +3965,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 +3976,9 @@ function openUserProfile(user) {
|
|
|
|
|
-webkit-user-drag: none;
|
|
|
|
|
touch-action: none;
|
|
|
|
|
}
|
|
|
|
|
.adminUiScope .thumbCard__media {
|
|
|
|
|
position: relative;
|
|
|
|
|
}
|
|
|
|
|
.adminUiScope .thumbCard:active {
|
|
|
|
|
cursor: grabbing;
|
|
|
|
|
}
|
|
|
|
|
@@ -3854,10 +4001,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 +5212,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;
|
|
|
|
|
}
|
|
|
|
|
|