관리자 태그와 목록 메뉴 개선 v1.5.0
This commit is contained in:
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-26 v1.5.0 — 글쓰기 태그 입력을 검색형 선택으로 정리
|
||||
|
||||
게시물 태그는 여러 개 저장되지만 관리자 글 목록에서는 운영자가 빠르게 분류를 읽는 것이 우선이므로 첫 번째 태그만 대표 태그로 표시한다. 글쓰기 화면은 직접 입력 흐름을 유지하되, 메인 태그처럼 이미 관리자가 등록한 태그는 다시 타이핑하지 않아도 되도록 오른쪽 트리거를 선택 드롭다운으로 바꾼다. 기존 태그는 이름과 슬러그 부분 일치로 추천해 `no` 입력만으로 `note` 같은 태그를 찾고, 방향키와 Enter로 추가할 수 있게 한다. 태그별 색상은 관리 화면에서 이미 운영자가 지정하는 분류 신호이므로 글쓰기 배지와 글 목록 대표 배지에도 같은 색상을 반영한다.
|
||||
|
||||
## 2026-05-22 v1.4.7 — 라이브 모드 인라인 마크다운 직렬화
|
||||
|
||||
라이브 편집 영역은 화면에 `<strong>`·`<em>` 등으로 표시되지만, blur 시 저장 경로가 `textContent`만 읽으면 `**`·`*` 마커가 빠진다. 문단 이동 시 이전 블록이 blur·commit 되므로 방향키만으로도 서식이 사라진 것처럼 보였다. `readEditableTextFromElement`가 DOM 인라인 노드를 마크다운으로 다시 직렬화하도록 수정한다.
|
||||
|
||||
@@ -78,7 +78,7 @@
|
||||
|------|-----------|
|
||||
| components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) |
|
||||
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글 등 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
|
||||
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 |
|
||||
@@ -124,16 +124,16 @@
|
||||
|------|------|
|
||||
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 기간 선택형 방문자수·평균 체류·50% 스크롤 차트, 접속자 목록, 인기 게시물 참여 지표) |
|
||||
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 읽기 전용 태그 배지, 행 끝 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/index.vue | 글 목록, 헤더 우측에 필터(상태·태그·정렬)와 «새 글»을 한 줄 배치, 예약/초안/발행 텍스트 상태, 제목 옆 댓글 수, 첫 번째 태그 색상 대표 배지, 화면 기준 행 more vert 메뉴(추천·삭제) |
|
||||
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
|
||||
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
|
||||
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/index.vue | 페이지 목록, 화면 기준 행 more vert 메뉴(수정·삭제) |
|
||||
| pages/admin/pages/new.vue | 페이지 작성, 저장 토스트 |
|
||||
| pages/admin/pages/[id].vue | 페이지 수정, 저장/삭제 토스트 |
|
||||
| pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 |
|
||||
| pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` |
|
||||
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·팝오버) |
|
||||
| components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) |
|
||||
| composables/useAdminRowMenu.js | 관리자 행 메뉴 열림 상태·바깥 클릭 닫기 |
|
||||
| pages/admin/tags/index.vue | 태그 관리(메인 태그 드래그 정렬 자동 저장·more vert 메뉴, 일반 태그 배지 more vert 메뉴·검색/정렬, 태그 추가 버튼), 액션 피드백 토스트 |
|
||||
| pages/admin/tags/new.vue | 태그 생성 |
|
||||
|
||||
@@ -134,6 +134,8 @@ layouts/
|
||||
- 관리자 우측 캔버스는 기본 `min-h-screen`과 `bg-paper`를 유지해 콘텐츠 높이가 짧아도 배경이 화면 전체를 채운다. 일반 관리자 화면은 레이아웃 레벨에서 넉넉한 외부 여백을 둔다.
|
||||
- 대시보드 메뉴는 관리자 기본 페이지(`/admin`)로 이동하는 활성 링크로 표시한다.
|
||||
- 게시글 메뉴 라벨은 `게시글`로 표시하고, 우측 `+` 아이콘은 `/admin/posts/new`로 바로 이동한다.
|
||||
- 관리자 글 목록의 태그 컬럼은 게시물 태그 배열의 첫 번째 항목만 대표 태그로 표시하며, 배지는 태그 고유 색상을 반영한다.
|
||||
- 관리자 글쓰기의 Tags 입력은 배지형 다중 입력을 유지하며, 오른쪽 트리거로 메인 태그를 드롭다운에서 추가할 수 있다. 입력 중에는 기존 태그 이름·슬러그 부분 일치 결과를 추천하고, 방향키와 Enter로 선택할 수 있다. 선택된 태그 배지는 태그 고유 색상을 반영한다.
|
||||
- 메뉴 관리 항목은 `네비게이션`으로 표시한다.
|
||||
- 게시글·페이지·태그·미디어·네비게이션·멤버·설정 메뉴는 전용 SVG 아이콘을 사용한다.
|
||||
- 멤버 메뉴는 우측에 현재 총 멤버 수를 표시한다.
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v1.5.0
|
||||
|
||||
- 관리자 글 목록: 태그 컬럼에 첫 번째 태그만 대표 태그로 표시하도록 수정.
|
||||
- 관리자 글쓰기: 태그 입력 오른쪽 트리거를 기존 태그 제거가 아닌 메인 태그 선택 드롭다운으로 동작하도록 수정.
|
||||
- 관리자 글쓰기: 기존 태그 부분 검색과 방향키·Enter 선택 추가.
|
||||
- 관리자 글 목록·글쓰기: 태그 배지에 태그별 고유 색상 반영.
|
||||
- 관리자 글쓰기: 게시물 설정 사이드바의 상태·태그 드롭다운 화살표와 닫기 버튼 아이콘 정리.
|
||||
- 관리자 글·페이지 목록: 마지막 행 더보기 메뉴가 테이블 영역에 잘리던 문제 수정.
|
||||
|
||||
## v1.4.7
|
||||
|
||||
- 관리자 글쓰기 라이브 모드: 문단 이동(방향키) 시 굵게·기울임 등 인라인 마크다운이 사라지던 문제 수정.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "1.4.2",
|
||||
"version": "1.5.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"imports": {
|
||||
|
||||
@@ -8,11 +8,13 @@ definePageMeta({
|
||||
const deletingId = ref('')
|
||||
const togglingFeaturedId = ref('')
|
||||
const openPostMenuId = ref('')
|
||||
const postMenuStyle = ref({})
|
||||
const errorMessage = ref('')
|
||||
const statusFilter = ref('all')
|
||||
const tagFilter = ref('all')
|
||||
const featuredFilter = ref('all')
|
||||
const sortOrder = ref('newest')
|
||||
const postMenuTriggerRefs = new Map()
|
||||
|
||||
const { data: posts, refresh } = await useFetch('/admin/api/posts', {
|
||||
default: () => []
|
||||
@@ -26,6 +28,8 @@ const { data: siteSettings } = await useFetch('/admin/api/settings', {
|
||||
default: () => ({ showPostUpdatedAt: false })
|
||||
})
|
||||
|
||||
const defaultTagColor = '#15171a'
|
||||
|
||||
/**
|
||||
* 게시물 발행일 라벨을 반환한다.
|
||||
* @param {Object} post - 게시물
|
||||
@@ -137,6 +141,31 @@ const getPostStatusClass = (post) => {
|
||||
*/
|
||||
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
|
||||
})
|
||||
|
||||
/**
|
||||
* 게시물의 대표 태그 슬러그를 반환한다.
|
||||
* @param {Object} post - 게시물
|
||||
* @returns {string} 첫 번째 태그 슬러그
|
||||
*/
|
||||
const getRepresentativeTagSlug = (post) => post.tags?.[0] || ''
|
||||
|
||||
const usedTagSlugs = computed(() => {
|
||||
const slugs = new Set()
|
||||
for (const post of posts.value) {
|
||||
@@ -207,6 +236,52 @@ const closePostMenu = () => {
|
||||
openPostMenuId.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 행 메뉴 트리거 엘리먼트를 저장한다.
|
||||
* @param {string} postId - 게시물 ID
|
||||
* @param {Element|null} element - 트리거 엘리먼트
|
||||
* @returns {void}
|
||||
*/
|
||||
const setPostMenuTriggerRef = (postId, element) => {
|
||||
if (!element) {
|
||||
postMenuTriggerRefs.delete(postId)
|
||||
return
|
||||
}
|
||||
|
||||
postMenuTriggerRefs.set(postId, element)
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시물 행 메뉴 위치를 화면 기준으로 계산한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const updatePostMenuPosition = () => {
|
||||
if (!import.meta.client || !openPostMenuId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const trigger = postMenuTriggerRefs.get(openPostMenuId.value)
|
||||
if (!trigger) {
|
||||
return
|
||||
}
|
||||
|
||||
const rect = trigger.getBoundingClientRect()
|
||||
const menuWidth = 176
|
||||
const estimatedHeight = 112
|
||||
const margin = 8
|
||||
const left = Math.max(margin, Math.min(rect.right - menuWidth, window.innerWidth - menuWidth - margin))
|
||||
const opensUp = rect.bottom + estimatedHeight + margin > window.innerHeight
|
||||
const top = opensUp
|
||||
? Math.max(margin, rect.top - estimatedHeight - 4)
|
||||
: rect.bottom + 4
|
||||
|
||||
postMenuStyle.value = {
|
||||
left: `${left}px`,
|
||||
top: `${top}px`,
|
||||
width: `${menuWidth}px`
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 행 메뉴 열기/닫기
|
||||
* @param {string} postId - 게시물 ID
|
||||
@@ -285,10 +360,23 @@ const deletePost = async (post) => {
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
||||
window.addEventListener('resize', updatePostMenuPosition)
|
||||
window.addEventListener('scroll', updatePostMenuPosition, true)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('pointerdown', onPostMenuDocumentPointerDown)
|
||||
window.removeEventListener('resize', updatePostMenuPosition)
|
||||
window.removeEventListener('scroll', updatePostMenuPosition, true)
|
||||
})
|
||||
|
||||
watch(openPostMenuId, async (postId) => {
|
||||
if (!postId) {
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
updatePostMenuPosition()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -412,13 +500,12 @@ onBeforeUnmount(() => {
|
||||
</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">
|
||||
<div v-if="getRepresentativeTagSlug(post)" 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] bg-[#ecd2de] px-2 text-xs font-semibold text-[#e04e87]"
|
||||
class="admin-posts__tag-badge inline-flex h-6 items-center rounded-[3px] border px-2 text-xs font-semibold"
|
||||
:style="createTagBadgeStyle(getTagColor(getRepresentativeTagSlug(post)))"
|
||||
>
|
||||
{{ tag }}
|
||||
{{ getTagName(getRepresentativeTagSlug(post)) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-else class="admin-posts__tag-empty text-muted">-</span>
|
||||
@@ -437,6 +524,7 @@ onBeforeUnmount(() => {
|
||||
data-admin-post-row-menu
|
||||
>
|
||||
<button
|
||||
:ref="(element) => setPostMenuTriggerRef(post.id, element)"
|
||||
class="admin-posts__row-menu-trigger inline-flex size-9 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#eceff2] focus-visible:outline focus-visible:ring-2 focus-visible:ring-[#8e9cac] focus-visible:ring-offset-1 disabled:pointer-events-none disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="deletingId === post.id || togglingFeaturedId === post.id"
|
||||
@@ -461,30 +549,34 @@ onBeforeUnmount(() => {
|
||||
<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="openPostMenuId === post.id"
|
||||
class="admin-posts__row-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"
|
||||
>
|
||||
<button
|
||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:disabled="togglingFeaturedId === post.id"
|
||||
@click="togglePostFeatured(post)"
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="openPostMenuId === post.id"
|
||||
class="admin-posts__row-menu-popover fixed z-[80] overflow-hidden rounded-xl border border-[#e2e5e9] bg-white py-2 text-sm text-[#3f4650] shadow-[0_18px_50px_rgba(15,23,42,0.16)]"
|
||||
:style="postMenuStyle"
|
||||
role="menu"
|
||||
data-admin-post-row-menu
|
||||
>
|
||||
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left text-[#c0392b] hover:bg-[#fef2f2] disabled:opacity-50"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:disabled="deletingId === post.id"
|
||||
@click="deletePost(post)"
|
||||
>
|
||||
게시글 삭제
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left hover:bg-[#f3f5f7] disabled:opacity-50"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:disabled="togglingFeaturedId === post.id"
|
||||
@click="togglePostFeatured(post)"
|
||||
>
|
||||
{{ post.isFeatured ? '추천 제거' : '게시글 추천' }}
|
||||
</button>
|
||||
<button
|
||||
class="admin-posts__row-menu-item block w-full px-4 py-2.5 text-left text-[#c0392b] hover:bg-[#fef2f2] disabled:opacity-50"
|
||||
type="button"
|
||||
role="menuitem"
|
||||
:disabled="deletingId === post.id"
|
||||
@click="deletePost(post)"
|
||||
>
|
||||
게시글 삭제
|
||||
</button>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
Reference in New Issue
Block a user