관리자 태그와 목록 메뉴 개선 v1.5.0

This commit is contained in:
2026-05-26 10:56:57 +09:00
parent 6536465b12
commit 0ad2ab3f9d
9 changed files with 450 additions and 77 deletions

View File

@@ -8,11 +8,13 @@ definePageMeta({
const deletingId = ref('')
const togglingFeaturedId = ref('')
const openPostMenuId = ref('')
const postMenuStyle = ref({})
const errorMessage = ref('')
const statusFilter = ref('all')
const tagFilter = ref('all')
const featuredFilter = ref('all')
const sortOrder = ref('newest')
const postMenuTriggerRefs = new Map()
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
default: () => []
@@ -26,6 +28,8 @@ const { data: siteSettings } = await useFetch('/admin/api/settings', {
default: () => ({ showPostUpdatedAt: false })
})
const defaultTagColor = '#15171a'
/**
* 게시물 발행일 라벨을 반환한다.
* @param {Object} post - 게시물
@@ -137,6 +141,31 @@ const getPostStatusClass = (post) => {
*/
const getTagName = (slug) => tags.value.find((tag) => tag.slug === slug)?.name || slug
/**
* 태그 슬러그의 고유 색상을 반환한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 색상
*/
const getTagColor = (slug) => tags.value.find((tag) => tag.slug === slug)?.color || defaultTagColor
/**
* 태그 배지 스타일을 생성한다.
* @param {string} color - 태그 색상
* @returns {Object} 배지 인라인 스타일
*/
const createTagBadgeStyle = (color) => ({
backgroundColor: `color-mix(in srgb, ${color} 14%, white)`,
borderColor: `color-mix(in srgb, ${color} 34%, white)`,
color
})
/**
* 게시물의 대표 태그 슬러그를 반환한다.
* @param {Object} post - 게시물
* @returns {string} 첫 번째 태그 슬러그
*/
const getRepresentativeTagSlug = (post) => post.tags?.[0] || ''
const usedTagSlugs = computed(() => {
const slugs = new Set()
for (const post of posts.value) {
@@ -207,6 +236,52 @@ const closePostMenu = () => {
openPostMenuId.value = ''
}
/**
* 게시물 행 메뉴 트리거 엘리먼트를 저장한다.
* @param {string} postId - 게시물 ID
* @param {Element|null} element - 트리거 엘리먼트
* @returns {void}
*/
const setPostMenuTriggerRef = (postId, element) => {
if (!element) {
postMenuTriggerRefs.delete(postId)
return
}
postMenuTriggerRefs.set(postId, element)
}
/**
* 게시물 행 메뉴 위치를 화면 기준으로 계산한다.
* @returns {void}
*/
const updatePostMenuPosition = () => {
if (!import.meta.client || !openPostMenuId.value) {
return
}
const trigger = postMenuTriggerRefs.get(openPostMenuId.value)
if (!trigger) {
return
}
const rect = trigger.getBoundingClientRect()
const menuWidth = 176
const estimatedHeight = 112
const margin = 8
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
const top = opensUp
? Math.max(margin, rect.top - estimatedHeight - 4)
: rect.bottom + 4
postMenuStyle.value = {
left: `${left}px`,
top: `${top}px`,
width: `${menuWidth}px`
}
}
/**
* 행 메뉴 열기/닫기
* @param {string} postId - 게시물 ID
@@ -285,10 +360,23 @@ const deletePost = async (post) => {
onMounted(() => {
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
window.addEventListener('resize', updatePostMenuPosition)
window.addEventListener('scroll', updatePostMenuPosition, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
window.removeEventListener('resize', updatePostMenuPosition)
window.removeEventListener('scroll', updatePostMenuPosition, true)
})
watch(openPostMenuId, async (postId) => {
if (!postId) {
return
}
await nextTick()
updatePostMenuPosition()
})
</script>
@@ -412,13 +500,12 @@ onBeforeUnmount(() => {
</span>
</td>
<td class="admin-posts__cell px-4 py-4">
<div v-if="post.tags.length" class="admin-posts__tag-list flex flex-wrap gap-1.5">
<div v-if="getRepresentativeTagSlug(post)" class="admin-posts__tag-list flex flex-wrap gap-1.5">
<span
v-for="tag in post.tags"
:key="tag"
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] bg-[#ecd2de] px-2 text-xs font-semibold text-[#e04e87]"
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] border px-2 text-xs font-semibold"
:style="createTagBadgeStyle(getTagColor(getRepresentativeTagSlug(post)))"
>
{{ tag }}
{{ getTagName(getRepresentativeTagSlug(post)) }}
</span>
</div>
<span v-else class="admin-posts__tag-empty text-muted">-</span>
@@ -437,6 +524,7 @@ onBeforeUnmount(() => {
data-admin-post-row-menu
>
<button
:ref="(element) => setPostMenuTriggerRef(post.id, element)"
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"
@@ -461,30 +549,34 @@ onBeforeUnmount(() => {
<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)"
<Teleport to="body">
<div
v-if="openPostMenuId === post.id"
class="admin-posts__row-menu-popover fixed z-[80] 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)]"
:style="postMenuStyle"
role="menu"
data-admin-post-row-menu
>
{{ 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>
<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>
</Teleport>
</div>
</td>
</tr>