1663 lines
61 KiB
Vue
1663 lines
61 KiB
Vue
<script setup>
|
||
definePageMeta({
|
||
layout: 'admin'
|
||
})
|
||
|
||
const deletingId = ref('')
|
||
const togglingFeaturedId = ref('')
|
||
const togglingPinnedId = ref('')
|
||
const { openMenuId: openPostMenuId, closeMenu: closePostMenu } = useAdminRowMenu()
|
||
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 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' }
|
||
]
|
||
|
||
/** 게시글 상태 필터 옵션 */
|
||
const postStatusFilterOptions = [
|
||
{ value: 'all', label: '전체 상태' },
|
||
{ value: 'published', label: '공개' },
|
||
{ value: 'draft', label: '초안' },
|
||
{ value: 'scheduled', label: '예약' },
|
||
{ value: 'members', label: '멤버십' },
|
||
{ value: 'private', label: '비공개' }
|
||
]
|
||
|
||
/** 게시글 추천 필터 옵션 */
|
||
const postFeaturedFilterOptions = [
|
||
{ value: 'all', label: '전체 글' },
|
||
{ value: 'featured', label: '추천만' }
|
||
]
|
||
|
||
/** 게시글 목록 정렬 옵션 */
|
||
const postSortOptions = [
|
||
{ value: 'newest', label: '최신순' },
|
||
{ value: 'oldest', label: '오래된순' }
|
||
]
|
||
|
||
/**
|
||
* 게시물별 통계 기본값을 생성한다.
|
||
* @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)))
|
||
|
||
/**
|
||
* 게시물 목록 썸네일 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 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 {PointerEvent} event - 포인터 이벤트
|
||
* @returns {void}
|
||
*/
|
||
const onPostListDocumentPointerDown = (event) => {
|
||
if (isTagFilterOpen.value && event.target instanceof Node && !tagFilterRef.value?.contains(event.target)) {
|
||
closeTagFilter()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 키 입력 대상이 편집 가능한 요소인지 확인한다.
|
||
* @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', onPostListDocumentPointerDown)
|
||
document.addEventListener('keydown', onDocumentKeydown)
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
document.removeEventListener('pointerdown', onPostListDocumentPointerDown)
|
||
document.removeEventListener('keydown', onDocumentKeydown)
|
||
})
|
||
|
||
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">
|
||
<AdminSearchInput
|
||
ref="postSearchInputRef"
|
||
:value="searchInput"
|
||
label="글 검색"
|
||
placeholder="글 검색"
|
||
root-class="admin-posts__search"
|
||
input-class="admin-posts__search-input"
|
||
@input="onSearchInput"
|
||
@compositionupdate="onSearchInput"
|
||
@compositionend="onSearchCompositionEnd"
|
||
/>
|
||
<AdminSelectInput
|
||
v-model="statusFilter"
|
||
:options="postStatusFilterOptions"
|
||
label="상태 필터"
|
||
root-class="admin-posts__filter"
|
||
select-class="admin-posts__filter-select"
|
||
/>
|
||
<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>
|
||
<AdminPopoverSurface
|
||
v-if="isTagFilterOpen && hasTagFilterSuggestions"
|
||
id="admin-posts-tag-filter-list"
|
||
root-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-[#d7dce0] py-1 text-sm"
|
||
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>
|
||
</AdminPopoverSurface>
|
||
<AdminPopoverSurface
|
||
v-else-if="isTagFilterOpen && tagFilterSearch.trim()"
|
||
as="p"
|
||
root-class="admin-posts__tag-filter-empty absolute left-0 right-0 top-[calc(100%+0.25rem)] z-30 rounded border-[#d7dce0] px-3 py-2 text-xs text-muted"
|
||
>
|
||
일치하는 태그가 없습니다.
|
||
</AdminPopoverSurface>
|
||
</div>
|
||
<AdminSelectInput
|
||
v-model="featuredFilter"
|
||
:options="postFeaturedFilterOptions"
|
||
label="추천 필터"
|
||
root-class="admin-posts__filter"
|
||
select-class="admin-posts__filter-select"
|
||
/>
|
||
<AdminSelectInput
|
||
v-model="sortOrder"
|
||
:options="postSortOptions"
|
||
label="정렬"
|
||
root-class="admin-posts__filter"
|
||
select-class="admin-posts__filter-select"
|
||
/>
|
||
</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">
|
||
<AdminRowMoreMenu
|
||
v-model:open-menu-id="openPostMenuId"
|
||
:item-id="post.id"
|
||
menu-label="게시글 메뉴"
|
||
:disabled="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
|
||
:busy="deletingId === post.id || togglingFeaturedId === post.id || togglingPinnedId === post.id"
|
||
>
|
||
<button
|
||
class="admin-row-more-menu__item"
|
||
type="button"
|
||
role="menuitem"
|
||
:disabled="togglingPinnedId === post.id"
|
||
@click="togglePostPinned(post)"
|
||
>
|
||
{{ post.adminPinned ? '고정 해제' : '상단 고정' }}
|
||
</button>
|
||
<button
|
||
class="admin-row-more-menu__item"
|
||
type="button"
|
||
role="menuitem"
|
||
:disabled="togglingFeaturedId === post.id"
|
||
@click="togglePostFeatured(post)"
|
||
>
|
||
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
||
</button>
|
||
<button
|
||
class="admin-row-more-menu__item admin-row-more-menu__item--danger"
|
||
type="button"
|
||
role="menuitem"
|
||
:disabled="deletingId === post.id"
|
||
@click="deletePost(post)"
|
||
>
|
||
게시글 삭제
|
||
</button>
|
||
</AdminRowMoreMenu>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<p v-if="postItems.length === 0" class="admin-posts__empty mt-6 text-sm text-muted">
|
||
{{ totalPostCount === 0 ? '아직 작성된 글이 없습니다.' : '조건에 맞는 글이 없습니다.' }}
|
||
</p>
|
||
|
||
<AdminPagination
|
||
:page="postPage"
|
||
:page-count="postPageCount"
|
||
label="글 목록 페이지"
|
||
@update:page="setPostPage"
|
||
/>
|
||
|
||
<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>
|