관리자 글 목록 태그 필터와 썸네일 개선

This commit is contained in:
2026-07-03 10:23:50 +09:00
parent b4e408dec8
commit 3f80bbb4ea
10 changed files with 240 additions and 18 deletions

View File

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