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": {