관리자 목록 more vert 메뉴 통일 및 태그 메뉴 정렬 수정
AdminRowMoreMenu 공통 컴포넌트로 글·태그·페이지·미디어·네비게이션 행 액션을 ⋮ 팝오버로 통일. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user