관리자 글 링크 참조 확인 추가

This commit is contained in:
2026-07-10 15:50:13 +09:00
parent e48f886956
commit d708381e09
9 changed files with 420 additions and 6 deletions

View File

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