관리자 글 링크 참조 확인 추가
This commit is contained in:
@@ -26,8 +26,13 @@ const analyticsModalMetric = ref('views')
|
||||
const analyticsModalChartPageOffset = ref(0)
|
||||
const analyticsModalLoading = ref(false)
|
||||
const analyticsModalError = ref('')
|
||||
const referenceModalPost = ref(null)
|
||||
const referenceModalData = ref(createDefaultPostReferences())
|
||||
const referenceModalLoading = ref(false)
|
||||
const referenceModalError = ref('')
|
||||
const postMenuTriggerRefs = new Map()
|
||||
const tagFilterRef = ref(null)
|
||||
const postSearchInputRef = ref(null)
|
||||
|
||||
const {
|
||||
displayValue: searchInput,
|
||||
@@ -122,6 +127,17 @@ function createDefaultPostAnalytics() {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 참조 목록 기본값을 생성한다.
|
||||
* @returns {{ target: null, posts: Array }} 게시물 참조 목록 기본값
|
||||
*/
|
||||
function createDefaultPostReferences() {
|
||||
return {
|
||||
target: null,
|
||||
posts: []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 숫자를 관리자 목록 표시 형식으로 변환한다.
|
||||
* @param {number} value - 숫자 값
|
||||
@@ -448,6 +464,12 @@ const getPostThumbnailSrc = (post) => post.featuredImageThumbnail || post.featur
|
||||
/** 게시물 통계 모달이 열려 있는지 */
|
||||
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value))
|
||||
|
||||
/** 게시물 참조 모달이 열려 있는지 */
|
||||
const isReferenceModalOpen = computed(() => Boolean(referenceModalPost.value))
|
||||
|
||||
/** 게시물 참조 목록 */
|
||||
const referenceModalPosts = computed(() => referenceModalData.value?.posts || [])
|
||||
|
||||
/** 통계 모달 검색 유입 조회수 */
|
||||
const analyticsModalSearchViews = computed(() => getAnalyticsSourceGroupViews(['search']))
|
||||
|
||||
@@ -570,6 +592,81 @@ const closeAnalyticsModal = () => {
|
||||
analyticsModalData.value = createDefaultPostAnalytics()
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 참조 목록을 조회한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {Promise<{ target: Object | null, posts: Array }>} 참조 목록
|
||||
*/
|
||||
const fetchPostReferences = async (post) => {
|
||||
return await $fetch(`/admin/api/posts/${post.id}/references`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 참조 모달을 연다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {Promise<void>} 모달 열기 완료
|
||||
*/
|
||||
const openReferenceModal = async (post) => {
|
||||
closePostMenu()
|
||||
referenceModalPost.value = post
|
||||
referenceModalData.value = createDefaultPostReferences()
|
||||
referenceModalError.value = ''
|
||||
referenceModalLoading.value = true
|
||||
|
||||
try {
|
||||
referenceModalData.value = await fetchPostReferences(post)
|
||||
} catch (error) {
|
||||
referenceModalError.value = error?.data?.message || '링크한 게시물을 불러오지 못했습니다.'
|
||||
} finally {
|
||||
referenceModalLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 참조 모달을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeReferenceModal = () => {
|
||||
referenceModalPost.value = null
|
||||
referenceModalData.value = createDefaultPostReferences()
|
||||
referenceModalError.value = ''
|
||||
referenceModalLoading.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 삭제 확인 메시지를 생성한다.
|
||||
* @param {Object} post - 삭제 대상 게시물
|
||||
* @param {{ posts?: Array }} references - 참조 목록
|
||||
* @param {boolean} didReferenceCheckFail - 참조 확인 실패 여부
|
||||
* @returns {string} 삭제 확인 메시지
|
||||
*/
|
||||
const createDeletePostMessage = (post, references, didReferenceCheckFail = false) => {
|
||||
const title = post.title || '(제목 없음)'
|
||||
const referencePosts = references?.posts || []
|
||||
|
||||
if (didReferenceCheckFail) {
|
||||
return `"${title}" 글을 삭제할까요?\n\n이 글을 링크한 게시물 목록은 확인하지 못했습니다.`
|
||||
}
|
||||
|
||||
if (!referencePosts.length) {
|
||||
return `"${title}" 글을 삭제할까요?`
|
||||
}
|
||||
|
||||
const referenceList = referencePosts
|
||||
.slice(0, 5)
|
||||
.map((item) => `- ${item.title || '(제목 없음)'}`)
|
||||
.join('\n')
|
||||
const moreCount = Math.max(referencePosts.length - 5, 0)
|
||||
const moreText = moreCount ? `\n- 외 ${moreCount}개` : ''
|
||||
|
||||
return [
|
||||
`"${title}" 글을 삭제할까요?`,
|
||||
'',
|
||||
`이 글을 링크한 게시물 ${referencePosts.length}개가 있습니다.`,
|
||||
referenceList + moreText
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* 통계 모달 집계 단위를 변경한다.
|
||||
* @param {string} period - 집계 단위
|
||||
@@ -722,7 +819,7 @@ const updatePostMenuPosition = () => {
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const menuWidth = 176
|
||||
const estimatedHeight = 112
|
||||
const estimatedHeight = 152
|
||||
const margin = 8
|
||||
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
|
||||
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
|
||||
@@ -767,6 +864,28 @@ const onPostMenuDocumentPointerDown = (event) => {
|
||||
closePostMenu()
|
||||
}
|
||||
|
||||
/**
|
||||
* 키 입력 대상이 편집 가능한 요소인지 확인한다.
|
||||
* @param {EventTarget | null} target - 이벤트 대상
|
||||
* @returns {boolean} 편집 가능 여부
|
||||
*/
|
||||
const isEditableKeyboardTarget = (target) => {
|
||||
if (!(target instanceof HTMLElement)) {
|
||||
return false
|
||||
}
|
||||
|
||||
return target.isContentEditable || ['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)
|
||||
}
|
||||
|
||||
/**
|
||||
* 글 검색 입력창으로 포커스를 이동한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusPostSearchInput = () => {
|
||||
postSearchInputRef.value?.focus()
|
||||
postSearchInputRef.value?.select?.()
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 키 입력을 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
@@ -780,6 +899,30 @@ const onDocumentKeydown = (event) => {
|
||||
|
||||
if (event.key === 'Escape' && isAnalyticsModalOpen.value) {
|
||||
closeAnalyticsModal()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && isReferenceModalOpen.value) {
|
||||
closeReferenceModal()
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
event.key.toLowerCase() === 'f'
|
||||
&& !event.metaKey
|
||||
&& !event.ctrlKey
|
||||
&& !event.altKey
|
||||
&& !event.shiftKey
|
||||
&& !event.isComposing
|
||||
&& !event.defaultPrevented
|
||||
&& !isAnalyticsModalOpen.value
|
||||
&& !isReferenceModalOpen.value
|
||||
&& !isEditableKeyboardTarget(event.target)
|
||||
) {
|
||||
event.preventDefault()
|
||||
closePostMenu()
|
||||
closeTagFilter()
|
||||
focusPostSearchInput()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -841,7 +984,16 @@ const togglePostPinned = async (post) => {
|
||||
const deletePost = async (post) => {
|
||||
closePostMenu()
|
||||
|
||||
if (!confirm(`"${post.title || '(제목 없음)'}" 글을 삭제할까요?`)) {
|
||||
let references = createDefaultPostReferences()
|
||||
let didReferenceCheckFail = false
|
||||
|
||||
try {
|
||||
references = await fetchPostReferences(post)
|
||||
} catch {
|
||||
didReferenceCheckFail = true
|
||||
}
|
||||
|
||||
if (!confirm(createDeletePostMessage(post, references, didReferenceCheckFail))) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -933,6 +1085,7 @@ watch(postPageCount, (pageCount) => {
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
</svg>
|
||||
<input
|
||||
ref="postSearchInputRef"
|
||||
:value="searchInput"
|
||||
class="admin-posts__search-input h-10 w-52 rounded border border-line bg-white pl-9 pr-3 text-sm text-[#394047] outline-none transition-colors placeholder:text-[#8e9cac] hover:border-[#c8ced3] focus:border-[#8e9cac]"
|
||||
type="search"
|
||||
@@ -1239,6 +1392,14 @@ watch(postPageCount, (pageCount) => {
|
||||
>
|
||||
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7]"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
@click="openReferenceModal(post)"
|
||||
>
|
||||
링크 확인
|
||||
</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"
|
||||
@@ -1304,6 +1465,84 @@ watch(postPageCount, (pageCount) => {
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isReferenceModalOpen"
|
||||
class="admin-posts__reference-modal fixed inset-0 z-[90] flex items-center justify-center bg-black/40 px-4 py-6"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="admin-posts-reference-modal-title"
|
||||
@pointerdown.self="closeReferenceModal"
|
||||
>
|
||||
<article class="admin-posts__reference-modal-panel max-h-[86vh] w-full max-w-2xl overflow-y-auto rounded-lg border border-line bg-white shadow-xl">
|
||||
<header class="admin-posts__reference-modal-header flex items-start justify-between gap-4 border-b border-line px-5 py-4">
|
||||
<div class="min-w-0">
|
||||
<p class="text-xs font-semibold uppercase text-muted">
|
||||
링크 확인
|
||||
</p>
|
||||
<h2 id="admin-posts-reference-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
|
||||
{{ referenceModalPost?.title || '(제목 없음)' }}
|
||||
</h2>
|
||||
<p class="mt-1 truncate text-xs text-muted">
|
||||
/post/{{ referenceModalPost?.slug }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="admin-posts__reference-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
|
||||
type="button"
|
||||
@click="closeReferenceModal"
|
||||
>
|
||||
닫기
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-posts__reference-modal-body p-5">
|
||||
<p v-if="referenceModalLoading" class="text-sm text-muted">
|
||||
이 글을 링크한 게시물을 확인하는 중입니다.
|
||||
</p>
|
||||
<p v-else-if="referenceModalError" class="text-sm text-red-600">
|
||||
{{ referenceModalError }}
|
||||
</p>
|
||||
<template v-else>
|
||||
<p class="text-sm text-muted">
|
||||
이 글을 링크한 게시물 {{ referenceModalPosts.length }}개
|
||||
</p>
|
||||
<ul v-if="referenceModalPosts.length" class="mt-4 divide-y divide-line border border-line">
|
||||
<li
|
||||
v-for="item in referenceModalPosts"
|
||||
:key="item.id"
|
||||
class="admin-posts__reference-item bg-white px-4 py-3"
|
||||
>
|
||||
<div class="flex flex-wrap items-center justify-between gap-3">
|
||||
<NuxtLink
|
||||
class="min-w-0 font-semibold text-ink hover:opacity-70"
|
||||
:to="`/admin/posts/${item.id}`"
|
||||
@click="closeReferenceModal"
|
||||
>
|
||||
{{ item.title || '(제목 없음)' }}
|
||||
</NuxtLink>
|
||||
<span class="shrink-0 text-xs text-muted">
|
||||
{{ item.referenceCount }}회 링크
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap items-center gap-2 text-xs text-muted">
|
||||
<span>{{ getPostStatusLabel(item) }}</span>
|
||||
<span class="text-[#d7dce0]">|</span>
|
||||
<span class="truncate">/post/{{ item.slug }}</span>
|
||||
<span class="text-[#d7dce0]">|</span>
|
||||
<span>수정 {{ formatAdminDateTimeLabel(item.updatedAt) }}</span>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="mt-4 border border-line bg-paper px-4 py-5 text-sm text-muted">
|
||||
이 게시물을 링크한 다른 게시물이 없습니다.
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="isAnalyticsModalOpen"
|
||||
|
||||
Reference in New Issue
Block a user