Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdc7ee42e2 | |||
| fd3f61ca2b | |||
| 47638b8b3e |
@@ -148,6 +148,37 @@ function mapCustomItemRow(row) {
|
||||
}
|
||||
}
|
||||
|
||||
function getSharedItemDisplayPriority(item) {
|
||||
if (!item) return 99
|
||||
if (item.sourceType === 'user' && !item.replacedAt) return 0
|
||||
if (item.sourceType === 'user') return 1
|
||||
if (item.sourceType === 'template') return 2
|
||||
if (item.sourceType === 'asset' || item.isAssetLibraryItem) return 3
|
||||
return 4
|
||||
}
|
||||
|
||||
function collapseSharedLibraryItems(items) {
|
||||
const grouped = new Map()
|
||||
for (const item of items || []) {
|
||||
const key = String(item?.src || '').trim()
|
||||
if (!key) continue
|
||||
if (!grouped.has(key)) grouped.set(key, [])
|
||||
grouped.get(key).push(item)
|
||||
}
|
||||
|
||||
return Array.from(grouped.values())
|
||||
.map((group) =>
|
||||
group
|
||||
.slice()
|
||||
.sort((a, b) => {
|
||||
const priorityDiff = getSharedItemDisplayPriority(a) - getSharedItemDisplayPriority(b)
|
||||
if (priorityDiff !== 0) return priorityDiff
|
||||
return Number(b.createdAt || 0) - Number(a.createdAt || 0)
|
||||
})[0]
|
||||
)
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
}
|
||||
|
||||
function mapImageAssetRow(row) {
|
||||
if (!row) return null
|
||||
return {
|
||||
@@ -1832,7 +1863,7 @@ async function getCustomItemUsageMeta() {
|
||||
}
|
||||
}
|
||||
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all' } = {}) {
|
||||
async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMode = 'all', collapseShared = false } = {}) {
|
||||
const normalizedLimit = Math.min(Math.max(Number(limit) || 50, 1), 200)
|
||||
const normalizedPage = Math.max(Number(page) || 1, 1)
|
||||
const searchText = (queryText || '').trim()
|
||||
@@ -2076,9 +2107,10 @@ async function listCustomItems({ queryText = '', page = 1, limit = 50, filterMod
|
||||
})
|
||||
.sort((a, b) => Number(b.createdAt || 0) - Number(a.createdAt || 0))
|
||||
|
||||
const total = allItems.length
|
||||
const visibleItems = collapseShared ? collapseSharedLibraryItems(allItems) : allItems
|
||||
const total = visibleItems.length
|
||||
const offset = (normalizedPage - 1) * normalizedLimit
|
||||
const pagedItems = allItems.slice(offset, offset + normalizedLimit)
|
||||
const pagedItems = visibleItems.slice(offset, offset + normalizedLimit)
|
||||
|
||||
return {
|
||||
items: pagedItems,
|
||||
|
||||
@@ -394,6 +394,10 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
q: z.string().trim().max(120).optional().default(''),
|
||||
page: z.coerce.number().int().min(1).optional().default(1),
|
||||
limit: z.coerce.number().int().min(1).max(200).optional().default(50),
|
||||
collapseShared: z
|
||||
.union([z.string(), z.boolean(), z.number()])
|
||||
.optional()
|
||||
.transform((value) => value === true || value === 1 || value === '1' || value === 'true'),
|
||||
filter: z
|
||||
.enum(['library', 'all', 'user', 'template', 'asset', 'thumbnail', 'avatar', 'unused', 'unused-user', 'replaced-user', 'unused-admin'])
|
||||
.optional()
|
||||
@@ -407,6 +411,7 @@ router.get('/custom-items', requireAdmin, async (req, res) => {
|
||||
page: parsed.data.page,
|
||||
limit: parsed.data.limit,
|
||||
filterMode: parsed.data.filter,
|
||||
collapseShared: parsed.data.collapseShared,
|
||||
})
|
||||
res.json(result)
|
||||
})
|
||||
@@ -862,6 +867,27 @@ router.post('/custom-items/:itemId/promote', requireAdmin, async (req, res) => {
|
||||
res.json({ item })
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/unlink-template', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
topicId: z.string().trim().min(1),
|
||||
})
|
||||
const parsed = schema.safeParse(req.body)
|
||||
if (!parsed.success) return res.status(400).json({ error: 'bad_request' })
|
||||
|
||||
const template = await findTopicById(parsed.data.topicId)
|
||||
if (!template) return res.status(404).json({ error: 'topic_not_found' })
|
||||
|
||||
const sourceItem = await findLibraryItemForReplacement(req.params.itemId)
|
||||
if (!sourceItem?.src) return res.status(404).json({ error: 'not_found' })
|
||||
|
||||
const templateItems = await listTopicItems(template.id)
|
||||
const matchedItems = templateItems.filter((item) => item?.src === sourceItem.src)
|
||||
if (!matchedItems.length) return res.status(404).json({ error: 'linked_template_item_not_found' })
|
||||
|
||||
await Promise.all(matchedItems.map((item) => deleteTopicItem(item.id)))
|
||||
res.json({ ok: true, deletedCount: matchedItems.length, topicId: template.id, src: sourceItem.src })
|
||||
})
|
||||
|
||||
router.post('/custom-items/:itemId/replace', requireAdmin, async (req, res) => {
|
||||
const schema = z.object({
|
||||
targetItemId: z.string().trim().min(1),
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 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`를 하나로 묶어 보여주는 편이 더 자연스럽다고 정리했다.
|
||||
- 아이템 모달의 연결 템플릿 배지는 단순히 해당 템플릿 화면으로 점프하는 것보다, 그 자리에서 `이 템플릿에서 제외`를 바로 수행하는 액션이 훨씬 직접적이므로 배지에 제거 버튼을 붙이는 쪽이 더 낫다고 판단했다.
|
||||
- 한글 태그 입력은 IME 조합 중 `Enter`가 중간 문자열까지 함께 커밋될 수 있으므로, 배지형 태그 입력에서는 조합 상태를 명시적으로 감지해 완성된 문자열만 태그로 추가하는 편이 맞다고 정리했다.
|
||||
|
||||
## 2026-04-06 v1.4.87
|
||||
- 템플릿 태그는 이름/slug 검색과 역할이 겹치고, 운영자가 실제로 원하는 것은 “템플릿 찾기”보다 “아이템 묶음 분류”에 가까웠으므로 템플릿 화면에서 직접 노출하지 않는 편이 더 맞다고 정리했다.
|
||||
- 태그 입력도 카드 곳곳에 흩어져 있으면 메인 작업인 업로드와 이름 정리가 묻히기 쉬우므로, 태그는 관리자 아이템 모달에서만 배지형으로 다루고 템플릿 화면 본문은 가볍게 유지하는 쪽을 택했다.
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
# 업데이트 로그
|
||||
|
||||
## 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` 버튼으로 해당 템플릿에서 이미지를 바로 제외할 수 있게 바꿨다.
|
||||
- 아이템 모달의 `새 템플릿 만들기` 버튼은 현재 흐름에선 분기만 늘린다고 보고 숨겼다. 이제 아이템 추가는 이미 선택한 템플릿 기준으로만 진행된다.
|
||||
- 배지형 태그 입력은 한글 IME 조합 중 `Enter`를 눌렀을 때 초성/중간 문자열이 중복 등록되던 문제를 막기 위해 조합 중 입력을 따로 감지하도록 보강했다.
|
||||
- 확인: `node --check backend/src/db.js`, `node --check backend/src/routes/admin.js`, `npm run build`
|
||||
|
||||
## 2026-04-06 v1.4.87
|
||||
- 템플릿 설정 화면에서는 더 이상 템플릿 태그를 직접 입력하지 않도록 정리했다. 템플릿 자체는 이름과 slug로만 관리하고, 운영용 태그는 아이템 모달 안에서만 다루는 흐름으로 단순화했다.
|
||||
- 관리자 아이템 모달의 태그 입력은 쉼표 문자열 대신 배지형 입력으로 바꿨다. 태그를 입력하고 `Enter`를 누르면 아래에 배지로 붙고, 각 배지의 `X` 버튼으로 개별 제거할 수 있다.
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = defineProps({
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const draft = ref('')
|
||||
const isComposing = ref(false)
|
||||
|
||||
const normalizedTags = computed(() =>
|
||||
Array.from(
|
||||
@@ -50,6 +51,7 @@ function removeTag(tag) {
|
||||
}
|
||||
|
||||
function handleKeydown(event) {
|
||||
if (event.isComposing || isComposing.value) return
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault()
|
||||
addDraftTag()
|
||||
@@ -62,8 +64,17 @@ function handleKeydown(event) {
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
if (isComposing.value) return
|
||||
addDraftTag()
|
||||
}
|
||||
|
||||
function handleCompositionStart() {
|
||||
isComposing.value = true
|
||||
}
|
||||
|
||||
function handleCompositionEnd() {
|
||||
isComposing.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,6 +94,8 @@ function handleBlur() {
|
||||
:maxlength="maxTagLength"
|
||||
@keydown="handleKeydown"
|
||||
@blur="handleBlur"
|
||||
@compositionstart="handleCompositionStart"
|
||||
@compositionend="handleCompositionEnd"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
export function useAdminCustomItems({
|
||||
api,
|
||||
toast,
|
||||
@@ -26,8 +24,6 @@ export function useAdminCustomItems({
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -76,6 +72,7 @@ export function useAdminCustomItems({
|
||||
page: 1,
|
||||
limit: 50,
|
||||
filter: 'all',
|
||||
collapseShared: true,
|
||||
})
|
||||
customItemReplacementItems.value = (data.items || []).filter((item) => item?.id && item.id !== currentItemId)
|
||||
} catch (e) {
|
||||
@@ -138,15 +135,6 @@ export function useAdminCustomItems({
|
||||
customItemDeleteModalOpen.value = false
|
||||
}
|
||||
|
||||
function jumpToTemplateAdmin(templateId) {
|
||||
if (!templateId) return
|
||||
closeCustomItemModal()
|
||||
setTab('template-admin')
|
||||
nextTick(() => {
|
||||
selectAdminTemplate(templateId)
|
||||
})
|
||||
}
|
||||
|
||||
async function removeCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
if (!item) return
|
||||
@@ -255,6 +243,30 @@ export function useAdminCustomItems({
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkCustomItemTemplate(item = modalTargetCustomItem.value, template) {
|
||||
resetMessages()
|
||||
if (!item?.id || !template?.id) {
|
||||
error.value = '제외할 템플릿 정보를 찾지 못했어요.'
|
||||
return
|
||||
}
|
||||
|
||||
const ok = window.confirm(`"${template.name}" 템플릿에서 이 이미지를 제외할까요?`)
|
||||
if (!ok) return
|
||||
|
||||
try {
|
||||
await api.unlinkAdminCustomItemTemplate(item.id, { topicId: template.id })
|
||||
if (selectedTemplateId.value === template.id) await loadTemplate()
|
||||
await refreshCustomItems()
|
||||
modalTargetCustomItem.value = {
|
||||
...item,
|
||||
linkedTemplates: (item.linkedTemplates || []).filter((entry) => entry.id !== template.id),
|
||||
}
|
||||
success.value = `"${template.name}" 템플릿에서 이미지를 제외했어요.`
|
||||
} catch (e) {
|
||||
error.value = '템플릿 연결 해제에 실패했어요.'
|
||||
}
|
||||
}
|
||||
|
||||
async function replaceCustomItem(item = modalTargetCustomItem.value) {
|
||||
resetMessages()
|
||||
const targetItem = customItemReplacementItems.value.find((entry) => entry.id === customItemReplacementTargetId.value)
|
||||
@@ -315,12 +327,12 @@ export function useAdminCustomItems({
|
||||
closeCustomItemModal,
|
||||
openCustomItemDeleteModal,
|
||||
closeCustomItemDeleteModal,
|
||||
jumpToTemplateAdmin,
|
||||
removeCustomItem,
|
||||
removeUnusedCustomItems,
|
||||
showUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
promoteCustomItem,
|
||||
unlinkCustomItemTemplate,
|
||||
refreshReplacementCandidates,
|
||||
replaceCustomItem,
|
||||
restoreCustomItem,
|
||||
|
||||
@@ -77,9 +77,9 @@ export const api = {
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}`, { method: 'PATCH', body: payload }),
|
||||
updateAdminTemplateItem: (templateId, itemId, payload) =>
|
||||
request(`/api/admin/templates/${encodeURIComponent(templateId)}/items/${encodeURIComponent(itemId)}`, { method: 'PATCH', body: payload }),
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all' } = {}) =>
|
||||
listAdminCustomItems: ({ q = '', page = 1, limit = 50, filter = 'all', collapseShared = false } = {}) =>
|
||||
request(
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}`
|
||||
`/api/admin/custom-items?q=${encodeURIComponent(q)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}&filter=${encodeURIComponent(filter)}&collapseShared=${encodeURIComponent(collapseShared ? '1' : '0')}`
|
||||
),
|
||||
listAdminTierLists: ({ q = '', topicId = '', sort = 'recent', minFavorites = 0, page = 1, limit = 50 } = {}) =>
|
||||
request(`/api/admin/tierlists?q=${encodeURIComponent(q)}&topicId=${encodeURIComponent(topicId)}&sort=${encodeURIComponent(sort)}&minFavorites=${encodeURIComponent(minFavorites)}&page=${encodeURIComponent(page)}&limit=${encodeURIComponent(limit)}`),
|
||||
@@ -104,6 +104,8 @@ export const api = {
|
||||
cleanupAdminUnusedImageAssets: (payload) => request('/api/admin/image-assets/cleanup', { method: 'POST', body: payload || {} }),
|
||||
promoteAdminTemplateItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/promote`, { method: 'POST', body: payload }),
|
||||
unlinkAdminCustomItemTemplate: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/unlink-template`, { method: 'POST', body: payload }),
|
||||
replaceAdminCustomItem: (itemId, payload) =>
|
||||
request(`/api/admin/custom-items/${encodeURIComponent(itemId)}/replace`, { method: 'POST', body: payload }),
|
||||
restoreAdminCustomItem: (itemId) =>
|
||||
|
||||
@@ -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
|
||||
@@ -891,6 +900,7 @@ async function refreshCustomItems() {
|
||||
page: customItemPage.value,
|
||||
limit: customItemLimit.value,
|
||||
filter: customItemFilter.value,
|
||||
collapseShared: !['user', 'template', 'unused-user', 'replaced-user'].includes(customItemFilter.value),
|
||||
})
|
||||
customItems.value = data.items || []
|
||||
customItemTotal.value = data.total || 0
|
||||
@@ -1096,12 +1106,12 @@ const {
|
||||
closeCustomItemModal,
|
||||
openCustomItemDeleteModal,
|
||||
closeCustomItemDeleteModal,
|
||||
jumpToTemplateAdmin,
|
||||
removeCustomItem,
|
||||
removeUnusedCustomItems,
|
||||
showUnusedCustomItems,
|
||||
saveCustomItemModalLabel,
|
||||
promoteCustomItem,
|
||||
unlinkCustomItemTemplate,
|
||||
refreshReplacementCandidates,
|
||||
replaceCustomItem,
|
||||
restoreCustomItem,
|
||||
@@ -1131,8 +1141,6 @@ const {
|
||||
selectedTemplateId,
|
||||
refreshCustomItems,
|
||||
loadTemplate,
|
||||
setTab,
|
||||
selectAdminTemplate,
|
||||
resetMessages,
|
||||
success,
|
||||
error,
|
||||
@@ -1320,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') {
|
||||
@@ -1331,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
|
||||
@@ -1359,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',
|
||||
}
|
||||
@@ -1372,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',
|
||||
@@ -1389,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' })
|
||||
@@ -1826,6 +1912,7 @@ async function searchTemplateLibraryItems() {
|
||||
page: 1,
|
||||
limit: 50,
|
||||
filter: 'library',
|
||||
collapseShared: true,
|
||||
})
|
||||
templateLibraryItemResults.value = (data.items || []).filter((item) => item?.id)
|
||||
templateLibraryItemSelectedIds.value = templateLibraryItemSelectedIds.value.filter((id) =>
|
||||
@@ -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">
|
||||
@@ -2377,7 +2487,6 @@ function openUserProfile(user) {
|
||||
</div>
|
||||
<div class="customItemModal__pickerActions">
|
||||
<button class="btn btn--ghost" type="button" @click="openTemplatePickerModal('custom-item-target')">템플릿 선택</button>
|
||||
<button class="btn btn--ghost btn--small customItemModal__createTemplateButton" type="button" @click="openTemplateCreateModal">새 템플릿 만들기</button>
|
||||
</div>
|
||||
<template v-if="canReplaceModalTarget">
|
||||
<div class="customItemModal__pickerHead">
|
||||
@@ -2463,7 +2572,10 @@ function openUserProfile(user) {
|
||||
<div class="customItemModal__linked">
|
||||
<span class="customItemModal__label">이 이미지를 사용하는 템플릿</span>
|
||||
<div v-if="visibleLinkedTemplates.length" class="customItemModal__chips">
|
||||
<button v-for="template in visibleLinkedTemplates" :key="template.id" type="button" class="pill pill--link" @click="jumpToTemplateAdmin(template.id)">{{ template.name }}</button>
|
||||
<span v-for="template in visibleLinkedTemplates" :key="template.id" class="customItemModal__templateChip">
|
||||
<span>{{ template.name }}</span>
|
||||
<button class="customItemModal__templateChipRemove" type="button" @click="unlinkCustomItemTemplate(modalTargetCustomItem, template)">X</button>
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="hint hint--tight">아직 템플릿에 연결된 항목이 없어요.</div>
|
||||
</div>
|
||||
@@ -2562,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>
|
||||
@@ -3569,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;
|
||||
@@ -3577,6 +3704,7 @@ function openUserProfile(user) {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
align-content: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.adminUiScope .templateSettingsCard__meta {
|
||||
color: var(--theme-text-soft);
|
||||
@@ -3590,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);
|
||||
@@ -3820,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);
|
||||
@@ -3830,6 +3976,9 @@ function openUserProfile(user) {
|
||||
-webkit-user-drag: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.adminUiScope .thumbCard__media {
|
||||
position: relative;
|
||||
}
|
||||
.adminUiScope .thumbCard:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
@@ -3852,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;
|
||||
@@ -4034,9 +4198,6 @@ function openUserProfile(user) {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.adminUiScope .customItemModal__createTemplateButton {
|
||||
justify-self: start;
|
||||
}
|
||||
.adminUiScope .customItemModal__body {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
@@ -4121,6 +4282,26 @@ function openUserProfile(user) {
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.adminUiScope .customItemModal__templateChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 11px;
|
||||
border-radius: 999px;
|
||||
background: var(--theme-pill-bg);
|
||||
border: 1px solid var(--theme-border);
|
||||
font-size: 12px;
|
||||
color: var(--theme-text);
|
||||
}
|
||||
.adminUiScope .customItemModal__templateChipRemove {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: var(--theme-text-soft);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
.adminUiScope .customItemModal__title {
|
||||
font-size: 19px;
|
||||
font-weight: 900;
|
||||
@@ -5031,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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user