관리자 목록 more vert 메뉴 통일 및 태그 메뉴 정렬 수정

AdminRowMoreMenu 공통 컴포넌트로 글·태그·페이지·미디어·네비게이션 행 액션을 ⋮ 팝오버로 통일.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-19 14:14:28 +09:00
parent 797a6dd5a0
commit 02d33996c5
10 changed files with 540 additions and 106 deletions

View File

@@ -30,6 +30,15 @@ const draggingUrls = ref([])
const { toast, showToast } = useAdminToast()
const { openMenuId, closeMenu } = useAdminRowMenu()
/**
* 폴더 행 메뉴 id
* @param {string} folder - 폴더 경로
* @returns {string}
*/
const getFolderMenuId = (folder) => `folder:${folder}`
const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
default: () => []
})
@@ -332,6 +341,8 @@ const submitCreateFolderModal = async () => {
* @returns {Promise<void>}
*/
const removeMediaFolder = async (folder) => {
closeMenu()
if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
return
}
@@ -602,18 +613,26 @@ const deleteMedia = async (item) => {
<span class="admin-media__folder-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
</button>
<button
<AdminRowMoreMenu
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)"
class="admin-media__folder-delete mr-1 inline-flex size-8 shrink-0 items-center justify-center rounded text-current opacity-40 transition hover:opacity-100 hover:text-red-300 disabled:opacity-25"
type="button"
:disabled="deletingFolder === folder"
:aria-label="`${folder} 폴더 삭제`"
@click.stop="removeMediaFolder(folder)"
v-model:open-menu-id="openMenuId"
:item-id="getFolderMenuId(folder)"
menu-label="폴더 메뉴"
size="sm"
class="mr-0.5 shrink-0"
:inverse="activeFolder === folder"
:busy="deletingFolder === folder"
>
<svg class="size-4 shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="deletingFolder === folder"
@click="removeMediaFolder(folder)"
>
폴더 삭제
</button>
</AdminRowMoreMenu>
</div>
</div>

View File

@@ -9,6 +9,8 @@ const saving = ref(false)
const activeTab = ref('primary')
const { toast, showToast, clearToast } = useAdminToast()
const { openMenuId, closeMenu } = useAdminRowMenu()
const { data: navigationItems } = await useFetch('/admin/api/navigation', {
default: () => []
})
@@ -118,6 +120,16 @@ const removeItemCascade = (rootId) => {
items.value = items.value.filter((i) => !toRemove.has(String(i.id)))
}
/**
* 메뉴 항목을 삭제한다(하위 포함).
* @param {string} itemId - 항목 id
* @returns {void}
*/
const removeNavItem = (itemId) => {
closeMenu()
removeItemCascade(itemId)
}
/**
* 항목 id로 레코드를 찾는다.
* @param {string} id - 항목 id
@@ -750,7 +762,7 @@ const saveNavigation = async () => {
상단 메뉴가 없습니다. 버튼으로 항목을 추가하세요.
</div>
<div v-else class="admin-navigation__primary-table overflow-hidden rounded border border-line bg-white">
<div v-else class="admin-navigation__primary-table overflow-x-auto rounded border border-line bg-white">
<table class="admin-navigation__primary-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__primary-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
@@ -763,8 +775,8 @@ const saveNavigation = async () => {
<th class="admin-navigation__primary-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__primary-cell px-4 py-3">
관리
<th class="admin-navigation__primary-cell admin-navigation__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
@@ -817,14 +829,21 @@ const saveNavigation = async () => {
required
>
</td>
<td class="admin-navigation__primary-cell px-4 py-3 align-middle">
<button
class="admin-navigation__primary-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(row.item.id)"
<td class="admin-navigation__primary-cell admin-navigation__cell-actions relative w-12 px-2 py-3 text-right align-middle">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="`nav-${row.item.id}`"
menu-label="메뉴 항목 메뉴"
>
삭제
</button>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
@click="removeNavItem(row.item.id)"
>
메뉴 항목 삭제
</button>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>
@@ -847,7 +866,7 @@ const saveNavigation = async () => {
하단 메뉴가 없습니다.
</div>
<div v-else class="admin-navigation__footer-table overflow-hidden rounded border border-line">
<div v-else class="admin-navigation__footer-table overflow-x-auto rounded border border-line">
<table class="admin-navigation__footer-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__footer-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
@@ -860,8 +879,8 @@ const saveNavigation = async () => {
<th class="admin-navigation__footer-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__footer-cell px-4 py-3">
관리
<th class="admin-navigation__footer-cell admin-navigation__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
@@ -898,14 +917,21 @@ const saveNavigation = async () => {
required
>
</td>
<td class="admin-navigation__footer-cell px-4 py-4">
<button
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(item.id)"
<td class="admin-navigation__footer-cell admin-navigation__cell-actions relative w-12 px-2 py-4 text-right">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="`nav-${item.id}`"
menu-label="메뉴 항목 메뉴"
>
삭제
</button>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
@click="removeNavItem(item.id)"
>
메뉴 항목 삭제
</button>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>
@@ -931,7 +957,7 @@ const saveNavigation = async () => {
추천 사이트가 없습니다.
</div>
<div v-else class="admin-navigation__recommended-table overflow-hidden rounded border border-line">
<div v-else class="admin-navigation__recommended-table overflow-x-auto rounded border border-line">
<table class="admin-navigation__recommended-table-inner w-full border-collapse text-left text-sm">
<thead class="admin-navigation__recommended-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
@@ -944,8 +970,8 @@ const saveNavigation = async () => {
<th class="admin-navigation__recommended-cell px-4 py-3">
URL
</th>
<th class="admin-navigation__recommended-cell px-4 py-3">
관리
<th class="admin-navigation__recommended-cell admin-navigation__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
@@ -982,14 +1008,21 @@ const saveNavigation = async () => {
required
>
</td>
<td class="admin-navigation__recommended-cell px-4 py-4">
<button
class="rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700"
type="button"
@click="removeItemCascade(item.id)"
<td class="admin-navigation__recommended-cell admin-navigation__cell-actions relative w-12 px-2 py-4 text-right">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="`nav-${item.id}`"
menu-label="메뉴 항목 메뉴"
>
삭제
</button>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
@click="removeNavItem(item.id)"
>
메뉴 항목 삭제
</button>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>

View File

@@ -6,6 +6,8 @@ definePageMeta({
const deletingId = ref('')
const errorMessage = ref('')
const { openMenuId, closeMenu } = useAdminRowMenu()
const { data: pages, refresh } = await useFetch('/admin/api/pages', {
default: () => []
})
@@ -34,6 +36,8 @@ const formatDate = (value) => {
* @returns {Promise<void>} 삭제 처리 결과
*/
const deletePage = async (page) => {
closeMenu()
if (!confirm(`"${page.title}" 페이지를 삭제할까요?`)) {
return
}
@@ -74,13 +78,15 @@ const deletePage = async (page) => {
{{ errorMessage }}
</p>
<div class="admin-pages__table mt-8 overflow-hidden border border-line">
<div class="admin-pages__table mt-8 overflow-x-auto border border-line">
<table class="admin-pages__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-pages__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-pages__cell px-4 py-3">제목</th>
<th class="admin-pages__cell px-4 py-3">수정일</th>
<th class="admin-pages__cell px-4 py-3">관리</th>
<th class="admin-pages__cell admin-pages__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
<tbody class="admin-pages__table-body divide-y divide-line bg-white">
@@ -96,15 +102,31 @@ const deletePage = async (page) => {
<td class="admin-pages__cell px-4 py-4">
{{ formatDate(page.updatedAt) }}
</td>
<td class="admin-pages__cell px-4 py-4">
<button
class="admin-pages__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50"
type="button"
:disabled="deletingId === page.id"
@click="deletePage(page)"
<td class="admin-pages__cell admin-pages__cell-actions relative w-12 px-2 py-4 text-right">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="page.id"
menu-label="페이지 메뉴"
:busy="deletingId === page.id"
>
{{ deletingId === page.id ? '삭제 중' : '삭제' }}
</button>
<NuxtLink
class="admin-row-more-menu__item"
:to="`/admin/pages/${page.id}`"
role="menuitem"
@click="closeMenu"
>
페이지 수정
</NuxtLink>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="deletingId === page.id"
@click="deletePage(page)"
>
페이지 삭제
</button>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>

View File

@@ -1,9 +1,13 @@
<script setup>
import { toAdminPostStoredTitle } from '~/lib/admin-post-title.js'
definePageMeta({
layout: 'admin'
})
const deletingId = ref('')
const togglingFeaturedId = ref('')
const openPostMenuId = ref('')
const errorMessage = ref('')
const statusFilter = ref('all')
const tagFilter = ref('all')
@@ -172,13 +176,95 @@ const filteredPosts = computed(() => {
})
})
/**
* 게시물 수정 API 본문을 구성한다.
* @param {Object} post - 게시물
* @param {Object} [overrides] - 덮어쓸 필드
* @returns {Object} PUT 본문
*/
const buildPostUpdateBody = (post, overrides = {}) => ({
title: toAdminPostStoredTitle(post.title),
slug: post.slug,
content: post.content ?? '',
excerpt: post.excerpt ?? '',
featuredImage: post.featuredImage ?? null,
isFeatured: overrides.isFeatured ?? Boolean(post.isFeatured),
seoTitle: post.seoTitle ?? '',
seoDescription: post.seoDescription ?? '',
canonicalUrl: post.canonicalUrl ?? '',
noindex: Boolean(post.noindex),
ogImage: post.ogImage ?? null,
status: post.status,
publishedAt: post.publishedAt,
tags: post.tags ?? []
})
/**
* 열린 행 메뉴를 닫는다.
* @returns {void}
*/
const closePostMenu = () => {
openPostMenuId.value = ''
}
/**
* 행 메뉴 열기/닫기
* @param {string} postId - 게시물 ID
* @returns {void}
*/
const togglePostMenu = (postId) => {
openPostMenuId.value = openPostMenuId.value === postId ? '' : postId
}
/**
* 외부 클릭 시 행 메뉴 닫기
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const onPostMenuDocumentPointerDown = (event) => {
if (!openPostMenuId.value || !(event.target instanceof HTMLElement)) {
return
}
if (event.target.closest('[data-admin-post-row-menu]')) {
return
}
closePostMenu()
}
/**
* 게시물 추천 여부를 토글한다.
* @param {Object} post - 게시물
* @returns {Promise<void>}
*/
const togglePostFeatured = async (post) => {
closePostMenu()
togglingFeaturedId.value = post.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${post.id}`, {
method: 'PUT',
body: buildPostUpdateBody(post, { isFeatured: !post.isFeatured })
})
await refresh()
} catch (error) {
errorMessage.value = error?.data?.message || '추천 설정을 변경하지 못했습니다.'
} finally {
togglingFeaturedId.value = ''
}
}
/**
* 게시물 삭제
* @param {Object} post - 삭제할 게시물
* @returns {Promise<void>} 삭제 처리 결과
*/
const deletePost = async (post) => {
if (!confirm(`"${post.title}" 글을 삭제할까요?`)) {
closePostMenu()
if (!confirm(`"${post.title || '(제목 없음)'}" 글을 삭제할까요?`)) {
return
}
@@ -196,6 +282,14 @@ const deletePost = async (post) => {
deletingId.value = ''
}
}
onMounted(() => {
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
})
</script>
<template>
@@ -265,7 +359,7 @@ const deletePost = async (post) => {
{{ errorMessage }}
</p>
<div class="admin-posts__table mt-8 overflow-hidden border border-line">
<div class="admin-posts__table mt-8 overflow-x-auto border border-line">
<table class="admin-posts__table-inner w-full border-collapse text-left text-sm">
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
@@ -277,7 +371,9 @@ const deletePost = async (post) => {
<th class="admin-posts__cell px-4 py-3">상태</th>
<th class="admin-posts__cell px-4 py-3">태그</th>
<th class="admin-posts__cell px-4 py-3">발행일</th>
<th class="admin-posts__cell px-4 py-3">관리</th>
<th class="admin-posts__cell admin-posts__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
@@ -335,26 +431,61 @@ const deletePost = async (post) => {
{{ getUpdatedDateLabel(post) }}
</p>
</td>
<td class="admin-posts__cell px-4 py-4">
<button
class="admin-posts__delete-icon inline-flex size-9 items-center justify-center rounded text-muted opacity-35 transition-all hover:opacity-100 hover:text-red-600 focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-20"
type="button"
:disabled="deletingId === post.id"
:aria-label="deletingId === post.id ? '삭제 ' : '삭제'"
@click="deletePost(post)"
<td class="admin-posts__cell admin-posts__cell-actions relative w-12 px-2 py-4 text-right">
<div
class="admin-posts__row-menu relative inline-flex justify-end"
data-admin-post-row-menu
>
<span v-if="deletingId === post.id" class="admin-posts__delete-progress text-[10px] font-semibold text-muted" aria-hidden="true"></span>
<svg
v-else
class="size-5 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden="true"
<button
class="admin-posts__row-menu-trigger inline-flex size-9 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#eceff2] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
type="button"
:disabled="deletingId === post.id || togglingFeaturedId === post.id"
:aria-expanded="openPostMenuId === post.id"
aria-haspopup="menu"
:aria-label="openPostMenuId === post.id ? '게시글 메뉴 닫기' : '게시글 메뉴'"
@click.stop="togglePostMenu(post.id)"
>
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" />
</svg>
</button>
<span
v-if="deletingId === post.id || togglingFeaturedId === post.id"
class="text-[10px] font-semibold text-muted"
aria-hidden="true"
></span>
<svg
v-else
class="size-6 shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 -960 960 960"
fill="currentColor"
aria-hidden="true"
>
<path d="M480-160q-33 0-56.5-23.5T400-240q0-33 23.5-56.5T480-320q33 0 56.5 23.5T560-240q0 33-23.5 56.5T480-160Zm0-240q-33 0-56.5-23.5T400-480q0-33 23.5-56.5T480-560q33 0 56.5 23.5T560-480q0 33-23.5 56.5T480-400Zm0-240q-33 0-56.5-23.5T400-720q0-33 23.5-56.5T480-800q33 0 56.5 23.5T560-720q0 33-23.5 56.5T480-640Z" />
</svg>
</button>
<div
v-if="openPostMenuId === post.id"
class="admin-posts__row-menu-popover absolute right-0 top-full z-30 mt-1 min-w-[11rem] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
role="menu"
>
<button
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
type="button"
role="menuitem"
:disabled="togglingFeaturedId === post.id"
@click="togglePostFeatured(post)"
>
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
</button>
<button
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left text-[#c0392b] hover:bg-[#fef2f2] disabled:opacity-50"
type="button"
role="menuitem"
:disabled="deletingId === post.id"
@click="deletePost(post)"
>
게시글 삭제
</button>
</div>
</div>
</td>
</tr>
</tbody>

View File

@@ -10,6 +10,8 @@ const promotingTagId = ref('')
const demotingTagId = ref('')
const deletingGeneralTagId = ref('')
const toast = ref(null)
const { openMenuId, closeMenu } = useAdminRowMenu()
let toastTimer = null
const generalTagQuery = ref('')
const generalTagSortMode = ref('recent')
@@ -247,6 +249,8 @@ const setGeneralTagSortMode = (mode) => {
* @returns {Promise<void>}
*/
const promoteToMainTag = async (tag) => {
closeMenu()
if (promotingTagId.value) {
return
}
@@ -280,6 +284,8 @@ const promoteToMainTag = async (tag) => {
* @returns {Promise<void>}
*/
const demoteToGeneralTag = async (tag) => {
closeMenu()
if (demotingTagId.value) {
return
}
@@ -313,6 +319,8 @@ const demoteToGeneralTag = async (tag) => {
* @returns {Promise<void>}
*/
const deleteGeneralTag = async (tag) => {
closeMenu()
if (!confirm(`"${tag.name}" 일반 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
return
}
@@ -354,7 +362,7 @@ onBeforeUnmount(() => {
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다.
</p>
<div class="admin-tags__table mt-6 overflow-hidden border border-line">
<div class="admin-tags__table mt-6 overflow-x-auto border border-line">
<div class="flex items-center justify-between border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">메인 태그</p>
<span v-if="savingOrder" class="inline-flex items-center gap-2 text-xs font-semibold text-muted">
@@ -370,7 +378,9 @@ onBeforeUnmount(() => {
<th class="admin-tags__cell px-4 py-3">이름</th>
<th class="admin-tags__cell px-4 py-3">슬러그</th>
<th class="admin-tags__cell px-4 py-3">설명</th>
<th class="admin-tags__cell px-4 py-3">관리</th>
<th class="admin-tags__cell admin-tags__cell-actions w-12 px-2 py-3 text-right">
<span class="sr-only">관리</span>
</th>
</tr>
</thead>
<tbody class="admin-tags__table-body divide-y divide-line bg-white">
@@ -407,27 +417,38 @@ onBeforeUnmount(() => {
<td class="admin-tags__cell px-4 py-4 text-muted">
{{ tag.description || '-' }}
</td>
<td class="admin-tags__cell px-4 py-4">
<div class="admin-tags__actions flex gap-2">
<NuxtLink class="admin-tags__edit rounded border border-line px-3 py-1.5 text-xs font-semibold" :to="`/admin/tags/${tag.id}`">
수정
<td class="admin-tags__cell admin-tags__cell-actions relative w-12 px-2 py-4 text-right">
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="tag.id"
menu-label="태그 메뉴"
:busy="demotingTagId === tag.id"
>
<NuxtLink
class="admin-row-more-menu__item"
:to="`/admin/tags/${tag.id}`"
role="menuitem"
@click="closeMenu"
>
태그 수정
</NuxtLink>
<button
class="rounded border border-line px-3 py-1.5 text-xs font-semibold disabled:opacity-50"
class="admin-row-more-menu__item"
type="button"
role="menuitem"
:disabled="demotingTagId === tag.id"
@click="demoteToGeneralTag(tag)"
>
{{ demotingTagId === tag.id ? '변경 중' : '제외' }}
메인에서 제외
</button>
</div>
</AdminRowMoreMenu>
</td>
</tr>
</tbody>
</table>
</div>
<div class="admin-tags__table mt-8 overflow-hidden border border-line">
<div class="admin-tags__table admin-tags__table--general mt-8 overflow-visible border border-line">
<div class="flex items-center justify-between gap-3 border-b border-line bg-[#f7f7f5] px-4 py-2.5">
<p class="text-xs font-semibold uppercase text-muted">일반 태그</p>
<NuxtLink class="admin-tags__new rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white" to="/admin/tags/new">
@@ -474,28 +495,46 @@ onBeforeUnmount(() => {
<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"
class="admin-tags__general-badge group inline-flex max-w-full items-center gap-2 rounded-full border border-line bg-[#f7f7f5] py-1.5 pl-3 pr-1 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)"
<span class="h-3 w-1 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="min-w-0 truncate font-semibold text-ink">{{ tag.name }}</span>
<span class="shrink-0 text-xs text-muted">{{ tag.postCount || 0 }}</span>
<AdminRowMoreMenu
v-model:open-menu-id="openMenuId"
:item-id="tag.id"
menu-label="태그 메뉴"
size="sm"
:busy="promotingTagId === tag.id || deletingGeneralTagId === tag.id"
>
{{ 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)"
>
{{ deletingGeneralTagId === tag.id ? '삭제 중' : '삭제' }}
</button>
<NuxtLink
class="admin-row-more-menu__item"
:to="`/admin/tags/${tag.id}`"
role="menuitem"
@click="closeMenu"
>
태그 수정
</NuxtLink>
<button
class="admin-row-more-menu__item"
type="button"
role="menuitem"
:disabled="promotingTagId === tag.id"
@click="promoteToMainTag(tag)"
>
메인 태그로 전환
</button>
<button
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
type="button"
role="menuitem"
:disabled="deletingGeneralTagId === tag.id"
@click="deleteGeneralTag(tag)"
>
태그 삭제
</button>
</AdminRowMoreMenu>
</div>
</div>
<p v-else class="text-sm text-muted">