diff --git a/components/admin/AdminRowMoreMenu.vue b/components/admin/AdminRowMoreMenu.vue new file mode 100644 index 0000000..119c21f --- /dev/null +++ b/components/admin/AdminRowMoreMenu.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/composables/useAdminRowMenu.js b/composables/useAdminRowMenu.js new file mode 100644 index 0000000..15826a6 --- /dev/null +++ b/composables/useAdminRowMenu.js @@ -0,0 +1,45 @@ +/** + * 관리자 테이블·목록 행 more vert 메뉴 열림 상태 + * @returns {{ openMenuId: import('vue').Ref, 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 + } +} diff --git a/docs/map.md b/docs/map.md index 6dca1af..e3d180a 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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·스팸 플레이스홀더 | diff --git a/docs/spec.md b/docs/spec.md index e89612a..c5fe246 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -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이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다. diff --git a/docs/update.md b/docs/update.md index e8b4a70..9b334df 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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 충돌로 하단 항목이 반복 선택되던 문제 수정. diff --git a/pages/admin/media/index.vue b/pages/admin/media/index.vue index 26cbdb9..c17db94 100644 --- a/pages/admin/media/index.vue +++ b/pages/admin/media/index.vue @@ -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} */ 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) => { {{ getFolderName(folder) }} {{ folderMediaCounts[folder] || 0 }} - + + diff --git a/pages/admin/navigation/index.vue b/pages/admin/navigation/index.vue index 0095e9e..09fba04 100644 --- a/pages/admin/navigation/index.vue +++ b/pages/admin/navigation/index.vue @@ -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 () => { 상단 메뉴가 없습니다. 위 버튼으로 항목을 추가하세요. -
+
@@ -763,8 +775,8 @@ const saveNavigation = async () => { - @@ -817,14 +829,21 @@ const saveNavigation = async () => { required > - @@ -847,7 +866,7 @@ const saveNavigation = async () => { 하단 메뉴가 없습니다. -
URL - 관리 + + 관리
- + +
@@ -860,8 +879,8 @@ const saveNavigation = async () => { - @@ -898,14 +917,21 @@ const saveNavigation = async () => { required > - @@ -931,7 +957,7 @@ const saveNavigation = async () => { 추천 사이트가 없습니다. - @@ -944,8 +970,8 @@ const saveNavigation = async () => { - @@ -982,14 +1008,21 @@ const saveNavigation = async () => { required > - diff --git a/pages/admin/pages/index.vue b/pages/admin/pages/index.vue index b7af9a9..fe3dffe 100644 --- a/pages/admin/pages/index.vue +++ b/pages/admin/pages/index.vue @@ -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} 삭제 처리 결과 */ const deletePage = async (page) => { + closeMenu() + if (!confirm(`"${page.title}" 페이지를 삭제할까요?`)) { return } @@ -74,13 +78,15 @@ const deletePage = async (page) => { {{ errorMessage }}

-
+
- + @@ -96,15 +102,31 @@ const deletePage = async (page) => { - diff --git a/pages/admin/posts/index.vue b/pages/admin/posts/index.vue index 80c2206..e1a32eb 100644 --- a/pages/admin/posts/index.vue +++ b/pages/admin/posts/index.vue @@ -1,9 +1,13 @@
제목 수정일관리 + 관리 +
{{ formatDate(page.updatedAt) }} - + + 페이지 수정 + + +