Files
sori.studio/pages/admin/posts/index.vue

1855 lines
70 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
definePageMeta({
layout: 'admin'
})
const deletingId = ref('')
const togglingFeaturedId = ref('')
const togglingPinnedId = ref('')
const openPostMenuId = ref('')
const postMenuStyle = ref({})
const errorMessage = ref('')
const statusFilter = ref('all')
const tagFilter = ref('all')
const tagFilterSearch = ref('')
const featuredFilter = ref('all')
const sortOrder = ref('newest')
const searchQuery = ref('')
const postPage = ref(1)
const postPageSize = 50
const isTagFilterOpen = ref(false)
const activeTagFilterOptionIndex = ref(0)
const analyticsModalPost = ref(null)
const analyticsModalData = ref(createDefaultPostAnalytics())
const analyticsModalPeriod = ref('daily')
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 referenceModalSearchQuery = ref('')
const postMenuTriggerRefs = new Map()
const tagFilterRef = ref(null)
const postSearchInputRef = ref(null)
const {
displayValue: searchInput,
onInput: onSearchInput,
onCompositionEnd: onSearchCompositionEnd
} = useImeSearchInput(searchQuery, { debounce: 200 })
const {
displayValue: tagFilterSearchInput,
onInput: onTagFilterSearchInput,
onCompositionEnd: onTagFilterSearchCompositionEnd
} = useImeSearchInput(tagFilterSearch)
const postsQuery = computed(() => ({
paged: 'true',
page: postPage.value,
pageSize: postPageSize,
q: searchQuery.value.trim() || undefined,
status: statusFilter.value === 'all' ? undefined : statusFilter.value,
tag: tagFilter.value === 'all' ? undefined : tagFilter.value,
featured: featuredFilter.value === 'all' ? undefined : featuredFilter.value,
sort: sortOrder.value
}))
const { data: postsResponse, refresh: refreshPosts } = await useFetch('/admin/api/posts', {
query: postsQuery,
default: () => ({
items: [],
pagination: {
page: 1,
pageSize: postPageSize,
total: 0,
pageCount: 1
},
counts: {
total: 0,
featured: 0,
pinned: 0
}
})
})
const { data: tags } = await useFetch('/admin/api/tags', {
default: () => []
})
const { data: siteSettings } = await useFetch('/admin/api/settings', {
default: () => ({ showPostUpdatedAt: false })
})
const defaultTagColor = '#15171a'
const analyticsPeriodOptions = [
{ label: '일간', value: 'daily', days: 14 },
{ label: '주간', value: 'weekly', days: 84 },
{ label: '월간', value: 'monthly', days: 365 }
]
const analyticsMetricOptions = [
{ label: '조회수', value: 'views' },
{ label: '방문자', value: 'visitors' }
]
/**
* 게시물별 통계 기본값을 생성한다.
* @returns {Object} 게시물별 통계 기본값
*/
function createDefaultPostAnalytics() {
return {
updatedAt: '',
total: {
views: 0,
visitors: 0,
reads: 0,
avgEngagedSeconds: 0,
scroll50: 0,
scroll75: 0,
scroll100: 0
},
period: {
views: 0,
visitors: 0,
reads: 0,
avgEngagedSeconds: 0,
scroll50: 0,
scroll75: 0,
scroll100: 0
},
trends: [],
sources: [],
devices: [],
keywords: []
}
}
/**
* 게시물 참조 목록 기본값을 생성한다.
* @returns {{ target: null, posts: Array, pages: Array }} 게시물 참조 목록 기본값
*/
function createDefaultPostReferences() {
return {
target: null,
posts: [],
pages: []
}
}
/**
* 숫자를 관리자 목록 표시 형식으로 변환한다.
* @param {number} value - 숫자 값
* @returns {string} 표시 문자열
*/
const formatAdminNumber = (value) => {
return new Intl.NumberFormat('ko-KR').format(Number(value || 0))
}
/**
* 날짜 값을 YYYY.MM.DD HH:mm 형식으로 표시한다.
* @param {string | null} value - 날짜 값
* @returns {string} 표시 문자열
*/
const formatAdminDateTimeLabel = (value) => {
if (!value) {
return '-'
}
const date = new Date(value)
if (Number.isNaN(date.getTime())) {
return '-'
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
const hours = String(date.getHours()).padStart(2, '0')
const minutes = String(date.getMinutes()).padStart(2, '0')
return `${year}.${month}.${day} ${hours}:${minutes}`
}
/**
* 초 단위 체류시간을 표시 문자열로 변환한다.
* @param {number} seconds - 초
* @returns {string} 표시 문자열
*/
const formatEngagedDuration = (seconds) => {
const value = Number(seconds) || 0
if (value < 60) {
return `${value}초`
}
const minutes = Math.floor(value / 60)
const remainSeconds = value % 60
return remainSeconds > 0 ? `${minutes}분 ${remainSeconds}초` : `${minutes}분`
}
/**
* 게시물 통계 기간 표시명을 반환한다.
* @returns {string} 기간 표시명
*/
const getAnalyticsPeriodLabel = () => {
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.label || '일간'
}
/**
* 게시물 통계 기간 조회 일수를 반환한다.
* @returns {number} 조회 일수
*/
const getAnalyticsPeriodDays = () => {
return analyticsPeriodOptions.find((option) => option.value === analyticsModalPeriod.value)?.days || 14
}
/**
* 게시물 발행일 라벨을 반환한다.
* @param {Object} post - 게시물
* @returns {string} 발행일 또는 '-'
*/
const getPublishDateLabel = (post) => {
if (!post.publishedAt) {
return '-'
}
return formatPostDateTime(post.publishedAt)
}
/**
* 게시물 수정일 보조 라벨을 반환한다.
* @param {Object} post - 게시물
* @returns {string} 수정일 라벨 또는 빈 문자열
*/
const getUpdatedDateLabel = (post) => {
if (!siteSettings.value?.showPostUpdatedAt || !wasPostUpdatedAfterPublish(post)) {
return ''
}
return `수정: ${formatPostDateTime(post.updatedAt)}`
}
/**
* 게시물 공개 여부 확인
* @param {Object} post - 게시물
* @returns {boolean} 공개 여부
*/
const isPublicPost = (post) => post.status === 'published'
&& (!post.publishedAt || new Date(post.publishedAt) <= new Date())
/**
* 게시물 상태 필터 키 생성
* @param {Object} post - 게시물
* @returns {'published' | 'scheduled' | 'draft' | 'members' | 'private'} 상태 키
*/
const getPostStatusKey = (post) => {
if (post.status === 'members' || post.status === 'private') {
return post.status
}
if (post.status === 'published' && !isPublicPost(post)) {
return 'scheduled'
}
if (post.status === 'published') {
return 'published'
}
return 'draft'
}
/**
* 게시물 상태 표시 문자열 생성
* @param {Object} post - 게시물
* @returns {string} 상태 표시 문자열
*/
const getPostStatusLabel = (post) => {
const statusKey = getPostStatusKey(post)
if (statusKey === 'scheduled') {
return '예약'
}
if (statusKey === 'published') {
return '공개'
}
if (statusKey === 'members') {
return '멤버십'
}
if (statusKey === 'private') {
return '비공개'
}
return '초안'
}
/**
* 게시물 상태 텍스트 클래스 생성
* @param {Object} post - 게시물
* @returns {string} 상태 텍스트 클래스
*/
const getPostStatusClass = (post) => {
const statusKey = getPostStatusKey(post)
if (statusKey === 'scheduled') {
return 'font-bold text-[#30cf43]'
}
if (statusKey === 'draft') {
return 'font-bold text-[#fb2d8d]'
}
if (statusKey === 'members') {
return 'font-bold text-[#5a63d8]'
}
if (statusKey === 'private') {
return 'font-bold text-[#15171a]'
}
if (statusKey === 'published') {
return 'text-[#99A3AD]'
}
return 'font-bold text-[#8e9cac]'
}
/**
* 태그 슬러그의 표시 이름을 조회한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 표시 이름
*/
const getTagName = (slug) => tags.value.find((tag) => tag.slug === slug)?.name || slug
/**
* 태그 슬러그의 고유 색상을 반환한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 색상
*/
const getTagColor = (slug) => tags.value.find((tag) => tag.slug === slug)?.color || defaultTagColor
/**
* 태그 배지 스타일을 생성한다.
* @param {string} color - 태그 색상
* @returns {Object} 배지 인라인 스타일
*/
const createTagBadgeStyle = (color) => ({
backgroundColor: `color-mix(in srgb, ${color} 14%, white)`,
borderColor: `color-mix(in srgb, ${color} 34%, white)`,
color
})
/** 현재 페이지 게시물 목록 */
const postItems = computed(() => postsResponse.value?.items || [])
/** 관리자 글 목록 페이지 정보 */
const postPagination = computed(() => postsResponse.value?.pagination || {
page: 1,
pageSize: postPageSize,
total: 0,
pageCount: 1
})
/** 관리자 글 목록 통계 정보 */
const postCounts = computed(() => postsResponse.value?.counts || {
total: 0,
featured: 0,
pinned: 0
})
/** 게시물에 사용 중인 태그 옵션 */
const usedTagOptions = computed(() => tags.value
.filter((tag) => Number(tag.postCount || 0) > 0)
.map((tag) => ({
slug: tag.slug,
name: tag.name || tag.slug,
color: tag.color || defaultTagColor
}))
.sort((left, right) => left.name.localeCompare(right.name, 'ko')))
const tagFilterSuggestionOptions = computed(() => {
const keyword = tagFilterSearch.value.trim().toLowerCase()
const matchedTags = usedTagOptions.value
.filter((tag) => {
if (!keyword) {
return true
}
return tag.name.toLowerCase().includes(keyword) || tag.slug.toLowerCase().includes(keyword)
})
.slice(0, 20)
const shouldShowAllOption = !keyword || '전체 태그'.includes(keyword) || 'all'.includes(keyword)
return [
...(shouldShowAllOption ? [{ slug: 'all', name: '전체 태그', color: defaultTagColor }] : []),
...matchedTags
]
})
/** 태그 필터 추천 항목이 있는지 여부 */
const hasTagFilterSuggestions = computed(() => tagFilterSuggestionOptions.value.length > 0)
/** 현재 태그 필터 표시명 */
const selectedTagFilterLabel = computed(() => tagFilter.value === 'all' ? '전체 태그' : getTagName(tagFilter.value))
/** 전체 게시물 수 */
const totalPostCount = computed(() => Number(postCounts.value.total || 0))
/** 추천(`isFeatured`) 게시물 수 */
const featuredPostCount = computed(() => Number(postCounts.value.featured || 0))
/** 관리자 목록 상단 고정 게시물 수 */
const pinnedPostCount = computed(() => Number(postCounts.value.pinned || 0))
/** 필터 적용 후 총 게시물 수 */
const filteredPostCount = computed(() => Number(postPagination.value.total || 0))
/** 필터가 적용됐는지 */
const hasActiveListFilters = computed(() => statusFilter.value !== 'all'
|| tagFilter.value !== 'all'
|| featuredFilter.value !== 'all'
|| Boolean(searchQuery.value.trim()))
/** 글 목록 전체 페이지 수 */
const postPageCount = computed(() => Math.max(1, Number(postPagination.value.pageCount || 1)))
/** 글 목록 페이지 버튼 항목 */
const postPaginationItems = computed(() => {
const total = postPageCount.value
const current = postPage.value
if (total <= 7) {
return Array.from({ length: total }, (_, index) => index + 1)
}
const pages = new Set([1, total, current - 1, current, current + 1])
if (current <= 4) {
pages.add(2)
pages.add(3)
pages.add(4)
pages.add(5)
}
if (current >= total - 3) {
pages.add(total - 4)
pages.add(total - 3)
pages.add(total - 2)
pages.add(total - 1)
}
const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right)
return orderedPages.reduce((items, page, index) => {
const previousPage = orderedPages[index - 1]
if (previousPage && page - previousPage > 1) {
items.push(`ellipsis-${previousPage}-${page}`)
}
items.push(page)
return items
}, [])
})
/**
* 게시물 목록 썸네일 URL을 반환한다.
* @param {Object} post - 게시물
* @returns {string} 썸네일 URL
*/
const getPostThumbnailSrc = (post) => post.featuredImageThumbnail || post.featuredImage || ''
/** 게시물 통계 모달이 열려 있는지 */
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value))
/** 게시물 참조 모달이 열려 있는지 */
const isReferenceModalOpen = computed(() => Boolean(referenceModalPost.value))
/** 게시물 참조 목록 */
const referenceModalPosts = computed(() => referenceModalData.value?.posts || [])
/** 페이지 참조 목록 */
const referenceModalPages = computed(() => referenceModalData.value?.pages || [])
/** 게시물·페이지 통합 참조 목록 */
const referenceModalItems = computed(() => [
...referenceModalPosts.value.map((item) => ({ ...item, referenceType: 'post' })),
...referenceModalPages.value.map((item) => ({ ...item, referenceType: 'page' }))
])
/** 게시물 참조 모달 검색어 */
const normalizedReferenceModalSearchQuery = computed(() => referenceModalSearchQuery.value.trim().toLowerCase())
/** 검색어가 적용된 콘텐츠 참조 목록 */
const filteredReferenceModalPosts = computed(() => {
const keyword = normalizedReferenceModalSearchQuery.value
if (!keyword) {
return referenceModalItems.value
}
return referenceModalItems.value.filter((post) => {
return [
post.title,
post.slug,
post.status,
post.referenceType
].some((value) => String(value || '').toLowerCase().includes(keyword))
})
})
/** 통계 모달 검색 유입 조회수 */
const analyticsModalSearchViews = computed(() => getAnalyticsSourceGroupViews(['search']))
/** 통계 모달 SNS 유입 조회수 */
const analyticsModalSnsViews = computed(() => getAnalyticsSourceGroupViews(['sns']))
/** 통계 모달 기타 유입 조회수 */
const analyticsModalOtherViews = computed(() => getAnalyticsSourceGroupViews(['direct', 'other']))
/** 통계 모달 모바일 디바이스 행 */
const analyticsModalMobileDevice = computed(() => {
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === '모바일')
})
/** 통계 모달 PC 디바이스 행 */
const analyticsModalPcDevice = computed(() => {
return (analyticsModalData.value.devices || []).find((row) => row.deviceType === 'PC')
})
/** 통계 모달 디바이스 총 조회수 */
const analyticsModalTotalDevicePageViews = computed(() => {
return (analyticsModalData.value.devices || []).reduce((sum, row) => sum + Number(row.pageViews || 0), 0)
})
/**
* 유입 그룹 표시명을 반환한다.
* @param {string} sourceGroup - 유입 그룹
* @returns {string} 표시명
*/
const getTrafficGroupLabel = (sourceGroup) => {
if (sourceGroup === 'search') {
return '검색'
}
if (sourceGroup === 'sns') {
return 'SNS'
}
if (sourceGroup === 'direct') {
return '직접'
}
return '기타'
}
/**
* 유입 그룹별 조회수를 합산한다.
* @param {string[]} groups - 유입 그룹
* @returns {number} 조회수 합계
*/
const getAnalyticsSourceGroupViews = (groups) => {
return (analyticsModalData.value.sources || [])
.filter((source) => groups.includes(source.sourceGroup))
.reduce((sum, source) => sum + Number(source.pageViews || 0), 0)
}
/**
* 디바이스 조회 비율을 반환한다.
* @param {number} value - 조회수
* @returns {string} 비율
*/
const formatAnalyticsDevicePercent = (value) => {
const total = analyticsModalTotalDevicePageViews.value
if (!total) {
return '0.0%'
}
return `${((Number(value || 0) / total) * 100).toFixed(1)}%`
}
/**
* 게시물 통계 모달 데이터를 조회한다.
* @returns {Promise<void>} 조회 완료
*/
const fetchAnalyticsModalData = async () => {
if (!analyticsModalPost.value?.id) {
return
}
analyticsModalLoading.value = true
analyticsModalError.value = ''
try {
analyticsModalData.value = await $fetch(`/admin/api/analytics/posts/${analyticsModalPost.value.id}`, {
query: {
period: analyticsModalPeriod.value,
days: getAnalyticsPeriodDays()
}
})
} catch (error) {
analyticsModalData.value = createDefaultPostAnalytics()
analyticsModalError.value = error?.data?.message || '게시물 통계를 불러오지 못했습니다.'
} finally {
analyticsModalLoading.value = false
}
}
/**
* 게시물 통계 모달을 연다.
* @param {Object} post - 게시물
* @returns {Promise<void>} 모달 열기 완료
*/
const openAnalyticsModal = async (post) => {
closePostMenu()
analyticsModalPost.value = post
analyticsModalPeriod.value = 'daily'
analyticsModalMetric.value = 'views'
analyticsModalChartPageOffset.value = 0
analyticsModalData.value = createDefaultPostAnalytics()
await fetchAnalyticsModalData()
}
/**
* 게시물 통계 모달을 닫는다.
* @returns {void}
*/
const closeAnalyticsModal = () => {
analyticsModalPost.value = null
analyticsModalError.value = ''
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 = ''
referenceModalSearchQuery.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 = ''
referenceModalSearchQuery.value = ''
referenceModalLoading.value = false
}
/**
* 삭제 확인 메시지를 생성한다.
* @param {Object} post - 삭제 대상 게시물
* @param {{ posts?: Array, pages?: Array }} references - 참조 목록
* @param {boolean} didReferenceCheckFail - 참조 확인 실패 여부
* @returns {string} 삭제 확인 메시지
*/
const createDeletePostMessage = (post, references, didReferenceCheckFail = false) => {
const title = post.title || '(제목 없음)'
const referencePosts = references?.posts || []
const referencePages = references?.pages || []
const referenceItems = [
...referencePosts.map((item) => ({ ...item, referenceType: 'post' })),
...referencePages.map((item) => ({ ...item, referenceType: 'page' }))
]
if (didReferenceCheckFail) {
return `"${title}" 글을 삭제할까요?\n\n이 글을 링크한 콘텐츠 목록은 확인하지 못했습니다.`
}
if (!referenceItems.length) {
return `"${title}" 글을 삭제할까요?`
}
const referenceList = referenceItems
.slice(0, 5)
.map((item) => `- ${item.referenceType === 'page' ? '페이지' : '게시물'}: ${item.title || '(제목 없음)'}`)
.join('\n')
const moreCount = Math.max(referenceItems.length - 5, 0)
const moreText = moreCount ? `\n- 외 ${moreCount}개` : ''
return [
`"${title}" 글을 삭제할까요?`,
'',
`이 글을 링크한 콘텐츠 ${referenceItems.length}개가 있습니다.`,
referenceList + moreText
].join('\n')
}
/**
* 통계 모달 집계 단위를 변경한다.
* @param {string} period - 집계 단위
* @returns {Promise<void>} 기간 변경 완료
*/
const selectAnalyticsModalPeriod = async (period) => {
analyticsModalPeriod.value = period
analyticsModalChartPageOffset.value = 0
await fetchAnalyticsModalData()
}
/**
* 열린 행 메뉴를 닫는다.
* @returns {void}
*/
const closePostMenu = () => {
openPostMenuId.value = ''
}
/**
* 태그 필터 드롭다운을 연다.
* @returns {void}
*/
const openTagFilter = () => {
isTagFilterOpen.value = true
}
/**
* 태그 필터 드롭다운을 닫는다.
* @returns {void}
*/
const closeTagFilter = () => {
isTagFilterOpen.value = false
tagFilterSearch.value = ''
activeTagFilterOptionIndex.value = 0
}
/**
* 태그 필터 드롭다운을 토글한다.
* @returns {void}
*/
const toggleTagFilter = () => {
if (isTagFilterOpen.value) {
closeTagFilter()
return
}
openTagFilter()
}
/**
* 태그 필터 항목을 선택한다.
* @param {Object} option - 태그 필터 항목
* @returns {void}
*/
const selectTagFilterOption = (option) => {
tagFilter.value = option.slug
closeTagFilter()
}
/**
* 태그 필터를 전체 태그로 초기화한다.
* @returns {void}
*/
const clearTagFilter = () => {
tagFilter.value = 'all'
closeTagFilter()
}
/**
* 태그 필터 키 입력을 처리한다.
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleTagFilterKeydown = (event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
openTagFilter()
activeTagFilterOptionIndex.value = Math.min(
activeTagFilterOptionIndex.value + 1,
Math.max(tagFilterSuggestionOptions.value.length - 1, 0)
)
return
}
if (event.key === 'ArrowUp') {
event.preventDefault()
openTagFilter()
activeTagFilterOptionIndex.value = Math.max(activeTagFilterOptionIndex.value - 1, 0)
return
}
if (event.key === 'Enter' && isTagFilterOpen.value && hasTagFilterSuggestions.value) {
event.preventDefault()
selectTagFilterOption(tagFilterSuggestionOptions.value[activeTagFilterOptionIndex.value])
return
}
if (event.key === 'Escape' && isTagFilterOpen.value) {
event.preventDefault()
closeTagFilter()
}
}
/**
* 관리자 글 목록 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {void}
*/
const setPostPage = (page) => {
postPage.value = Math.min(Math.max(Number(page) || 1, 1), postPageCount.value)
closePostMenu()
if (import.meta.client) {
window.scrollTo({
top: 0,
behavior: 'smooth'
})
}
}
/**
* 게시물 행 메뉴 트리거 엘리먼트를 저장한다.
* @param {string} postId - 게시물 ID
* @param {Element|null} element - 트리거 엘리먼트
* @returns {void}
*/
const setPostMenuTriggerRef = (postId, element) => {
if (!element) {
postMenuTriggerRefs.delete(postId)
return
}
postMenuTriggerRefs.set(postId, element)
}
/**
* 게시물 행 메뉴 위치를 화면 기준으로 계산한다.
* @returns {void}
*/
const updatePostMenuPosition = () => {
if (!import.meta.client || !openPostMenuId.value) {
return
}
const trigger = postMenuTriggerRefs.get(openPostMenuId.value)
if (!trigger) {
return
}
const rect = trigger.getBoundingClientRect()
const menuWidth = 176
const estimatedHeight = 112
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
const top = opensUp
? Math.max(margin, rect.top - estimatedHeight - 4)
: rect.bottom + 4
postMenuStyle.value = {
left: `${left}px`,
top: `${top}px`,
width: `${menuWidth}px`
}
}
/**
* 행 메뉴 열기/닫기
* @param {string} postId - 게시물 ID
* @returns {void}
*/
const togglePostMenu = (postId) => {
openPostMenuId.value = openPostMenuId.value === postId ? '' : postId
}
/**
* 외부 클릭 시 행 메뉴 닫기
* @param {PointerEvent} event - 포인터 이벤트
* @returns {void}
*/
const onPostMenuDocumentPointerDown = (event) => {
if (isTagFilterOpen.value && event.target instanceof Node && !tagFilterRef.value?.contains(event.target)) {
closeTagFilter()
}
if (!openPostMenuId.value || !(event.target instanceof HTMLElement)) {
return
}
if (event.target.closest('[data-admin-post-row-menu]')) {
return
}
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 - 키보드 이벤트
* @returns {void}
*/
const onDocumentKeydown = (event) => {
if (event.key === 'Escape' && isTagFilterOpen.value) {
closeTagFilter()
return
}
if (event.key === 'Escape' && isAnalyticsModalOpen.value) {
closeAnalyticsModal()
return
}
if (event.key === 'Escape' && isReferenceModalOpen.value) {
closeReferenceModal()
return
}
if (
['f', 'ㄹ'].includes(event.key.toLowerCase())
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& !event.shiftKey
&& !event.isComposing
&& !event.defaultPrevented
&& !isAnalyticsModalOpen.value
&& !isReferenceModalOpen.value
&& !isEditableKeyboardTarget(event.target)
) {
event.preventDefault()
closePostMenu()
closeTagFilter()
focusPostSearchInput()
}
}
/**
* 게시물 추천 여부를 토글한다.
* @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}/featured`, {
method: 'PUT',
body: {
isFeatured: !post.isFeatured
}
})
await refreshPosts()
} catch (error) {
errorMessage.value = error?.data?.message || '추천 설정을 변경하지 못했습니다.'
} finally {
togglingFeaturedId.value = ''
}
}
/**
* 게시물 관리자 목록 고정 여부를 토글한다.
* @param {Object} post - 게시물
* @returns {Promise<void>}
*/
const togglePostPinned = async (post) => {
closePostMenu()
togglingPinnedId.value = post.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${post.id}/pinned`, {
method: 'PUT',
body: {
adminPinned: !post.adminPinned
}
})
await refreshPosts()
} catch (error) {
errorMessage.value = error?.data?.message || '고정 설정을 변경하지 못했습니다.'
} finally {
togglingPinnedId.value = ''
}
}
/**
* 게시물 삭제
* @param {Object} post - 삭제할 게시물
* @returns {Promise<void>} 삭제 처리 결과
*/
const deletePost = async (post) => {
closePostMenu()
let references = createDefaultPostReferences()
let didReferenceCheckFail = false
try {
references = await fetchPostReferences(post)
} catch {
didReferenceCheckFail = true
}
if (!confirm(createDeletePostMessage(post, references, didReferenceCheckFail))) {
return
}
deletingId.value = post.id
errorMessage.value = ''
try {
await $fetch(`/admin/api/posts/${post.id}`, {
method: 'DELETE'
})
await refreshPosts()
} catch (error) {
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
} finally {
deletingId.value = ''
}
}
onMounted(() => {
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
document.addEventListener('keydown', onDocumentKeydown)
window.addEventListener('resize', updatePostMenuPosition)
window.addEventListener('scroll', updatePostMenuPosition, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
document.removeEventListener('keydown', onDocumentKeydown)
window.removeEventListener('resize', updatePostMenuPosition)
window.removeEventListener('scroll', updatePostMenuPosition, true)
})
watch(openPostMenuId, async (postId) => {
if (!postId) {
return
}
await nextTick()
updatePostMenuPosition()
})
watch(tagFilterSearch, () => {
activeTagFilterOptionIndex.value = 0
})
watch([searchQuery, statusFilter, tagFilter, featuredFilter, sortOrder], () => {
postPage.value = 1
})
watch(postPageCount, (pageCount) => {
if (postPage.value > pageCount) {
postPage.value = pageCount
}
})
</script>
<template>
<section class="admin-posts bg-paper p-6">
<div class="admin-posts__header flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<p class="admin-posts__eyebrow text-xs font-semibold uppercase text-muted">
Posts
</p>
<h1 class="admin-posts__title mt-2 text-3xl font-semibold">
글 목록
</h1>
<p class="admin-posts__count mt-2 text-sm text-muted">
총 {{ totalPostCount }}개
<template v-if="featuredPostCount > 0">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-featured">추천 {{ featuredPostCount }}개</span>
</template>
<template v-if="pinnedPostCount > 0">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-pinned">고정 {{ pinnedPostCount }}개</span>
</template>
<template v-if="hasActiveListFilters && filteredPostCount !== totalPostCount">
<span class="admin-posts__count-separator text-[#c8ced3]"> · </span>
<span class="admin-posts__count-filtered">표시 {{ filteredPostCount }}개</span>
</template>
</p>
</div>
<div class="admin-posts__header-actions flex min-w-0 flex-1 flex-wrap items-center justify-start gap-2 lg:flex-nowrap lg:justify-end">
<div class="admin-posts__filters flex min-w-0 flex-wrap items-center gap-2">
<label class="admin-posts__search relative">
<span class="sr-only">글 검색</span>
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m21 21-4.34-4.34" />
<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"
placeholder="글 검색"
@input="onSearchInput"
@compositionupdate="onSearchInput"
@compositionend="onSearchCompositionEnd"
>
</label>
<label class="admin-posts__filter">
<span class="sr-only">상태 필터</span>
<span class="admin-posts__filter-select-wrap relative block">
<select v-model="statusFilter" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="all">전체 상태</option>
<option value="published">공개</option>
<option value="draft">초안</option>
<option value="scheduled">예약</option>
<option value="members">멤버십</option>
<option value="private">비공개</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<div
ref="tagFilterRef"
class="admin-posts__tag-filter relative w-52"
>
<span class="sr-only">태그 필터</span>
<div class="admin-posts__tag-filter-editor flex h-10 items-center gap-1 rounded border border-line bg-white px-2 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<button
v-if="tagFilter !== 'all'"
class="admin-posts__tag-filter-token inline-flex min-w-0 max-w-[8rem] items-center gap-1 rounded bg-[#f1f3f4] px-2 py-1 text-xs font-semibold text-[#394047] ring-1 ring-[#e3e6e8]"
type="button"
title="태그 필터 해제"
@click="clearTagFilter"
>
<span class="truncate">{{ selectedTagFilterLabel }}</span>
<span aria-hidden="true">×</span>
</button>
<input
:value="tagFilterSearchInput"
class="admin-posts__tag-filter-input min-w-0 flex-1 bg-transparent px-1 text-sm text-[#394047] outline-none placeholder:text-[#394047]"
type="text"
role="combobox"
:aria-expanded="isTagFilterOpen"
aria-controls="admin-posts-tag-filter-list"
:placeholder="tagFilter === 'all' ? '전체 태그' : '태그 검색'"
autocomplete="off"
@input="onTagFilterSearchInput($event); openTagFilter()"
@compositionupdate="onTagFilterSearchInput"
@compositionend="onTagFilterSearchCompositionEnd"
@focus="openTagFilter"
@keydown="handleTagFilterKeydown"
>
<button
class="admin-posts__tag-filter-trigger grid size-7 shrink-0 place-items-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4]"
type="button"
:aria-expanded="isTagFilterOpen"
aria-label="태그 필터 목록 열기"
@mousedown.prevent
@click="toggleTagFilter"
>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down size-5" width="24" height="24" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
</div>
<div
v-if="isTagFilterOpen && hasTagFilterSuggestions"
id="admin-posts-tag-filter-list"
class="admin-posts__tag-filter-list absolute left-0 right-0 top-[calc(100%+0.25rem)] z-30 max-h-64 overflow-y-auto rounded-lg border border-[#d7dce0] bg-white py-1 text-sm shadow-[0_18px_50px_rgba(15,23,42,0.14)]"
role="listbox"
>
<button
v-for="(option, index) in tagFilterSuggestionOptions"
:key="option.slug"
class="admin-posts__tag-filter-option flex w-full items-center justify-between gap-2 px-3 py-2 text-left transition-colors"
:class="index === activeTagFilterOptionIndex ? 'bg-[#f1f3f4] text-[#15171a]' : 'text-[#394047] hover:bg-[#f1f3f4]'"
type="button"
role="option"
:aria-selected="tagFilter === option.slug"
@mousedown.prevent
@click="selectTagFilterOption(option)"
>
<span class="min-w-0 truncate">{{ option.name }}</span>
<span v-if="option.slug === tagFilter" class="shrink-0 text-xs text-[#8e9cac]">선택됨</span>
</button>
</div>
<p
v-else-if="isTagFilterOpen && tagFilterSearch.trim()"
class="admin-posts__tag-filter-empty absolute left-0 right-0 top-[calc(100%+0.25rem)] z-30 rounded border border-[#d7dce0] bg-white px-3 py-2 text-xs text-muted shadow-[0_18px_50px_rgba(15,23,42,0.14)]"
>
일치하는 태그가 없습니다.
</p>
</div>
<label class="admin-posts__filter">
<span class="sr-only">추천 필터</span>
<span class="admin-posts__filter-select-wrap relative block">
<select v-model="featuredFilter" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="all">전체 글</option>
<option value="featured">추천만</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
<label class="admin-posts__filter">
<span class="sr-only">정렬</span>
<span class="admin-posts__filter-select-wrap relative block">
<select v-model="sortOrder" class="admin-posts__filter-select h-10 appearance-none rounded border border-line bg-white pl-3 pr-9 text-sm text-[#394047] outline-none transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac]">
<option value="newest">최신순</option>
<option value="oldest">오래된순</option>
</select>
<svg class="pointer-events-none absolute right-3 top-1/2 size-4 -translate-y-1/2 text-[#394047]" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6" /></svg>
</span>
</label>
</div>
<NuxtLink class="admin-posts__new shrink-0 rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white" to="/admin/posts/new">
새 글
</NuxtLink>
</div>
</div>
<p v-if="errorMessage" class="admin-posts__error mt-6 rounded border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{{ errorMessage }}
</p>
<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">
<thead class="admin-posts__table-head bg-[#f5f5f2] text-xs uppercase text-muted">
<tr>
<th class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-3 text-center">
<span class="sr-only">추천</span>
<span class="inline-flex size-4 items-center justify-center text-[#f5a623]" aria-hidden="true">★</span>
</th>
<th class="admin-posts__cell admin-posts__cell-thumbnail w-16 px-2 py-3">
<span class="sr-only">썸네일</span>
</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>
</thead>
<tbody class="admin-posts__table-body divide-y divide-line bg-white">
<tr v-for="post in postItems" :key="post.id" class="admin-posts__row group">
<td class="admin-posts__cell admin-posts__cell-featured w-10 px-2 py-4 text-center">
<span
v-if="post.isFeatured"
class="admin-posts__featured-star inline-flex size-8 items-center justify-center text-[#f5a623]"
title="추천 글"
aria-label="추천 글"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" />
</svg>
</span>
</td>
<td class="admin-posts__cell admin-posts__cell-thumbnail w-16 px-2 py-4">
<span class="admin-posts__thumbnail block h-9 w-12 overflow-hidden rounded bg-[#eef1f4]">
<img
v-if="getPostThumbnailSrc(post)"
class="h-full w-full object-cover"
:src="getPostThumbnailSrc(post)"
:alt="post.title || '게시물 썸네일'"
loading="lazy"
>
</span>
</td>
<td class="admin-posts__cell px-4 py-4">
<div class="admin-posts__title-row flex flex-wrap items-center gap-x-2 gap-y-1">
<NuxtLink class="admin-posts__title-link font-semibold hover:opacity-70" :to="`/admin/posts/${post.id}`">
{{ post.title || '(제목 없음)' }}
</NuxtLink>
<span class="admin-posts__comment-count text-xs font-medium text-muted">
댓글 {{ post.commentCount || 0 }}
</span>
<span
v-if="post.adminPinned"
class="admin-posts__pinned-badge inline-flex h-5 items-center rounded bg-[#15171a] px-1.5 text-[10px] font-semibold text-white"
>
고정
</span>
</div>
<div class="admin-posts__slug-row mt-1 flex items-center gap-1 text-xs text-muted">
<span class="admin-posts__slug truncate">/post/{{ post.slug }}</span>
<NuxtLink
v-if="isPublicPost(post)"
class="admin-posts__preview-link inline-flex size-5 items-center justify-center rounded text-[#8e9cac] opacity-0 transition-colors hover:text-[#394047] focus:opacity-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-[#8e9cac] group-hover:opacity-100 group-focus-within:opacity-100"
:to="`/post/${post.slug}`"
target="_blank"
rel="noopener noreferrer"
aria-label="공개 글 보기"
title="공개 글 보기"
@click.stop
>
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 7 7 17" />
<path d="M8 7h9v9" />
</svg>
</NuxtLink>
</div>
</td>
<td class="admin-posts__cell px-4 py-4">
<span
class="admin-posts__status-text text-xs"
:class="getPostStatusClass(post)"
>
{{ getPostStatusLabel(post) }}
</span>
</td>
<td class="admin-posts__cell px-4 py-4">
<div v-if="post.tags?.length" class="admin-posts__tag-list flex flex-wrap gap-1.5">
<span
v-for="tag in post.tags"
:key="tag"
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] border px-2 text-xs font-semibold"
:style="createTagBadgeStyle(getTagColor(tag))"
>
{{ getTagName(tag) }}
</span>
</div>
<span v-else class="admin-posts__tag-empty text-muted">-</span>
</td>
<td class="admin-posts__cell admin-posts__cell-backlinks px-4 py-4">
<button
v-if="Number(post.backlinkCount || 0) > 0"
class="admin-posts__backlink-count text-sm font-semibold text-[#394047] underline-offset-4 transition-colors hover:text-[#15171a] hover:underline"
type="button"
@click="openReferenceModal(post)"
>
{{ formatAdminNumber(post.backlinkCount) }}
</button>
<span v-else class="admin-posts__backlink-empty text-muted">0</span>
</td>
<td class="admin-posts__cell admin-posts__cell-views px-4 py-4">
<p class="admin-posts__view-count text-[#394047]">
{{ formatAdminNumber(post.totalViews) }}
</p>
<button
class="admin-posts__analytics-link mt-1 inline-flex text-xs text-muted transition-colors hover:text-[#2f6feb]"
type="button"
@pointerdown.stop
@click.stop="openAnalyticsModal(post)"
>
통계
</button>
</td>
<td class="admin-posts__cell px-4 py-4">
<p class="admin-posts__publish-date text-[#394047]">
{{ getPublishDateLabel(post) }}
</p>
<p v-if="getUpdatedDateLabel(post)" class="admin-posts__updated-date mt-1 text-xs text-muted">
{{ getUpdatedDateLabel(post) }}
</p>
</td>
<td class="admin-posts__cell admin-posts__cell-actions relative w-12 px-2 py-4 text-right">
<div
class="admin-posts__row-menu relative inline-flex justify-end"
data-admin-post-row-menu
>
<button
:ref="(element) => setPostMenuTriggerRef(post.id, element)"
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"
type="button"
:disabled="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
:aria-expanded="openPostMenuId === post.id"
aria-haspopup="menu"
:aria-label="openPostMenuId === post.id ? '게시글 메뉴 닫기' : '게시글 메뉴'"
@click.stop="togglePostMenu(post.id)"
>
<span
v-if="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
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>
<Teleport to="body">
<div
v-if="openPostMenuId === post.id"
class="admin-posts__row-menu-popover fixed z-[80] 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)]"
:style="postMenuStyle"
role="menu"
data-admin-post-row-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="togglingPinnedId === post.id"
@click="togglePostPinned(post)"
>
{{ post.adminPinned ? '고정 해제' : '상단 고정' }}
</button>
<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>
</Teleport>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<p v-if="postItems.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
{{ totalPostCount === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
</p>
<nav
v-if="postPageCount > 1"
class="admin-posts__pagination mt-6 flex flex-wrap items-center justify-center gap-2"
aria-label="글 목록 페이지"
>
<button
class="admin-posts__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="postPage <= 1"
aria-label="이전 페이지"
@click="setPostPage(postPage - 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m15 18-6-6 6-6" />
</svg>
</button>
<template v-for="item in postPaginationItems" :key="item">
<span v-if="typeof item === 'string'" class="admin-posts__pagination-ellipsis px-2 text-sm font-semibold text-muted">
...
</span>
<button
v-else
class="admin-posts__pagination-page grid size-9 place-items-center rounded-full text-sm font-semibold transition"
:class="item === postPage ? 'bg-[#15171a] text-white' : 'text-muted hover:bg-white hover:text-ink'"
type="button"
:aria-current="item === postPage ? 'page' : undefined"
@click="setPostPage(item)"
>
{{ item }}
</button>
</template>
<button
class="admin-posts__pagination-arrow grid size-9 place-items-center rounded-full border border-line bg-white text-ink transition hover:border-[#c8ced3] disabled:cursor-not-allowed disabled:opacity-40"
type="button"
:disabled="postPage >= postPageCount"
aria-label="다음 페이지"
@click="setPostPage(postPage + 1)"
>
<svg class="size-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m9 18 6-6-6-6" />
</svg>
</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 flex h-[min(720px,86vh)] w-full max-w-2xl flex-col overflow-hidden 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 inline-flex cursor-pointer items-center justify-center rounded text-sm font-semibold whitespace-nowrap text-[#5d6673] transition hover:text-[#15171a]"
type="button"
title="닫기 (ESC)"
aria-label="백링크 모달 닫기"
@click="closeReferenceModal"
>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" class="pointer-events-none size-5" fill="none" aria-hidden="true">
<line x1="0.75" y1="23.249" x2="23.25" y2="0.749" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
<line x1="23.25" y1="23.249" x2="0.75" y2="0.749" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" />
</svg>
</button>
</header>
<div class="admin-posts__reference-modal-body flex min-h-0 flex-1 flex-col 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>
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="text-sm text-muted">
이 글을 링크한 콘텐츠 {{ referenceModalItems.length }}개
</p>
<label class="admin-posts__reference-search relative w-full sm:w-60">
<span class="sr-only">백링크 검색</span>
<svg class="pointer-events-none absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[#8e9cac]" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="m21 21-4.34-4.34" />
<circle cx="11" cy="11" r="8" />
</svg>
<input
v-model="referenceModalSearchQuery"
class="admin-posts__reference-search-input h-9 w-full 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"
placeholder="콘텐츠 검색"
>
</label>
</div>
<ul v-if="filteredReferenceModalPosts.length" class="mt-4 min-h-0 flex-1 divide-y divide-line overflow-y-auto border border-line">
<li
v-for="item in filteredReferenceModalPosts"
: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">
<span class="min-w-0 font-semibold text-ink">
<span class="mr-2 inline-flex h-5 items-center rounded bg-paper px-2 text-[11px] font-semibold text-muted">
{{ item.referenceType === 'page' ? '페이지' : '게시물' }}
</span>
{{ item.title || '(제목 없음)' }}
</span>
<div class="flex shrink-0 items-center gap-2">
<NuxtLink
class="admin-posts__reference-move inline-flex size-7 items-center justify-center rounded text-[#8e9cac] transition-colors hover:bg-paper hover:text-[#394047]"
:to="item.referenceType === 'page' ? `/admin/pages/${item.id}` : `/admin/posts/${item.id}`"
aria-label="콘텐츠로 이동"
title="콘텐츠로 이동"
@click="closeReferenceModal"
>
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M17 7 7 17" />
<path d="M8 7h9v9" />
</svg>
</NuxtLink>
</div>
</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">/{{ item.referenceType === 'page' ? 'pages' : 'post' }}/{{ item.slug }}</span>
<span class="text-[#d7dce0]">|</span>
<span>수정 {{ formatAdminDateTimeLabel(item.updatedAt) }}</span>
</div>
</li>
</ul>
<p v-else-if="referenceModalItems.length" class="mt-4 border border-line bg-paper px-4 py-5 text-sm text-muted">
검색 결과가 없습니다.
</p>
<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"
class="admin-posts__analytics-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-analytics-modal-title"
@pointerdown.self="closeAnalyticsModal"
>
<article class="admin-posts__analytics-modal-panel max-h-[90vh] w-full max-w-4xl overflow-y-auto rounded-lg border border-line bg-white shadow-xl">
<header class="admin-posts__analytics-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-analytics-modal-title" class="mt-1 truncate text-lg font-semibold text-ink">
{{ analyticsModalPost?.title || '(제목 없음)' }}
</h2>
<p class="mt-1 text-xs text-muted">
{{ formatAdminDateTimeLabel(analyticsModalData.updatedAt) }} 기준
</p>
</div>
<button
class="admin-posts__analytics-modal-close border border-line px-3 py-1 text-xs font-medium text-muted hover:bg-paper hover:text-ink"
type="button"
@click="closeAnalyticsModal"
>
닫기
</button>
</header>
<div class="admin-posts__analytics-modal-body space-y-4 p-5">
<p v-if="analyticsModalLoading" class="text-sm text-muted">
게시물 통계를 불러오는 중입니다.
</p>
<p v-else-if="analyticsModalError" class="text-sm text-red-600">
{{ analyticsModalError }}
</p>
<template v-else>
<div class="admin-posts__analytics-modal-summary grid gap-3 sm:grid-cols-4">
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
누적 조회수
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.total.views) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
방문자
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.visitors) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
평균 체류
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatEngagedDuration(analyticsModalData.period.avgEngagedSeconds) }}
</strong>
</article>
<article class="border border-line bg-paper p-3">
<p class="text-[11px] font-semibold uppercase text-muted">
50% 스크롤
</p>
<strong class="mt-1 block text-lg text-ink">
{{ formatAdminNumber(analyticsModalData.period.scroll50) }}
</strong>
</article>
</div>
<section class="admin-posts__analytics-modal-chart border border-line bg-paper p-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-xs font-semibold uppercase text-muted">
통계 추이
</h3>
<p class="mt-1 text-xs text-muted">
{{ getAnalyticsPeriodLabel() }} 기준
</p>
</div>
<div class="flex flex-wrap gap-2">
<div class="inline-flex border border-line bg-white">
<button
v-for="metric in analyticsMetricOptions"
:key="metric.value"
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
:class="analyticsModalMetric === metric.value ? 'bg-[#15171a] !text-white hover:!bg-[#15171a] hover:!text-white' : 'text-muted hover:bg-paper hover:text-ink'"
type="button"
@click="analyticsModalMetric = metric.value"
>
{{ metric.label }}
</button>
</div>
<div class="inline-flex border border-line bg-white">
<button
v-for="option in analyticsPeriodOptions"
:key="option.value"
class="h-8 border-r border-line px-3 text-xs font-semibold last:border-r-0"
:class="analyticsModalPeriod === option.value ? 'bg-[#15171a] !text-white hover:!bg-[#15171a] hover:!text-white' : 'text-muted hover:bg-paper hover:text-ink'"
type="button"
@click="selectAnalyticsModalPeriod(option.value)"
>
{{ option.label }}
</button>
</div>
</div>
</div>
<AdminAnalyticsTrendChart
class="mt-4"
:rows="analyticsModalData.trends"
:period="analyticsModalPeriod"
:metric-key="analyticsModalMetric"
:metric-label="analyticsModalMetric === 'views' ? '조회수' : '방문자'"
:format-value="formatAdminNumber"
v-model:page-offset="analyticsModalChartPageOffset"
compact
bar-height-class="h-36"
/>
</section>
<div class="grid gap-4 lg:grid-cols-2">
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
검색
</h3>
<p class="mt-1 text-xs text-muted">
조회 {{ formatAdminNumber(analyticsModalSearchViews) }}
</p>
<ul class="mt-3 space-y-2">
<li
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'search')"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ row.sourceName }}</span>
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
</li>
</ul>
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'search')" class="mt-3 text-xs text-muted">
해당 기간에 집계된 검색 유입이 없습니다.
</p>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
SNS
</h3>
<p class="mt-1 text-xs text-muted">
조회 {{ formatAdminNumber(analyticsModalSnsViews) }}
</p>
<ul class="mt-3 space-y-2">
<li
v-for="row in analyticsModalData.sources.filter((source) => source.sourceGroup === 'sns')"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ row.sourceName }}</span>
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
</li>
</ul>
<p v-if="!analyticsModalData.sources.some((source) => source.sourceGroup === 'sns')" class="mt-3 text-xs text-muted">
해당 기간에 집계된 SNS 유입이 없습니다.
</p>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
기타
</h3>
<p class="mt-1 text-xs text-muted">
조회 {{ formatAdminNumber(analyticsModalOtherViews) }}
</p>
<ul class="mt-3 space-y-2">
<li
v-for="row in analyticsModalData.sources.filter((source) => ['direct', 'other'].includes(source.sourceGroup))"
:key="`${row.sourceGroup}-${row.sourceName}`"
class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs last:border-b-0 last:pb-0"
>
<span class="font-medium text-ink">{{ getTrafficGroupLabel(row.sourceGroup) }} · {{ row.sourceName }}</span>
<span class="text-right text-muted">조회 {{ formatAdminNumber(row.pageViews) }} · 방문자 {{ formatAdminNumber(row.visitors) }}</span>
</li>
</ul>
<p v-if="!analyticsModalData.sources.some((source) => ['direct', 'other'].includes(source.sourceGroup))" class="mt-3 text-xs text-muted">
해당 기간에 집계된 기타 유입이 없습니다.
</p>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4">
<h3 class="text-xs font-semibold uppercase text-muted">
디바이스
</h3>
<ul class="mt-3 space-y-2">
<li class="flex items-center justify-between gap-3 border-b border-line pb-2 text-xs">
<span class="font-medium text-ink">모바일</span>
<span>{{ formatAnalyticsDevicePercent(analyticsModalMobileDevice?.pageViews) }}</span>
</li>
<li class="flex items-center justify-between gap-3 text-xs">
<span class="font-medium text-ink">PC</span>
<span>{{ formatAnalyticsDevicePercent(analyticsModalPcDevice?.pageViews) }}</span>
</li>
</ul>
</section>
<section class="admin-posts__analytics-modal-section border border-line bg-paper p-4 lg:col-span-2">
<h3 class="text-xs font-semibold uppercase text-muted">
유입 키워드
</h3>
<ul v-if="analyticsModalData.keywords.length" class="mt-3 grid gap-2 md:grid-cols-2">
<li
v-for="row in analyticsModalData.keywords"
:key="`${row.sourceName}-${row.keyword}`"
class="flex items-center justify-between gap-3 border border-line bg-white px-3 py-2 text-xs"
>
<span class="min-w-0 truncate font-medium text-ink">{{ row.keyword }}</span>
<span class="shrink-0 text-right text-muted">{{ row.sourceName }} · {{ formatAdminNumber(row.pageViews) }}</span>
</li>
</ul>
<p v-else class="mt-3 text-xs text-muted">
해당 기간에 수집된 유입 키워드가 없습니다.
</p>
</section>
</div>
</template>
</div>
</article>
</div>
</Teleport>
</section>
</template>