글 설정 태그와 대표 이미지 흐름 정리
This commit is contained in:
@@ -46,6 +46,13 @@
|
||||
margin: 0;
|
||||
background: var(--site-bg);
|
||||
}
|
||||
|
||||
html.admin-post-editor-document,
|
||||
body.admin-post-editor-document {
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-07 v0.0.42
|
||||
|
||||
### 태그 입력과 대표 이미지 선택 흐름 결정
|
||||
|
||||
관리자 글 설정의 태그 입력은 단순 텍스트 필드가 아니라 배지형 입력으로 처리한다. 태그 입력 중 Enter가 폼 제출로 전파되면 의도치 않게 게시물이 저장 또는 발행될 수 있으므로, Enter와 쉼표는 태그 추가 동작으로만 사용한다.
|
||||
|
||||
Canonical URL과 OG 이미지는 별도 입력 항목에서 제외한다. 현재 운영 흐름에서는 기본 글 주소와 대표 이미지가 자연스러운 기본값이며, 별도 OG 이미지를 관리하면 글 설정 패널이 불필요하게 길어지고 대표 이미지와 공유 이미지가 어긋날 수 있기 때문이다.
|
||||
|
||||
대표 이미지는 업로드 탭과 미디어 라이브러리 탭을 함께 제공한다. 작성자는 새 이미지를 바로 올릴 수도 있고, 이미 업로드한 이미지를 재사용할 수도 있어야 하기 때문이다.
|
||||
|
||||
## 2026-05-07 v0.0.41
|
||||
|
||||
### 명령 메뉴 계층과 개발 도구 표시 결정
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지/OG 이미지 선택, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 업로드/미디어 선택, 배지형 태그 입력, 로컬 자동 저장, 예약 발행 시각 입력, SEO 설정, 미리보기 요청 |
|
||||
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||
|
||||
14
docs/spec.md
14
docs/spec.md
@@ -306,6 +306,7 @@ components/content/
|
||||
- 공개 가능한 글의 보기 액션은 게시물 설정 패널의 Post URL 라벨 오른쪽에 표시한다.
|
||||
- 글 삭제 액션은 게시물 설정 패널 하단의 빨간 outline 버튼으로 제공한다.
|
||||
- 대표 이미지는 본문 제목 위의 에디터 흐름 안에서 추가, 변경, 삭제한다.
|
||||
- 대표 이미지 선택 모달은 이미지 업로드 탭과 미디어 라이브러리 탭을 제공한다.
|
||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||
- 글 작성/수정 중인 입력값은 브라우저 `localStorage`에 자동 저장한다.
|
||||
@@ -319,13 +320,14 @@ components/content/
|
||||
- 예약 발행 글은 관리자 목록에서 예약 상태로 표시하되 공개 게시물 목록과 상세 API에는 발행 시각 이후부터 노출한다.
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
||||
- 태그 입력은 배지형 입력으로 제공하며 Enter 또는 쉼표 입력 시 태그를 추가하고, 배지의 x 버튼으로 삭제한다.
|
||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 이미지 업로드 탭으로 설정한다.
|
||||
- 대표 이미지가 설정되면 관리자 글 폼에 썸네일과 삭제/변경 액션을 표시한다.
|
||||
- 글 SEO 설정은 SEO 제목, SEO 설명, canonical URL, 검색엔진 노출 제외 여부를 저장한다.
|
||||
- 글 SEO 설정은 SEO 제목, SEO 설명, 검색엔진 노출 제외 여부를 저장한다.
|
||||
- Canonical URL은 별도 입력을 받지 않고 기본 글 주소를 사용한다.
|
||||
- 공개 게시물 상세 화면은 SEO 제목이 없으면 글 제목, SEO 설명이 없으면 요약을 메타 태그 기본값으로 사용한다.
|
||||
- 검색엔진 노출 제외가 켜진 글은 robots 메타를 `noindex, nofollow`로 출력한다.
|
||||
- 글 OG 이미지는 미디어 선택 또는 새 이미지 업로드로 설정하며, 공개 상세 화면의 `og:image`와 Twitter large image 카드에 사용한다.
|
||||
- OG 이미지가 없으면 대표 이미지를 `og:image` fallback으로 사용한다.
|
||||
- 공개 상세 화면의 `og:image`와 Twitter large image 카드는 대표 이미지를 기본값으로 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||
- 이미지/갤러리 삽입 시 파일명은 alt 값으로 자동 입력하지 않는다.
|
||||
- 이미지 블록 alt 입력은 이미지 hover 또는 focus 상태에서만 표시한다.
|
||||
@@ -390,7 +392,7 @@ components/content/
|
||||
- 미디어 파일 경로, 사용 현황, 용량 등 세부 정보는 썸네일 더블 클릭 시 열리는 상세 모달에서 표시한다.
|
||||
- 미디어 폴더는 실제 파일 경로를 옮기지 않고 `media_metadata` 테이블에 URL별 경로 메타데이터로 저장한다.
|
||||
- 글쓰기 미디어 선택 창은 업로드 미디어 목록에서 이미지를 선택해 단일 이미지 또는 갤러리에 삽입한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지, 게시물 OG 이미지, 본문 내 URL을 기준으로 표시한다.
|
||||
- 미디어 사용 현황은 게시물/페이지의 대표 이미지와 본문 내 URL을 기준으로 표시한다.
|
||||
- 사용 중인 미디어는 저장된 콘텐츠 URL이 깨지지 않도록 파일명 변경과 삭제를 차단한다.
|
||||
- 향후 미디어 라이브러리는 프로필/사이트 설정 이미지 사용처 추적을 제공한다.
|
||||
|
||||
@@ -452,6 +454,6 @@ APP_PORT=43118
|
||||
|
||||
## 버전 관리
|
||||
|
||||
- 현재 버전: v0.0.41
|
||||
- 현재 버전: v0.0.42
|
||||
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
|
||||
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.42
|
||||
|
||||
- 관리자 글쓰기 화면의 문서 스크롤 잠금 클래스를 html/body에 직접 적용하도록 보강.
|
||||
- 관리자 글 설정의 태그 입력을 Figma 기준 배지형 입력으로 수정.
|
||||
- 태그 입력 중 Enter가 게시물 저장으로 이어지던 문제 수정.
|
||||
- 관리자 글 설정에서 Canonical URL과 OG 이미지 입력 UI 제거.
|
||||
- 게시물 저장 시 Canonical URL은 기본 글 주소, OG 이미지는 대표 이미지를 따르도록 정리.
|
||||
- 대표 이미지 선택 모달에 이미지 업로드와 미디어 라이브러리 탭 추가.
|
||||
- 기술 명세 현재 버전을 v0.0.42로 갱신.
|
||||
- 패키지 버전을 0.0.42로 갱신.
|
||||
|
||||
## v0.0.41
|
||||
|
||||
- 관리자 블록 에디터 `/` 명령 메뉴가 아래 블록 텍스트와 겹쳐 보이던 문제 수정.
|
||||
|
||||
@@ -4,6 +4,32 @@ const route = useRoute()
|
||||
const isPostEditorRoute = computed(() => route.path === '/admin/posts/new'
|
||||
|| (route.path.startsWith('/admin/posts/') && route.path !== '/admin/posts/preview'))
|
||||
|
||||
const editorDocumentClass = 'admin-post-editor-document'
|
||||
|
||||
/**
|
||||
* 글쓰기 전체 화면 문서 스크롤 잠금 적용
|
||||
* @returns {void}
|
||||
*/
|
||||
const syncPostEditorDocumentClass = () => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||
document.body.classList.toggle(editorDocumentClass, isPostEditorRoute.value)
|
||||
}
|
||||
|
||||
watchEffect(syncPostEditorDocumentClass)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (!import.meta.client) {
|
||||
return
|
||||
}
|
||||
|
||||
document.documentElement.classList.remove(editorDocumentClass)
|
||||
document.body.classList.remove(editorDocumentClass)
|
||||
})
|
||||
|
||||
/**
|
||||
* 관리자 로그아웃
|
||||
* @returns {Promise<void>} 로그아웃 처리 결과
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.41",
|
||||
"version": "0.0.42",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.41",
|
||||
"version": "0.0.42",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.41",
|
||||
"version": "0.0.42",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -21,8 +21,7 @@ const siteUrl = computed(() => String(config.public.siteUrl || '').replace(/\/$/
|
||||
const pageUrl = computed(() => `${siteUrl.value}/post/${post.value.slug}`)
|
||||
const seoTitle = computed(() => post.value.seoTitle || post.value.title)
|
||||
const seoDescription = computed(() => post.value.seoDescription || post.value.excerpt || 'sori.studio 개인 블로그')
|
||||
const canonicalUrl = computed(() => post.value.canonicalUrl || pageUrl.value)
|
||||
const ogImage = computed(() => post.value.ogImage || post.value.featuredImage || '')
|
||||
const ogImage = computed(() => post.value.featuredImage || '')
|
||||
|
||||
/**
|
||||
* 절대 URL 생성
|
||||
@@ -46,7 +45,7 @@ useHead(() => ({
|
||||
link: [
|
||||
{
|
||||
rel: 'canonical',
|
||||
href: canonicalUrl.value
|
||||
href: pageUrl.value
|
||||
}
|
||||
],
|
||||
meta: [
|
||||
|
||||
@@ -203,13 +203,6 @@ const getContentMediaUsage = (contentItem, url) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (contentItem.ogImage === url) {
|
||||
usages.push({
|
||||
location: 'ogImage',
|
||||
label: 'OG 이미지'
|
||||
})
|
||||
}
|
||||
|
||||
if (contentItem.content?.includes(url)) {
|
||||
usages.push({
|
||||
location: 'content',
|
||||
|
||||
Reference in New Issue
Block a user