글쓰기 초안 저장 상태 보정

This commit is contained in:
2026-07-03 11:23:04 +09:00
parent 955ae90697
commit 243dfbc5e2
9 changed files with 99 additions and 10 deletions

View File

@@ -720,9 +720,15 @@ const createPostPayload = (options = {}) => {
/**
* 현재 게시물 입력값을 문자열로 직렬화한다.
* @param {Object|null} [payload] - 직렬화할 게시물 입력값
* @returns {string} 직렬화된 게시물 입력값
*/
const serializePostPayload = () => JSON.stringify(createPostPayload())
const serializePostPayload = (payload = null) => {
const sourcePayload = payload || createPostPayload()
const { tagNames: _tagNames, ...comparablePayload } = sourcePayload
return JSON.stringify(comparablePayload)
}
const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPostSnapshot.value)
@@ -1560,7 +1566,7 @@ const unpublishModalKind = computed(() => (
* @returns {void}
*/
const markSaved = (savedPayload = null) => {
savedPostSnapshot.value = savedPayload ? JSON.stringify(savedPayload) : serializePostPayload()
savedPostSnapshot.value = serializePostPayload(savedPayload)
const publishedAt = resolvePayloadPublishedAt()
if (publishedAt) {
@@ -1677,6 +1683,7 @@ onBeforeUnmount(() => {
defineExpose({
clearAutosave: clearAutosaveLocal,
createPostPayload,
markSaved,
allowNextRouteLeave,
takePendingAutosavePayload

View File

@@ -1,5 +1,11 @@
# 의사결정 이력
## 2026-07-03 v1.10.24 — 글쓰기 저장 기준은 실제 저장 필드만 본다
관리자 글쓰기 폼은 저장 payload에 태그 표시명 보조값(`tagNames`)을 함께 담아 새 태그 이름을 만들 수 있게 한다. 하지만 기존 태그의 표시명은 태그 API가 늦게 도착하면 슬러그에서 실제 이름으로 바뀔 수 있고, 이 값까지 미저장 비교에 포함하면 사용자가 아무것도 하지 않아도 비공개·멤버십 글에서 이탈 경고와 `Update` 활성화가 발생한다. 따라서 미저장 비교는 제목, 본문, 상태, 태그 슬러그처럼 실제 게시물 저장 상태를 바꾸는 필드만 기준으로 한다.
신규 글은 첫 자동 저장이 `POST`로 DB 행을 만든 뒤 편집 URL로 이동한다. 이 사이에 사용자가 내용을 더 입력하면 새 편집 화면이 첫 요청 payload만 받아 최신 입력이 사라질 수 있으므로, 첫 `POST` 직후 현재 폼 payload를 다시 읽고 차이가 있으면 생성된 글에 `PUT`으로 최신 값을 반영한 뒤 이동한다. 이동 중에는 짧은 안내 오버레이를 띄워 첫 초안 저장이 진행 중임을 명확히 한다.
## 2026-07-03 v1.10.23 — 관리자 글 목록은 서버에서 먼저 자른다
하루에 수십 개씩 이전 글을 추가하면 관리자 글 목록은 곧 수천 건 규모가 된다. 목록 화면이 전체 글을 한 번에 받은 뒤 브라우저에서 검색·필터링하면 응답 크기뿐 아니라 태그 필터와 정렬 계산도 글 수에 비례해 커진다. 따라서 글 목록 화면은 `/admin/api/posts?paged=true` 응답을 사용해 서버에서 검색·상태·태그·추천·정렬 필터와 페이지네이션을 먼저 적용한다.

View File

@@ -131,7 +131,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·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·저장 중 추가 변경 재자동저장·발행 저장 중 이탈 확인·발행·예약·멤버십·비공개 상태 저장, 미저장 비교에서 태그 표시명 보조값 제외, 공개 상태 전환 시 빈 발행 시각 현재 시각 보정, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 오른쪽 하단 본문 통계(단어·문자·공백·읽기 시간·블록·이미지), 미리보기 emit·미저장 이탈 가드, 자동 생성 썸네일을 숨기는 원본 전용 대표 이미지 선택, 대표 이미지 본문 상단 표시 토글, 추천 글 토글, 사이트 설정 기준 태그 최대 개수 제한, 태그 색상 배지 다중 입력·최근 사용 태그 5개 우선 추천·메인 태그 드롭다운·부분 검색 추천, 새 태그 입력명 유지와 `/`→`-` 슬러그 변환 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `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·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
@@ -187,7 +187,7 @@
| pages/admin/index.vue | 대시보드(상단 요약: 현재 접속자·오늘 접속자·게시물 수, 일일·주간·월간·년간 선택형 방문자수·평균 체류·50% 스크롤 차트, 차트 막대 클릭 날짜·구간 상세 모달, 방문자 유입 정보·디바이스·유입 키워드, 접속자 목록, 인기 게시물 월간 조회수·작성일, 인기 페이지 참여 지표) |
| pages/admin/login.vue | 관리자 로그인, 일반 로그인과 같은 다크 인증 스타일·우측 배치 및 내부 오른쪽 정렬 폼, 이메일·비밀번호 모두 입력 시에만 제출 버튼 활성 |
| pages/admin/posts/index.vue | 글 목록, 본문 없는 `/admin/api/posts?paged=true` 페이지 응답 사용, 서버 검색·필터(상태·검색형 태그 combobox·추천·정렬)와 번호형 페이지네이션, 헤더 우측 «새 글» 배치, 예약/초안/공개/멤버십/비공개 텍스트 상태, 추천 표시와 제목 사이 `featuredImageThumbnail` 우선 대표 이미지 썸네일, 제목 옆 댓글 수, 제목 아래 주소·공개 보기 hover 아이콘, 별도 조회수 열의 누적 조회수·통계 모달 버튼, 적용 태그 전체 색상 배지, 화면 기준 행 more vert 메뉴(추천 전용 API·삭제) |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/new.vue | 글 작성, Ghost 스타일 작성 폼, 초안 `POST` 디바운스 자동 저장·첫 저장 준비 오버레이·첫 생성 중 추가 입력 PUT 반영·이탈 직전 플러시, 저장 토스트 |
| pages/admin/posts/[id].vue | 글 수정, `AdminPostForm`, 저장·초안 디바운스 자동 저장(`autoSaving`)·이탈 직전 플러시·삭제·토스트 |
| pages/admin/posts/[id]/analytics.vue | 게시물별 통계, 누적 조회수·기간별 조회/방문자 차트·유입/디바이스/키워드·읽음/체류/스크롤 지표 |
| pages/admin/posts/preview.vue | 글 미리보기(공개 상세와 동일한 중앙 컬럼·수평 패딩) |

View File

@@ -744,7 +744,7 @@ 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`으로 플러시한다.
- **초안**(예약 발행 제외)은 입력 변경 후 약 1.2초 디바운스로 서버 자동 저장을 호출한다(슬러그가 유효할 때만). 제목이 비어 있으면 DB/API 저장 시에만 `(제목 없음)` 플레이스홀더를 쓰고, 관리자 폼·목록 API 응답의 `title`은 빈 문자열로 내려 준다. 임시 슬러그(`d`+24자리 hex)는 제목을 직접 수정하기 전까지 제목 입력에 따라 슬러그가 따라가며, 사용자가 슬러그를 직접 고친 뒤에는 자동 연동하지 않는다. 신규 작성 화면 마운트 시 슬러그가 비어 있으면 임시 슬러그를 채운다. 기존 글은 `PUT /admin/api/posts/:id`, **신규 작성**은 첫 저장 시 `POST /admin/api/posts`로 행을 만든 뒤 `replace`로 `/admin/posts/:id` 편집 화면으로 옮긴다. 신규 글 첫 자동 저장 중에는 초안 저장 준비 안내 오버레이를 표시하고, 첫 `POST` 이후 이동 직전 현재 폼의 최신 제목·본문·태그·옵션이 첫 요청 payload와 다르면 생성된 글에 `PUT`으로 한 번 더 반영한다. 저장 요청 중 사용자가 추가 입력한 본문·태그는 성공 응답 시 저장 완료로 오판하지 않고, 저장 완료 후 남은 변경으로 다시 자동 저장한다. 미저장 변경 비교는 태그 표시명 보조값(`tagNames`)처럼 비동기 태그 API 로딩으로만 바뀌는 값을 제외하고 실제 저장 필드 기준으로 판단한다. **이미 발행·예약으로 서버에 반영된 글**은 사이드바 등으로 폼만 초안처럼 바뀌어도 자동 저장하지 않으며, `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`으로 저장한다.

View File

@@ -5,6 +5,10 @@
## 2차 관리자 개발
- 관리자 글 목록 전용 고정 표시 검토.
- 비공개 글 중 발행 예정·보류 중인 글만 전체 글 목록 상단에 유지할 수 있도록 공개 추천(`isFeatured`)과 별개의 관리자 전용 고정 플래그가 필요하다.
- 단순히 비공개 글 전체를 최신순보다 위로 올리면 작성 완료 후 비공개 보관한 글까지 섞이므로, 별도 `adminPinned` 같은 상태와 목록 정렬·필터 UI를 함께 설계한다.
## 프론트엔드 개발
- 업로드 총량 제한 및 메모리 사용량 개선.

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.10.24
- 관리자 글쓰기 미저장 변경 비교에서 태그 표시명 보조값을 제외해 태그 API 로딩만으로 비공개 글 이탈 경고가 뜨던 문제 수정.
- 신규 글 첫 초안 생성 중 추가 입력된 제목·본문·태그·옵션을 생성된 글에 한 번 더 반영한 뒤 편집 URL로 이동하도록 수정.
- 신규 글 첫 자동 저장 중 초안 저장 준비 안내 오버레이를 표시하도록 추가.
## v1.10.23
- 관리자 글 목록이 `/admin/api/posts?paged=true` 페이지 응답을 사용하도록 수정.

4
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "sori.studio",
"version": "1.10.23",
"version": "1.10.24",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
"version": "1.10.23",
"version": "1.10.24",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "1.10.23",
"version": "1.10.24",
"private": true,
"type": "module",
"imports": {

View File

@@ -10,6 +10,54 @@ const postForm = ref(null)
const toast = ref(null)
let toastTimer = null
/**
* 비교용 게시물 입력값을 직렬화한다.
* @param {Object|null|undefined} payload - 게시물 입력값
* @returns {string} 비교 문자열
*/
const serializeComparablePayload = (payload) => {
if (!payload) {
return ''
}
const { tagNames: _tagNames, ...comparablePayload } = payload
return JSON.stringify(comparablePayload)
}
/**
* 두 게시물 입력값이 같은지 확인한다.
* @param {Object|null|undefined} left - 첫 번째 입력값
* @param {Object|null|undefined} right - 두 번째 입력값
* @returns {boolean} 동일 여부
*/
const isSameComparablePayload = (left, right) => serializeComparablePayload(left) === serializeComparablePayload(right)
/**
* 첫 초안 생성 중 추가 입력된 최신 값을 생성된 게시물에 반영한다.
* @param {string} postId - 생성된 게시물 ID
* @param {Object} firstPayload - 첫 생성 요청 입력값
* @returns {Promise<Object>} 최종 저장 기준 입력값
*/
const syncLatestDraftAfterCreate = async (postId, firstPayload) => {
let savedPayload = firstPayload
for (let index = 0; index < 3; index += 1) {
const latestPayload = postForm.value?.createPostPayload?.() || savedPayload
if (isSameComparablePayload(latestPayload, savedPayload)) {
return savedPayload
}
await $fetch(`/admin/api/posts/${postId}`, {
method: 'PUT',
body: latestPayload
})
savedPayload = latestPayload
}
return savedPayload
}
/**
* 저장 상태 토스트 표시
* @param {'success'|'error'|'info'} type - 토스트 타입
@@ -53,7 +101,8 @@ const savePost = async (payload) => {
body: payload
})
postForm.value?.markSaved(payload)
const savedPayload = await syncLatestDraftAfterCreate(post.id, payload)
postForm.value?.markSaved(savedPayload)
postForm.value?.clearAutosave()
postForm.value?.allowNextRouteLeave()
sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
@@ -90,7 +139,8 @@ const createDraftAutosave = async (payload, options = {}) => {
body: payload
})
postForm.value?.markSaved(payload)
const savedPayload = await syncLatestDraftAfterCreate(post.id, payload)
postForm.value?.markSaved(savedPayload)
postForm.value?.clearAutosave()
postForm.value?.allowNextRouteLeave()
if (!skipNavigate) {
@@ -133,6 +183,22 @@ onBeforeUnmount(() => {
@autosave="createDraftAutosave"
@preview="previewPost"
/>
<div
v-if="autoSaving"
class="admin-post-editor__autosave-overlay fixed inset-0 z-40 flex items-center justify-center bg-white/72 backdrop-blur-[2px]"
role="status"
aria-live="polite"
>
<div class="admin-post-editor__autosave-panel w-[min(28rem,calc(100vw-2rem))] rounded-lg border border-line bg-white px-5 py-4 text-center shadow-[0_18px_50px_rgba(15,23,42,0.14)]">
<div class="admin-post-editor__autosave-spinner mx-auto size-6 animate-spin rounded-full border-2 border-[#d7dde2] border-t-[#15171a]" aria-hidden="true" />
<p class="mt-3 text-sm font-semibold text-ink">
초안 저장 공간을 준비하는 중입니다.
</p>
<p class="mt-1 text-xs leading-5 text-muted">
방금 입력한 제목, 본문, 태그, 옵션을 저장한 뒤 편집 화면으로 이어갑니다.
</p>
</div>
</div>
<div
v-if="toast"
class="admin-post-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"