From 0eca6b288ec3fb196b719a30b645363a90c1263d Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 1 Jul 2026 15:24:55 +0900 Subject: [PATCH] =?UTF-8?q?=EA=B2=8C=EC=8B=9C=EB=AC=BC=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=20=EC=A4=91=20=EC=B6=94=EA=B0=80=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=20=EB=B3=B4=EC=A1=B4=20=EB=B0=8F=20v1.10.0=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/admin/AdminPostForm.vue | 28 ++++++++++++++++++++++++++-- docs/changelog.md | 5 +++++ docs/deploy.md | 2 +- docs/history.md | 4 ++++ docs/map.md | 2 +- docs/spec.md | 4 ++-- docs/update.md | 6 ++++++ package-lock.json | 4 ++-- package.json | 2 +- pages/admin/posts/[id].vue | 4 ++-- pages/admin/posts/new.vue | 4 ++-- 11 files changed, 52 insertions(+), 13 deletions(-) diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index 97effc0..b03405e 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -685,6 +685,9 @@ const isRouteLeaveDirty = computed(() => { if (!hasUnsavedPostChanges.value) { return false } + if (props.saving) { + return true + } if (['publishedLive', 'scheduled', 'members', 'private'].includes(persistedPublishKind.value)) { return true } @@ -1482,10 +1485,11 @@ const unpublishModalKind = computed(() => ( /** * 현재 입력값을 저장 완료 기준점으로 표시한다. + * @param {Object|null} [savedPayload] - 실제 서버에 저장된 입력값 * @returns {void} */ -const markSaved = () => { - savedPostSnapshot.value = serializePostPayload() +const markSaved = (savedPayload = null) => { + savedPostSnapshot.value = savedPayload ? JSON.stringify(savedPayload) : serializePostPayload() const publishedAt = resolvePayloadPublishedAt() if (publishedAt) { @@ -1556,6 +1560,26 @@ watch(serializedPostFingerprint, () => { }, 1200) }) +watch(() => [props.saving, props.autoSaving], ([saving, autoSaving]) => { + if (!import.meta.client || saving || autoSaving) { + return + } + if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value) { + return + } + + window.clearTimeout(draftPersistTimer) + draftPersistTimer = window.setTimeout(() => { + if (props.saving || props.autoSaving) { + return + } + if (!shouldServerAutosaveDraft.value || !hasUnsavedPostChanges.value || !canPersistDraftPayload.value) { + return + } + emit('autosave', createPostPayload()) + }, 250) +}) + onMounted(() => { if (!props.initialPost?.id && !String(form.slug || '').trim()) { const suffix = typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function' diff --git a/docs/changelog.md b/docs/changelog.md index 76f49e3..3c4deb5 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -1,5 +1,10 @@ # 업데이트 요약 +## v1.10.0 + +- 글쓰기에서 Post URL 입력 직후 첫 자동 저장 중 태그나 본문을 추가하면 저장 완료 후 입력값이 사라질 수 있던 문제를 수정했다. +- 발행 저장이 끝나기 전에 화면을 벗어나려 할 때 미저장 이탈 확인이 동작하도록 보정했다. + ## v1.9.34 - Markdown 표와 라이브 미리보기에서 링크 텍스트 안의 굵게 같은 중첩 인라인 문법과 `
` 줄바꿈 처리를 보강했다. diff --git a/docs/deploy.md b/docs/deploy.md index bad4d49..856fcf3 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,6 +1,6 @@ # 배포 가이드 -> 로컬 기준 v1.9.34에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. +> 로컬 기준 v1.10.0에서 `npm run verify` 검증을 통과했다. NAS 실제 컨테이너 기동과 도메인/프록시 접속 검증은 운영 배포 단계에서 진행한다. ## 빌드 유형 diff --git a/docs/history.md b/docs/history.md index d0e256b..d8b0101 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,9 @@ # 의사결정 이력 +## 2026-07-01 v1.10.0 — 저장 완료 기준은 요청 시점 payload로 고정한다 + +게시물 저장 요청이 진행되는 동안 사용자가 태그나 본문을 계속 수정할 수 있다. 성공 응답 시점의 화면 값을 그대로 저장 완료 기준으로 삼으면 실제 서버에 저장된 payload보다 최신 입력까지 저장된 것으로 오판하고, 신규 글의 첫 자동 저장 후 편집 URL로 이동하면서 최신 입력이 사라질 수 있다. 따라서 저장 완료 스냅샷은 요청에 보낸 payload로 고정하고, 저장 중 생긴 추가 변경은 저장이 끝난 뒤 다시 자동 저장되게 한다. 발행 저장 중에는 미저장 변경으로 간주해 라우트 이탈 확인을 유지한다. + ## 2026-07-01 v1.9.34 — 인라인 문법은 링크와 색상 안에서도 같은 규칙으로 렌더링한다 Markdown 표 셀과 라이브 미리보기는 같은 인라인 렌더러를 공유하므로, 링크 텍스트나 색상 토큰 내부에 굵게·기울임·줄바꿈 같은 문법이 들어가도 공개 화면과 관리자 화면이 같은 결과를 보여야 한다. 따라서 링크 텍스트를 단순 문자열로 출력하지 않고 다시 인라인 세그먼트로 렌더링하며, `
` 표기도 멀티라인 인라인 변환의 줄바꿈 입력으로 정규화한다. diff --git a/docs/map.md b/docs/map.md index fcb39b7..f9eb827 100644 --- a/docs/map.md +++ b/docs/map.md @@ -124,7 +124,7 @@ | components/admin/AdminAdsSettingsCard.vue | 관리자 사이트 설정의 위치별 Ads 코드 카드(메인 피드·메인 인피드·오른쪽 사이드·게시물 오른쪽 사이드·게시물 본문 상단·인아티클·하단) | | components/admin/AdminPostExportFileRow.vue | 관리자 사이트 설정 내보내기 작업의 분할 파일 선택 행 | | components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 | -| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 카드 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 | +| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 카드 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 | | components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드 썸네일 파생 파일 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `block-panel` emit, 에디터 본문 `Cmd/Ctrl+Z` 히스토리와 선택 위치 복원, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 소스·라이브 `Cmd+Shift+K` 줄 삭제, 소스·라이브 `Cmd/Ctrl+K` 링크 삽입, 코드·콜아웃·토글 내부 줄 삭제, 라이브 fenced 블록 현재 닫는 줄 기준 교체, 블록 패널 바깥 클릭·지원 블록 밖 커서 이동 시 닫기·미디어 모달 중 유지, 인용 마지막 줄 아래 방향키 외부 문단 포커스 이동, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원(코드·콜아웃·토글 선언 줄은 본문 줄로 보정), 라이브 슬래시 명령 후 포커스 복원 지연 | | components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 | diff --git a/docs/spec.md b/docs/spec.md index d72f720..bd3b41d 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -729,8 +729,8 @@ components/content/ - 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다. - 글 작성/수정 화면은 페이지 제목 헤더를 별도로 두지 않고, 폼 상단의 Ghost 스타일 도구막대에서 목록 이동, 상태 문구, Preview, 상태별 주요 액션(Publish / Update·Unpublish / Update·Unschedule), 설정 패널 토글을 제공한다. - 도구막대 상태 문구는 영어로 표시한다. **초안**: 편집 중 `Draft`, 저장(수동·서버 자동 저장) 진행 중 `Saving...`, 서버 기준과 동일할 때 `Draft - Saved`(신규 작성에서도 첫 `POST` 저장 후에는 `Draft - Saved`를 사용할 수 있다). **즉시 발행**: 공개 URL이 있으면 `Published ↗`를 링크로, 없으면 동일 문구만 표시한다. **예약 발행**: `Scheduled`를 `#2BBA3C`·보통 굵기로 표시하고 마우스 오버 시 영문 한 줄로 예약 시각을 `title` 툴팁에 보여 준다. **멤버십**은 `Members`, **비공개**는 `Private`로 표시한다. -- **초안**(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만 `(제목 없음)` 플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의 `title`은 빈 문자열로 내려 준다. 임시 슬러그(`d`+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은 `PUT /admin/api/posts/:id`, **신규 작성**은 첫 저장 시 `POST /admin/api/posts`로 행을 만든 뒤 `replace`로 `/admin/posts/:id` 편집 화면으로 옮긴다. **이미 발행·예약으로 서버에 반영된 글**은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며, `Update`를 눌렀을 때만 `PUT`으로 반영한다(툴바의 `Publish`/`Update`/`Unpublish`/`Unschedule` 분기도 서버에 반영된 게시 형태를 기준으로 한다). 다른 화면으로 나가기 직전에는 디바운스 대기 중인 초안 변경이 있으면 타이머를 취소하고 한 번에 `POST` 또는 `PUT`으로 플러시한다. -- `Publish`는 **서버에 아직 초안으로만 저장된 글**에만 표시되며 클릭 시 전체 화면 발행 모달을 연다. 초안에서 연 모달의 기본 선택은 **발행·지금 바로**이다. 모달 본문은 뷰포트 세로 중앙에 가깝게 배치하고, 상단에는 제목·닫기만 둔다(도구막대의 `Preview` 버튼은 두지 않는다). 모달에서는 상태(발행/초안)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정하며, 예약 시각은 날짜·시간(KST 표기) 입력을 분리한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다. +- **초안**(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만 `(제목 없음)` 플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의 `title`은 빈 문자열로 내려 준다. 임시 슬러그(`d`+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은 `PUT /admin/api/posts/:id`, **신규 작성**은 첫 저장 시 `POST /admin/api/posts`로 행을 만든 뒤 `replace`로 `/admin/posts/:id` 편집 화면으로 옮긴다. 저장 요청 중 사용자가 추가 입력한 본문·태그는 성공 응답 시 저장 완료로 오판하지 않고, 저장 완료 후 남은 변경으로 다시 자동 저장한다. **이미 발행·예약으로 서버에 반영된 글**은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며, `Update`를 눌렀을 때만 `PUT`으로 반영한다(툴바의 `Publish`/`Update`/`Unpublish`/`Unschedule` 분기도 서버에 반영된 게시 형태를 기준으로 한다). 다른 화면으로 나가기 직전에는 디바운스 대기 중인 초안 변경이 있으면 타이머를 취소하고 한 번에 `POST` 또는 `PUT`으로 플러시한다. +- `Publish`는 **서버에 아직 초안으로만 저장된 글**에만 표시되며 클릭 시 전체 화면 발행 모달을 연다. 초안에서 연 모달의 기본 선택은 **발행·지금 바로**이다. 모달 본문은 뷰포트 세로 중앙에 가깝게 배치하고, 상단에는 제목·닫기만 둔다(도구막대의 `Preview` 버튼은 두지 않는다). 모달에서는 상태(발행/초안)와 발행 시점(즉시/예약)을 최종 선택한 뒤 확정하며, 예약 시각은 날짜·시간(KST 표기) 입력을 분리한다. 모달에서는 행마다 접힌 상태로 현재 선택 요약만 보이고, 행을 눌러 펼친 뒤 버튼으로 값을 고른다. 게시 상태 행에는 Ghost와 같은 종이비행기 아이콘·펼침 화살표 SVG를 쓰고, 발행 시점 행에는 시계 아이콘·동일 화살표를 쓴다. 발행이 아닌 상태에서는 발행 시점 행 자체를 숨긴다. 발행 저장 요청이 진행되는 동안 다른 화면으로 이동하려 하면 미저장 이탈 확인을 표시한다. - `Update`는 발행·예약·멤버십·비공개 글에 표시되며, 마지막 저장 이후 변경이 있을 때만 활성화된다(활성 텍스트 `#394047`, 비활성 `#8E9CAC`). `Publish`·활성화된 `Update`·`Unpublish`·`Unschedule`에는 호버 시 배경 `#f1f3f4`를 적용한다. - `Unpublish`·`Unschedule` 클릭 시 Ghost형 전체 화면 확인 화면을 연다. 발행·예약 시각 요약과 **발행 취소하고 초안으로 되돌리기 →**(또는 예약 취소 문구) 링크를 눌렀을 때만 `status`를 초안으로 되돌리고 `published_at`을 비운 뒤 `PUT`으로 저장한다. - 글 작성/수정 화면은 관리자 사이드바 네비게이션을 숨기고, 관리자 공통 내부 패딩 없이 전체 화면 편집 모드로 표시한다. diff --git a/docs/update.md b/docs/update.md index 120838f..f75573a 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,11 @@ # 업데이트 이력 +## v1.10.0 + +- 관리자 글쓰기 자동 저장 중 추가 입력한 본문·태그가 저장 완료 처리와 화면 이동으로 사라지지 않도록 수정. +- 게시물 저장·발행 요청 중 추가 변경을 실제 저장된 payload와 구분하고, 남은 변경은 저장 완료 후 다시 자동 저장되도록 보정. +- 게시물 발행 저장 진행 중 다른 화면으로 이동하려 할 때 미저장 이탈 확인이 동작하도록 수정. + ## v1.9.34 - Markdown 인라인 링크 텍스트 내부의 굵게·기울임·색상 등 중첩 인라인 렌더링 추가. diff --git a/package-lock.json b/package-lock.json index be383e6..508f5cd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.9.34", + "version": "1.10.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.9.34", + "version": "1.10.0", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 53b512a..1c364f0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.9.34", + "version": "1.10.0", "private": true, "type": "module", "imports": { diff --git a/pages/admin/posts/[id].vue b/pages/admin/posts/[id].vue index 5b3a375..c32c6bc 100644 --- a/pages/admin/posts/[id].vue +++ b/pages/admin/posts/[id].vue @@ -96,7 +96,7 @@ const savePost = async (payload) => { }) post.value = updatedPost - postForm.value?.markSaved() + postForm.value?.markSaved(payload) postForm.value?.clearAutosave() showToast('success', '변경 내용이 저장되었습니다.') } catch (error) { @@ -125,7 +125,7 @@ const autosaveDraftPost = async (payload) => { }) post.value = updatedPost - postForm.value?.markSaved() + postForm.value?.markSaved(payload) } catch (error) { const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.' showToast('error', msg) diff --git a/pages/admin/posts/new.vue b/pages/admin/posts/new.vue index 6e30022..ff49085 100644 --- a/pages/admin/posts/new.vue +++ b/pages/admin/posts/new.vue @@ -53,7 +53,7 @@ const savePost = async (payload) => { body: payload }) - postForm.value?.markSaved() + postForm.value?.markSaved(payload) postForm.value?.clearAutosave() postForm.value?.allowNextRouteLeave() sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({ @@ -90,7 +90,7 @@ const createDraftAutosave = async (payload, options = {}) => { body: payload }) - postForm.value?.markSaved() + postForm.value?.markSaved(payload) postForm.value?.clearAutosave() postForm.value?.allowNextRouteLeave() if (!skipNavigate) {