diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue
index 7f9e7d5..553c022 100644
--- a/components/admin/AdminMarkdownEditor.vue
+++ b/components/admin/AdminMarkdownEditor.vue
@@ -1979,7 +1979,11 @@ const focusPreviewAfterSlashCommand = (line, replacementLines) => {
nextTick(() => {
nextTick(() => {
- previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset)
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'end', offset)
+ })
+ })
})
})
}
diff --git a/components/content/ContentMarkdownCalloutEditor.vue b/components/content/ContentMarkdownCalloutEditor.vue
index bb27005..15a6531 100644
--- a/components/content/ContentMarkdownCalloutEditor.vue
+++ b/components/content/ContentMarkdownCalloutEditor.vue
@@ -33,16 +33,25 @@ const props = defineProps({
}
})
-const emit = defineEmits(['commit', 'delete-line', 'insert-below', 'merge-with-previous', 'leave-block'])
+const emit = defineEmits(['commit', 'delete-line', 'insert-below', 'merge-with-previous', 'leave-block', 'focus-line'])
+
+const bodyLines = computed(() => {
+ const lines = String(props.modelValue ?? '').replace(/\r/g, '').split('\n')
+ return lines.length ? lines : ['']
+})
+
+const bodyLineEntries = computed(() => bodyLines.value.map((text, index) => ({
+ text,
+ index,
+ sourceLine: props.bodySourceLine + index
+})))
/**
* 콜아웃 마크다운 줄을 반영한다.
- * @param {string} body - 본문
+ * @param {string[]} contentLines - 본문 줄
* @returns {void}
*/
-const commitCallout = (body) => {
- const contentLines = String(body ?? '').replace(/\r/g, '').split('\n')
-
+const commitCalloutLines = (contentLines) => {
emit('commit', [
buildCalloutOpenerLine({
calloutEmojiEnabled: props.calloutEmojiEnabled,
@@ -55,21 +64,96 @@ const commitCallout = (body) => {
}
/**
- * 본문 편집 반영
- * @param {string} body - 본문
- * @returns {void}
+ * 인라인 편집 값을 문자열로 정규화한다.
+ * @param {string|{ value?: string }} payload - 편집 페이로드
+ * @returns {string} 편집 값
*/
-const onBodyCommit = (body) => {
- commitCallout(body)
+const normalizeInlineValue = (payload) => {
+ if (typeof payload === 'string') {
+ return payload
+ }
+
+ return String(payload?.value ?? '')
}
/**
- * 콜아웃 아래로 이탈한다.
- * @param {Object} payload - 이탈 페이로드
+ * 아래 줄 삽입 페이로드를 정규화한다.
+ * @param {string|Object} payload - 아래 줄 삽입 페이로드
+ * @returns {{ value: string, before: string, after: string, caretAtStart: boolean }}
+ */
+const normalizeInsertPayload = (payload) => {
+ if (typeof payload === 'string') {
+ return {
+ value: payload,
+ before: '',
+ after: '',
+ caretAtStart: false
+ }
+ }
+
+ return {
+ value: String(payload?.value ?? ''),
+ before: String(payload?.before ?? ''),
+ after: String(payload?.after ?? ''),
+ caretAtStart: payload?.caretAtStart === true
+ }
+}
+
+/**
+ * 지정한 콜아웃 본문 줄에 포커스를 요청한다.
+ * @param {number} sourceLine - 원본 줄 번호
* @returns {void}
*/
-const onExitBelow = (payload) => {
- emit('insert-below', payload)
+const focusCalloutLine = (sourceLine) => {
+ emit('focus-line', {
+ line: sourceLine,
+ position: 'start',
+ offset: 0
+ })
+}
+
+/**
+ * 본문 한 줄 편집 반영
+ * @param {number} lineIndex - 본문 줄 인덱스
+ * @param {string|{ value?: string }} payload - 편집 페이로드
+ * @returns {void}
+ */
+const onBodyLineCommit = (lineIndex, payload) => {
+ const nextLines = [...bodyLines.value]
+ nextLines[lineIndex] = normalizeInlineValue(payload)
+ commitCalloutLines(nextLines)
+}
+
+/**
+ * 현재 줄 아래에 콜아웃 본문 줄을 추가한다.
+ * @param {number} lineIndex - 본문 줄 인덱스
+ * @param {Object|string} payload - 아래 줄 삽입 페이로드
+ * @returns {void}
+ */
+const onBodyLineInsertBelow = (lineIndex, payload) => {
+ const { value, before, after, caretAtStart } = normalizeInsertPayload(payload)
+ const nextLines = [...bodyLines.value]
+
+ if (caretAtStart && after.length) {
+ nextLines[lineIndex] = after
+ nextLines.splice(lineIndex, 0, '')
+ focusCalloutLine(props.bodySourceLine + lineIndex)
+ commitCalloutLines(nextLines)
+ return
+ }
+
+ if (before.length && after.length) {
+ nextLines[lineIndex] = before
+ nextLines.splice(lineIndex + 1, 0, after)
+ focusCalloutLine(props.bodySourceLine + lineIndex + 1)
+ commitCalloutLines(nextLines)
+ return
+ }
+
+ nextLines[lineIndex] = value
+ nextLines.splice(lineIndex + 1, 0, '')
+ focusCalloutLine(props.bodySourceLine + lineIndex + 1)
+ commitCalloutLines(nextLines)
}
@@ -90,17 +174,21 @@ const onExitBelow = (payload) => {
{{ calloutEmoji || '💡' }}
+
-
+
+
+
diff --git a/components/content/ContentMarkdownEditableInline.vue b/components/content/ContentMarkdownEditableInline.vue
index 15012d8..42fa721 100644
--- a/components/content/ContentMarkdownEditableInline.vue
+++ b/components/content/ContentMarkdownEditableInline.vue
@@ -103,6 +103,8 @@ const suppressBlurCommit = ref(false)
const splitLock = ref(false)
/** 조합 중 Enter 후 compositionend에서 분리할지 */
const pendingSplitAfterComposition = ref(false)
+/** 조합 종료 직후 중복 Enter를 무시할 기준 시각 */
+const suppressComposedEnterUntil = ref(0)
const showingRaw = ref(false)
/** @returns {string} Enter 동작 모드 */
@@ -526,7 +528,7 @@ const navigateToAdjacentBlock = (direction, column, caretMode = 'column') => {
const target = elements[currentIndex + direction]
if (!target) {
- if (resolvedEnterMode.value === 'multiline' && direction === 1) {
+ if (['insert-below', 'multiline'].includes(resolvedEnterMode.value) && direction === 1) {
emit('insert-below', buildInsertBelowPayload())
}
@@ -771,9 +773,14 @@ const onKeydown = (event) => {
&& props.sourceLine !== null
&& !readEditorValue().trim()
) {
+ const lineContext = getCaretLineContext()
+ const sourceLine = resolvedEnterMode.value === 'multiline'
+ ? props.sourceLine + lineContext.lineIndex
+ : props.sourceLine
+
event.preventDefault()
event.stopPropagation()
- emit('delete-line', props.sourceLine)
+ emit('delete-line', sourceLine)
return
}
@@ -863,6 +870,17 @@ const onKeydown = (event) => {
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
+ if (
+ event.key === 'Enter'
+ && !event.shiftKey
+ && suppressComposedEnterUntil.value
+ && Date.now() < suppressComposedEnterUntil.value
+ ) {
+ event.preventDefault()
+ event.stopPropagation()
+ return
+ }
+
if (event.key === 'Enter' && !event.shiftKey && parseSlashInput(readEditorValue())) {
event.preventDefault()
event.stopPropagation()
@@ -880,6 +898,7 @@ const onKeydown = (event) => {
if (event.isComposing || event.keyCode === 229) {
pendingSplitAfterComposition.value = true
+ suppressComposedEnterUntil.value = Date.now() + 240
return
}
@@ -917,6 +936,7 @@ const onCompositionEnd = () => {
}
pendingSplitAfterComposition.value = false
+ suppressComposedEnterUntil.value = Date.now() + 240
const enterMode = showingRaw.value ? 'insert-below' : resolvedEnterMode.value
if (enterMode !== 'split-paragraph' && enterMode !== 'insert-below') {
diff --git a/components/content/ContentMarkdownRenderer.vue b/components/content/ContentMarkdownRenderer.vue
index e8af7d0..7fa2a01 100644
--- a/components/content/ContentMarkdownRenderer.vue
+++ b/components/content/ContentMarkdownRenderer.vue
@@ -13,7 +13,6 @@ import {
hasListMarker,
hasQuoteMarker,
isEmptyListMarkerLine,
- isEmptyQuoteMarkerLine,
parseOrderedListMarker,
stripListMarker,
stripQuoteMarker
@@ -1291,6 +1290,21 @@ const onCalloutBlockInsertBelow = (block, payload) => {
onInsertBelowBlock(block, { lines: [''] })
}
+/**
+ * 자식 편집기가 요청한 원본 줄에 포커스를 둔다.
+ * @param {{ line?: number, position?: 'start'|'end'|'auto', offset?: number|null }} payload - 포커스 요청
+ * @returns {void}
+ */
+const onEditorFocusLine = (payload) => {
+ if (typeof payload?.line !== 'number') {
+ return
+ }
+
+ pendingFocusLine.value = payload.line
+ pendingFocusPosition.value = payload.position || 'auto'
+ pendingFocusOffset.value = typeof payload.offset === 'number' ? payload.offset : null
+}
+
/**
* 토글 편집 반영
* @param {Object} block - 토글 블록
@@ -1751,16 +1765,6 @@ const onQuoteLineInsertBelow = (block, lineIndex, payload) => {
nextLines[lineIndex] = formatQuoteLine(value, raw)
}
- const committedLine = nextLines[lineIndex] ?? ''
-
- if (isEmptyQuoteMarkerLine(committedLine)) {
- nextLines[lineIndex] = ''
- pendingFocusLine.value = block.meta.startLine + lineIndex
- pendingFocusPosition.value = 'start'
- commitInlineBlockLines(block, nextLines)
- return
- }
-
nextLines.splice(lineIndex + 1, 0, '> ')
pendingFocusLine.value = block.meta.startLine + lineIndex + 1
pendingFocusPosition.value = 'start'
@@ -2610,7 +2614,8 @@ onBeforeUnmount(() => {
@commit="onCalloutBlockCommit(block, $event)"
@delete-line="onDeleteLine"
@insert-below="onCalloutBlockInsertBelow(block, $event)"
- @merge-with-previous="onMergeWithPreviousLine(block.meta.startLine + 1, $event)"
+ @merge-with-previous="onMergeWithPreviousLine"
+ @focus-line="onEditorFocusLine"
/>
{
return 'prose-callout--yellow'
}
- if (props.background === 'red') {
- return 'prose-callout--red'
+ if (props.background === 'blue') {
+ return 'prose-callout--blue'
}
if (props.background === 'purple') {
return 'prose-callout--purple'
}
- return 'prose-callout--blue'
+ return 'prose-callout--red'
})
diff --git a/docs/changelog.md b/docs/changelog.md
index b5a27ab..b23b789 100644
--- a/docs/changelog.md
+++ b/docs/changelog.md
@@ -1,5 +1,11 @@
# 업데이트 요약
+## v1.5.50
+
+- 라이브 작성 모드에서 한글 인용문 Enter가 외부 문단으로 빠지지 않고 다음 인용 줄을 만들도록 보강했다.
+- 마지막 인용 줄에서 아래 방향키로 외부 문단을 만들며 빠져나갈 수 있게 했다.
+- 콜아웃 본문을 줄 단위로 편집해 현재 줄 삭제와 한글 Enter 줄 추가가 더 안정적으로 동작하도록 했다.
+
## v1.5.49
- 라이브 작성 모드에서 코드·콜아웃·토글 내부 `Cmd+Shift+K` 줄 삭제가 동작하도록 수정했다.
diff --git a/docs/deploy.md b/docs/deploy.md
index c0fd14b..02ed8bc 100644
--- a/docs/deploy.md
+++ b/docs/deploy.md
@@ -68,6 +68,15 @@ docker exec sori-studio-db pg_isready -U sori_studio -d sori_studio
docker exec sori-studio-db psql -U sori_studio -d sori_studio -c 'SELECT count(*) AS posts_count FROM posts;'
```
+### v1.5.50 참고
+
+- 추가 DB 마이그레이션은 없다.
+- 라이브 모드에서 한글 `> 텍스트` 입력 후 Enter 시 다음 인용 줄이 생성되고 커서가 내부에 있는지 확인한다.
+- 라이브 인용 마지막 줄에서 아래 방향키 입력 시 외부 빈 문단이 생성되고 커서가 이동하는지 확인한다.
+- 라이브 콜아웃 본문 여러 줄에서 2번째·3번째 줄 `Cmd+Shift+K`가 해당 줄만 삭제하는지 확인한다.
+- 라이브 콜아웃에서 한글 입력 후 Enter 시 본문 줄이 1줄만 추가되는지 확인한다.
+- `/콜아웃` Enter 생성 후 콜아웃 본문에 마지막 한글 조합 글자가 남지 않는지 확인한다.
+
### v1.5.49 참고
- 추가 DB 마이그레이션은 없다.
diff --git a/docs/map.md b/docs/map.md
index 4ececf8..38f5f8d 100644
--- a/docs/map.md
+++ b/docs/map.md
@@ -93,7 +93,7 @@
| components/admin/AdminMediaVideoThumbnail.vue | 관리자 미디어 목록 비디오 항목의 초반 프레임 캔버스 썸네일 |
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, Ghost형 툴바(왼쪽 상태 텍스트·Publish/Update/Unpublish/Unschedule, 서버 반영 상태 기준 분기), 초안만 서버 디바운스 자동 저장·신규 임시 슬러그·발행·예약·멤버십·비공개 상태 저장, 발행 모달(중앙 배치), 좌우 설정 패널(작은 화면은 오른쪽 고정 오버레이), 오른쪽 `View Post` 링크, 미리보기 emit·미저장 이탈 가드, 추천 글 토글, 태그 색상 배지 다중 입력·메인 태그 드롭다운·부분 검색 추천 |
| components/admin/AdminPageForm.vue | 관리자 페이지 작성/수정 폼, 게시글 작성과 같은 전체 화면 에디터·상단 저장 툴바·접이식 오른쪽 설정 패널, 페이지 공개 상태 선택, HTML 문서 기본 모드, 빈 본문/`!`+Tab HTML 골격 자동 완성, 항상 보이는 일반 텍스트/HTML 모드 선택, 한글 제목 영문 슬러그 자동 변환, HTML textarea 커서 위치 파일 URL 삽입 |
-| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 코드·콜아웃·토글 내부 줄 삭제, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원 |
+| components/admin/AdminMarkdownEditor.vue | 관리자 글 Markdown-first 에디터, 라이브·소스 모드 `/` 슬래시 명령·미디어 모달(이미지·갤러리·비디오·오디오·파일), 커서 블록 컨텍스트·`block-panel` emit, 라이브 이미지 설정 패널·이미지↔갤러리 드래그 변환(`merge-images-to-gallery`·`insert-image-to-gallery`·`extract-gallery-image`), 코드·콜아웃·토글 내부 줄 삭제, 블록 패널 바깥 클릭 닫기·미디어 모달 중 유지, 인용·콜아웃·코드·토글 선언 줄 옵션 수정, IME 조합 중 블록 패널 유지, 소스 모드 wrap 라인 번호 보정·라이브↔소스 위치 복원, 라이브 슬래시 명령 후 포커스 복원 지연 |
| components/admin/AdminEditorBlockPanel.vue | 게시물 설정 사이드바 오버레이 블록 설정(이미지·갤러리·임베드·인용 배경색·콜아웃·코드·토글), 갤러리 선택 이미지 강조 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 콜아웃 Emoji on/off·이모지 프리셋·인용과 같은 배경 프리셋 선택(우측 고정 설정 패널), 갤러리 복수 미디어 선택·이미지 수별 열 배치·삽입 위치 표시 드래그 순서 변경, 한글 조합 입력 처리, Shift+Enter 줄바꿈, 코드 블록 단축 변환, AFFiNE 참고 세로 막대형 블록 핸들 선택/삭제/드래그 이동과 삽입선 표시, 하단 빈 입력 블록 유지, 본문 placeholder 표시 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼(이름/슬러그/설명/색상만 편집) |
@@ -111,7 +111,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
-| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
+| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `
` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 Enter 줄 추가·마지막 줄 아래 방향키 이탈, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
| components/content/ProseList.vue | 목록 |
diff --git a/docs/spec.md b/docs/spec.md
index ccabb81..e1f6efc 100644
--- a/docs/spec.md
+++ b/docs/spec.md
@@ -587,24 +587,24 @@ components/content/
- `POST /admin/api/members/:id/avatar` - 관리자 회원 썸네일 업로드 및 즉시 반영(`/uploads/members/avatars/YYYY/MM`, WebP 변환·중앙 1:1 크롭)
- `PUT /admin/api/members/:id/role` - 회원 권한 변경(`owner`/`admin`/`vip`/`member`)
-> 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
-> 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
-> 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
-> 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다.
-> 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다.
-> 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
-> 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.
-> 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
-> 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
-> 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
-> 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
-> 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
-> 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
-> 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
-> 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
-> 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
-> 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
-> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
+- 글 발행/초안 전환은 `PUT /admin/api/posts/:id`의 `status`와 `published_at`으로 처리한다. 예약 글은 별도 enum이 아니라 `published`와 미래 시각의 `published_at` 조합이다.
+- 게시물 상태는 `draft`, `published`, `members`, `private`를 사용한다. `members`는 VIP 이상 등급 회원에게만 공개한다. 로그인은 댓글 작성을 위한 기본 회원 기능이며, 멤버십 글 공개 기준으로 사용하지 않는다. `private`는 관리자 편집 화면에서는 보이지만 공개 사용자 화면에서는 노출하지 않는다.
+- 관리자 글 목록 맨 오른쪽 **관리** 열은 more vert(⋮) 버튼으로 행 메뉴를 연다. 메뉴에서 **게시글 추천**·**추천 제거**·**게시글 삭제**를 선택한다(사이드바 사용자 메뉴와 같은 팝오버 스타일).
+- 관리자 글 목록 상단은 좌측에 제목·**총 N개**(추천 M개·필터 시 표시 K개) 요약, 우측에 검색, 상태·태그·**추천(전체/추천만)**·정렬 필터와 «새 글» 버튼을 한 줄(좁은 화면에서는 줄바꿈)로 둔다. 검색은 제목·슬러그·요약·본문·태그 기준 부분 일치로 적용한다.
+- 관리자 글 목록 표 첫 열은 `is_featured`(추천 글)일 때만 금색 별 아이콘을 표시한다. 추천 여부는 글 편집 설정의 «추천 글» 토글로 지정한다. 추천 열과 제목 열 사이에는 대표 이미지 썸네일을 작은 크기로 표시하며, 이미지가 없으면 회색 placeholder만 유지한다.
+- 관리자 글 목록의 날짜 열은 **발행일**(`published_at`, 시·분 포함)이며, `showPostUpdatedAt`이 true이고 발행 후 수정이 있으면 아래에 `수정: …` 보조 줄을 표시한다.
+- 관리자 글 목록 기본 정렬(최신순·오래된순)은 **발행일** 기준이며, `published_at`이 없는 초안 등은 **수정일**(`updated_at`)로 대체한다. API(`listAdminPosts`)와 화면 필터 정렬 모두 동일 규칙을 쓴다.
+- 태그 삭제 시 `post_tags` 연결도 데이터베이스 외래 키 규칙에 따라 함께 삭제된다.
+- 공개 `GET /api/tags`는 `managed`(메인 태그)만 반환한다.
+- 관리자 태그 목록 응답은 각 태그의 `postCount`, `lastUsedAt`, `updatedAt`을 포함한다.
+- 관리자 태그 목록은 `managed` 우선, `sort_order ASC, 최근 사용/수정 DESC, name ASC` 기준으로 정렬한다.
+- 메인 태그 순서 저장은 드래그 드롭 직후 자동으로 실행되며, 드래그 순서를 받아 `sort_order`를 순차 값으로 다시 저장한다.
+- 메인 태그 순서 저장 중에는 추가 드래그를 잠시 막고 저장 상태를 표시한다.
+- 관리자 태그 추가 화면에서 직접 생성한 태그는 기본적으로 `general`(일반 태그)로 생성하고, 태그 관리 화면의 일반 태그 목록에 바로 표시한다.
+- 게시물 작성에서 새로 생기는 태그는 기본적으로 `general`(일반 태그)로 생성한다.
+- 메인 태그는 목록에서 `일반 태그로 변경` 액션으로 강등하며, 일반 태그는 배지형 전체 목록에서 확인·필터·최근 사용순·많이 사용순·이름순 정렬·메인 전환·삭제를 수행한다.
+- 태그 관리 화면에서 순서 저장·메인 전환·강등·삭제 등의 성공·실패 피드백은 우측 상단 토스트로 표시한다.
+- 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
### 관리자 글 편집
diff --git a/docs/update.md b/docs/update.md
index 77b39c0..6b3def1 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -1,5 +1,11 @@
# 업데이트 이력
+## v1.5.50
+
+- 게시물 글쓰기: 라이브 모드 인용 Enter가 한글 조합 입력 뒤에도 내부 다음 인용 줄을 만들도록 수정.
+- 게시물 글쓰기: 라이브 모드 인용 마지막 줄에서 아래 방향키로 외부 문단을 만들며 빠져나오도록 보강.
+- 게시물 글쓰기: 라이브 모드 콜아웃 본문을 줄 단위 편집으로 전환하고 한글 조합 Enter 중복 줄 생성 문제를 완화.
+
## v1.5.49
- 게시물 글쓰기: 라이브 모드 `Cmd+Shift+K`가 코드·콜아웃·토글 블록 내부 현재 줄을 삭제하도록 수정.
diff --git a/package-lock.json b/package-lock.json
index f794675..93d35bf 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "sori.studio",
- "version": "1.5.49",
+ "version": "1.5.50",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sori.studio",
- "version": "1.5.49",
+ "version": "1.5.50",
"hasInstallScript": true,
"dependencies": {
"@nuxtjs/tailwindcss": "^6.14.0",
diff --git a/package.json b/package.json
index c81bd8a..f94260e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "sori.studio",
- "version": "1.5.49",
+ "version": "1.5.50",
"private": true,
"type": "module",
"imports": {