From 79fb354d9180210c909d7bedc9394d1162505aa2 Mon Sep 17 00:00:00 2001
From: zenn
Date: Tue, 12 May 2026 09:56:52 +0900
Subject: [PATCH] =?UTF-8?q?v0.0.86:=20=EB=AF=B8=EB=A6=AC=EB=B3=B4=EA=B8=B0?=
=?UTF-8?q?=20=ED=8C=A8=EB=94=A9,=20=ED=83=9C=EA=B7=B8=20=ED=95=9C?=
=?UTF-8?q?=EA=B8=80=20=EC=9C=A0=EC=A7=80,=20SEO=20=EC=9E=90=EB=8F=99,=20?=
=?UTF-8?q?=ED=83=9C=EA=B7=B8=20=EA=B4=80=EB=A6=AC=20=ED=86=A0=EC=8A=A4?=
=?UTF-8?q?=ED=8A=B8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
components/admin/AdminPostForm.vue | 90 +++++++++++------------
docs/history.md | 32 ++++++++
docs/map.md | 6 +-
docs/spec.md | 15 +++-
docs/update.md | 23 ++++++
package.json | 2 +-
pages/admin/posts/preview.vue | 28 ++++---
pages/admin/tags/index.vue | 70 +++++++++++-------
server/repositories/content-repository.js | 44 ++++++++---
server/utils/admin-post-input.js | 4 +-
10 files changed, 212 insertions(+), 102 deletions(-)
diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue
index 90a40a6..a98f18a 100644
--- a/components/admin/AdminPostForm.vue
+++ b/components/admin/AdminPostForm.vue
@@ -96,8 +96,6 @@ const form = reactive({
excerpt: props.initialPost.excerpt || '',
content: props.initialPost.content || '',
featuredImage: props.initialPost.featuredImage || '',
- seoTitle: props.initialPost.seoTitle || '',
- seoDescription: props.initialPost.seoDescription || '',
noindex: Boolean(props.initialPost.noindex),
status: props.initialPost.status || 'draft',
publishedAt: toDateTimeLocalValue(props.initialPost.publishedAt),
@@ -150,6 +148,24 @@ const toSlug = (value) => value
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
+/**
+ * 게시물 태그 입력 토큰 정규화(한글 유지, 공백은 하이픈으로)
+ * @param {string} value - 원본 문자열
+ * @returns {string} 정규화된 태그 문자열
+ */
+const normalizeTagToken = (value) => {
+ const raw = String(value).normalize('NFC').trim().toLowerCase()
+ if (!raw) {
+ return ''
+ }
+
+ return raw
+ .replace(/\s+/g, '-')
+ .replace(/[^a-z0-9가-힣-]+/g, '')
+ .replace(/-+/g, '-')
+ .replace(/^-|-$/g, '')
+}
+
watch(() => form.title, (title) => {
if (!slugTouched.value) {
form.slug = toSlug(title)
@@ -166,14 +182,29 @@ const touchSlug = () => {
}
/**
- * 쉼표 구분 태그 문자열을 슬러그 배열로 변환
+ * 쉼표 구분 태그 문자열을 토큰 배열로 변환
* @param {string} value - 태그 입력 문자열
- * @returns {Array} 태그 슬러그 목록
+ * @returns {Array} 태그 토큰 목록
*/
-const parseTags = (value) => [...new Set(value
- .split(',')
- .map((tag) => toSlug(tag))
- .filter(Boolean))]
+const parseTags = (value) => {
+ const seen = new Set()
+ const out = []
+
+ for (const part of value.split(',')) {
+ const tag = normalizeTagToken(part)
+ if (!tag) {
+ continue
+ }
+ const dedupeKey = tag.toLowerCase()
+ if (seen.has(dedupeKey)) {
+ continue
+ }
+ seen.add(dedupeKey)
+ out.push(tag)
+ }
+
+ return out
+}
const selectedTags = computed(() => parseTags(form.tagsText))
@@ -218,8 +249,8 @@ const createPostPayload = () => {
excerpt: form.excerpt.trim(),
content: form.content,
featuredImage: form.featuredImage.trim() || null,
- seoTitle: form.seoTitle.trim(),
- seoDescription: form.seoDescription.trim(),
+ seoTitle: form.title.trim(),
+ seoDescription: form.excerpt.trim(),
canonicalUrl: '',
noindex: form.noindex,
ogImage: null,
@@ -239,8 +270,6 @@ const createAutosavePayload = () => ({
excerpt: form.excerpt,
content: form.content,
featuredImage: form.featuredImage,
- seoTitle: form.seoTitle,
- seoDescription: form.seoDescription,
noindex: form.noindex,
status: form.status,
publishedAt: form.publishedAt,
@@ -258,8 +287,6 @@ const isEmptyAutosavePayload = (payload) => ![
payload.excerpt,
payload.content,
payload.featuredImage,
- payload.seoTitle,
- payload.seoDescription,
payload.tagsText
].some((value) => String(value || '').trim())
@@ -416,7 +443,7 @@ const removeFeaturedImage = () => {
* @returns {void}
*/
const addTagFromInput = () => {
- const nextTag = toSlug(tagInput.value)
+ const nextTag = normalizeTagToken(tagInput.value)
if (!nextTag) {
tagInput.value = ''
@@ -794,43 +821,16 @@ defineExpose({
-
+
- SEO
+ 검색 노출
- 검색 결과와 공유 미리보기에 사용할 기본 메타 정보를 설정합니다.
+ 메타 제목·설명은 저장 시 글 제목과 요약을 그대로 사용합니다.
-
-
-
-
-
+
{{ previewError }}
-
-
-
- PREVIEW
-
-
- {{ previewPost.title || '제목 없음' }}
-
-
+
+
+
+
+
+ PREVIEW
+
+
+ {{ previewPost.title || '제목 없음' }}
+
+
-
-
+
+
+
+
diff --git a/pages/admin/tags/index.vue b/pages/admin/tags/index.vue
index 451e46b..fc33c8c 100644
--- a/pages/admin/tags/index.vue
+++ b/pages/admin/tags/index.vue
@@ -9,8 +9,8 @@ const savingOrder = ref(false)
const promotingTagId = ref('')
const demotingTagId = ref('')
const deletingGeneralTagId = ref('')
-const errorMessage = ref('')
-const infoMessage = ref('')
+const toast = ref(null)
+let toastTimer = null
const generalTagQuery = ref('')
const generalTagSearchResults = ref([])
const generalTagSearchLoading = ref(false)
@@ -21,6 +21,20 @@ const { data: tags, refresh } = await useFetch('/admin/api/tags', {
const managedTags = computed(() => tags.value.filter((tag) => tag.tagType === 'managed'))
+/**
+ * 피드백 토스트 표시
+ * @param {'success'|'error'|'info'} type - 토스트 타입
+ * @param {string} message - 표시 메시지
+ * @returns {void}
+ */
+const showToast = (type, message) => {
+ window.clearTimeout(toastTimer)
+ toast.value = { type, message }
+ toastTimer = window.setTimeout(() => {
+ toast.value = null
+ }, 3200)
+}
+
/**
* 관리용 태그 드래그 시작
* @param {DragEvent} event - 드래그 이벤트
@@ -105,8 +119,6 @@ const saveManagedOrder = async () => {
}
savingOrder.value = true
- errorMessage.value = ''
- infoMessage.value = ''
try {
const reordered = await $fetch('/admin/api/tags/reorder', {
@@ -118,9 +130,9 @@ const saveManagedOrder = async () => {
tags.value = [...reordered]
await refresh()
- infoMessage.value = '메인 태그 순서가 저장되었습니다.'
+ showToast('success', '메인 태그 순서가 저장되었습니다.')
} catch (error) {
- errorMessage.value = error?.data?.message || '정렬 순서를 저장하지 못했습니다.'
+ showToast('error', error?.data?.message || '정렬 순서를 저장하지 못했습니다.')
} finally {
savingOrder.value = false
}
@@ -138,8 +150,6 @@ const searchGeneralTags = async () => {
}
generalTagSearchLoading.value = true
- errorMessage.value = ''
- infoMessage.value = ''
try {
generalTagSearchResults.value = await $fetch('/admin/api/tags', {
@@ -150,7 +160,7 @@ const searchGeneralTags = async () => {
}
})
} catch (error) {
- errorMessage.value = error?.data?.message || '일반 태그 검색에 실패했습니다.'
+ showToast('error', error?.data?.message || '일반 태그 검색에 실패했습니다.')
} finally {
generalTagSearchLoading.value = false
}
@@ -167,8 +177,6 @@ const promoteToMainTag = async (tag) => {
}
promotingTagId.value = tag.id
- errorMessage.value = ''
- infoMessage.value = ''
try {
await $fetch(`/admin/api/tags/${tag.id}`, {
@@ -184,9 +192,9 @@ const promoteToMainTag = async (tag) => {
})
await refresh()
await searchGeneralTags()
- infoMessage.value = `"${tag.name}" 태그를 메인 태그로 전환했습니다.`
+ showToast('success', `"${tag.name}" 태그를 메인 태그로 전환했습니다.`)
} catch (error) {
- errorMessage.value = error?.data?.message || '메인 태그 전환에 실패했습니다.'
+ showToast('error', error?.data?.message || '메인 태그 전환에 실패했습니다.')
} finally {
promotingTagId.value = ''
}
@@ -203,8 +211,6 @@ const demoteToGeneralTag = async (tag) => {
}
demotingTagId.value = tag.id
- errorMessage.value = ''
- infoMessage.value = ''
try {
await $fetch(`/admin/api/tags/${tag.id}`, {
@@ -220,9 +226,9 @@ const demoteToGeneralTag = async (tag) => {
})
await refresh()
await searchGeneralTags()
- infoMessage.value = `"${tag.name}" 태그를 일반 태그로 변경했습니다.`
+ showToast('success', `"${tag.name}" 태그를 일반 태그로 변경했습니다.`)
} catch (error) {
- errorMessage.value = error?.data?.message || '일반 태그 변경에 실패했습니다.'
+ showToast('error', error?.data?.message || '일반 태그 변경에 실패했습니다.')
} finally {
demotingTagId.value = ''
}
@@ -239,8 +245,6 @@ const deleteGeneralTag = async (tag) => {
}
deletingGeneralTagId.value = tag.id
- errorMessage.value = ''
- infoMessage.value = ''
try {
await $fetch(`/admin/api/tags/${tag.id}`, {
@@ -248,14 +252,18 @@ const deleteGeneralTag = async (tag) => {
})
await refresh()
await searchGeneralTags()
- infoMessage.value = `"${tag.name}" 일반 태그를 삭제했습니다.`
+ showToast('success', `"${tag.name}" 일반 태그를 삭제했습니다.`)
} catch (error) {
- errorMessage.value = error?.data?.message || '일반 태그를 삭제하지 못했습니다.'
+ showToast('error', error?.data?.message || '일반 태그를 삭제하지 못했습니다.')
} finally {
deletingGeneralTagId.value = ''
}
}
+onBeforeUnmount(() => {
+ window.clearTimeout(toastTimer)
+})
+
@@ -277,13 +285,6 @@ const deleteGeneralTag = async (tag) => {
메인 태그는 홈페이지 카테고리 영역에서 사용되며 드래그 정렬이 가능합니다. 일반 태그는 검색으로 찾아 메인 태그로 전환할 수 있습니다.
-
- {{ errorMessage }}
-
-
- {{ infoMessage }}
-
-