관리자 태그와 목록 메뉴 개선 v1.5.0

This commit is contained in:
2026-05-26 10:56:57 +09:00
parent 6536465b12
commit 0ad2ab3f9d
9 changed files with 450 additions and 77 deletions

View File

@@ -65,6 +65,9 @@ const isLoadingMedia = ref(false)
const isUploadingFeaturedImage = ref(false)
const isSettingsOpen = ref(true)
const tagInput = ref('')
const tagInputRef = ref(null)
const isTagSuggestionsOpen = ref(false)
const activeTagSuggestionIndex = ref(0)
const isTagInputComposing = ref(false)
const isTitleInputComposing = ref(false)
const activeMediaPickerTab = ref('upload')
@@ -77,6 +80,12 @@ const publishTiming = ref('now')
const scheduledPublishAt = ref('')
const publishModalExpandedSection = ref(null)
const { data: adminTags } = useFetch('/admin/api/tags', {
default: () => []
})
const defaultTagColor = '#15171a'
/**
* ISO 날짜를 datetime-local 입력값으로 변환
* @param {string} value - ISO 날짜 문자열
@@ -364,6 +373,32 @@ const parseTags = (value) => {
const selectedTags = computed(() => parseTags(form.tagsText))
const selectedTagKeys = computed(() => new Set(selectedTags.value.map((tag) => tag.toLowerCase())))
const availableAdminTags = computed(() => Array.isArray(adminTags.value) ? adminTags.value : [])
const managedTagOptions = computed(() => availableAdminTags.value.filter((tag) => tag.tagType === 'managed'))
const tagSuggestionOptions = computed(() => {
const keyword = normalizeTagToken(tagInput.value)
const sourceTags = keyword ? availableAdminTags.value : managedTagOptions.value
return sourceTags
.filter((tag) => {
if (!tag?.slug || selectedTagKeys.value.has(tag.slug.toLowerCase())) {
return false
}
if (!keyword) {
return true
}
return tag.name.toLowerCase().includes(keyword) || tag.slug.toLowerCase().includes(keyword)
})
.slice(0, 8)
})
const hasTagSuggestions = computed(() => isTagSuggestionsOpen.value && tagSuggestionOptions.value.length > 0)
/**
* 예약 발행 여부 확인
* @returns {boolean} 예약 발행 여부
@@ -656,16 +691,41 @@ const removeFeaturedImage = () => {
* 태그 입력값을 배지 목록에 추가
* @returns {void}
*/
const addTagFromInput = () => {
const nextTag = normalizeTagToken(tagInput.value)
const addTagToken = (value) => {
const nextTag = normalizeTagToken(value)
if (!nextTag) {
tagInput.value = ''
return
}
form.tagsText = [...new Set([...selectedTags.value, nextTag])].join(', ')
const nextTags = [...selectedTags.value]
if (!selectedTagKeys.value.has(nextTag.toLowerCase())) {
nextTags.push(nextTag)
}
form.tagsText = nextTags.join(', ')
tagInput.value = ''
activeTagSuggestionIndex.value = 0
}
/**
* 태그 입력값을 배지 목록에 추가
* @returns {void}
*/
const addTagFromInput = () => {
addTagToken(tagInput.value)
}
/**
* 기존 태그 추천 항목을 배지 목록에 추가
* @param {Object} tag - 태그 항목
* @returns {void}
*/
const selectTagSuggestion = (tag) => {
addTagToken(tag.slug)
isTagSuggestionsOpen.value = false
tagInputRef.value?.focus()
}
/**
@@ -677,6 +737,75 @@ const removeTag = (tag) => {
form.tagsText = selectedTags.value.filter((item) => item !== tag).join(', ')
}
/**
* 태그 슬러그의 화면 표시 이름을 반환한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 표시 이름
*/
const getTagDisplayName = (slug) => availableAdminTags.value.find((tag) => tag.slug === slug)?.name || slug
/**
* 태그 슬러그의 고유 색상을 반환한다.
* @param {string} slug - 태그 슬러그
* @returns {string} 태그 색상
*/
const getTagColor = (slug) => availableAdminTags.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
})
/**
* 태그 추천 목록의 보조 라벨을 반환한다.
* @param {Object} tag - 태그 항목
* @returns {string} 보조 라벨
*/
const getTagSuggestionMeta = (tag) => {
const typeLabel = tag.tagType === 'managed' ? '메인' : '일반'
return tag.name.toLowerCase() === tag.slug.toLowerCase() ? typeLabel : `${typeLabel} · ${tag.slug}`
}
/**
* 태그 추천 목록을 열고 입력에 포커스한다.
* @returns {void}
*/
const openTagSuggestions = () => {
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = 0
tagInputRef.value?.focus()
}
/**
* 태그 추천 목록을 토글한다.
* @returns {void}
*/
const toggleTagSuggestions = () => {
if (isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = false
return
}
openTagSuggestions()
}
/**
* 태그 입력 포커스 해제 처리
* @returns {void}
*/
const handleTagBlur = () => {
window.setTimeout(() => {
addTagFromInput()
isTagSuggestionsOpen.value = false
}, 80)
}
/**
* 태그 입력 키 처리
* @param {KeyboardEvent} event - 키보드 이벤트
@@ -687,8 +816,44 @@ const handleTagKeydown = (event) => {
return
}
if (event.key === 'ArrowDown' && tagSuggestionOptions.value.length) {
event.preventDefault()
if (!isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = 0
return
}
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = (activeTagSuggestionIndex.value + 1) % tagSuggestionOptions.value.length
return
}
if (event.key === 'ArrowUp' && tagSuggestionOptions.value.length) {
event.preventDefault()
if (!isTagSuggestionsOpen.value) {
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = tagSuggestionOptions.value.length - 1
return
}
isTagSuggestionsOpen.value = true
activeTagSuggestionIndex.value = activeTagSuggestionIndex.value === 0
? tagSuggestionOptions.value.length - 1
: activeTagSuggestionIndex.value - 1
return
}
if (event.key === 'Escape' && isTagSuggestionsOpen.value) {
event.preventDefault()
isTagSuggestionsOpen.value = false
return
}
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
if (event.key === 'Enter' && hasTagSuggestions.value) {
selectTagSuggestion(tagSuggestionOptions.value[activeTagSuggestionIndex.value] || tagSuggestionOptions.value[0])
return
}
addTagFromInput()
return
}
@@ -1393,8 +1558,10 @@ defineExpose({
<h2 class="admin-post-form__settings-title text-xl font-bold text-black">
게시물 설정
</h2>
<button class="admin-post-form__settings-close grid size-8 place-items-center rounded text-[#394047] transition-colors hover:bg-[#eff1f2] hover:text-black" type="button" aria-label="게시물 설정 닫기" @click="toggleSettingsPanel">
<span aria-hidden="true">x</span>
<button class="admin-post-form__settings-close grid size-8 place-items-center rounded text-neutral-900 transition-colors hover:bg-[#eff1f2] hover:text-neutral-500" type="button" aria-label="게시물 설정 닫기" @click="toggleSettingsPanel">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" height="16" width="16" class="h-[1.1rem] w-[1.1rem]" aria-hidden="true">
<path stroke-linecap="round" stroke-width="0.4" fill="currentColor" stroke="#000000" stroke-linejoin="round" d="M.44,21.44a1.49,1.49,0,0,0,0,2.12,1.5,1.5,0,0,0,2.12,0l9.26-9.26a.25.25,0,0,1,.36,0l9.26,9.26a1.5,1.5,0,0,0,2.12,0,1.49,1.49,0,0,0,0-2.12L14.3,12.18a.25.25,0,0,1,0-.36l9.26-9.26A1.5,1.5,0,0,0,21.44.44L12.18,9.7a.25.25,0,0,1-.36,0L2.56.44A1.5,1.5,0,0,0,.44,2.56L9.7,11.82a.25.25,0,0,1,0,.36Z" />
</svg>
</button>
</div>
@@ -1443,10 +1610,16 @@ defineExpose({
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">상태</span>
<select v-model="form.status" class="admin-post-form__select h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">발행</option>
</select>
<span class="admin-post-form__select-wrap relative block">
<select v-model="form.status" class="admin-post-form__select h-[38px] w-full appearance-none rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 pr-10 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none">
<option value="draft">초안</option>
<option value="published">발행</option>
</select>
<svg xmlns="http://www.w3.org/2000/svg" class="icon icon-tabler icon-tabler-chevron-down pointer-events-none absolute right-3 top-1/2 size-5 -translate-y-1/2 text-[#15171a]" 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>
</span>
</label>
<div v-if="form.status === 'published'" class="admin-post-form__field grid gap-2 text-sm">
@@ -1504,36 +1677,76 @@ defineExpose({
<div class="admin-post-form__field grid gap-1 text-sm">
<span class="admin-post-form__label h-[22px] font-bold text-[#15171a]">Tags</span>
<label class="admin-post-form__tag-editor flex min-h-[38px] w-full items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<span
v-for="tag in selectedTags"
:key="tag"
class="admin-post-form__tag-badge inline-flex h-6 shrink-0 items-center gap-1.5 rounded-[3px] bg-[#ecd2de] px-2 text-sm text-[#e04e87]"
>
<span>{{ tag }}</span>
<button
class="admin-post-form__tag-remove inline-flex size-4 shrink-0 items-center justify-center rounded text-[#e04e87] transition-colors hover:bg-[#e7c3d2]"
type="button"
:aria-label="`${tag} 태그 삭제`"
@click="removeTag(tag)"
<div class="admin-post-form__tag-combobox relative">
<div class="admin-post-form__tag-editor flex min-h-[38px] w-full flex-wrap items-center gap-2 rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-1 transition-colors hover:border-[#c8ced3] focus-within:border-[#8e9cac]">
<span
v-for="tag in selectedTags"
:key="tag"
class="admin-post-form__tag-badge inline-flex h-6 shrink-0 items-center gap-1.5 rounded-[3px] border px-2 text-sm font-semibold"
:style="createTagBadgeStyle(getTagColor(tag))"
>
<svg class="size-2.5" version="1" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" />
<span>{{ getTagDisplayName(tag) }}</span>
<button
class="admin-post-form__tag-remove inline-flex size-4 shrink-0 items-center justify-center rounded text-current transition-colors hover:bg-white/45"
type="button"
:aria-label="`${getTagDisplayName(tag)} 태그 삭제`"
@click="removeTag(tag)"
>
<svg class="size-2.5" version="1" viewBox="0 0 24 24" aria-hidden="true">
<path fill="currentColor" d="M12.707 12L23.854.854a.5.5 0 00-.707-.707L12 11.293.854.146a.5.5 0 00-.707.707L11.293 12 .146 23.146a.5.5 0 00.708.708L12 12.707l11.146 11.146a.5.5 0 10.708-.706L12.707 12z" />
</svg>
</button>
</span>
<input
ref="tagInputRef"
v-model="tagInput"
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="태그 입력"
role="combobox"
:aria-expanded="hasTagSuggestions"
aria-autocomplete="list"
@focus="openTagSuggestions"
@input="openTagSuggestions"
@blur="handleTagBlur"
@keydown="handleTagKeydown"
@compositionstart="isTagInputComposing = true"
@compositionend="isTagInputComposing = false"
>
<button
class="admin-post-form__tag-dropdown-trigger ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#15171a] transition-colors hover:bg-[#e3e6e8] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac]"
type="button"
:aria-expanded="hasTagSuggestions"
aria-label="메인 태그 목록 열기"
@mousedown.prevent="toggleTagSuggestions"
>
<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>
</span>
<input
v-model="tagInput"
class="admin-post-form__tag-input min-w-0 flex-1 border-0 bg-transparent p-0 text-sm text-black outline-none placeholder:text-[#8e9cac]"
type="text"
placeholder="태그 입력"
@blur="addTagFromInput"
@keydown="handleTagKeydown"
@compositionstart="isTagInputComposing = true"
@compositionend="isTagInputComposing = false"
</div>
<div
v-if="hasTagSuggestions"
class="admin-post-form__tag-suggestions absolute left-0 right-0 top-full z-40 mt-1 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"
>
<span class="admin-post-form__tag-chevron text-xs text-[#394047]" aria-hidden="true"></span>
</label>
<button
v-for="(tag, index) in tagSuggestionOptions"
:key="tag.id || tag.slug"
class="admin-post-form__tag-suggestion flex w-full items-center justify-between gap-3 px-3 py-2 text-left transition-colors"
:class="index === activeTagSuggestionIndex ? 'bg-[#f3f5f7]' : 'hover:bg-[#f7f8f9]'"
type="button"
role="option"
:aria-selected="index === activeTagSuggestionIndex"
@mouseenter="activeTagSuggestionIndex = index"
@mousedown.prevent="selectTagSuggestion(tag)"
>
<span class="admin-post-form__tag-suggestion-name min-w-0 truncate font-semibold text-[#15171a]">{{ tag.name }}</span>
<span class="admin-post-form__tag-suggestion-meta shrink-0 text-xs text-muted">{{ getTagSuggestionMeta(tag) }}</span>
</button>
</div>
</div>
</div>
<label class="admin-post-form__featured-toggle flex items-center justify-between gap-4 border-t border-[#e3e6e8] pt-5 text-sm">

View File

@@ -37,6 +37,9 @@ const props = defineProps({
}
})
const triggerRef = ref(null)
const popoverStyle = ref({})
/** @type {import('vue').ComputedRef<boolean>} */
const isOpen = computed(() => openMenuId.value === props.itemId)
@@ -53,6 +56,32 @@ const triggerToneClass = computed(() => (
: 'text-[#394047] hover:bg-[#eceff2]'
))
/**
* 행 메뉴 위치를 화면 기준으로 계산한다.
* @returns {void}
*/
const updatePopoverPosition = () => {
if (!import.meta.client || !triggerRef.value) {
return
}
const rect = triggerRef.value.getBoundingClientRect()
const menuWidth = props.size === 'sm' ? 176 : 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
popoverStyle.value = {
left: `${left}px`,
top: `${top}px`,
width: `${menuWidth}px`
}
}
/**
* 메뉴 열기/닫기
* @returns {void}
@@ -64,6 +93,25 @@ const toggleMenu = () => {
openMenuId.value = isOpen.value ? '' : props.itemId
}
watch(isOpen, async (open) => {
if (!open) {
return
}
await nextTick()
updatePopoverPosition()
})
onMounted(() => {
window.addEventListener('resize', updatePopoverPosition)
window.addEventListener('scroll', updatePopoverPosition, true)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', updatePopoverPosition)
window.removeEventListener('scroll', updatePopoverPosition, true)
})
</script>
<template>
@@ -73,6 +121,7 @@ const toggleMenu = () => {
@mousedown.stop
>
<button
ref="triggerRef"
class="admin-row-more-menu__trigger inline-flex items-center justify-center rounded transition-colors focus-visible:outline focus-visible:ring-2 focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
:class="[triggerSizeClass, triggerToneClass]"
type="button"
@@ -100,13 +149,17 @@ const toggleMenu = () => {
<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>
<div
v-if="isOpen"
class="admin-row-more-menu__popover absolute right-0 top-full z-30 mt-1 min-w-[11rem] 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)]"
role="menu"
>
<slot />
</div>
<Teleport to="body">
<div
v-if="isOpen"
class="admin-row-more-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="popoverStyle"
role="menu"
data-admin-row-menu
>
<slot />
</div>
</Teleport>
</div>
</template>