admin: streamline item modal actions

This commit is contained in:
2026-04-06 12:10:46 +09:00
parent 632bebb8f9
commit 47638b8b3e
8 changed files with 143 additions and 27 deletions

View File

@@ -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>

View File

@@ -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,

View File

@@ -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) =>

View File

@@ -891,6 +891,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 +1097,12 @@ const {
closeCustomItemModal,
openCustomItemDeleteModal,
closeCustomItemDeleteModal,
jumpToTemplateAdmin,
removeCustomItem,
removeUnusedCustomItems,
showUnusedCustomItems,
saveCustomItemModalLabel,
promoteCustomItem,
unlinkCustomItemTemplate,
refreshReplacementCandidates,
replaceCustomItem,
restoreCustomItem,
@@ -1131,8 +1132,6 @@ const {
selectedTemplateId,
refreshCustomItems,
loadTemplate,
setTab,
selectAdminTemplate,
resetMessages,
success,
error,
@@ -1826,6 +1825,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) =>
@@ -2377,7 +2377,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 +2462,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>
@@ -4034,9 +4036,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 +4120,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;