글쓰기 확장 블록 추가
This commit is contained in:
@@ -62,6 +62,24 @@ const blockCommands = [
|
||||
description: '여러 이미지 업로드',
|
||||
keywords: ['gallery', 'images', '갤러리', '사진']
|
||||
},
|
||||
{
|
||||
type: 'callout',
|
||||
label: '콜아웃',
|
||||
description: '강조 안내 블록',
|
||||
keywords: ['callout', 'notice', 'info', '콜아웃', '안내']
|
||||
},
|
||||
{
|
||||
type: 'toggle',
|
||||
label: '토글',
|
||||
description: '접고 펼치는 본문 블록',
|
||||
keywords: ['toggle', 'details', '토글', '접기']
|
||||
},
|
||||
{
|
||||
type: 'embed',
|
||||
label: '임베드',
|
||||
description: 'YouTube 등 외부 링크 삽입',
|
||||
keywords: ['embed', 'youtube', 'link', '임베드', '유튜브']
|
||||
},
|
||||
{
|
||||
type: 'quote',
|
||||
label: '인용',
|
||||
@@ -104,6 +122,7 @@ const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '',
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
title: options.title || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
@@ -127,6 +146,27 @@ const parseImageLine = (line) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 닫힘 표식까지의 행 목록을 반환
|
||||
* @param {Array<string>} lines - 전체 마크다운 행
|
||||
* @param {number} startIndex - 본문 시작 인덱스
|
||||
* @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
|
||||
*/
|
||||
const collectFencedLines = (lines, startIndex) => {
|
||||
const contentLines = []
|
||||
let index = startIndex
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
contentLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
return {
|
||||
contentLines,
|
||||
nextIndex: index + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 마크다운 문자열을 에디터 블록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
@@ -147,21 +187,40 @@ const parseMarkdownToBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const images = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
const image = parseImageLine(lines[index])
|
||||
|
||||
contentLines.forEach((contentLine) => {
|
||||
const image = parseImageLine(contentLine)
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
})
|
||||
|
||||
blocks.push(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images }))
|
||||
index += 1
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::callout') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createEditorBlock('callout', contentLines.join('\n'), null, `editor-block-${blocks.length}`))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::toggle')) {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
|
||||
blocks.push(createEditorBlock('toggle', contentLines.join('\n'), null, `editor-block-${blocks.length}`, { title }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::embed') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createEditorBlock('embed', '', null, `editor-block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -265,6 +324,25 @@ const serializeBlocks = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (block.type === 'callout') {
|
||||
return text
|
||||
? { type: block.type, value: `:::callout\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (block.type === 'toggle') {
|
||||
const title = block.title.trim()
|
||||
return title || text
|
||||
? { type: block.type, value: `:::toggle ${title || '더 보기'}\n${text}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (block.type === 'embed') {
|
||||
return block.url.trim()
|
||||
? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
|
||||
: null
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
@@ -316,7 +394,7 @@ const emitContent = () => {
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {boolean} 텍스트 입력 블록 여부
|
||||
*/
|
||||
const isTextBlock = (block) => !['divider', 'image', 'gallery'].includes(block.type)
|
||||
const isTextBlock = (block) => !['divider', 'image', 'gallery', 'toggle', 'embed'].includes(block.type)
|
||||
|
||||
/**
|
||||
* 블록 DOM 요소를 저장
|
||||
@@ -361,6 +439,23 @@ const focusBlock = (index) => {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 구조형 블록의 첫 입력 필드로 커서 이동
|
||||
* @param {number} index - 블록 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusStructuredBlock = (index) => {
|
||||
nextTick(() => {
|
||||
const blockId = editorBlocks.value[index]?.id
|
||||
const row = document.querySelector(`[data-editor-block-id="${blockId}"]`)
|
||||
const field = row?.querySelector('input, textarea, button')
|
||||
|
||||
if (field) {
|
||||
field.focus()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 슬래시 메뉴 표시 방향 갱신
|
||||
* @param {number} index - 블록 인덱스
|
||||
@@ -418,6 +513,7 @@ const getBlockClass = (block) => [
|
||||
'admin-block-editor__heading--h2 text-4xl': block.type === 'heading' && block.level === 2,
|
||||
'admin-block-editor__heading--h3 text-3xl': block.type === 'heading' && block.level === 3,
|
||||
'admin-block-editor__quote my-5 border-l-4 border-ink bg-surface px-5 py-3 text-xl font-medium leading-8': block.type === 'quote',
|
||||
'admin-block-editor__callout my-5 min-h-14 rounded border border-line bg-surface px-5 py-4 text-[16px] leading-7': block.type === 'callout',
|
||||
'admin-block-editor__list relative min-h-8 pl-7 text-[17px] leading-8 before:absolute before:left-2 before:top-3 before:h-2 before:w-2 before:rounded-full before:bg-current': block.type === 'list',
|
||||
'admin-block-editor__code my-5 min-h-14 whitespace-pre-wrap rounded bg-[#15171a] px-4 py-3 font-mono text-sm leading-6 text-white': block.type === 'code'
|
||||
}
|
||||
@@ -572,6 +668,7 @@ const applyCommand = (command) => {
|
||||
block.text = ''
|
||||
block.url = ''
|
||||
block.alt = ''
|
||||
block.title = ''
|
||||
block.width = 'regular'
|
||||
block.images = []
|
||||
const element = blockRefs.value[index]
|
||||
@@ -593,6 +690,11 @@ const applyCommand = (command) => {
|
||||
|
||||
if (isTextBlock(block)) {
|
||||
focusBlock(index)
|
||||
return
|
||||
}
|
||||
|
||||
if (['toggle', 'embed'].includes(block.type)) {
|
||||
focusStructuredBlock(index)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -836,7 +938,7 @@ const handleEnter = (event, index) => {
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (['divider', 'image', 'gallery'].includes(currentBlock.type)) {
|
||||
if (['divider', 'image', 'gallery', 'toggle', 'embed'].includes(currentBlock.type)) {
|
||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||
emitContent()
|
||||
focusBlock(index + 1)
|
||||
@@ -870,7 +972,7 @@ const handleBackspace = (event, index) => {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTextBlock(block) && (block.url || block.images.length)) {
|
||||
if (!isTextBlock(block) && (block.url || block.images.length || block.title || block.text)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -903,6 +1005,14 @@ const shouldShowPlaceholder = (block, index) => !block.text && (
|
||||
(index === 0 && editorBlocks.value.length === 1)
|
||||
)
|
||||
|
||||
/**
|
||||
* 텍스트 필드 변경 내용을 저장용 콘텐츠에 반영
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateStructuredBlock = () => {
|
||||
emitContent()
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (value) => {
|
||||
if (isApplyingExternalValue.value) {
|
||||
return
|
||||
@@ -936,6 +1046,7 @@ defineExpose({
|
||||
v-for="(block, index) in editorBlocks"
|
||||
:key="block.id"
|
||||
class="admin-block-editor__row relative"
|
||||
:data-editor-block-id="block.id"
|
||||
>
|
||||
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
|
||||
|
||||
@@ -1026,6 +1137,50 @@ defineExpose({
|
||||
</div>
|
||||
</figure>
|
||||
|
||||
<section
|
||||
v-else-if="block.type === 'toggle'"
|
||||
class="admin-block-editor__toggle my-6 rounded border border-line bg-paper p-5"
|
||||
@focusin="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<input
|
||||
v-model="block.title"
|
||||
class="admin-block-editor__toggle-title w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
|
||||
type="text"
|
||||
placeholder="토글 제목"
|
||||
@input="updateStructuredBlock"
|
||||
>
|
||||
<textarea
|
||||
v-model="block.text"
|
||||
class="admin-block-editor__toggle-body mt-3 min-h-24 w-full resize-y border-0 bg-transparent text-sm leading-7 text-ink outline-none placeholder:text-soft"
|
||||
placeholder="펼쳤을 때 보일 내용을 입력하세요"
|
||||
@input="updateStructuredBlock"
|
||||
@keydown.enter.stop
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section
|
||||
v-else-if="block.type === 'embed'"
|
||||
class="admin-block-editor__embed my-6 rounded border border-dashed border-line bg-surface p-5"
|
||||
@focusin="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<input
|
||||
v-model="block.url"
|
||||
class="admin-block-editor__embed-url w-full border-0 bg-transparent text-base font-semibold text-ink outline-none placeholder:text-soft"
|
||||
type="url"
|
||||
placeholder="https://www.youtube.com/watch?v=..."
|
||||
@input="updateStructuredBlock"
|
||||
>
|
||||
<p class="admin-block-editor__embed-help mt-2 text-xs text-muted">
|
||||
YouTube 링크는 공개 화면에서 영상으로 표시됩니다.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<component
|
||||
:is="getBlockTag(block)"
|
||||
v-else
|
||||
|
||||
@@ -25,6 +25,7 @@ const createBlock = (type = 'paragraph', text = '', level = null, id = '', optio
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
title: options.title || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
@@ -48,6 +49,27 @@ const parseImageLine = (line) => {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 닫힘 표식까지의 행 목록을 반환
|
||||
* @param {Array<string>} lines - 전체 마크다운 행
|
||||
* @param {number} startIndex - 본문 시작 인덱스
|
||||
* @returns {{contentLines: Array<string>, nextIndex: number}} 블록 본문과 다음 인덱스
|
||||
*/
|
||||
const collectFencedLines = (lines, startIndex) => {
|
||||
const contentLines = []
|
||||
let index = startIndex
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
contentLines.push(lines[index])
|
||||
index += 1
|
||||
}
|
||||
|
||||
return {
|
||||
contentLines,
|
||||
nextIndex: index + 1
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
@@ -68,21 +90,40 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const images = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
const image = parseImageLine(lines[index])
|
||||
|
||||
contentLines.forEach((contentLine) => {
|
||||
const image = parseImageLine(contentLine)
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
})
|
||||
|
||||
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
|
||||
index += 1
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::callout') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createBlock('callout', contentLines.join('\n'), null, `block-${blocks.length}`))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith(':::toggle')) {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
const title = trimmedLine.replace(/^:::toggle\s*/, '').trim()
|
||||
blocks.push(createBlock('toggle', contentLines.join('\n'), null, `block-${blocks.length}`, { title }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::embed') {
|
||||
const { contentLines, nextIndex } = collectFencedLines(lines, index + 1)
|
||||
blocks.push(createBlock('embed', '', null, `block-${blocks.length}`, { url: contentLines.join('\n').trim() }))
|
||||
index = nextIndex
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -206,6 +247,13 @@ const showNextImage = () => {
|
||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||
{{ block.alt }}
|
||||
</ProseImage>
|
||||
<ProseCallout v-else-if="block.type === 'callout'">
|
||||
{{ block.text }}
|
||||
</ProseCallout>
|
||||
<ProseToggle v-else-if="block.type === 'toggle'" :title="block.title || '더 보기'">
|
||||
{{ block.text }}
|
||||
</ProseToggle>
|
||||
<ProseEmbed v-else-if="block.type === 'embed'" :url="block.url" />
|
||||
<div v-else-if="block.type === 'gallery'" class="content-markdown-renderer__gallery my-8 grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<button
|
||||
v-for="(image, imageIndex) in block.images"
|
||||
|
||||
@@ -1,5 +1,57 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
url: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* YouTube 영상 ID를 추출
|
||||
* @param {string} value - 임베드 URL
|
||||
* @returns {string} YouTube 영상 ID
|
||||
*/
|
||||
const getYouTubeId = (value) => {
|
||||
try {
|
||||
const parsedUrl = new URL(value)
|
||||
|
||||
if (parsedUrl.hostname.includes('youtu.be')) {
|
||||
return parsedUrl.pathname.replace('/', '')
|
||||
}
|
||||
|
||||
if (parsedUrl.hostname.includes('youtube.com')) {
|
||||
return parsedUrl.searchParams.get('v') || parsedUrl.pathname.split('/').pop() || ''
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
|
||||
return ''
|
||||
}
|
||||
|
||||
const youtubeId = computed(() => getYouTubeId(props.url))
|
||||
const youtubeEmbedUrl = computed(() => youtubeId.value ? `https://www.youtube.com/embed/${youtubeId.value}` : '')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="prose-embed my-8 border border-line bg-paper p-5">
|
||||
<slot />
|
||||
<div class="prose-embed my-8 overflow-hidden border border-line bg-paper">
|
||||
<iframe
|
||||
v-if="youtubeEmbedUrl"
|
||||
class="prose-embed__frame aspect-video w-full"
|
||||
:src="youtubeEmbedUrl"
|
||||
title="Embedded video"
|
||||
loading="lazy"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
|
||||
allowfullscreen
|
||||
/>
|
||||
<a
|
||||
v-else
|
||||
class="prose-embed__link block p-5 text-sm font-semibold text-ink hover:opacity-70"
|
||||
:href="url"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
{{ url }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-02 v0.0.20
|
||||
|
||||
### 콜아웃, 토글, 임베드 블록 저장 방식 결정
|
||||
|
||||
콜아웃, 토글, 임베드는 기존 `content` 마크다운 문자열 안에 `:::callout`, `:::toggle`, `:::embed` fenced block으로 저장한다. 이미지 갤러리와 같은 확장 문법을 사용하면 DB 스키마를 바꾸지 않고도 관리자 작성 화면과 공개 렌더러를 함께 확장할 수 있기 때문이다.
|
||||
|
||||
임베드는 1차로 YouTube URL만 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다. Twitter 등 외부 서비스별 스크립트 임베드는 SSR 안정성과 개인정보/스크립트 로딩 정책을 검토한 뒤 별도 단계에서 확장한다.
|
||||
|
||||
## 2026-05-02 v0.0.19
|
||||
|
||||
### 블록 에디터 조합 입력과 이미지 캡션 표시 보정
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼, 대표 이미지 선택 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록, 한글 조합 입력 처리 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리/콜아웃/토글/임베드 블록, 한글 조합 입력 처리 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
@@ -35,7 +35,7 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링 |
|
||||
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링, 확장 블록 파싱 |
|
||||
| components/content/ProseHeading.vue | h1~h6 제목 |
|
||||
| components/content/ProseImage.vue | 본문 내 이미지 |
|
||||
| components/content/ProseList.vue | 목록 |
|
||||
|
||||
@@ -219,7 +219,7 @@ components/content/
|
||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 콜아웃, 토글, 임베드, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
||||
- 한글 등 조합형 입력 중에는 단축 문법과 슬래시 메뉴 상태를 확정하지 않고 조합 종료 뒤 반영한다.
|
||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||
@@ -238,6 +238,10 @@ components/content/
|
||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||
- 이미지와 갤러리 블록은 기존 업로드 미디어 선택 또는 새 이미지 업로드를 제공한다.
|
||||
- 콜아웃 블록은 `:::callout` fenced block 안에 본문을 저장한다.
|
||||
- 토글 블록은 `:::toggle 제목` fenced block 안에 펼침 본문을 저장한다.
|
||||
- 임베드 블록은 `:::embed` fenced block 안에 URL을 저장한다.
|
||||
- YouTube 임베드 URL은 공개 화면에서 iframe으로 렌더링하고, 그 외 URL은 외부 링크로 표시한다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
- [ ] 미디어 선택 창 브라우저 수동 QA: 기존 이미지 선택, 갤러리 추가, 빈 미디어 상태 확인
|
||||
- [ ] 대표 이미지 브라우저 수동 QA: 기존 미디어 선택, 새 업로드, 썸네일 표시, 삭제/변경 확인
|
||||
- [ ] 미디어 라이브러리 상세 모달 수동 QA: 검색, 사용 현황, 파일명 변경, 삭제 잠금 확인
|
||||
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
||||
- [ ] 콜아웃, 토글, 임베드 블록 브라우저 수동 QA: `/` 메뉴 선택, 저장/수정 왕복, 공개 렌더링 확인
|
||||
- [ ] 글 작성 중 자동 저장
|
||||
|
||||
## 2차 관리자 개발
|
||||
@@ -49,7 +49,7 @@
|
||||
- [ ] ProseFile 실제 파일 데이터 연결
|
||||
- [ ] ProseProduct 실제 상품 카드 데이터 연결
|
||||
- [ ] ProseHeaderCard Simple/Wide/Full-width/Split 변형 구현
|
||||
- [ ] ProseEmbed YouTube, Twitter 실제 렌더링 연결
|
||||
- [ ] ProseEmbed Twitter 실제 렌더링 연결
|
||||
|
||||
## 데이터베이스
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.20
|
||||
|
||||
- 관리자 블록 에디터에 콜아웃 블록 추가.
|
||||
- 관리자 블록 에디터에 토글 블록 추가.
|
||||
- 관리자 블록 에디터에 임베드 블록 추가.
|
||||
- 공개 본문 렌더러에 콜아웃, 토글, 임베드 마크다운 확장 파싱 추가.
|
||||
- YouTube 임베드 URL을 공개 화면에서 iframe으로 렌더링하도록 수정.
|
||||
- 패키지 버전을 0.0.20으로 갱신.
|
||||
|
||||
## v0.0.19
|
||||
|
||||
- 관리자 블록 에디터의 한글 조합 입력 중복 방지 처리 추가.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.20",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.20",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.19",
|
||||
"version": "0.0.20",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user