글 설정 태그와 대표 이미지 흐름 정리
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user