diff --git a/components/admin/AdminMemberForm.vue b/components/admin/AdminMemberForm.vue index a86a5ce..1f191cf 100644 --- a/components/admin/AdminMemberForm.vue +++ b/components/admin/AdminMemberForm.vue @@ -16,6 +16,7 @@ const isNewMember = computed(() => props.mode === 'new') const saveMessage = ref('') const saveError = ref('') const isSaving = ref(false) +const savedMemberSnapshot = ref('') const form = reactive({ username: '', @@ -58,6 +59,12 @@ const normalizedLabels = computed(() => [...new Set( .filter(Boolean) )]) +/** + * 회원 저장 요청 본문을 문자열로 직렬화한다. + * @returns {string} 직렬화된 회원 입력값 + */ +const serializeMemberPayload = () => JSON.stringify(getMemberPayload()) + /** * 날짜 표시 형식 변환 * @param {string | null} value - ISO 날짜 문자열 @@ -131,6 +138,14 @@ const getMemberPayload = () => ({ note: form.note }) +const hasUnsavedMemberChanges = computed(() => serializeMemberPayload() !== savedMemberSnapshot.value) + +const { + isUnsavedModalOpen, + stayOnUnsavedPage, + leaveUnsavedPage +} = useAdminUnsavedChangesGuard(hasUnsavedMemberChanges) + /** * 회원 기본 정보를 저장한다. * @returns {Promise} @@ -156,6 +171,7 @@ const saveMember = async () => { body: payload }) + savedMemberSnapshot.value = serializeMemberPayload() emit('saved', saved) saveMessage.value = '저장되었습니다.' } catch (error) { @@ -164,6 +180,10 @@ const saveMember = async () => { isSaving.value = false } } + +watch(() => props.member, () => { + savedMemberSnapshot.value = serializeMemberPayload() +}, { immediate: true, flush: 'post' }) diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index a98f18a..14d6e23 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -49,6 +49,7 @@ const tagInput = ref('') const isTagInputComposing = ref(false) const activeMediaPickerTab = ref('upload') const selectedMediaPickerUrl = ref('') +const savedPostSnapshot = ref('') /** * ISO 날짜를 datetime-local 입력값으로 변환 @@ -260,6 +261,14 @@ const createPostPayload = () => { } } +/** + * 현재 게시물 입력값을 문자열로 직렬화한다. + * @returns {string} 직렬화된 게시물 입력값 + */ +const serializePostPayload = () => JSON.stringify(createPostPayload()) + +const hasUnsavedPostChanges = computed(() => serializePostPayload() !== savedPostSnapshot.value) + /** * 자동 저장 데이터 생성 * @returns {Object} 자동 저장 데이터 @@ -374,6 +383,15 @@ const discardAutosave = () => { autosaveStatus.value = '' } +const { + isUnsavedModalOpen, + stayOnUnsavedPage, + leaveUnsavedPage, + allowNextRouteLeave +} = useAdminUnsavedChangesGuard(hasUnsavedPostChanges, { + onLeaveConfirmed: discardAutosave +}) + /** * 미디어 라이브러리 목록 조회 * @returns {Promise} @@ -582,9 +600,19 @@ const toggleSettingsPanel = () => { isSettingsOpen.value = !isSettingsOpen.value } +/** + * 현재 입력값을 저장 완료 기준점으로 표시한다. + * @returns {void} + */ +const markSaved = () => { + savedPostSnapshot.value = serializePostPayload() +} + watch(form, scheduleAutosave, { deep: true }) onMounted(() => { + markSaved() + const savedRaw = localStorage.getItem(autosaveKey.value) if (!savedRaw) { @@ -610,7 +638,9 @@ onBeforeUnmount(() => { }) defineExpose({ - clearAutosave: discardAutosave + clearAutosave: discardAutosave, + markSaved, + allowNextRouteLeave }) @@ -952,5 +982,10 @@ defineExpose({ + diff --git a/components/admin/AdminUnsavedChangesModal.vue b/components/admin/AdminUnsavedChangesModal.vue new file mode 100644 index 0000000..b7b2285 --- /dev/null +++ b/components/admin/AdminUnsavedChangesModal.vue @@ -0,0 +1,61 @@ + + + diff --git a/composables/useAdminUnsavedChangesGuard.js b/composables/useAdminUnsavedChangesGuard.js new file mode 100644 index 0000000..f6e10b5 --- /dev/null +++ b/composables/useAdminUnsavedChangesGuard.js @@ -0,0 +1,100 @@ +/** + * 관리자 편집 화면의 미저장 변경 이탈을 막는다. + * @param {import('vue').Ref | import('vue').ComputedRef} isDirty - 변경 여부 + * @param {{ onLeaveConfirmed?: () => void | Promise }} options - 이탈 승인 옵션 + * @returns {{ + * isUnsavedModalOpen: import('vue').Ref, + * stayOnUnsavedPage: () => void, + * leaveUnsavedPage: () => Promise, + * allowNextRouteLeave: () => void + * }} 이탈 확인 상태와 동작 + */ +export const useAdminUnsavedChangesGuard = (isDirty, options = {}) => { + const isUnsavedModalOpen = ref(false) + const pendingRoute = ref(null) + const isNextRouteAllowed = ref(false) + + /** + * 현재 이탈을 막아야 하는지 확인한다. + * @returns {boolean} 이탈 차단 여부 + */ + const shouldBlockLeave = () => Boolean(unref(isDirty)) && !isNextRouteAllowed.value + + /** + * 다음 라우트 이동을 한 번 허용한다. + * @returns {void} + */ + const allowNextRouteLeave = () => { + isNextRouteAllowed.value = true + } + + /** + * 현재 페이지에 머문다. + * @returns {void} + */ + const stayOnUnsavedPage = () => { + pendingRoute.value = null + isUnsavedModalOpen.value = false + } + + /** + * 미저장 변경을 버리고 이동한다. + * @returns {Promise} + */ + const leaveUnsavedPage = async () => { + const route = pendingRoute.value + pendingRoute.value = null + isUnsavedModalOpen.value = false + isNextRouteAllowed.value = true + + await options.onLeaveConfirmed?.() + + if (route?.fullPath) { + await navigateTo(route.fullPath) + } + } + + onBeforeRouteLeave((to) => { + if (isNextRouteAllowed.value) { + isNextRouteAllowed.value = false + return true + } + + if (!shouldBlockLeave()) { + return true + } + + pendingRoute.value = to + isUnsavedModalOpen.value = true + return false + }) + + /** + * 브라우저 탭 닫기와 새로고침을 기본 확인창으로 막는다. + * @param {BeforeUnloadEvent} event - 브라우저 이탈 이벤트 + * @returns {void} + */ + const handleBeforeUnload = (event) => { + if (!shouldBlockLeave()) { + return + } + + event.preventDefault() + event.returnValue = '' + } + + onMounted(() => { + window.addEventListener('beforeunload', handleBeforeUnload) + }) + + onBeforeUnmount(() => { + window.removeEventListener('beforeunload', handleBeforeUnload) + }) + + return { + isUnsavedModalOpen, + stayOnUnsavedPage, + leaveUnsavedPage, + allowNextRouteLeave + } +} diff --git a/docs/history.md b/docs/history.md index 33a43af..a50a693 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,5 +1,11 @@ # 의사결정 이력 +## 2026-05-13 v0.0.112 + +### 관리자 편집 화면 이탈 확인 공통화 + +게시글 작성 화면은 로컬 자동 저장이 있지만 서버 저장 전 변경사항을 사용자가 의식하지 못한 채 목록으로 이동할 수 있었다. 멤버 편집 화면도 같은 편집 맥락을 가지므로, 라우트 이탈은 Ghost형 공통 모달로 한 번 확인하고 브라우저 새로고침·탭 닫기는 브라우저 기본 확인에 맡긴다. 게시글에서 이탈을 승인한 경우에는 임시 자동 저장본도 함께 버려 사용자가 명시적으로 떠난 내용을 다음 진입 때 다시 제안하지 않도록 한다. + ## 2026-05-13 v0.0.111 ### 관리자 멤버 상세와 추가 화면 분리 diff --git a/docs/map.md b/docs/map.md index ac249c7..ddcc259 100644 --- a/docs/map.md +++ b/docs/map.md @@ -55,12 +55,19 @@ | 파일 | 화면 위치 | |------|-----------| -| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 | +| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost 스타일 전체 화면 에디터, 좌우 분할 설정 패널, 설정 패널 전환 애니메이션, Post URL 보기 액션, 하단 삭제 액션, 대표 이미지 hover 액션과 업로드/미디어 선택 확정, 배지형 태그 입력(한글 유지), 로컬 자동 저장, 미저장 변경사항 이탈 확인, 예약 발행 시각 입력, 검색 노출 제외(noindex), 저장 시 제목·요약을 SEO 메타로 반영, 미리보기 요청 | | components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 대표 이미지 선택 | | components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 | | components/admin/AdminNavPrimaryBranch.vue | 관리자 상단 네비 트리(테이블·태그와 동일한 행 드래그 하이라이트, 하위·삭제) | | components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) | -| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 2열, 기본 정보·레이블·관리자 노트·활동 요약) | +| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 요약 1fr·입력 2fr, 기본 정보·레이블·관리자 노트·활동 요약, 미저장 변경사항 이탈 확인) | +| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달 | + +## 관리자 컴포저블 + +| 파일 | 화면 위치 | +|------|-----------| +| composables/useAdminUnsavedChangesGuard.js | 관리자 게시글/멤버 편집 화면 라우트 이탈 확인과 브라우저 `beforeunload` 연결 | ## 콘텐츠 컴포넌트 diff --git a/docs/spec.md b/docs/spec.md index 4fc5a84..dd03303 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -556,7 +556,8 @@ components/content/ - 관리자 멤버 목록은 Ghost형 테이블 기준으로 닉네임 아래 이메일, 가입일 아래 최근 활동을 함께 표시한다. - 관리자 멤버 목록은 멤버 검색과 멤버 추가 버튼을 제공한다. - 관리자 멤버 목록은 뉴스레터 지표 대신 댓글 작성 개수를 표시한다. -- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. +- 관리자 멤버 상세/추가 화면은 Ghost형 편집 화면을 기준으로 썸네일, 이름, 이메일, 레이블, 관리자 노트를 편집한다. 본문은 3분할 그리드로 요약 영역 1fr, 입력 영역 2fr 비율을 사용한다. 기존 회원 상세는 가입 정보와 활동 요약을 표시하고, 신규 회원 화면은 활동 영역을 표시하지 않는다. +- 관리자 게시글 작성/수정과 멤버 추가/수정 화면은 저장되지 않은 변경사항이 있을 때 내부 라우트 이동을 공통 확인 모달로 막는다. 브라우저 새로고침·탭 닫기는 브라우저 기본 `beforeunload` 확인을 사용한다. 게시글 화면에서 이탈을 승인하면 해당 로컬 자동 저장본은 삭제한다. - `users.member_labels`는 관리자용 문자열 배열이며, 이후 사용자별 칭호/분류 용도로 확장한다. `users.member_note`는 관리자에게만 보이는 500자 이하 메모다. - 관리자 멤버 권한은 `소유자(owner)`, `관리자(admin)`, `멤버(member)` 3단계를 유지하되, 목록 화면에서는 변경 UI를 노출하지 않고 상세/후속 화면에서 변경한다. - 관리자 페이지 접근은 `/admin/api/auth/me` 확인 후 허용한다. diff --git a/docs/update.md b/docs/update.md index 9589185..8f3d3f3 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,14 @@ # 업데이트 이력 +## v0.0.112 + +- 관리자 멤버 폼 본문을 3분할 그리드로 변경하고 요약 1fr, 입력 영역 2fr 비율로 조정. +- 관리자 공통 미저장 변경사항 이탈 확인 모달 추가. +- 관리자 게시글 작성/수정 화면에 미저장 변경사항 라우트 이탈 확인과 브라우저 이탈 기본 확인 연결. +- 관리자 멤버 추가/수정 화면에 미저장 변경사항 라우트 이탈 확인과 브라우저 이탈 기본 확인 연결. +- 이탈 승인 시 게시글 로컬 자동 저장본 삭제 처리 추가. +- 패키지 버전 `0.0.112`로 갱신. + ## v0.0.111 - 관리자 멤버 상세 화면(`/admin/members/:id`) 추가. diff --git a/package-lock.json b/package-lock.json index 83d2b63..9bf4163 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "0.0.111", + "version": "0.0.112", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "0.0.111", + "version": "0.0.112", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index e5b7b28..a7b70fa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "0.0.111", + "version": "0.0.112", "private": true, "type": "module", "imports": { diff --git a/pages/admin/posts/[id].vue b/pages/admin/posts/[id].vue index 0492f09..66abf91 100644 --- a/pages/admin/posts/[id].vue +++ b/pages/admin/posts/[id].vue @@ -95,6 +95,7 @@ const savePost = async (payload) => { }) post.value = updatedPost + postForm.value?.markSaved() postForm.value?.clearAutosave() showToast('success', '변경 내용이 저장되었습니다.') } catch (error) { @@ -122,6 +123,7 @@ const deletePost = async () => { await $fetch(`/admin/api/posts/${id.value}`, { method: 'DELETE' }) + postForm.value?.allowNextRouteLeave() await navigateTo('/admin/posts') } catch (error) { errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.' diff --git a/pages/admin/posts/new.vue b/pages/admin/posts/new.vue index 7efc3b4..8898a39 100644 --- a/pages/admin/posts/new.vue +++ b/pages/admin/posts/new.vue @@ -52,7 +52,9 @@ const savePost = async (payload) => { body: payload }) + postForm.value?.markSaved() postForm.value?.clearAutosave() + postForm.value?.allowNextRouteLeave() sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({ type: 'success', message: '글이 저장되었습니다.'