관리자 목록 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

@@ -0,0 +1,140 @@
<script setup>
const openMenuId = defineModel('openMenuId', {
type: String,
default: ''
})
const props = defineProps({
/** 이 메뉴 인스턴스의 고유 id */
itemId: {
type: String,
required: true
},
/** 트리거 비활성화 */
disabled: {
type: Boolean,
default: false
},
/** 처리 중(… 표시) */
busy: {
type: Boolean,
default: false
},
/** 접근성 라벨 접두사 */
menuLabel: {
type: String,
default: '메뉴'
},
/** 트리거 크기: md(테이블) | sm(배지·사이드) */
size: {
type: String,
default: 'md'
},
/** 어두운 배경 위 트리거(미디어 폴더 선택 행 등) */
inverse: {
type: Boolean,
default: false
}
})
/** @type {import('vue').ComputedRef<boolean>} */
const isOpen = computed(() => openMenuId.value === props.itemId)
/** @type {import('vue').ComputedRef<string>} */
const triggerSizeClass = computed(() => (props.size === 'sm' ? 'size-7' : 'size-9'))
/** @type {import('vue').ComputedRef<string>} */
const iconSizeClass = computed(() => (props.size === 'sm' ? 'size-5' : 'size-6'))
/** @type {import('vue').ComputedRef<string>} */
const triggerToneClass = computed(() => (
props.inverse
? 'text-white hover:bg-white/15 focus-visible:ring-white/40'
: 'text-[#394047] hover:bg-[#eceff2]'
))
/**
* 메뉴 열기/닫기
* @returns {void}
*/
const toggleMenu = () => {
if (props.disabled) {
return
}
openMenuId.value = isOpen.value ? '' : props.itemId
}
</script>
<template>
<div
class="admin-row-more-menu relative inline-flex justify-end"
data-admin-row-menu
@mousedown.stop
>
<button
class="admin-row-more-menu__trigger inline-flex items-center justify-center rounded transition-colors focus-visible:outline focus-visible:ring-2 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
:class="[triggerSizeClass, triggerToneClass]"
type="button"
:disabled="disabled"
:aria-expanded="isOpen"
aria-haspopup="menu"
:aria-label="isOpen ? `${menuLabel} 닫기` : menuLabel"
@mousedown.stop
@click.stop="toggleMenu"
>
<span
v-if="busy"
class="text-[10px] font-semibold text-muted"
aria-hidden="true"
></span>
<svg
v-else
class="admin-row-more-menu__icon shrink-0"
:class="iconSizeClass"
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="isOpen"
class="admin-row-more-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"
>
<slot />
</div>
</div>
</template>
<style scoped>
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item) {
display: block;
width: 100%;
padding: 0.625rem 1rem;
text-align: left;
font-size: 0.875rem;
line-height: 1.25rem;
color: #3f4650;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:hover) {
background: #f3f5f7;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item:disabled) {
opacity: 0.5;
cursor: not-allowed;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger) {
color: #c0392b;
}
.admin-row-more-menu__popover :deep(.admin-row-more-menu__item--danger:hover) {
background: #fef2f2;
}
</style>

View File

@@ -0,0 +1,45 @@
/**
* 관리자 테이블·목록 행 more vert 메뉴 열림 상태
* @returns {{ openMenuId: import('vue').Ref<string>, closeMenu: () => void }}
*/
export const useAdminRowMenu = () => {
const openMenuId = ref('')
/**
* 열린 메뉴를 닫는다.
* @returns {void}
*/
const closeMenu = () => {
openMenuId.value = ''
}
/**
* 문서 바깥 클릭 시 메뉴를 닫는다.
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const onDocumentPointerDown = (event) => {
if (!openMenuId.value || !(event.target instanceof HTMLElement)) {
return
}
if (event.target.closest('[data-admin-row-menu]')) {
return
}
closeMenu()
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown)
})
return {
openMenuId,
closeMenu
}
}

View File

@@ -113,16 +113,18 @@
|------|------|
| pages/admin/index.vue | 대시보드 |
| pages/admin/login.vue | 관리자 로그인(이메일·비밀번호 모두 입력 시에만 제출 버튼 활성) |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 삭제는 휴지통 아이콘 |
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
| pages/admin/pages/index.vue | 페이지 목록 |
| pages/admin/pages/index.vue | 페이지 목록, 행 more vert 메뉴(수정·삭제) |
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 사이트 탭, 상단은 1뎁스 제한·평면 테이블+계층형 개요·행 드래그(위/중/아래), 하단·추천은 평면 드래그, `useAdminToast` |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·팝오버) |
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
| pages/admin/tags/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 수정일 표시 토글·저장), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(커버 이미지·오버레이 텍스트), 타임존·어나운스·Import/Export·스팸 플레이스홀더 |

View File

@@ -448,7 +448,7 @@ components/content/
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다.
> 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다.
> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다.
> 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.

View File

@@ -5,6 +5,9 @@
- 홈 상단: Ghost형 헤딩·구독 폼 제거. 사이트 설정「메인 화면」에서 커버 이미지(720px)·오버레이 제목·본문 설정. `HomeHero.vue`, 마이그레이션 `027_site_settings_home_cover.sql`.
- 메인 화면 설정: 커버 업로드 시 제목·본문이 리셋되지 않도록 업로드는 파일만·저장 버튼에서 이미지·텍스트 일괄 `PUT` 반영.
- 테마 깜빡임: head 인라인 스크립트로 `data-theme` 선적용. 로딩 스플래시(`app.html`·캐시된 사이트 로고).
- 관리자 글 목록: 휴지통 대신 more vert 행 메뉴(추천·추천 제거·삭제).
- 관리자 태그 관리: 메인·일반 태그 모두 more vert 메뉴(수정·전환·제외·삭제).
- 관리자 공통 `AdminRowMoreMenu`·`useAdminRowMenu`: 페이지·미디어 폴더·네비게이션 삭제 UI 통일. 태그 메뉴 항목 좌측 정렬 수정.
- 홈 Latest 피드: List(썸네일+본문)·Compact(텍스트만)·Cards(2열) 보기 구분. 메뉴 List/Compact 선택값과 레이아웃 일치. Default 클릭 시 Compact로 전환. Cards 상단 여백·테두리 클리핑 수정.
- 게시물 카드: 대표 이미지 없을 때 썸네일 영역에 제목 텍스트 플레이스홀더(`PostCardMedia`). 홈 Latest·태그·게시물 목록 공통.
- 슬래시 메뉴: 키보드 ↓ 이동 시 scrollIntoView+mouseenter 충돌로 하단 항목이 반복 선택되던 문제 수정.

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