관리자 목록 more vert 메뉴 통일 및 태그 메뉴 정렬 수정
AdminRowMoreMenu 공통 컴포넌트로 글·태그·페이지·미디어·네비게이션 행 액션을 ⋮ 팝오버로 통일. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
140
components/admin/AdminRowMoreMenu.vue
Normal file
140
components/admin/AdminRowMoreMenu.vue
Normal 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>
|
||||||
45
composables/useAdminRowMenu.js
Normal file
45
composables/useAdminRowMenu.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
12
docs/map.md
12
docs/map.md
@@ -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·스팸 플레이스홀더 |
|
||||||
|
|||||||
@@ -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이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
|
||||||
|
|||||||
@@ -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 충돌로 하단 항목이 반복 선택되던 문제 수정.
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
Reference in New Issue
Block a user