구형 에디터 제거와 토스트 공통화

This commit is contained in:
2026-07-23 16:23:01 +09:00
parent b61a6a2295
commit 151349edc2
13 changed files with 55 additions and 2697 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +1,7 @@
## 2026-07-23 v1.11.3 — 구형 블록 에디터 파일을 제거한다
Markdown-first 에디터가 현재 게시물 작성 흐름을 담당하고 있고, `AdminBlockEditor.vue`는 실제 코드에서 더 이상 참조되지 않는다. 이전 동작을 되돌리기 위한 맥락은 Git 이력과 문서에 남아 있으므로, 실행 경로에 연결되지 않은 큰 컴포넌트 파일을 계속 보관하기보다 제거해 관리자 에디터 구조를 단순하게 유지한다.
## 2026-07-22 v1.11.0 — v1.10 구간을 minor로 마감하고 패치 기준을 다시 잡는다
v1.10.0부터 v1.10.88까지 이어진 보안·백업·미디어 카탈로그·검증 보강을 공개 기록 `v1.11`로 묶고 관리 버전을 `1.11.0`으로 올린다. 이후 일상 수정은 `1.11.1`부터 패치로 쌓아, minor 단위 공지와 개발 버전 번호를 다시 맞춘다.

View File

@@ -1,6 +1,6 @@
# 파일-화면 매핑
> 현재 문서는 구현 예정 파일 기준 매핑이다. 실제 파일이 생성되면 경로와 화면 연결을 즉시 갱신한다.
> 현재 구현 기준으로 주요 파일과 화면·기능 연결을 정리한다.
## 레이아웃
@@ -183,7 +183,6 @@
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 저장 payload 기준 미저장 비교·라우트/브라우저 이탈 확인, 페이지 공개 상태 선택, HTML 문서 기본 모드, 홈 Pages 진열장 노출 메타와 등록된 페이지 섹션 검색형 선택, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입, HTML 태그 내부 스마트 따옴표 정규화 |
| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령(`/표` 기본 표 템플릿 포함)·미디어 모달(이미지·갤러리·비디오·오디오·파일, 업로드 탭 기본·첫 순서, 업로드 완료 파일 자동 선택, 라이브러리 탭은 `/admin/api/media?paged=1` 서버 페이지 응답·파일명/사용처 제목 검색·번호형 페이지 이동, 카드·본문·라이트박스·Pages 커버 자동 생성 이미지 숨김, 원본 URL 선택 유지·갤러리 선택 순서 배지·목록 미리보기는 720px 본문 파생 이미지 또는 카드 썸네일 우선, 검색 결과 수와 무관한 고정 높이 패널, 업로드 중 중복 업로드·닫기 차단·로딩 표시), 코드 블록 본문 슬래시 명령 비활성화, 커서 블록 컨텍스트·선택 텍스트 색상 컨텍스트 `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 | 게시물 설정 사이드바 오버레이 블록 설정(선택 텍스트 색상, 단일 이미지 대표 너비 셀렉트(전체·갤러리 2장 1칸·갤러리 3장 1칸·갤러리 3장 2칸·직접 입력)·직접 너비·이미지·갤러리·링크·iframe·HTML·인용 배경색·콜아웃 제목·아이콘·배경색·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/HTML 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상, 태그 헤더 이미지 업로드·삭제, 오버레이 제목·본문 편집, 수정 화면 변경 저장 버튼 변경사항 기준 활성화) |
| components/admin/AdminMemberForm.vue | 관리자 멤버 추가/수정 폼(Ghost형 3분할, 기존 회원 보기/수정 모드 분리, 요약 1fr·입력 2fr, 원형 썸네일 hover 등록·변경·삭제, 기본 정보·레이블·관리자 노트·활동 요약, 설정 메뉴의 비밀번호 변경·멤버 삭제 모달, 저장 토스트, 미저장 변경사항 이탈 확인) |
| components/admin/AdminUnsavedChangesModal.vue | 관리자 편집 화면 공통 미저장 변경사항 이탈 확인 모달(상단 40px 위치) |

View File

@@ -1,5 +1,28 @@
# sori.studio 할 일
## v1.11 내부 개선
- 거대 컴포넌트 책임 분리 진행.
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
- `pages/admin/settings/index.vue`: 설정 카드별 저장·상태·검증 로직을 카드 컴포넌트 또는 composable 단위로 정리.
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
- `components/admin/AdminPostForm.vue`: 태그 입력, 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
- 관리자 UI 공통 컴포넌트 정리.
- 모달 패널, 필터 팝오버, 행 메뉴, 페이지네이션 툴바의 radius·shadow·z-index·색상 규칙을 공통화.
- 게시글/페이지/미디어 목록의 검색·필터·정렬 레이아웃을 같은 패턴으로 유지.
- 최근 브라우저 회귀 테스트 추가.
- 태그 추천 선택 후 다음 태그 후보가 포커스 해제 없이 유지되는지 확인.
- 관리자 미디어 대표 이미지/본문 이미지 탭 전환 중 역할 배지가 노출되지 않는지 확인.
- 단일 이미지 `{size=}` 너비가 사용자 뷰어와 라이브 모드에 동일하게 반영되는지 확인.
- 미디어 카탈로그 전환 코드 수명 정리.
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
- fallback은 운영 안정성에 필요하므로 제거 전 reconcile·로그·테스트 기준을 먼저 정리.
- 운영 의존성 보안 업데이트 검토.
- `sharp <0.35.0` audit 경고는 breaking change 가능성이 있어 별도 검증 브랜치에서 이미지 업로드·썸네일 생성·본문 이미지 백필까지 확인 후 업데이트.
- 문서 구조 정리.
- `docs/update.md` 장기 누적 이력은 최신 작업 중심으로 유지하고 오래된 상세 이력은 역할에 맞게 분리하는 방안 검토.
## 1차 관리자 개발
@@ -21,4 +44,3 @@
- 원시 집계 단계에서도 같은 탭의 여러 게시물 이동이 스크롤 구간 계산에 섞이지 않도록 별도 콘텐츠 세션 저장 구조 검토.
## 배포

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## v1.11.3
- v1.11 내부 개선 TODO 추가.
- 현재 코드에서 참조되지 않는 구형 `AdminBlockEditor.vue` 제거.
- 파일-화면 매핑 문서를 현재 구현 기준 표현으로 정리.
- v1.11 내부 개선 TODO에서 구형 블록 에디터 검토 항목 완료 처리.
- 게시글 작성·수정, 페이지 작성·수정, 태그 목록, 사이트 설정 토스트를 공용 `useAdminToast` 기준으로 통일.
## v1.11.2
- 관리자 미디어 `카드 썸네일` 탭 명칭을 `대표 이미지`로 변경.

4
package-lock.json generated
View File

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

View File

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

View File

@@ -9,8 +9,7 @@ const saving = ref(false)
const deleting = ref(false)
const errorMessage = ref('')
const pageForm = ref(null)
const toast = ref(null)
let toastTimer = null
const { toast, showToast } = useAdminToast()
const { data: page } = await useFetch(() => `/admin/api/pages/${id.value}`)
@@ -23,20 +22,6 @@ if (!page.value) {
const publicPageUrl = computed(() => page.value?.slug ? `/pages/${page.value.slug}` : '')
/**
* 저장 상태 토스트 표시
* @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}
@@ -111,10 +96,6 @@ const deletePage = async () => {
}
onMounted(showStoredToast)
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
</script>
<template>
@@ -135,7 +116,7 @@ onBeforeUnmount(() => {
/>
<div
v-if="toast"
class="admin-page-edit__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
class="admin-page-edit__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] 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',

View File

@@ -7,22 +7,7 @@ const saving = ref(false)
const errorMessage = ref('')
const pageForm = ref(null)
const createdPageId = ref('')
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)
}
const { toast, showToast } = useAdminToast()
/**
* 새 고정 페이지 저장
@@ -54,10 +39,6 @@ const savePage = async (payload) => {
saving.value = false
}
}
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
</script>
<template>
@@ -68,7 +49,7 @@ onBeforeUnmount(() => {
<AdminPageForm ref="pageForm" :saving="saving" @submit="savePage" />
<div
v-if="toast"
class="admin-page-editor__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
class="admin-page-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] 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',

View File

@@ -10,8 +10,7 @@ const autoSaving = ref(false)
const deleting = ref(false)
const errorMessage = ref('')
const postForm = ref(null)
const toast = ref(null)
let toastTimer = null
const { toast, showToast, clearToast } = useAdminToast()
const { data: post } = await useFetch(() => `/admin/api/posts/${id.value}`)
@@ -32,20 +31,6 @@ const isPublicPost = (value) => value?.status === 'published'
const publicPostUrl = computed(() => post.value?.slug ? `/post/${post.value.slug}` : '')
/**
* 저장 상태 토스트 표시
* @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)
}
/**
* 저장 오류가 슬러그 중복인지 확인한다.
* @param {unknown} error - 저장 오류
@@ -109,8 +94,7 @@ const savePost = async (payload) => {
showToast('success', '변경 내용이 저장되었습니다.')
} catch (error) {
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
clearToast()
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
@@ -146,8 +130,7 @@ const autosaveDraftPost = async (payload) => {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
postForm.value?.markAutosaveFailed(payload)
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
clearToast()
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return false
@@ -196,10 +179,6 @@ const deletePost = async () => {
}
onMounted(showStoredToast)
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
</script>
<template>
@@ -223,7 +202,7 @@ onBeforeUnmount(() => {
/>
<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="admin-post-edit__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] 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',

View File

@@ -7,8 +7,7 @@ const saving = ref(false)
const autoSaving = ref(false)
const errorMessage = ref('')
const postForm = ref(null)
const toast = ref(null)
let toastTimer = null
const { toast, showToast, clearToast } = useAdminToast()
/**
* 비교용 게시물 입력값을 직렬화한다.
@@ -58,20 +57,6 @@ const syncLatestDraftAfterCreate = async (postId, firstPayload) => {
return savedPayload
}
/**
* 저장 상태 토스트 표시
* @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)
}
/**
* 저장 오류가 슬러그 중복인지 확인한다.
* @param {unknown} error - 저장 오류
@@ -120,8 +105,7 @@ const savePost = async (payload) => {
await navigateTo(`/admin/posts/${post.id}`)
} catch (error) {
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
clearToast()
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return
@@ -166,8 +150,7 @@ const createDraftAutosave = async (payload, options = {}) => {
const msg = error?.data?.message || error?.message || '자동 저장에 실패했습니다.'
postForm.value?.markAutosaveFailed(payload)
if (isDuplicateSlugError(error)) {
window.clearTimeout(toastTimer)
toast.value = null
clearToast()
errorMessage.value = ''
postForm.value?.markSlugError('이미 사용 중인 슬러그입니다.')
return false
@@ -189,9 +172,6 @@ onBeforeRouteLeave(async () => {
return createDraftAutosave(payload, { skipNavigate: true })
})
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
</script>
<template>
@@ -225,7 +205,7 @@ onBeforeUnmount(() => {
</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"
class="admin-post-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] 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',

View File

@@ -80,7 +80,7 @@ const postExportChunkSize = ref(500)
const postExportMaxFileSizeMb = ref(500)
const postImportExportPanel = ref('')
const errorMessage = ref('')
const toast = ref(null)
const { toast, showToast } = useAdminToast()
const logoInputRef = ref(null)
const homeCoverInputRef = ref(null)
const homeCoverDarkInputRef = ref(null)
@@ -182,7 +182,6 @@ const adsSnapshot = reactive({
adPostInArticleCode: '',
adPostBottomCode: ''
})
let toastTimer = null
let scrollSpyFrame = null
let postExportRefreshTimer = null
@@ -906,20 +905,6 @@ const closeSettings = async () => {
await navigateTo('/admin')
}
/**
* 저장 상태 토스트 표시
* @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)
}
/**
* 게시물 export 작업을 요청한다.
* @returns {Promise<void>}
@@ -2178,7 +2163,6 @@ onMounted(() => {
})
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
window.clearInterval(postExportRefreshTimer)
if (scrollSpyFrame) {
cancelAnimationFrame(scrollSpyFrame)
@@ -4106,7 +4090,7 @@ onBeforeUnmount(() => {
<div
v-if="toast"
class="admin-settings-screen__toast fixed right-5 top-24 z-[60] rounded-md border px-4 py-3 text-sm font-semibold shadow-lg md:top-28"
class="admin-settings-screen__toast fixed right-5 top-24 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded-md border px-4 py-3 text-sm font-semibold shadow-lg md:top-28"
: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',

View File

@@ -9,11 +9,10 @@ const savingOrder = ref(false)
const promotingTagId = ref('')
const demotingTagId = ref('')
const deletingGeneralTagId = ref('')
const toast = ref(null)
const { toast, showToast } = useAdminToast()
const TAG_TOAST_STORAGE_KEY = 'SORI_ADMIN_TAG_TOAST'
const { openMenuId, closeMenu } = useAdminRowMenu()
let toastTimer = null
const generalTagQuery = ref('')
const {
displayValue: generalTagQueryInput,
@@ -103,20 +102,6 @@ const isManagedOrderDirty = computed(() => {
return currentIds.some((id, index) => id !== base[index])
})
/**
* 피드백 토스트 표시
* @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)
}
onMounted(() => {
const storedToast = sessionStorage.getItem(TAG_TOAST_STORAGE_KEY)
@@ -361,10 +346,6 @@ const deleteGeneralTag = async (tag) => {
}
}
onBeforeUnmount(() => {
window.clearTimeout(toastTimer)
})
</script>
<template>
@@ -573,7 +554,7 @@ onBeforeUnmount(() => {
<div
v-if="toast"
class="admin-tags__toast fixed right-5 top-5 z-50 rounded border px-4 py-3 text-sm font-semibold shadow-lg"
class="admin-tags__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] 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',