일반 태그 배지 목록 정리

This commit is contained in:
2026-05-15 10:50:25 +09:00
parent 9e544d97fa
commit 536ee7079e
9 changed files with 142 additions and 63 deletions

View File

@@ -12,14 +12,47 @@ const deletingGeneralTagId = ref('')
const toast = ref(null)
let toastTimer = null
const generalTagQuery = ref('')
const generalTagSearchResults = ref([])
const generalTagSearchLoading = ref(false)
const generalTagSortMode = ref('recent')
const { data: tags, refresh } = await useFetch('/admin/api/tags', {
default: () => []
})
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
const generalTags = computed(() => tags.value.filter((tag) => tag.tagType === 'general'))
const filteredGeneralTags = computed(() => {
const keyword = generalTagQuery.value.trim().toLowerCase()
const sortedTags = [...generalTags.value].sort((a, b) => {
if (generalTagSortMode.value === 'count') {
const countDiff = Number(b.postCount || 0) - Number(a.postCount || 0)
if (countDiff !== 0) {
return countDiff
}
}
if (generalTagSortMode.value === 'name') {
return a.name.localeCompare(b.name, 'ko')
}
const aTime = new Date(a.lastUsedAt || a.updatedAt || 0).getTime()
const bTime = new Date(b.lastUsedAt || b.updatedAt || 0).getTime()
if (aTime !== bTime) {
return bTime - aTime
}
return a.name.localeCompare(b.name, 'ko')
})
if (!keyword) {
return sortedTags
}
return sortedTags.filter((tag) =>
tag.name.toLowerCase().includes(keyword) ||
tag.slug.toLowerCase().includes(keyword)
)
})
/** 서버 기준 메인 태그 id 순서(정렬 저장 버튼 활성 비교용) */
const baselineManagedTagIds = ref([])
@@ -184,27 +217,16 @@ const saveManagedOrder = async () => {
* @returns {Promise<void>}
*/
const searchGeneralTags = async () => {
const keyword = generalTagQuery.value.trim()
if (!keyword) {
generalTagSearchResults.value = []
return
}
generalTagQuery.value = generalTagQuery.value.trim()
}
generalTagSearchLoading.value = true
try {
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
query: {
tagType: 'general',
q: keyword,
limit: 30
}
})
} catch (error) {
showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.')
} finally {
generalTagSearchLoading.value = false
}
/**
* 일반 태그 정렬 기준을 변경한다.
* @param {'recent'|'count'|'name'} mode - 정렬 기준
* @returns {void}
*/
const setGeneralTagSortMode = (mode) => {
generalTagSortMode.value = mode
}
/**
@@ -232,7 +254,6 @@ const promoteToMainTag = async (tag) => {
}
})
await refreshTagsFromServer()
await searchGeneralTags()
showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
@@ -266,7 +287,6 @@ const demoteToGeneralTag = async (tag) => {
}
})
await refreshTagsFromServer()
await searchGeneralTags()
showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
@@ -292,7 +312,6 @@ const deleteGeneralTag = async (tag) => {
method: 'DELETE'
})
await refreshTagsFromServer()
await searchGeneralTags()
showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
} catch (error) {
showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
@@ -323,7 +342,7 @@ onBeforeUnmount(() => {
</NuxtLink>
</div>
<p class="mt-3 text-xs text-muted">
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 있습니다.
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다.
</p>
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
@@ -404,44 +423,65 @@ onBeforeUnmount(() => {
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
<div class="border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">일반 태그 검색</p>
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
</div>
<div class="space-y-3 bg-white p-4">
<div class="flex gap-2">
<div class="flex flex-col gap-3 xl:flex-row xl:items-center xl:justify-between">
<input
v-model="generalTagQuery"
type="text"
class="h-10 min-w-0 flex-1 rounded border border-line px-3 text-sm outline-none focus:border-[#8e9cac]"
placeholder="일반 태그 이름 또는 슬러그 검색"
placeholder="일반 태그 이름 또는 슬러그 필터"
@keydown.enter.prevent="searchGeneralTags"
>
<button
type="button"
class="h-10 rounded border border-line bg-white px-4 text-sm font-semibold disabled:opacity-50"
:disabled="generalTagSearchLoading"
@click="searchGeneralTags"
>
{{ generalTagSearchLoading ? '검색 중' : '검색' }}
</button>
</div>
<div v-if="generalTagSearchResults.length" class="divide-y divide-line rounded border border-line">
<div v-for="tag in generalTagSearchResults" :key="tag.id" class="flex items-center gap-3 px-3 py-2.5">
<span class="h-4 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-semibold text-ink">{{ tag.name }}</p>
<p class="truncate text-xs text-muted">{{ tag.slug }}</p>
</div>
<div class="inline-flex shrink-0 rounded border border-line bg-[#f7f7f5] p-1">
<button
type="button"
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'recent' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('recent')"
>
{{ promotingTagId === tag.id ? '전환 중' : '메인 태그로 전환' }}
최근 사용순
</button>
<button
type="button"
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'count' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('count')"
>
많이 사용순
</button>
<button
type="button"
class="rounded px-3 py-1.5 text-xs font-semibold"
:class="generalTagSortMode === 'name' ? 'bg-[#15171a] text-white' : 'text-muted'"
@click="setGeneralTagSortMode('name')"
>
이름순
</button>
</div>
</div>
<div v-if="filteredGeneralTags.length" class="flex flex-wrap gap-2">
<div
v-for="tag in filteredGeneralTags"
:key="tag.id"
class="admin-tags__general-badge group inline-flex max-w-full items-center gap-2 rounded-full border border-line bg-[#f7f7f5] px-3 py-2 text-sm"
:title="tag.slug"
>
<span class="h-3 w-1 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="truncate font-semibold text-ink">{{ tag.name }}</span>
<span class="text-xs text-muted">{{ tag.postCount || 0 }}</span>
<button
type="button"
class="rounded-full border border-line bg-white px-2 py-1 text-[11px] font-semibold disabled:opacity-50"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
>
{{ promotingTagId === tag.id ? '전환 중' : '메인' }}
</button>
<button
type="button"
class="rounded-full border border-red-200 bg-white px-2 py-1 text-[11px] font-semibold text-red-700 disabled:opacity-50"
:disabled="deletingGeneralTagId === tag.id"
@click="deleteGeneralTag(tag)"
>
@@ -449,8 +489,8 @@ onBeforeUnmount(() => {
</button>
</div>
</div>
<p v-else-if="generalTagQuery.trim() && !generalTagSearchLoading" class="text-sm text-muted">
검색 결과가 없습니다.
<p v-else class="text-sm text-muted">
{{ generalTagQuery.trim() ? '일치하는 일반 태그가 없습니다.' : '아직 일반 태그가 없습니다.' }}
</p>
</div>
</div>