From f757c3db78fb74b8481cb7bd0ca68171e8c0ae85 Mon Sep 17 00:00:00 2001 From: zenn Date: Thu, 7 May 2026 15:55:20 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B8=80=20=EC=84=A4=EC=A0=95=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=EC=99=80=20=EB=8C=80=ED=91=9C=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=9D=90=EB=A6=84=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- assets/css/main.css | 7 + components/admin/AdminPostForm.vue | 284 +++++++++++++++++------------ docs/history.md | 10 + docs/map.md | 2 +- docs/spec.md | 14 +- docs/update.md | 11 ++ layouts/admin.vue | 26 +++ package-lock.json | 4 +- package.json | 2 +- pages/post/[slug].vue | 5 +- server/utils/media-library.js | 7 - 11 files changed, 240 insertions(+), 132 deletions(-) diff --git a/assets/css/main.css b/assets/css/main.css index 5fa1cb5..393be6f 100644 --- a/assets/css/main.css +++ b/assets/css/main.css @@ -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 { diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 0fd46f4..44b804c 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -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} + */ +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} */ @@ -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} */ -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({ /> - +
+ Tags + +
@@ -730,16 +785,6 @@ defineExpose({ - -
- -
- OG 이미지 -
- -
-

- {{ form.ogImage }} -

-
- - - -
-
-
-
- - -
-
-
-

- 미디어를 불러오는 중입니다. -

-
- +
+ + +
+
+
+
+

+ 파일을 끌어 업로드 +

+

+ 또는 +

+ +
-

- 선택할 미디어가 없습니다. -

+ +
+
+
diff --git a/docs/history.md b/docs/history.md index a3599d8..fd55ad7 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,15 @@ # 의사결정 이력 +## 2026-05-07 v0.0.42 + +### 태그 입력과 대표 이미지 선택 흐름 결정 + +관리자 글 설정의 태그 입력은 단순 텍스트 필드가 아니라 배지형 입력으로 처리한다. 태그 입력 중 Enter가 폼 제출로 전파되면 의도치 않게 게시물이 저장 또는 발행될 수 있으므로, Enter와 쉼표는 태그 추가 동작으로만 사용한다. + +Canonical URL과 OG 이미지는 별도 입력 항목에서 제외한다. 현재 운영 흐름에서는 기본 글 주소와 대표 이미지가 자연스러운 기본값이며, 별도 OG 이미지를 관리하면 글 설정 패널이 불필요하게 길어지고 대표 이미지와 공유 이미지가 어긋날 수 있기 때문이다. + +대표 이미지는 업로드 탭과 미디어 라이브러리 탭을 함께 제공한다. 작성자는 새 이미지를 바로 올릴 수도 있고, 이미 업로드한 이미지를 재사용할 수도 있어야 하기 때문이다. + ## 2026-05-07 v0.0.41 ### 명령 메뉴 계층과 개발 도구 표시 결정 diff --git a/docs/map.md b/docs/map.md index f4c8b05..d23f788 100644 --- a/docs/map.md +++ b/docs/map.md @@ -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 | 관리자 태그 생성/수정 폼 | diff --git a/docs/spec.md b/docs/spec.md index dba6399..f60eedd 100644 --- a/docs/spec.md +++ b/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로 이미지를 업로드하고 `![alt](url){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 - 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가 - 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정 diff --git a/docs/update.md b/docs/update.md index 33b3eea..a937c39 100644 --- a/docs/update.md +++ b/docs/update.md @@ -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 - 관리자 블록 에디터 `/` 명령 메뉴가 아래 블록 텍스트와 겹쳐 보이던 문제 수정. diff --git a/layouts/admin.vue b/layouts/admin.vue index b1066fa..6b3157c 100644 --- a/layouts/admin.vue +++ b/layouts/admin.vue @@ -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} 로그아웃 처리 결과 diff --git a/package-lock.json b/package-lock.json index b05781b..f25934a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2bc81d9..62bde55 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.41", + "version": "0.0.42", "private": true, "type": "module", "scripts": { diff --git a/pages/post/[slug].vue b/pages/post/[slug].vue index 9d91b46..5f93dad 100644 --- a/pages/post/[slug].vue +++ b/pages/post/[slug].vue @@ -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: [ diff --git a/server/utils/media-library.js b/server/utils/media-library.js index 01eed40..7f193fb 100644 --- a/server/utils/media-library.js +++ b/server/utils/media-library.js @@ -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',