From b989193dab67febbd3cf2738276760dd57d43e5a Mon Sep 17 00:00:00 2001 From: zenn Date: Tue, 26 May 2026 11:36:01 +0900 Subject: [PATCH] =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20HTML=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1=20=EA=B8=B0=EB=B3=B8=EA=B0=92=EA=B3=BC=20=EC=9E=90?= =?UTF-8?q?=EC=82=B0=20=EC=97=85=EB=A1=9C=EB=93=9C=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?v1.5.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminPageForm.vue | 317 ++++++++---------- .../035_default_pages_to_html_document.sql | 2 + docs/changelog.md | 5 + docs/history.md | 4 + docs/map.md | 8 +- docs/spec.md | 18 +- docs/update.md | 9 + package-lock.json | 4 +- package.json | 2 +- server/utils/admin-page-input.js | 4 +- 10 files changed, 175 insertions(+), 198 deletions(-) create mode 100644 db/migrations/035_default_pages_to_html_document.sql diff --git a/components/admin/AdminPageForm.vue b/components/admin/AdminPageForm.vue index 1bd7107..a81b07d 100644 --- a/components/admin/AdminPageForm.vue +++ b/components/admin/AdminPageForm.vue @@ -32,30 +32,60 @@ const slugTouched = ref(Boolean(props.initialPage.slug)) const blockEditor = ref(null) const htmlEditor = ref(null) const editorMode = ref('write') -const mediaItems = ref([]) -const isMediaPickerOpen = ref(false) -const isLoadingMedia = ref(false) -const isUploadingFeaturedImage = ref(false) +const isUploadingPageAsset = ref(false) const isSettingsOpen = ref(true) const savedPageSnapshot = ref('') +const htmlCursorRange = reactive({ + start: 0, + end: 0 +}) const form = reactive({ title: props.initialPage.title || '', slug: props.initialPage.slug || '', - renderMode: props.initialPage.renderMode || 'markdown', - content: props.initialPage.content || '', - featuredImage: props.initialPage.featuredImage || '' + renderMode: props.initialPage.renderMode || 'html_document', + content: props.initialPage.content || '' }) /** - * 문자열을 URL 슬러그로 변환 + * 한글 음절 1자를 영문 표기로 변환 + * @param {string} char - 변환할 문자 + * @returns {string} 영문 표기 + */ +const romanizeHangulSyllable = (char) => { + const syllableCode = char.charCodeAt(0) + const hangulBase = 0xac00 + const hangulLast = 0xd7a3 + + if (syllableCode < hangulBase || syllableCode > hangulLast) { + return char + } + + const choseong = ['g', 'kk', 'n', 'd', 'tt', 'r', 'm', 'b', 'pp', 's', 'ss', '', 'j', 'jj', 'ch', 'k', 't', 'p', 'h'] + const jungseong = ['a', 'ae', 'ya', 'yae', 'eo', 'e', 'yeo', 'ye', 'o', 'wa', 'wae', 'oe', 'yo', 'u', 'wo', 'we', 'wi', 'yu', 'eu', 'ui', 'i'] + const jongseong = ['', 'k', 'k', 'ks', 'n', 'nj', 'nh', 't', 'l', 'lk', 'lm', 'lb', 'ls', 'lt', 'lp', 'lh', 'm', 'p', 'ps', 't', 't', 'ng', 't', 't', 'k', 't', 'p', 'h'] + + const offset = syllableCode - hangulBase + const choseongIndex = Math.floor(offset / 588) + const jungseongIndex = Math.floor((offset % 588) / 28) + const jongseongIndex = offset % 28 + + return `${choseong[choseongIndex]}${jungseong[jungseongIndex]}${jongseong[jongseongIndex]}` +} + +/** + * 문자열을 영문 URL 슬러그로 변환 * @param {string} value - 원본 문자열 - * @returns {string} 슬러그 + * @returns {string} 영문 슬러그 */ const toSlug = (value) => value + .normalize('NFC') + .split('') + .map((char) => romanizeHangulSyllable(char)) + .join('') .trim() .toLowerCase() - .replace(/[^a-z0-9가-힣\s-]/g, '') + .replace(/[^a-z0-9\s-]/g, '') .replace(/\s+/g, '-') .replace(/-+/g, '-') .replace(/^-|-$/g, '') @@ -72,8 +102,7 @@ const serializePageForm = () => JSON.stringify({ title: form.title.trim(), slug: pageSlug.value, renderMode: form.renderMode, - content: form.content, - featuredImage: form.featuredImage.trim() || null + content: form.content }) const hasUnsavedPageChanges = computed(() => serializePageForm() !== savedPageSnapshot.value) @@ -114,83 +143,6 @@ const toggleSettingsPanel = () => { isSettingsOpen.value = !isSettingsOpen.value } -/** - * 미디어 라이브러리 목록 조회 - * @returns {Promise} - */ -const fetchMediaItems = async () => { - isLoadingMedia.value = true - - try { - mediaItems.value = await $fetch('/admin/api/media') - } finally { - isLoadingMedia.value = false - } -} - -/** - * 대표 이미지 선택 창 열기 - * @returns {Promise} - */ -const openMediaPicker = async () => { - isMediaPickerOpen.value = true - await fetchMediaItems() -} - -/** - * 대표 이미지 선택 창 닫기 - * @returns {void} - */ -const closeMediaPicker = () => { - isMediaPickerOpen.value = false -} - -/** - * 대표 이미지 선택 - * @param {Object} item - 미디어 항목 - * @returns {void} - */ -const selectFeaturedImage = (item) => { - form.featuredImage = item.url - closeMediaPicker() -} - -/** - * 대표 이미지 삭제 - * @returns {void} - */ -const removeFeaturedImage = () => { - form.featuredImage = '' -} - -/** - * 대표 이미지 파일 업로드 - * @param {Event} event - 파일 입력 이벤트 - * @returns {Promise} - */ -const uploadFeaturedImage = async (event) => { - const files = event.target.files - - if (!files?.length) { - 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 || '' - } finally { - event.target.value = '' - isUploadingFeaturedImage.value = false - } -} - /** * 제목 입력 후 본문 에디터로 이동 * @param {KeyboardEvent} event - 키보드 이벤트 @@ -216,6 +168,73 @@ const setRenderMode = (mode) => { form.renderMode = mode } +/** + * HTML textarea 커서 위치를 기억한다. + * @returns {void} + */ +const rememberHtmlCursor = () => { + if (!htmlEditor.value) { + return + } + + htmlCursorRange.start = htmlEditor.value.selectionStart ?? form.content.length + htmlCursorRange.end = htmlEditor.value.selectionEnd ?? htmlCursorRange.start +} + +/** + * HTML 본문 커서 위치에 텍스트를 삽입한다. + * @param {string} text - 삽입할 텍스트 + * @returns {Promise} + */ +const insertTextAtHtmlCursor = async (text) => { + const start = Math.max(0, htmlCursorRange.start) + const end = Math.max(start, htmlCursorRange.end) + + form.content = `${form.content.slice(0, start)}${text}${form.content.slice(end)}` + + await nextTick() + + const nextCursor = start + text.length + htmlEditor.value?.focus() + htmlEditor.value?.setSelectionRange(nextCursor, nextCursor) + htmlCursorRange.start = nextCursor + htmlCursorRange.end = nextCursor +} + +/** + * 페이지 HTML 자산을 업로드하고 본문 커서 위치에 URL을 삽입한다. + * @param {Event} event - 파일 입력 이벤트 + * @returns {Promise} + */ +const uploadPageAsset = async (event) => { + const files = event.target.files + + if (!files?.length) { + return + } + + rememberHtmlCursor() + + const formData = new FormData() + formData.append('files', files[0]) + isUploadingPageAsset.value = true + + try { + const result = await $fetch('/admin/api/uploads', { + method: 'POST', + body: formData + }) + const uploadedUrl = result.files?.[0]?.url || '' + + if (uploadedUrl && form.renderMode === 'html_document') { + await insertTextAtHtmlCursor(uploadedUrl) + } + } finally { + event.target.value = '' + isUploadingPageAsset.value = false + } +} + /** * 페이지 입력값을 생성한다. * @returns {Object} 페이지 입력값 @@ -225,7 +244,7 @@ const createPayload = () => ({ slug: pageSlug.value, renderMode: form.renderMode, content: form.content, - featuredImage: form.featuredImage.trim() || null + featuredImage: null }) /** @@ -305,27 +324,7 @@ defineExpose({
-
-
- -
- -
-
- +
@@ -414,7 +419,7 @@ defineExpose({

-
+
페이지 형식
- - -
- - - + HTML 자산 + +

+ HTML 모드에서는 업로드된 파일 URL을 현재 커서 위치에 삽입합니다. 예: <img src="여기"> +

@@ -486,43 +477,5 @@ defineExpose({ - diff --git a/db/migrations/035_default_pages_to_html_document.sql b/db/migrations/035_default_pages_to_html_document.sql new file mode 100644 index 0000000..41a5d59 --- /dev/null +++ b/db/migrations/035_default_pages_to_html_document.sql @@ -0,0 +1,2 @@ +ALTER TABLE pages + ALTER COLUMN render_mode SET DEFAULT 'html_document'; diff --git a/docs/changelog.md b/docs/changelog.md index 32a41ba..e46cc60 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.5.3 + +- 페이지 작성 기본값을 HTML 문서 모드로 바꾸고, 페이지 슬러그도 한글 제목에서 영문으로 자동 생성되도록 개선했다. +- 페이지 작성 화면에서 대표 이미지를 제거하고, HTML 자산 업로드 시 업로드 URL을 현재 커서 위치에 바로 넣을 수 있게 했다. + ## v1.5.2 - 페이지 작성/수정 화면을 게시글 작성 화면처럼 전체 화면 에디터, 상단 저장 툴바, 오른쪽 설정 패널 구조로 변경했다. diff --git a/docs/history.md b/docs/history.md index d2a0e39..fd9d804 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-05-26 v1.5.3 — 고정 페이지 기본값을 HTML 랜딩 페이지 작성에 맞춤 + +고정 페이지는 운영에서 아직 본격 사용 전이고, 앞으로는 단일 랜딩 페이지 HTML을 붙여넣어 공개 URL에서 원문 HTML로 응답하는 용도가 중심이다. 따라서 새 페이지의 기본 형식은 HTML 문서 모드로 바꾸고, 기존 Markdown 모드는 실제 사용 가능성이 낮으므로 관리자 UI에서 `일반 텍스트`로 명확히 표시한다. 페이지 대표 이미지는 공개 원문 HTML 응답과 연결되지 않으므로 제거하고, HTML 안에서 필요한 이미지는 기존 미디어 업로드 API로 파일을 올린 뒤 URL을 현재 커서 위치에 삽입하는 방식으로 정리한다. 미디어 사용 여부는 페이지 본문 문자열 안에 저장된 URL도 검사하므로, HTML 코드에 업로드 URL이 포함되면 페이지 사용처로 추적된다. + ## 2026-05-26 v1.5.2 — 페이지 작성 화면을 게시글 작성 화면과 통일 고정 페이지는 랜딩 페이지 작성 용도로 확장되므로 일반 관리자 폼보다 게시글 작성과 같은 집중형 전체 화면 에디터가 더 적합하다. 게시글과 페이지의 작성 화면이 다르면 저장 위치, 설정 위치, 본문 입력 방식이 매번 달라져 운영 피로가 커진다. 따라서 페이지 작성/수정도 상단 툴바와 오른쪽 접이식 설정 패널을 쓰고, 페이지 형식 선택과 URL·대표 이미지·삭제 액션은 설정 패널로 모은다. 기본 콘텐츠 입력도 게시글과 같은 Markdown-first 에디터를 사용해 작성 경험을 통일한다. diff --git a/docs/map.md b/docs/map.md index aa983bf..aa13346 100644 --- a/docs/map.md +++ b/docs/map.md @@ -80,7 +80,7 @@ | components/admin/AdminSettingsNavIcon.vue | 사이트 설정 좌측 내비 항목 아이콘(`iconId`별 SVG·미구현 placeholder) | | components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 | | components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(영문 상태·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약 글은 Update로만 반영, 발행 모달(중앙 배치), 좌우 설정 패널, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 | -| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, Markdown/HTML 문서 모드 선택, HTML 붙여넣기 textarea, 대표 이미지 선택 | +| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, HTML 문서 기본 모드, 일반 텍스트 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 | | components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 | | components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드), 갤러리 선택 이미지 강조 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | @@ -130,8 +130,8 @@ | pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 | | pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) | | pages/admin/pages/index.vue | 페이지 목록, 화면 기준 행 more vert 메뉴(수정·삭제) | -| pages/admin/pages/new.vue | 전체 화면 페이지 작성, Markdown/HTML 문서 모드 저장, 저장 토스트 | -| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, Markdown/HTML 문서 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 | +| pages/admin/pages/new.vue | 전체 화면 페이지 작성, HTML 문서 기본 모드 저장, 저장 토스트 | +| pages/admin/pages/[id].vue | 전체 화면 페이지 수정, HTML 문서/일반 텍스트 모드 저장, 설정 패널 삭제, 저장/삭제 토스트 | | pages/admin/media/index.vue | 미디어 관리, **미디어 라이브러리/썸네일** 탭, 이미지·비디오·오디오·파일 종류 필터, 미사용 필터, 비디오 프레임 썸네일, 폴더 트리 more vert(폴더 삭제), 검색·드래그·상세 모달 등 | | pages/admin/navigation/index.vue | 메뉴 관리: 상단/하단/추천 탭, 행 more vert(메뉴 항목 삭제), 드래그 정렬, `useAdminToast` | | components/admin/AdminRowMoreMenu.vue | 관리자 행 more vert 메뉴(트리거·화면 기준 팝오버) | @@ -288,6 +288,8 @@ | db/migrations/031_analytics_engagement_and_realtime.sql | 체류·스크롤 집계 컬럼·실시간 접속 세션 테이블 | | db/migrations/032_add_post_author.sql | 게시물 작성자(`posts.author_id`) 컬럼 추가 및 기존 글 owner/admin backfill | | db/migrations/033_site_settings_home_cover_dark_image.sql | 사이트 설정 다크모드 홈 커버 이미지 URL 컬럼 추가 | +| db/migrations/034_add_page_render_mode.sql | 고정 페이지 렌더링 모드 컬럼 추가 | +| db/migrations/035_default_pages_to_html_document.sql | 고정 페이지 렌더링 모드 기본값을 HTML 문서로 변경 | ## 설정/배포 diff --git a/docs/spec.md b/docs/spec.md index df1fa6e..3b3528d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -91,7 +91,7 @@ - About, Projects, Links, Contact, 서비스 소개 페이지 등 고정 콘텐츠에 사용 - 기본 게시물 목록에는 노출하지 않음 - 헤더와 사이드바를 사용하지 않고 본문 중심 전체 화면으로 표시 -- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `markdown`이며 기존 Markdown 콘텐츠 렌더러를 사용한다. `html_document`는 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다. +- 페이지는 `renderMode`로 렌더링 방식을 구분한다. 기본값은 `html_document`이며 관리자에서 붙여넣은 전체 HTML 문서를 공개 `/pages/:slug` 요청에서 `text/html` 원문으로 응답한다. `markdown`은 관리자 UI에서 `일반 텍스트`로 표시하며 기존 Markdown 콘텐츠 렌더러를 사용한다. - HTML 문서 모드는 관리자만 저장하는 신뢰 콘텐츠를 전제로 하며, ``, `