관리자 글 목록 태그 필터와 썸네일 개선
This commit is contained in:
@@ -12,9 +12,12 @@ 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 isTagFilterOpen = ref(false)
|
||||
const activeTagFilterOptionIndex = ref(0)
|
||||
const analyticsModalPost = ref(null)
|
||||
const analyticsModalData = ref(createDefaultPostAnalytics())
|
||||
const analyticsModalPeriod = ref('daily')
|
||||
@@ -23,6 +26,7 @@ const analyticsModalChartPageOffset = ref(0)
|
||||
const analyticsModalLoading = ref(false)
|
||||
const analyticsModalError = ref('')
|
||||
const postMenuTriggerRefs = new Map()
|
||||
const tagFilterRef = ref(null)
|
||||
|
||||
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
@@ -304,6 +308,38 @@ const usedTagSlugs = computed(() => {
|
||||
return [...slugs].sort((a, b) => getTagName(a).localeCompare(getTagName(b), 'ko'))
|
||||
})
|
||||
|
||||
const usedTagOptions = computed(() => usedTagSlugs.value.map((slug) => ({
|
||||
slug,
|
||||
name: getTagName(slug),
|
||||
color: getTagColor(slug)
|
||||
})))
|
||||
|
||||
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(() => posts.value.length)
|
||||
|
||||
@@ -362,6 +398,13 @@ const filteredPosts = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물 목록 썸네일 URL을 반환한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 썸네일 URL
|
||||
*/
|
||||
const getPostThumbnailSrc = (post) => post.featuredImageThumbnail || post.featuredImage || ''
|
||||
|
||||
/** 게시물 통계 모달이 열려 있는지 */
|
||||
const isAnalyticsModalOpen = computed(() => Boolean(analyticsModalPost.value))
|
||||
|
||||
@@ -529,6 +572,91 @@ 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 {string} postId - 게시물 ID
|
||||
@@ -590,6 +718,10 @@ const togglePostMenu = (postId) => {
|
||||
* @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
|
||||
}
|
||||
@@ -607,6 +739,11 @@ const onPostMenuDocumentPointerDown = (event) => {
|
||||
* @returns {void}
|
||||
*/
|
||||
const onDocumentKeydown = (event) => {
|
||||
if (event.key === 'Escape' && isTagFilterOpen.value) {
|
||||
closeTagFilter()
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Escape' && isAnalyticsModalOpen.value) {
|
||||
closeAnalyticsModal()
|
||||
}
|
||||
@@ -684,6 +821,10 @@ watch(openPostMenuId, async (postId) => {
|
||||
await nextTick()
|
||||
updatePostMenuPosition()
|
||||
})
|
||||
|
||||
watch(tagFilterSearch, () => {
|
||||
activeTagFilterOptionIndex.value = 0
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -737,18 +878,77 @@ watch(openPostMenuId, async (postId) => {
|
||||
<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">
|
||||
<div
|
||||
ref="tagFilterRef"
|
||||
class="admin-posts__tag-filter relative w-52"
|
||||
>
|
||||
<span class="sr-only">태그 필터</span>
|
||||
<span class="admin-posts__filter-select-wrap relative block">
|
||||
<select v-model="tagFilter" 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 v-for="tag in usedTagSlugs" :key="tag" :value="tag">
|
||||
{{ getTagName(tag) }}
|
||||
</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 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
|
||||
v-model="tagFilterSearch"
|
||||
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="openTagFilter"
|
||||
@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">
|
||||
@@ -818,9 +1018,9 @@ watch(openPostMenuId, async (postId) => {
|
||||
<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="post.featuredImage"
|
||||
v-if="getPostThumbnailSrc(post)"
|
||||
class="h-full w-full object-cover"
|
||||
:src="post.featuredImage"
|
||||
:src="getPostThumbnailSrc(post)"
|
||||
:alt="post.title || '게시물 썸네일'"
|
||||
loading="lazy"
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user