글쓰기 입력 흐름과 저장 피드백 보정
This commit is contained in:
@@ -21,6 +21,9 @@ const mediaPickerTarget = ref(null)
|
|||||||
const isMediaPickerOpen = ref(false)
|
const isMediaPickerOpen = ref(false)
|
||||||
const isLoadingMedia = ref(false)
|
const isLoadingMedia = ref(false)
|
||||||
const isComposingText = ref(false)
|
const isComposingText = ref(false)
|
||||||
|
const isCompositionEnterGuardActive = ref(false)
|
||||||
|
const isNormalizingTrailingBlock = ref(false)
|
||||||
|
let compositionEnterGuardTimer = null
|
||||||
let blockIdSeed = 0
|
let blockIdSeed = 0
|
||||||
|
|
||||||
const imageWidthOptions = [
|
const imageWidthOptions = [
|
||||||
@@ -396,6 +399,41 @@ const emitContent = () => {
|
|||||||
*/
|
*/
|
||||||
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 비어 있는 문단 블록 여부 반환
|
||||||
|
* @param {Object|undefined} block - 에디터 블록
|
||||||
|
* @returns {boolean} 비어 있는 문단 블록 여부
|
||||||
|
*/
|
||||||
|
const isBlankParagraphBlock = (block) => block?.type === 'paragraph' && !block.text
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 마지막 클릭 가능 문단 블록 유지
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const normalizeTrailingTextBlock = () => {
|
||||||
|
if (isNormalizingTrailingBlock.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isNormalizingTrailingBlock.value = true
|
||||||
|
|
||||||
|
while (
|
||||||
|
editorBlocks.value.length > 1
|
||||||
|
&& isBlankParagraphBlock(editorBlocks.value.at(-1))
|
||||||
|
&& isBlankParagraphBlock(editorBlocks.value.at(-2))
|
||||||
|
) {
|
||||||
|
editorBlocks.value.pop()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isBlankParagraphBlock(editorBlocks.value.at(-1))) {
|
||||||
|
editorBlocks.value.push(createEditorBlock('paragraph'))
|
||||||
|
}
|
||||||
|
|
||||||
|
nextTick(() => {
|
||||||
|
isNormalizingTrailingBlock.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 블록 DOM 요소를 저장
|
* 블록 DOM 요소를 저장
|
||||||
* @param {Element|null} element - 블록 DOM 요소
|
* @param {Element|null} element - 블록 DOM 요소
|
||||||
@@ -564,7 +602,9 @@ const updateBlockText = (event, index) => {
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const startTextComposition = () => {
|
const startTextComposition = () => {
|
||||||
|
window.clearTimeout(compositionEnterGuardTimer)
|
||||||
isComposingText.value = true
|
isComposingText.value = true
|
||||||
|
isCompositionEnterGuardActive.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -576,6 +616,10 @@ const startTextComposition = () => {
|
|||||||
const finishTextComposition = (event, index) => {
|
const finishTextComposition = (event, index) => {
|
||||||
isComposingText.value = false
|
isComposingText.value = false
|
||||||
updateBlockText(event, index)
|
updateBlockText(event, index)
|
||||||
|
window.clearTimeout(compositionEnterGuardTimer)
|
||||||
|
compositionEnterGuardTimer = window.setTimeout(() => {
|
||||||
|
isCompositionEnterGuardActive.value = false
|
||||||
|
}, 120)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -681,11 +725,13 @@ const applyCommand = (command) => {
|
|||||||
|
|
||||||
if (command.type === 'divider') {
|
if (command.type === 'divider') {
|
||||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(index + 1)
|
focusBlock(index + 1)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
|
|
||||||
if (isTextBlock(block)) {
|
if (isTextBlock(block)) {
|
||||||
@@ -800,6 +846,7 @@ const selectMediaItem = (mediaItem) => {
|
|||||||
block.alt = ''
|
block.alt = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
closeMediaPicker()
|
closeMediaPicker()
|
||||||
}
|
}
|
||||||
@@ -825,6 +872,7 @@ const handleImageUpload = async (event, block) => {
|
|||||||
if (file) {
|
if (file) {
|
||||||
block.url = file.url
|
block.url = file.url
|
||||||
block.alt = ''
|
block.alt = ''
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -858,6 +906,7 @@ const handleGalleryUpload = async (event, block) => {
|
|||||||
width: 'regular'
|
width: 'regular'
|
||||||
}))
|
}))
|
||||||
]
|
]
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
} finally {
|
} finally {
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
@@ -873,6 +922,7 @@ const handleGalleryUpload = async (event, block) => {
|
|||||||
*/
|
*/
|
||||||
const updateImageWidth = (block, width) => {
|
const updateImageWidth = (block, width) => {
|
||||||
block.width = width
|
block.width = width
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -884,6 +934,7 @@ const updateImageWidth = (block, width) => {
|
|||||||
*/
|
*/
|
||||||
const removeGalleryImage = (block, imageIndex) => {
|
const removeGalleryImage = (block, imageIndex) => {
|
||||||
block.images.splice(imageIndex, 1)
|
block.images.splice(imageIndex, 1)
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,6 +977,11 @@ const highlightPreviousCommand = (event) => {
|
|||||||
const handleEnter = (event, index) => {
|
const handleEnter = (event, index) => {
|
||||||
const currentBlock = editorBlocks.value[index]
|
const currentBlock = editorBlocks.value[index]
|
||||||
|
|
||||||
|
if (isComposingText.value || isCompositionEnterGuardActive.value || event.isComposing || event.keyCode === 229) {
|
||||||
|
event.preventDefault()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (visibleCommands.value.length && currentBlock.text.startsWith('/')) {
|
if (visibleCommands.value.length && currentBlock.text.startsWith('/')) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
applyCommand(highlightedCommand.value || visibleCommands.value[0])
|
applyCommand(highlightedCommand.value || visibleCommands.value[0])
|
||||||
@@ -940,6 +996,7 @@ const handleEnter = (event, index) => {
|
|||||||
|
|
||||||
if (['divider', 'image', 'gallery', 'toggle', 'embed'].includes(currentBlock.type)) {
|
if (['divider', 'image', 'gallery', 'toggle', 'embed'].includes(currentBlock.type)) {
|
||||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(index + 1)
|
focusBlock(index + 1)
|
||||||
return
|
return
|
||||||
@@ -948,6 +1005,7 @@ const handleEnter = (event, index) => {
|
|||||||
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
|
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
|
||||||
currentBlock.type = 'paragraph'
|
currentBlock.type = 'paragraph'
|
||||||
currentBlock.level = null
|
currentBlock.level = null
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(index)
|
focusBlock(index)
|
||||||
return
|
return
|
||||||
@@ -955,6 +1013,7 @@ const handleEnter = (event, index) => {
|
|||||||
|
|
||||||
const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
|
const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
|
||||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
|
editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(index + 1)
|
focusBlock(index + 1)
|
||||||
}
|
}
|
||||||
@@ -978,6 +1037,7 @@ const handleBackspace = (event, index) => {
|
|||||||
|
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
editorBlocks.value.splice(index, 1)
|
editorBlocks.value.splice(index, 1)
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
focusBlock(Math.max(index - 1, 0))
|
focusBlock(Math.max(index - 1, 0))
|
||||||
}
|
}
|
||||||
@@ -1002,7 +1062,8 @@ const activateBlock = (block) => {
|
|||||||
*/
|
*/
|
||||||
const shouldShowPlaceholder = (block, index) => !block.text && (
|
const shouldShowPlaceholder = (block, index) => !block.text && (
|
||||||
activeBlockId.value === block.id ||
|
activeBlockId.value === block.id ||
|
||||||
(index === 0 && editorBlocks.value.length === 1)
|
(index === 0 && editorBlocks.value.length === 1) ||
|
||||||
|
index === editorBlocks.value.length - 1
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1010,6 +1071,7 @@ const shouldShowPlaceholder = (block, index) => !block.text && (
|
|||||||
* @returns {void}
|
* @returns {void}
|
||||||
*/
|
*/
|
||||||
const updateStructuredBlock = () => {
|
const updateStructuredBlock = () => {
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
emitContent()
|
emitContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,9 +1087,11 @@ watch(() => props.modelValue, (value) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
editorBlocks.value = parseMarkdownToBlocks(value)
|
editorBlocks.value = parseMarkdownToBlocks(value)
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
}, { immediate: true })
|
}, { immediate: true })
|
||||||
|
|
||||||
watch(editorBlocks, () => {
|
watch(editorBlocks, () => {
|
||||||
|
normalizeTrailingTextBlock()
|
||||||
isApplyingExternalValue.value = true
|
isApplyingExternalValue.value = true
|
||||||
nextTick(() => {
|
nextTick(() => {
|
||||||
isApplyingExternalValue.value = false
|
isApplyingExternalValue.value = false
|
||||||
@@ -1037,6 +1101,10 @@ watch(editorBlocks, () => {
|
|||||||
defineExpose({
|
defineExpose({
|
||||||
focusFirstBlock: () => focusBlock(0)
|
focusFirstBlock: () => focusBlock(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(compositionEnterGuardTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -1,5 +1,15 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-02 v0.0.22
|
||||||
|
|
||||||
|
### 글쓰기 하단 빈 블록과 저장 피드백 보정
|
||||||
|
|
||||||
|
이미지, 갤러리, 임베드 같은 비텍스트 블록이 글의 마지막에 오더라도 작성자가 이어서 글을 쓸 수 있도록 에디터 마지막에는 항상 빈 문단 블록을 유지한다. 이 빈 문단은 작성 편의를 위한 입력 지점이므로 내용이 없으면 저장 마크다운에는 포함하지 않는다.
|
||||||
|
|
||||||
|
한글 조합 입력 직후 Enter는 IME 확정 동작으로 들어오는 경우가 있으므로 즉시 새 블록 생성으로 처리하지 않는다. 조합 확정 Enter와 문단 이동 Enter를 분리해 마지막 글자가 다음 블록에 중복 입력되는 문제를 줄이기 위해서다.
|
||||||
|
|
||||||
|
저장 버튼을 눌렀을 때 동작 여부가 보이지 않으면 작성자가 같은 동작을 반복할 수 있으므로, 저장/수정/삭제 진행과 결과는 우측 상단 토스트로 표시한다. 새 글 저장 후 수정 화면으로 이동하는 경우에도 성공 토스트를 이어서 표시한다.
|
||||||
|
|
||||||
## 2026-05-02 v0.0.21
|
## 2026-05-02 v0.0.21
|
||||||
|
|
||||||
### 글 작성 중 자동 저장 범위 결정
|
### 글 작성 중 자동 저장 범위 결정
|
||||||
|
|||||||
@@ -27,7 +27,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택, 로컬 자동 저장 |
|
||||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리 |
|
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리, 하단 빈 입력 블록 유지 |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||||
|
|
||||||
## 콘텐츠 컴포넌트
|
## 콘텐츠 컴포넌트
|
||||||
@@ -57,8 +57,8 @@
|
|||||||
| pages/admin/index.vue | 대시보드 |
|
| pages/admin/index.vue | 대시보드 |
|
||||||
| pages/admin/login.vue | 관리자 로그인 |
|
| pages/admin/login.vue | 관리자 로그인 |
|
||||||
| pages/admin/posts/index.vue | 글 목록 |
|
| pages/admin/posts/index.vue | 글 목록 |
|
||||||
| pages/admin/posts/new.vue | 글 작성 |
|
| pages/admin/posts/new.vue | 글 작성, 저장 토스트 |
|
||||||
| pages/admin/posts/[id].vue | 글 수정 |
|
| pages/admin/posts/[id].vue | 글 수정, 저장/삭제 토스트 |
|
||||||
| pages/admin/pages/index.vue | 페이지 목록 |
|
| pages/admin/pages/index.vue | 페이지 목록 |
|
||||||
| pages/admin/media/index.vue | 미디어 관리 |
|
| pages/admin/media/index.vue | 미디어 관리 |
|
||||||
| pages/admin/tags/index.vue | 태그 관리 |
|
| pages/admin/tags/index.vue | 태그 관리 |
|
||||||
|
|||||||
@@ -222,8 +222,10 @@ components/content/
|
|||||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
||||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||||
|
- 한글 등 조합형 입력 직후 Enter는 새 블록 생성으로 처리하지 않는다.
|
||||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||||
|
- 에디터 마지막에는 클릭 가능한 빈 문단 블록을 항상 유지하며, 해당 블록이 비어 있으면 저장 콘텐츠에는 포함하지 않는다.
|
||||||
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
- 제목은 별도 라벨 영역이 아니라 에디터 상단의 큰 제목 입력으로 표시한다.
|
||||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||||
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
- 새 글 작성처럼 본문이 비어 있는 경우에도 빈 문단 블록을 먼저 생성한다.
|
||||||
@@ -231,6 +233,7 @@ components/content/
|
|||||||
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
- 자동 저장 키는 `SORI_ADMIN_POST_AUTOSAVE:new` 또는 `SORI_ADMIN_POST_AUTOSAVE:{postId}` 형식을 사용한다.
|
||||||
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
- 자동 저장본이 있으면 작성 화면에서 복원 또는 삭제를 선택할 수 있다.
|
||||||
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
- 글 저장 성공 시 해당 자동 저장본은 삭제한다.
|
||||||
|
- 글 저장/수정/삭제 진행과 성공/실패 상태는 화면 우측 상단 토스트로 표시한다.
|
||||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||||
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
- 대표 이미지는 URL 직접 입력이 아니라 미디어 선택 또는 새 이미지 업로드로 설정한다.
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
||||||
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
|
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
|
||||||
- [ ] 글 작성 중 자동 저장 브라우저 수동 QA: 새 글/수정 글 복원, 저장 성공 후 삭제, 빈 글 자동 저장 삭제 확인
|
- [ ] 글 작성 중 자동 저장 브라우저 수동 QA: 새 글/수정 글 복원, 저장 성공 후 삭제, 빈 글 자동 저장 삭제 확인
|
||||||
|
- [ ] 저장 토스트 브라우저 수동 QA: 새 글 저장 후 이동, 수정 저장, 저장 실패, 삭제 실패 상태 확인
|
||||||
|
|
||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,14 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.22
|
||||||
|
|
||||||
|
- 관리자 블록 에디터 마지막에 클릭 가능한 빈 문단 블록을 항상 유지하도록 수정.
|
||||||
|
- 빈 마지막 문단 블록은 저장 콘텐츠에 포함하지 않도록 유지.
|
||||||
|
- 한글 조합 입력 직후 Enter가 새 블록 입력으로 중복 처리되는 문제 보정.
|
||||||
|
- 관리자 글 저장/수정/삭제 진행 상태를 토스트로 표시하도록 추가.
|
||||||
|
- 새 글 저장 후 수정 화면으로 이동해도 저장 성공 토스트를 표시하도록 추가.
|
||||||
|
- 패키지 버전을 0.0.22로 갱신.
|
||||||
|
|
||||||
## v0.0.21
|
## v0.0.21
|
||||||
|
|
||||||
- 관리자 글 작성/수정 폼에 로컬 자동 저장 기능 추가.
|
- 관리자 글 작성/수정 폼에 로컬 자동 저장 기능 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.21",
|
"version": "0.0.22",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.21",
|
"version": "0.0.22",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.21",
|
"version": "0.0.22",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const saving = ref(false)
|
|||||||
const deleting = ref(false)
|
const deleting = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const postForm = ref(null)
|
const postForm = ref(null)
|
||||||
|
const toast = ref(null)
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
|
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
|
||||||
|
|
||||||
@@ -19,6 +21,39 @@ if (!post.value) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 상태 토스트 표시
|
||||||
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
|
* @param {string} message - 표시 메시지
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showToast = (type, message) => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toast.value = { type, message }
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.value = null
|
||||||
|
}, 3200)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 이전 화면에서 전달한 토스트 표시
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showStoredToast = () => {
|
||||||
|
const storedToast = sessionStorage.getItem('SORI_ADMIN_POST_TOAST')
|
||||||
|
|
||||||
|
if (!storedToast) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsedToast = JSON.parse(storedToast)
|
||||||
|
showToast(parsedToast.type || 'success', parsedToast.message || '저장되었습니다.')
|
||||||
|
} finally {
|
||||||
|
sessionStorage.removeItem('SORI_ADMIN_POST_TOAST')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 게시물 수정 저장
|
* 게시물 수정 저장
|
||||||
* @param {Object} payload - 게시물 입력값
|
* @param {Object} payload - 게시물 입력값
|
||||||
@@ -27,6 +62,7 @@ if (!post.value) {
|
|||||||
const savePost = async (payload) => {
|
const savePost = async (payload) => {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
showToast('info', '변경 내용을 저장하는 중입니다.')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updatedPost = await $fetch(`/admin/api/posts/${id.value}`, {
|
const updatedPost = await $fetch(`/admin/api/posts/${id.value}`, {
|
||||||
@@ -36,8 +72,10 @@ const savePost = async (payload) => {
|
|||||||
|
|
||||||
post.value = updatedPost
|
post.value = updatedPost
|
||||||
postForm.value?.clearAutosave()
|
postForm.value?.clearAutosave()
|
||||||
|
showToast('success', '변경 내용이 저장되었습니다.')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
@@ -54,6 +92,7 @@ const deletePost = async () => {
|
|||||||
|
|
||||||
deleting.value = true
|
deleting.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
showToast('info', '글을 삭제하는 중입니다.')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await $fetch(`/admin/api/posts/${id.value}`, {
|
await $fetch(`/admin/api/posts/${id.value}`, {
|
||||||
@@ -62,10 +101,17 @@ const deletePost = async () => {
|
|||||||
await navigateTo('/admin/posts')
|
await navigateTo('/admin/posts')
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '글을 삭제하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
} finally {
|
} finally {
|
||||||
deleting.value = false
|
deleting.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(showStoredToast)
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -102,5 +148,17 @@ const deletePost = async () => {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<AdminPostForm ref="postForm" :initial-post="post" submit-label="변경 저장" :saving="saving" @submit="savePost" />
|
<AdminPostForm ref="postForm" :initial-post="post" submit-label="변경 저장" :saving="saving" @submit="savePost" />
|
||||||
|
<div
|
||||||
|
v-if="toast"
|
||||||
|
class="admin-post-edit__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,22 @@ definePageMeta({
|
|||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const errorMessage = ref('')
|
const errorMessage = ref('')
|
||||||
const postForm = ref(null)
|
const postForm = ref(null)
|
||||||
|
const toast = ref(null)
|
||||||
|
let toastTimer = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 저장 상태 토스트 표시
|
||||||
|
* @param {'success'|'error'|'info'} type - 토스트 타입
|
||||||
|
* @param {string} message - 표시 메시지
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
const showToast = (type, message) => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
toast.value = { type, message }
|
||||||
|
toastTimer = window.setTimeout(() => {
|
||||||
|
toast.value = null
|
||||||
|
}, 3200)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 새 게시물 저장
|
* 새 게시물 저장
|
||||||
@@ -15,6 +31,7 @@ const postForm = ref(null)
|
|||||||
const savePost = async (payload) => {
|
const savePost = async (payload) => {
|
||||||
saving.value = true
|
saving.value = true
|
||||||
errorMessage.value = ''
|
errorMessage.value = ''
|
||||||
|
showToast('info', '글을 저장하는 중입니다.')
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const post = await $fetch('/admin/api/posts', {
|
const post = await $fetch('/admin/api/posts', {
|
||||||
@@ -23,13 +40,22 @@ const savePost = async (payload) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
postForm.value?.clearAutosave()
|
postForm.value?.clearAutosave()
|
||||||
|
sessionStorage.setItem('SORI_ADMIN_POST_TOAST', JSON.stringify({
|
||||||
|
type: 'success',
|
||||||
|
message: '글이 저장되었습니다.'
|
||||||
|
}))
|
||||||
await navigateTo(`/admin/posts/${post.id}`)
|
await navigateTo(`/admin/posts/${post.id}`)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
errorMessage.value = error?.data?.message || '글을 저장하지 못했습니다.'
|
||||||
|
showToast('error', errorMessage.value)
|
||||||
} finally {
|
} finally {
|
||||||
saving.value = false
|
saving.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.clearTimeout(toastTimer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -46,5 +72,17 @@ const savePost = async (payload) => {
|
|||||||
{{ errorMessage }}
|
{{ errorMessage }}
|
||||||
</p>
|
</p>
|
||||||
<AdminPostForm ref="postForm" submit-label="글 저장" :saving="saving" @submit="savePost" />
|
<AdminPostForm ref="postForm" submit-label="글 저장" :saving="saving" @submit="savePost" />
|
||||||
|
<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"
|
||||||
|
:class="{
|
||||||
|
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
|
||||||
|
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
|
||||||
|
'border-line bg-white text-ink': toast.type === 'info'
|
||||||
|
}"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
{{ toast.message }}
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
Reference in New Issue
Block a user