글 설정 태그와 대표 이미지 흐름 정리

This commit is contained in:
2026-05-07 15:55:20 +09:00
parent 0f60039126
commit f757c3db78
11 changed files with 240 additions and 132 deletions

View File

@@ -40,12 +40,13 @@ const isMediaPickerOpen = ref(false)
const mediaPickerTarget = ref('featuredImage')
const isLoadingMedia = ref(false)
const isUploadingFeaturedImage = ref(false)
const isUploadingOgImage = ref(false)
const autosaveTimer = ref(null)
const autosaveNotice = ref(null)
const autosaveStatus = ref('')
const isRestoringAutosave = ref(false)
const isSettingsOpen = ref(true)
const tagInput = ref('')
const activeMediaPickerTab = ref('upload')
/**
* ISO 날짜를 datetime-local 입력값으로 변환
@@ -95,9 +96,7 @@ const form = reactive({
featuredImage: props.initialPost.featuredImage || '',
seoTitle: props.initialPost.seoTitle || '',
seoDescription: props.initialPost.seoDescription || '',
canonicalUrl: props.initialPost.canonicalUrl || '',
noindex: Boolean(props.initialPost.noindex),
ogImage: props.initialPost.ogImage || '',
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
tagsText: props.initialPost.tags?.join(', ') || ''
@@ -145,6 +144,8 @@ const parseTags = (value) => [...new Set(value
.map((tag) => toSlug(tag))
.filter(Boolean))]
const selectedTags = computed(() => parseTags(form.tagsText))
/**
* 예약 발행 여부 확인
* @returns {boolean} 예약 발행 여부
@@ -188,12 +189,12 @@ const createPostPayload = () => {
featuredImage: form.featuredImage.trim() || null,
seoTitle: form.seoTitle.trim(),
seoDescription: form.seoDescription.trim(),
canonicalUrl: form.canonicalUrl.trim(),
canonicalUrl: '',
noindex: form.noindex,
ogImage: form.ogImage.trim() || null,
ogImage: null,
status: form.status,
publishedAt,
tags: parseTags(form.tagsText)
tags: selectedTags.value
}
}
@@ -209,9 +210,7 @@ const createAutosavePayload = () => ({
featuredImage: form.featuredImage,
seoTitle: form.seoTitle,
seoDescription: form.seoDescription,
canonicalUrl: form.canonicalUrl,
noindex: form.noindex,
ogImage: form.ogImage,
status: form.status,
publishedAt: form.publishedAt,
tagsText: form.tagsText
@@ -230,8 +229,6 @@ const isEmptyAutosavePayload = (payload) => ![
payload.featuredImage,
payload.seoTitle,
payload.seoDescription,
payload.canonicalUrl,
payload.ogImage,
payload.tagsText
].some((value) => String(value || '').trim())
@@ -339,6 +336,7 @@ const fetchMediaItems = async () => {
*/
const openMediaPicker = async (target = 'featuredImage') => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
isMediaPickerOpen.value = true
await fetchMediaItems()
}
@@ -370,15 +368,73 @@ const removeFeaturedImage = () => {
}
/**
* OG 이미지 삭제
* 태그 입력값을 배지 목록에 추가
* @returns {void}
*/
const removeOgImage = () => {
form.ogImage = ''
const addTagFromInput = () => {
const nextTag = toSlug(tagInput.value)
if (!nextTag) {
tagInput.value = ''
return
}
form.tagsText = [...new Set([...selectedTags.value, nextTag])].join(', ')
tagInput.value = ''
}
/**
* 태그 배지 삭제
* @param {string} tag - 삭제할 태그
* @returns {void}
*/
const removeTag = (tag) => {
form.tagsText = selectedTags.value.filter((item) => item !== tag).join(', ')
}
/**
* 태그 입력 키 처리
* @param {KeyboardEvent} event - 키보드 이벤트
* @returns {void}
*/
const handleTagKeydown = (event) => {
if (event.key === 'Enter' || event.key === ',') {
event.preventDefault()
addTagFromInput()
return
}
if (event.key === 'Backspace' && !tagInput.value && selectedTags.value.length) {
event.preventDefault()
removeTag(selectedTags.value.at(-1))
}
}
/**
* 대표 이미지 파일 업로드
* @param {File} file - 업로드 파일
* @returns {Promise<void>}
*/
const uploadFeaturedImageFile = async (file) => {
const formData = new FormData()
formData.append('files', file)
isUploadingFeaturedImage.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.featuredImage = result.files?.[0]?.url || ''
await fetchMediaItems()
closeMediaPicker()
} finally {
isUploadingFeaturedImage.value = false
}
}
/**
* 대표 이미지 파일 입력 처리
* @param {Event} event - 파일 입력 이벤트
* @returns {Promise<void>}
*/
@@ -389,48 +445,26 @@ const uploadFeaturedImage = async (event) => {
return
}
const formData = new FormData()
formData.append('files', files[0])
isUploadingFeaturedImage.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.featuredImage = result.files?.[0]?.url || ''
await uploadFeaturedImageFile(files[0])
} finally {
event.target.value = ''
isUploadingFeaturedImage.value = false
}
}
/**
* OG 이미지 파일 업로드
* @param {Event} event - 파일 입력 이벤트
* 대표 이미지 드롭 업로드
* @param {DragEvent} event - 드롭 이벤트
* @returns {Promise<void>}
*/
const uploadOgImage = async (event) => {
const files = event.target.files
const dropFeaturedImage = async (event) => {
const files = event.dataTransfer?.files
if (!files?.length) {
return
}
const formData = new FormData()
formData.append('files', files[0])
isUploadingOgImage.value = true
try {
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: formData
})
form.ogImage = result.files?.[0]?.url || ''
} finally {
event.target.value = ''
isUploadingOgImage.value = false
}
await uploadFeaturedImageFile(files[0])
}
/**
@@ -684,14 +718,35 @@ defineExpose({
/>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">태그</span>
<input
v-model="form.tagsText"
class="admin-post-form__input h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="text"
>
</label>
<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 grid size-3 place-items-center rounded text-[#e04e87] transition-colors hover:bg-[#e7c3d2]"
type="button"
:aria-label="`${tag} 태그 삭제`"
@click="removeTag(tag)"
>
<span aria-hidden="true">x</span>
</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"
>
<span class="admin-post-form__tag-chevron text-xs text-[#394047]" aria-hidden="true"></span>
</label>
</div>
<div class="admin-post-form__seo grid gap-3 border-t border-[#e3e6e8] pt-5 text-sm">
<div>
@@ -730,16 +785,6 @@ defineExpose({
</span>
</label>
<label class="admin-post-form__field grid gap-2">
<span class="admin-post-form__label font-medium">Canonical URL</span>
<input
v-model="form.canonicalUrl"
class="admin-post-form__input h-[38px] rounded border border-[#e3e6e8] bg-[#eff1f2] px-3 py-2 transition-colors hover:border-[#c8ced3] focus:border-[#8e9cac] focus:outline-none"
type="url"
placeholder="비워두면 기본 글 주소를 사용"
>
</label>
<label class="admin-post-form__checkbox flex items-start gap-2 text-sm">
<input
v-model="form.noindex"
@@ -752,39 +797,6 @@ defineExpose({
</span>
</label>
</div>
<div class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">OG 이미지</span>
<figure v-if="form.ogImage" class="admin-post-form__og-image overflow-hidden rounded border border-line bg-white">
<img class="admin-post-form__og-preview aspect-[1.91/1] w-full bg-surface object-cover" :src="form.ogImage" alt="">
<figcaption class="admin-post-form__og-actions grid gap-2 p-3">
<p class="admin-post-form__og-url break-all text-xs text-muted">
{{ form.ogImage }}
</p>
<div class="admin-post-form__og-buttons flex flex-wrap gap-2">
<button class="admin-post-form__og-change rounded border border-line px-3 py-1.5 text-xs font-semibold transition-colors hover:border-[#c8ced3] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('ogImage')">
변경
</button>
<label class="admin-post-form__og-reupload cursor-pointer rounded border border-line px-3 py-1.5 text-xs font-semibold transition-colors hover:border-[#c8ced3] hover:bg-[#eff1f2]">
업로드
<input class="sr-only" type="file" accept="image/*" @change="uploadOgImage">
</label>
<button class="admin-post-form__og-remove rounded border border-red-200 px-3 py-1.5 text-xs font-semibold text-red-700 transition-colors hover:bg-red-50" type="button" @click="removeOgImage">
삭제
</button>
</div>
</figcaption>
</figure>
<div v-else class="admin-post-form__og-empty grid gap-2 rounded border border-dashed border-line bg-white p-4">
<button class="admin-post-form__og-select rounded border border-line px-3 py-2 text-sm font-semibold transition-colors hover:border-[#c8ced3] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('ogImage')">
미디어에서 선택
</button>
<label class="admin-post-form__og-upload cursor-pointer rounded bg-[#15171a] px-3 py-2 text-center text-sm font-semibold text-white transition-colors hover:bg-black">
{{ isUploadingOgImage ? '업로드 중' : '새 이미지 업로드' }}
<input class="sr-only" type="file" accept="image/*" @change="uploadOgImage">
</label>
</div>
</div>
</div>
<div v-if="showDelete" class="admin-post-form__settings-bottom shrink-0 border-t border-[#e3e6e8] px-8 py-6">
<button
@@ -807,34 +819,82 @@ defineExpose({
aria-modal="true"
@click.self="closeMediaPicker"
>
<section class="admin-post-form__media-picker-panel max-h-[80vh] w-full max-w-4xl overflow-hidden bg-white text-ink shadow-xl">
<section class="admin-post-form__media-picker-panel flex max-h-[86vh] min-h-[620px] w-full max-w-5xl flex-col overflow-hidden bg-white text-ink shadow-xl">
<div class="admin-post-form__media-picker-header flex items-center justify-between border-b border-line px-5 py-4">
<h2 class="admin-post-form__media-picker-title text-lg font-semibold">
{{ mediaPickerTarget === 'ogImage' ? 'OG 이미지 선택' : '대표 이미지 선택' }}
대표 이미지
</h2>
<button class="admin-post-form__media-picker-close rounded border border-line px-3 py-1.5 text-sm font-semibold transition-colors hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
닫기
</button>
</div>
<div class="admin-post-form__media-picker-body max-h-[62vh] overflow-y-auto p-5">
<p v-if="isLoadingMedia" class="admin-post-form__media-picker-loading text-sm text-muted">
미디어를 불러오는 중입니다.
</p>
<div v-else-if="mediaItems.length" class="admin-post-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
<button
v-for="item in mediaItems"
:key="item.url"
class="admin-post-form__media-picker-item overflow-hidden border border-line bg-white text-left transition hover:border-[#8e9cac] hover:shadow-sm"
type="button"
@click="selectPickedImage(item)"
>
<img class="admin-post-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
</button>
<div class="admin-post-form__media-picker-tabs flex border-b border-line px-5">
<button
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'upload'"
>
이미지 업로드
</button>
<button
class="admin-post-form__media-picker-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-muted hover:text-[#15171a]'"
type="button"
@click="activeMediaPickerTab = 'library'"
>
미디어 라이브러리
</button>
</div>
<div class="admin-post-form__media-picker-body flex-1 overflow-y-auto p-5">
<div
v-if="activeMediaPickerTab === 'upload'"
class="admin-post-form__media-upload-zone grid min-h-[420px] place-items-center border border-dashed border-[#cfd5da] bg-white text-center"
@dragover.prevent
@drop.prevent="dropFeaturedImage"
>
<div class="admin-post-form__media-upload-inner grid gap-3">
<p class="admin-post-form__media-upload-title text-lg font-semibold text-[#15171a]">
파일을 끌어 업로드
</p>
<p class="admin-post-form__media-upload-or text-sm text-muted">
또는
</p>
<label class="admin-post-form__media-upload-button mx-auto inline-flex h-10 cursor-pointer items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50">
{{ isUploadingFeaturedImage ? '업로드 중' : '파일 선택' }}
<input class="sr-only" type="file" accept="image/*" @change="uploadFeaturedImage">
</label>
</div>
</div>
<p v-else class="admin-post-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
선택할 미디어가 없습니다.
</p>
<template v-else>
<p v-if="isLoadingMedia" class="admin-post-form__media-picker-loading text-sm text-muted">
미디어를 불러오는 중입니다.
</p>
<div v-else-if="mediaItems.length" class="admin-post-form__media-picker-grid grid grid-cols-3 gap-2 sm:grid-cols-4 md:grid-cols-6">
<button
v-for="item in mediaItems"
:key="item.url"
class="admin-post-form__media-picker-item overflow-hidden border border-line bg-white text-left transition hover:border-[#8e9cac] hover:shadow-sm"
type="button"
@click="selectPickedImage(item)"
>
<img class="admin-post-form__media-picker-image aspect-square w-full bg-surface object-cover" :src="item.url" :alt="item.title">
<span class="admin-post-form__media-picker-name block truncate px-2 py-1.5 text-xs font-semibold text-ink">{{ item.name }}</span>
</button>
</div>
<p v-else class="admin-post-form__media-picker-empty border border-dashed border-line p-8 text-center text-sm text-muted">
선택할 미디어가 없습니다.
</p>
</template>
</div>
<div class="admin-post-form__media-picker-footer flex h-14 shrink-0 items-center justify-end border-t border-line px-5">
<button
class="admin-post-form__media-picker-confirm h-9 rounded border border-line px-4 text-sm font-semibold text-muted disabled:cursor-not-allowed disabled:opacity-50"
type="button"
disabled
>
대표 이미지 설정
</button>
</div>
</section>
</div>