diff --git a/components/admin/AdminPostForm.vue b/components/admin/AdminPostForm.vue index bd140f8..f4446b5 100644 --- a/components/admin/AdminPostForm.vue +++ b/components/admin/AdminPostForm.vue @@ -16,12 +16,17 @@ const props = defineProps({ const emit = defineEmits(['submit']) +const autosaveStoragePrefix = 'SORI_ADMIN_POST_AUTOSAVE' const slugTouched = ref(Boolean(props.initialPost.slug)) const blockEditor = ref(null) const mediaItems = ref([]) const isMediaPickerOpen = ref(false) const isLoadingMedia = ref(false) const isUploadingFeaturedImage = ref(false) +const autosaveTimer = ref(null) +const autosaveNotice = ref(null) +const autosaveStatus = ref('') +const isRestoringAutosave = ref(false) const form = reactive({ title: props.initialPost.title || '', @@ -33,6 +38,8 @@ const form = reactive({ tagsText: props.initialPost.tags?.join(', ') || '' }) +const autosaveKey = computed(() => `${autosaveStoragePrefix}:${props.initialPost.id || 'new'}`) + /** * 문자열을 URL 슬러그로 변환 * @param {string} value - 원본 문자열 @@ -71,6 +78,139 @@ const parseTags = (value) => [...new Set(value .map((tag) => toSlug(tag)) .filter(Boolean))] +/** + * 게시물 입력값 생성 + * @returns {Object} 게시물 입력값 + */ +const createPostPayload = () => { + const publishedAt = form.status === 'published' + ? props.initialPost.publishedAt || new Date().toISOString() + : null + + return { + title: form.title.trim(), + slug: toSlug(form.slug || form.title), + excerpt: form.excerpt.trim(), + content: form.content, + featuredImage: form.featuredImage.trim() || null, + status: form.status, + publishedAt, + tags: parseTags(form.tagsText) + } +} + +/** + * 자동 저장 데이터 생성 + * @returns {Object} 자동 저장 데이터 + */ +const createAutosavePayload = () => ({ + title: form.title, + slug: form.slug, + excerpt: form.excerpt, + content: form.content, + featuredImage: form.featuredImage, + status: form.status, + tagsText: form.tagsText +}) + +/** + * 자동 저장 데이터가 비어 있는지 확인 + * @param {Object} payload - 자동 저장 데이터 + * @returns {boolean} 비어 있는지 여부 + */ +const isEmptyAutosavePayload = (payload) => ![ + payload.title, + payload.slug, + payload.excerpt, + payload.content, + payload.featuredImage, + payload.tagsText +].some((value) => String(value || '').trim()) + +/** + * 자동 저장 시각 표시 문자열 반환 + * @param {number} savedAt - 저장 시각 + * @returns {string} 표시 문자열 + */ +const formatAutosaveTime = (savedAt) => new Intl.DateTimeFormat('ko-KR', { + hour: '2-digit', + minute: '2-digit', + second: '2-digit' +}).format(new Date(savedAt)) + +/** + * 자동 저장본을 로컬 저장소에 기록 + * @returns {void} + */ +const saveAutosave = () => { + if (!import.meta.client || isRestoringAutosave.value) { + return + } + + const payload = createAutosavePayload() + + if (isEmptyAutosavePayload(payload)) { + localStorage.removeItem(autosaveKey.value) + autosaveStatus.value = '' + return + } + + const savedAt = Date.now() + localStorage.setItem(autosaveKey.value, JSON.stringify({ + savedAt, + payload + })) + autosaveStatus.value = `${formatAutosaveTime(savedAt)} 자동 저장됨` +} + +/** + * 자동 저장 예약 + * @returns {void} + */ +const scheduleAutosave = () => { + if (!import.meta.client || isRestoringAutosave.value) { + return + } + + window.clearTimeout(autosaveTimer.value) + autosaveTimer.value = window.setTimeout(saveAutosave, 900) +} + +/** + * 자동 저장본을 입력 폼에 복원 + * @returns {void} + */ +const restoreAutosave = () => { + if (!autosaveNotice.value?.payload) { + return + } + + isRestoringAutosave.value = true + Object.assign(form, autosaveNotice.value.payload) + slugTouched.value = Boolean(form.slug) + autosaveStatus.value = `${formatAutosaveTime(autosaveNotice.value.savedAt)} 자동 저장본 복원됨` + autosaveNotice.value = null + + nextTick(() => { + isRestoringAutosave.value = false + scheduleAutosave() + }) +} + +/** + * 자동 저장본을 삭제 + * @returns {void} + */ +const discardAutosave = () => { + if (!import.meta.client) { + return + } + + localStorage.removeItem(autosaveKey.value) + autosaveNotice.value = null + autosaveStatus.value = '' +} + /** * 미디어 라이브러리 목록 조회 * @returns {Promise} @@ -161,21 +301,39 @@ const focusContentEditor = () => { * @returns {void} */ const submitPost = () => { - const publishedAt = form.status === 'published' - ? props.initialPost.publishedAt || new Date().toISOString() - : null - - emit('submit', { - title: form.title.trim(), - slug: toSlug(form.slug || form.title), - excerpt: form.excerpt.trim(), - content: form.content, - featuredImage: form.featuredImage.trim() || null, - status: form.status, - publishedAt, - tags: parseTags(form.tagsText) - }) + emit('submit', createPostPayload()) } + +watch(form, scheduleAutosave, { deep: true }) + +onMounted(() => { + const savedRaw = localStorage.getItem(autosaveKey.value) + + if (!savedRaw) { + return + } + + try { + const saved = JSON.parse(savedRaw) + + if (saved?.payload && !isEmptyAutosavePayload(saved.payload)) { + autosaveNotice.value = saved + autosaveStatus.value = `${formatAutosaveTime(saved.savedAt)} 자동 저장본 있음` + } + } catch { + localStorage.removeItem(autosaveKey.value) + } +}) + +onBeforeUnmount(() => { + if (import.meta.client) { + window.clearTimeout(autosaveTimer.value) + } +}) + +defineExpose({ + clearAutosave: discardAutosave +}) diff --git a/pages/admin/posts/new.vue b/pages/admin/posts/new.vue index adbf7cb..1ff74bd 100644 --- a/pages/admin/posts/new.vue +++ b/pages/admin/posts/new.vue @@ -5,6 +5,7 @@ definePageMeta({ const saving = ref(false) const errorMessage = ref('') +const postForm = ref(null) /** * 새 게시물 저장 @@ -21,6 +22,7 @@ const savePost = async (payload) => { body: payload }) + postForm.value?.clearAutosave() await navigateTo(`/admin/posts/${post.id}`) } catch (error) { errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.' @@ -43,6 +45,6 @@ const savePost = async (payload) => {

{{ errorMessage }}

- +