관리자 목록 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/index.vue | 대시보드 |
| pages/admin/login.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/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 | | pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) | | 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/new.vue | 페이지 작성, 저장 토스트 |
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 | | pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 검색은 파일명·게시물/페이지 사용처 제목만, 라이브러리: 폴더 트리·드래그 이동 등, 현재 사이트 설정 로고·파비콘은 사용 중 파일로 잠금, 썸네일: 미참조 파일 삭제·이름 변경 가능, 상세 모달(연결 회원·폴더 편집·**다운로드**) | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 사이트 탭, 상단은 1뎁스 제한·평면 테이블+계층형 개요·행 드래그(위/중/아래), 하단·추천은 평면 드래그, `useAdminToast` | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·일반 강등, 일반 태그 검색/메인 전환/삭제, 일반 태그 헤더의 태그 추가 버튼), 액션 피드백 토스트 | | 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/new.vue | 태그 생성 |
| pages/admin/tags/[id].vue | 태그 수정 | | pages/admin/tags/[id].vue | 태그 수정 |
| pages/admin/settings/index.vue | 사이트 설정 Ghost형 전체 화면, **POST 설정**(`showPostUpdatedAt` 수정일 표시 토글·저장), 블로그 제목·설명·기타(로고·URL·저작권), **메인 화면**(커버 이미지·오버레이 텍스트), 타임존·어나운스·Import/Export·스팸 플레이스홀더 | | 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/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`member`)
> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다. > 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
> 관리자 글 목록의 삭제 액션은 휴지통 아이콘으로 표시하며, 기본은 낮은 강조로 두고 호버 시에만 색·불투명도를 올린다. > 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다. > 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 두며 필터는 «새 글» 바로 왼쪽에 배치한다.
> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. > 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다.
> 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다. > 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.

View File

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

View File

@@ -30,6 +30,15 @@ const draggingUrls = ref([])
const { toast, showToast } = useAdminToast() 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', { const { data: mediaItems, refresh } = await useFetch('/admin/api/media', {
default: () => [] default: () => []
}) })
@@ -332,6 +341,8 @@ const submitCreateFolderModal = async () => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const removeMediaFolder = async (folder) => { const removeMediaFolder = async (folder) => {
closeMenu()
if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) { if (!folder || folder === '미분류' || folder === MEDIA_THUMBNAIL_ROOT || folder.startsWith(`${MEDIA_THUMBNAIL_ROOT}/`)) {
return 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-name min-w-0 truncate">{{ getFolderName(folder) }}</span>
<span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span> <span class="admin-media__folder-count shrink-0 text-xs opacity-70">{{ folderMediaCounts[folder] || 0 }}</span>
</button> </button>
<button <AdminRowMoreMenu
v-if="folder !== '미분류' && !isThumbnailFolderPath(folder)" 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" v-model:open-menu-id="openMenuId"
type="button" :item-id="getFolderMenuId(folder)"
:disabled="deletingFolder === folder" menu-label="폴더 메뉴"
:aria-label="`${folder} 폴더 삭제`" size="sm"
@click.stop="removeMediaFolder(folder)" 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"> <button
<path d="M6 19c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V7H6v12zM19 4h-3.5l-1-1h-5l-1 1H5v2h14V4z" /> class="admin-row-more-menu__item admin-row-more-menu__item--danger"
</svg> type="button"
</button> role="menuitem"
:disabled="deletingFolder === folder"
@click="removeMediaFolder(folder)"
>
폴더 삭제
</button>
</AdminRowMoreMenu>
</div> </div>
</div> </div>

View File

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

View File

@@ -6,6 +6,8 @@ definePageMeta({
const deletingId = ref('') const deletingId = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const { openMenuId, closeMenu } = useAdminRowMenu()
const { data: pages, refresh } = await useFetch('/admin/api/pages', { const { data: pages, refresh } = await useFetch('/admin/api/pages', {
default: () => [] default: () => []
}) })
@@ -34,6 +36,8 @@ const formatDate = (value) => {
* @returns {Promise<void>} 삭제 처리 결과 * @returns {Promise<void>} 삭제 처리 결과
*/ */
const deletePage = async (page) => { const deletePage = async (page) => {
closeMenu()
if (!confirm(`"${page.title}" 페이지를 삭제할까요?`)) { if (!confirm(`"${page.title}" 페이지를 삭제할까요?`)) {
return return
} }
@@ -74,13 +78,15 @@ const deletePage = async (page) => {
{{ errorMessage }} {{ errorMessage }}
</p> </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"> <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"> <thead class="admin-pages__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr> <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 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> </tr>
</thead> </thead>
<tbody class="admin-pages__table-body divide-y divide-line bg-white"> <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"> <td class="admin-pages__cell px-4 py-4">
{{ formatDate(page.updatedAt) }} {{ formatDate(page.updatedAt) }}
</td> </td>
<td class="admin-pages__cell px-4 py-4"> <td class="admin-pages__cell admin-pages__cell-actions relative w-12 px-2 py-4 text-right">
<button <AdminRowMoreMenu
class="admin-pages__delete rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 disabled:opacity-50" v-model:open-menu-id="openMenuId"
type="button" :item-id="page.id"
:disabled="deletingId === page.id" menu-label="페이지 메뉴"
@click="deletePage(page)" :busy="deletingId === page.id"
> >
{{ deletingId === page.id ? '삭제 중' : '삭제' }} <NuxtLink
</button> 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> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -1,9 +1,13 @@
<script setup> <script setup>
import { toAdminPostStoredTitle } from '~/lib/admin-post-title.js'
definePageMeta({ definePageMeta({
layout: 'admin' layout: 'admin'
}) })
const deletingId = ref('') const deletingId = ref('')
const togglingFeaturedId = ref('')
const openPostMenuId = ref('')
const errorMessage = ref('') const errorMessage = ref('')
const statusFilter = ref('all') const statusFilter = ref('all')
const tagFilter = 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 - 삭제할 게시물 * @param {Object} post - 삭제할 게시물
* @returns {Promise<void>} 삭제 처리 결과 * @returns {Promise<void>} 삭제 처리 결과
*/ */
const deletePost = async (post) => { const deletePost = async (post) => {
if (!confirm(`"${post.title}" 글을 삭제할까요?`)) { closePostMenu()
if (!confirm(`"${post.title || '(제목 없음)'}" 글을 삭제할까요?`)) {
return return
} }
@@ -196,6 +282,14 @@ const deletePost = async (post) => {
deletingId.value = '' deletingId.value = ''
} }
} }
onMounted(() => {
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
})
</script> </script>
<template> <template>
@@ -265,7 +359,7 @@ const deletePost = async (post) => {
{{ errorMessage }} {{ errorMessage }}
</p> </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"> <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"> <thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr> <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 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> </tr>
</thead> </thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white"> <tbody class="admin-posts__table-body divide-y divide-line bg-white">
@@ -335,26 +431,61 @@ const deletePost = async (post) => {
{{ getUpdatedDateLabel(post) }} {{ getUpdatedDateLabel(post) }}
</p> </p>
</td> </td>
<td class="admin-posts__cell px-4 py-4"> <td class="admin-posts__cell admin-posts__cell-actions relative w-12 px-2 py-4 text-right">
<button <div
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" class="admin-posts__row-menu relative inline-flex justify-end"
type="button" data-admin-post-row-menu
:disabled="deletingId === post.id"
:aria-label="deletingId === post.id ? '삭제 ' : '삭제'"
@click="deletePost(post)"
> >
<span v-if="deletingId === post.id" class="admin-posts__delete-progress text-[10px] font-semibold text-muted" aria-hidden="true"></span> <button
<svg 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"
v-else type="button"
class="size-5 shrink-0" :disabled="deletingId === post.id || togglingFeaturedId === post.id"
xmlns="http://www.w3.org/2000/svg" :aria-expanded="openPostMenuId === post.id"
viewBox="0 0 24 24" aria-haspopup="menu"
fill="currentColor" :aria-label="openPostMenuId === post.id ? '게시글 메뉴 닫기' : '게시글 메뉴'"
aria-hidden="true" @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" /> <span
</svg> v-if="deletingId === post.id || togglingFeaturedId === post.id"
</button> 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> </td>
</tr> </tr>
</tbody> </tbody>

View File

@@ -10,6 +10,8 @@ const promotingTagId = ref('')
const demotingTagId = ref('') const demotingTagId = ref('')
const deletingGeneralTagId = ref('') const deletingGeneralTagId = ref('')
const toast = ref(null) const toast = ref(null)
const { openMenuId, closeMenu } = useAdminRowMenu()
let toastTimer = null let toastTimer = null
const generalTagQuery = ref('') const generalTagQuery = ref('')
const generalTagSortMode = ref('recent') const generalTagSortMode = ref('recent')
@@ -247,6 +249,8 @@ const setGeneralTagSortMode = (mode) => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const promoteToMainTag = async (tag) => { const promoteToMainTag = async (tag) => {
closeMenu()
if (promotingTagId.value) { if (promotingTagId.value) {
return return
} }
@@ -280,6 +284,8 @@ const promoteToMainTag = async (tag) => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const demoteToGeneralTag = async (tag) => { const demoteToGeneralTag = async (tag) => {
closeMenu()
if (demotingTagId.value) { if (demotingTagId.value) {
return return
} }
@@ -313,6 +319,8 @@ const demoteToGeneralTag = async (tag) => {
* @returns {Promise<void>} * @returns {Promise<void>}
*/ */
const deleteGeneralTag = async (tag) => { const deleteGeneralTag = async (tag) => {
closeMenu()
if (!confirm(`"${tag.name}" 일반 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) { if (!confirm(`"${tag.name}" 일반 태그를 삭제할까요? 연결된 글에서도 이 태그가 제거됩니다.`)) {
return return
} }
@@ -354,7 +362,7 @@ onBeforeUnmount(() => {
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다. 메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 아래 목록에서 확인하고 필요할 메인 태그로 전환할 있습니다.
</p> </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"> <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> <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"> <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 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> </tr>
</thead> </thead>
<tbody class="admin-tags__table-body divide-y divide-line bg-white"> <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"> <td class="admin-tags__cell px-4 py-4 text-muted">
{{ tag.description || '-' }} {{ tag.description || '-' }}
</td> </td>
<td class="admin-tags__cell px-4 py-4"> <td class="admin-tags__cell admin-tags__cell-actions relative w-12 px-2 py-4 text-right">
<div class="admin-tags__actions flex gap-2"> <AdminRowMoreMenu
<NuxtLink class="admin-tags__edit rounded border border-line px-3 py-1.5 text-xs font-semibold" :to="`/admin/tags/${tag.id}`"> 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> </NuxtLink>
<button <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" type="button"
role="menuitem"
:disabled="demotingTagId === tag.id" :disabled="demotingTagId === tag.id"
@click="demoteToGeneralTag(tag)" @click="demoteToGeneralTag(tag)"
> >
{{ demotingTagId === tag.id ? '변경 중' : '제외' }} 메인에서 제외
</button> </button>
</div> </AdminRowMoreMenu>
</td> </td>
</tr> </tr>
</tbody> </tbody>
</table> </table>
</div> </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"> <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> <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"> <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 <div
v-for="tag in filteredGeneralTags" v-for="tag in filteredGeneralTags"
:key="tag.id" :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" :title="tag.slug"
> >
<span class="h-3 w-1 rounded-full" :style="{ backgroundColor: tag.color }" /> <span class="h-3 w-1 shrink-0 rounded-full" :style="{ backgroundColor: tag.color }" />
<span class="truncate font-semibold text-ink">{{ tag.name }}</span> <span class="min-w-0 truncate font-semibold text-ink">{{ tag.name }}</span>
<span class="text-xs text-muted">{{ tag.postCount || 0 }}</span> <span class="shrink-0 text-xs text-muted">{{ tag.postCount || 0 }}</span>
<button <AdminRowMoreMenu
type="button" v-model:open-menu-id="openMenuId"
class="rounded-full border border-line bg-white px-2 py-1 text-[11px] font-semibold disabled:opacity-50" :item-id="tag.id"
:disabled="promotingTagId === tag.id" menu-label="태그 메뉴"
@click="promoteToMainTag(tag)" size="sm"
:busy="promotingTagId === tag.id || deletingGeneralTagId === tag.id"
> >
{{ promotingTagId === tag.id ? '전환 중' : '메인' }} <NuxtLink
</button> class="admin-row-more-menu__item"
<button :to="`/admin/tags/${tag.id}`"
type="button" role="menuitem"
class="rounded-full border border-red-200 bg-white px-2 py-1 text-[11px] font-semibold text-red-700 disabled:opacity-50" @click="closeMenu"
:disabled="deletingGeneralTagId === tag.id" >
@click="deleteGeneralTag(tag)" 태그 수정
> </NuxtLink>
{{ deletingGeneralTagId === tag.id ? '삭제 중' : '삭제' }} <button
</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>
</div> </div>
<p v-else class="text-sm text-muted"> <p v-else class="text-sm text-muted">