라이브 인용 콜아웃 입력 보정

This commit is contained in:
2026-06-04 15:00:39 +09:00
parent 675e6bca78
commit 35b9893eab
12 changed files with 204 additions and 66 deletions

View File

@@ -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)
})
})
})
})
}

View File

@@ -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)
}
</script>
@@ -90,17 +174,21 @@ const onExitBelow = (payload) => {
<span v-if="calloutEmojiEnabled">{{ calloutEmoji || '💡' }}</span>
<span v-else class="text-base text-[#8e9cac]">+</span>
</span>
<ContentMarkdownEditableInline
block-class="content-markdown-callout-editor__body min-w-0 flex-1 text-[15px] leading-8 text-[var(--site-text)]"
enter-mode="multiline"
:source-line="bodySourceLine"
:model-value="modelValue"
@commit="onBodyCommit"
@delete-line="emit('delete-line', $event)"
@insert-below="onExitBelow"
@merge-with-previous="emit('merge-with-previous', $event)"
@leave-block="emit('leave-block', $event)"
/>
<div class="content-markdown-callout-editor__body-lines min-w-0 flex-1">
<ContentMarkdownEditableInline
v-for="line in bodyLineEntries"
:key="`${blockSourceLine}-callout-line-${line.sourceLine}`"
block-class="content-markdown-callout-editor__body min-w-0 text-[15px] leading-8 text-[var(--site-text)]"
enter-mode="insert-below"
:source-line="line.sourceLine"
:model-value="line.text"
@commit="onBodyLineCommit(line.index, $event)"
@delete-line="emit('delete-line', $event)"
@insert-below="onBodyLineInsertBelow(line.index, $event)"
@merge-with-previous="emit('merge-with-previous', line.sourceLine, $event)"
@leave-block="emit('leave-block', $event)"
/>
</div>
</div>
</ProseCallout>
</div>

View File

@@ -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') {

View File

@@ -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"
/>
<ProseCallout
v-else-if="block.type === 'callout'"

View File

@@ -27,15 +27,15 @@ const backgroundClass = computed(() => {
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'
})
</script>

View File

@@ -1,5 +1,11 @@
# 업데이트 요약
## v1.5.50
- 라이브 작성 모드에서 한글 인용문 Enter가 외부 문단으로 빠지지 않고 다음 인용 줄을 만들도록 보강했다.
- 마지막 인용 줄에서 아래 방향키로 외부 문단을 만들며 빠져나갈 수 있게 했다.
- 콜아웃 본문을 줄 단위로 편집해 현재 줄 삭제와 한글 Enter 줄 추가가 더 안정적으로 동작하도록 했다.
## v1.5.49
- 라이브 작성 모드에서 코드·콜아웃·토글 내부 `Cmd+Shift+K` 줄 삭제가 동작하도록 수정했다.

View File

@@ -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 마이그레이션은 없다.

View File

@@ -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 `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 문단 text-base(16px), 빈 줄 spacer 보존·hard break `<br>` 처리, 확장 블록 파싱, 인용 배경 옵션(`> [!bg=...]`), 라이브 콜아웃·인용 포커스 기반 오른쪽 설정 패널 연결, 라이브 인용 Enter 줄 추가·마지막 줄 아래 방향키 이탈, 라이브 방향키 이동 시 편집 가능한 줄·카드형 블록 탐색, 라이브 코드·콜아웃·토글 내부 줄 삭제와 마지막 줄 블록 삭제, 라이브 이미지·갤러리 드래그 병합·추가·분리 UI, 갤러리 비율 기반 행 레이아웃, 라이브 갤러리 개별 이미지 편집·삭제, 리스트 마커 파란 계열 통일 |
| components/content/ProseHeading.vue | h1~h6 제목, 기본 mt-12 제거 |
| components/content/ProseImage.vue | 본문 내 이미지, 로드 실패·빈 URL placeholder |
| components/content/ProseList.vue | 목록 |

View File

@@ -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` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
### 관리자 글 편집

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v1.5.50
- 게시물 글쓰기: 라이브 모드 인용 Enter가 한글 조합 입력 뒤에도 내부 다음 인용 줄을 만들도록 수정.
- 게시물 글쓰기: 라이브 모드 인용 마지막 줄에서 아래 방향키로 외부 문단을 만들며 빠져나오도록 보강.
- 게시물 글쓰기: 라이브 모드 콜아웃 본문을 줄 단위 편집으로 전환하고 한글 조합 Enter 중복 줄 생성 문제를 완화.
## v1.5.49
- 게시물 글쓰기: 라이브 모드 `Cmd+Shift+K`가 코드·콜아웃·토글 블록 내부 현재 줄을 삭제하도록 수정.

4
package-lock.json generated
View File

@@ -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",

View File

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