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