관리자 블록형 글쓰기 추가

This commit is contained in:
2026-05-01 18:17:12 +09:00
parent 0fd18bfb48
commit 10bf6b422e
14 changed files with 701 additions and 286 deletions

View File

@@ -0,0 +1,541 @@
<script setup>
const props = defineProps({
modelValue: {
type: String,
default: ''
}
})
const emit = defineEmits(['update:modelValue'])
const editorBlocks = ref([])
const blockRefs = ref([])
const activeBlockId = ref('')
const slashQuery = ref('')
const isApplyingExternalValue = ref(false)
let blockIdSeed = 0
const blockCommands = [
{
type: 'paragraph',
label: '문단',
description: '기본 본문 블록',
keywords: ['p', 'text', 'paragraph', '문단']
},
{
type: 'heading',
level: 2,
label: '제목 2',
description: '섹션 제목',
keywords: ['h2', 'heading', 'title', '제목']
},
{
type: 'heading',
level: 3,
label: '제목 3',
description: '작은 섹션 제목',
keywords: ['h3', 'heading', 'subtitle', '제목']
},
{
type: 'quote',
label: '인용',
description: '강조 인용문',
keywords: ['quote', 'blockquote', '인용']
},
{
type: 'list',
label: '목록',
description: '불릿 목록',
keywords: ['list', 'bullet', '목록']
},
{
type: 'code',
label: '코드',
description: '코드 블록',
keywords: ['code', 'pre', '코드']
},
{
type: 'divider',
label: '구분선',
description: '본문 구간 분리',
keywords: ['divider', 'hr', 'line', '구분선']
}
]
/**
* 에디터 블록 생성
* @param {string} type - 블록 타입
* @param {string} text - 블록 텍스트
* @param {number|null} level - 제목 레벨
* @param {string} id - 블록 ID
* @returns {{ id: string, type: string, text: string, level: number|null }} 에디터 블록
*/
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '') => ({
id: id || `editor-block-new-${blockIdSeed += 1}`,
type,
text,
level
})
/**
* 저장된 마크다운 문자열을 에디터 블록으로 변환
* @param {string} markdown - 마크다운 문자열
* @returns {Array<Object>} 에디터 블록 목록
*/
const parseMarkdownToBlocks = (markdown) => {
const lines = markdown.split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const trimmedLine = line.trim()
if (!trimmedLine) {
index += 1
continue
}
if (trimmedLine.startsWith('```')) {
const codeLines = []
index += 1
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index])
index += 1
}
blocks.push(createEditorBlock('code', codeLines.join('\n'), null, `editor-block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine === '---') {
blocks.push(createEditorBlock('divider', '', null, `editor-block-${blocks.length}`))
index += 1
continue
}
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
if (headingMatch) {
blocks.push(createEditorBlock('heading', headingMatch[2], headingMatch[1].length, `editor-block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine.startsWith('> ')) {
blocks.push(createEditorBlock('quote', trimmedLine.replace(/^>\s?/, ''), null, `editor-block-${blocks.length}`))
index += 1
continue
}
if (/^- /.test(trimmedLine)) {
blocks.push(createEditorBlock('list', trimmedLine.replace(/^- /, ''), null, `editor-block-${blocks.length}`))
index += 1
continue
}
blocks.push(createEditorBlock('paragraph', trimmedLine, null, `editor-block-${blocks.length}`))
index += 1
}
return blocks.length ? blocks : [createEditorBlock('paragraph', '', null, 'editor-block-0')]
}
/**
* 에디터 블록 목록을 저장용 마크다운으로 변환
* @returns {string} 마크다운 문자열
*/
const serializeBlocks = () => {
const lines = editorBlocks.value
.map((block) => {
const text = block.text.trim()
if (block.type === 'divider') {
return { type: block.type, value: '---' }
}
if (!text) {
return null
}
if (block.type === 'heading') {
return { type: block.type, value: `${'#'.repeat(block.level || 2)} ${text}` }
}
if (block.type === 'quote') {
return { type: block.type, value: `> ${text}` }
}
if (block.type === 'list') {
return { type: block.type, value: `- ${text}` }
}
if (block.type === 'code') {
return { type: block.type, value: `\`\`\`\n${block.text}\n\`\`\`` }
}
return { type: block.type, value: text }
})
.filter(Boolean)
return lines.reduce((markdown, line, index) => {
if (index === 0) {
return line.value
}
const previousLine = lines[index - 1]
const joiner = previousLine.type === 'list' && line.type === 'list'
? '\n'
: '\n\n'
return `${markdown}${joiner}${line.value}`
}, '')
}
/**
* 부모 폼으로 콘텐츠 변경 전달
* @returns {void}
*/
const emitContent = () => {
emit('update:modelValue', serializeBlocks())
}
/**
* 블록 DOM 요소를 저장
* @param {Element|null} element - 블록 DOM 요소
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const setBlockRef = (element, index) => {
if (element) {
blockRefs.value[index] = element
}
}
/**
* 지정 블록으로 커서 이동
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const focusBlock = (index) => {
nextTick(() => {
const element = blockRefs.value[index]
if (!element) {
return
}
element.focus()
const selection = window.getSelection()
const range = document.createRange()
range.selectNodeContents(element)
range.collapse(false)
selection.removeAllRanges()
selection.addRange(range)
})
}
/**
* 블록 타입에 맞는 태그명 반환
* @param {Object} block - 에디터 블록
* @returns {string} HTML 태그명
*/
const getBlockTag = (block) => {
if (block.type === 'heading') {
return `h${block.level || 2}`
}
if (block.type === 'quote') {
return 'blockquote'
}
if (block.type === 'code') {
return 'pre'
}
return 'div'
}
/**
* 블록 타입에 맞는 클래스 반환
* @param {Object} block - 에디터 블록
* @returns {Array<string|Object>} 클래스 목록
*/
const getBlockClass = (block) => [
'admin-block-editor__block outline-none transition-colors',
{
'admin-block-editor__paragraph min-h-8 text-[17px] leading-8': block.type === 'paragraph',
'admin-block-editor__heading mt-8 min-h-10 font-semibold leading-tight': block.type === 'heading',
'admin-block-editor__heading--h1 text-5xl': block.type === 'heading' && block.level === 1,
'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__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'
}
]
/**
* 블록 텍스트 입력 처리
* @param {InputEvent} event - 입력 이벤트
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const updateBlockText = (event, index) => {
const block = editorBlocks.value[index]
const text = event.target.innerText.replace(/\n$/, '')
block.text = text
activeBlockId.value = block.id
applyMarkdownShortcut(block, index)
updateSlashQuery(block)
emitContent()
}
/**
* 마크다운 입력 단축 문법 적용
* @param {Object} block - 에디터 블록
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const applyMarkdownShortcut = (block, index) => {
const shortcutMap = [
{ marker: '# ', type: 'heading', level: 1 },
{ marker: '## ', type: 'heading', level: 2 },
{ marker: '### ', type: 'heading', level: 3 },
{ marker: '> ', type: 'quote' },
{ marker: '- ', type: 'list' },
{ marker: '``` ', type: 'code' }
].sort((a, b) => b.marker.length - a.marker.length)
const shortcut = shortcutMap.find((item) => block.text.startsWith(item.marker))
if (!shortcut) {
return
}
block.type = shortcut.type
block.level = shortcut.level || null
block.text = block.text.slice(shortcut.marker.length)
slashQuery.value = ''
nextTick(() => {
const element = blockRefs.value[index]
if (element) {
element.innerText = block.text
focusBlock(index)
}
})
}
/**
* 슬래시 메뉴 검색어 갱신
* @param {Object} block - 에디터 블록
* @returns {void}
*/
const updateSlashQuery = (block) => {
slashQuery.value = block.text.startsWith('/')
? block.text.slice(1).trim().toLowerCase()
: ''
}
const activeBlockIndex = computed(() => editorBlocks.value.findIndex((block) => block.id === activeBlockId.value))
const visibleCommands = computed(() => {
if (activeBlockIndex.value < 0) {
return []
}
const block = editorBlocks.value[activeBlockIndex.value]
if (!block?.text.startsWith('/')) {
return []
}
return blockCommands.filter((command) => [
command.label,
command.description,
...command.keywords
].some((keyword) => keyword.toLowerCase().includes(slashQuery.value)))
})
/**
* 슬래시 메뉴 명령 적용
* @param {Object} command - 블록 명령
* @returns {void}
*/
const applyCommand = (command) => {
const index = activeBlockIndex.value
if (index < 0) {
return
}
const block = editorBlocks.value[index]
block.type = command.type
block.level = command.level || null
block.text = ''
slashQuery.value = ''
if (command.type === 'divider') {
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
emitContent()
focusBlock(index + 1)
return
}
emitContent()
focusBlock(index)
}
/**
* 엔터 키로 다음 블록 생성
* @param {KeyboardEvent} event - 키보드 이벤트
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const handleEnter = (event, index) => {
const currentBlock = editorBlocks.value[index]
if (currentBlock.type === 'code' && !event.shiftKey) {
return
}
event.preventDefault()
if (currentBlock.type === 'divider') {
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
emitContent()
focusBlock(index + 1)
return
}
if (!currentBlock.text.trim() && currentBlock.type !== 'paragraph') {
currentBlock.type = 'paragraph'
currentBlock.level = null
emitContent()
focusBlock(index)
return
}
const nextType = currentBlock.type === 'list' ? 'list' : 'paragraph'
editorBlocks.value.splice(index + 1, 0, createEditorBlock(nextType))
emitContent()
focusBlock(index + 1)
}
/**
* 백스페이스 키로 빈 블록 삭제
* @param {KeyboardEvent} event - 키보드 이벤트
* @param {number} index - 블록 인덱스
* @returns {void}
*/
const handleBackspace = (event, index) => {
const block = editorBlocks.value[index]
if (block.text || editorBlocks.value.length <= 1) {
return
}
event.preventDefault()
editorBlocks.value.splice(index, 1)
emitContent()
focusBlock(Math.max(index - 1, 0))
}
/**
* 현재 블록 활성화
* @param {Object} block - 에디터 블록
* @returns {void}
*/
const activateBlock = (block) => {
activeBlockId.value = block.id
updateSlashQuery(block)
}
watch(() => props.modelValue, (value) => {
if (isApplyingExternalValue.value) {
return
}
const currentValue = serializeBlocks()
if (value === currentValue) {
return
}
editorBlocks.value = parseMarkdownToBlocks(value)
}, { immediate: true })
watch(editorBlocks, () => {
isApplyingExternalValue.value = true
nextTick(() => {
isApplyingExternalValue.value = false
})
}, { deep: true })
</script>
<template>
<div class="admin-block-editor rounded border border-line bg-white px-5 py-5">
<div class="admin-block-editor__surface post-prose grid gap-1">
<div
v-for="(block, index) in editorBlocks"
:key="block.id"
class="admin-block-editor__row relative"
>
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
<component
:is="getBlockTag(block)"
v-else
:ref="(element) => setBlockRef(element, index)"
:class="getBlockClass(block)"
contenteditable="true"
spellcheck="true"
:data-placeholder="index === 0 ? '본문을 입력하거나 / 눌러 블록을 선택하세요' : '/ 눌러 블록 선택'"
@focus="activateBlock(block)"
@input="updateBlockText($event, index)"
@keydown.enter="handleEnter($event, index)"
@keydown.backspace="handleBackspace($event, index)"
>
{{ block.text }}
</component>
<button
v-if="block.type === 'divider'"
class="admin-block-editor__divider-button w-full rounded py-2 text-left text-xs font-semibold text-muted"
type="button"
@click="activateBlock(block)"
@keydown.enter="handleEnter($event, index)"
>
구분선
</button>
<div
v-if="visibleCommands.length && activeBlockId === block.id"
class="admin-block-editor__slash-menu absolute left-0 top-full z-20 mt-2 w-72 overflow-hidden rounded border border-line bg-white shadow-lg"
>
<button
v-for="command in visibleCommands"
:key="`${command.type}-${command.level || 'default'}`"
class="admin-block-editor__slash-item grid w-full gap-0.5 px-4 py-3 text-left hover:bg-surface"
type="button"
@mousedown.prevent="applyCommand(command)"
>
<span class="admin-block-editor__slash-label text-sm font-semibold">{{ command.label }}</span>
<span class="admin-block-editor__slash-description text-xs text-muted">{{ command.description }}</span>
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.admin-block-editor__block:empty::before {
color: var(--site-soft);
content: attr(data-placeholder);
}
</style>

View File

@@ -1,152 +0,0 @@
<script setup>
const props = defineProps({
content: {
type: String,
default: ''
}
})
/**
* HTML 특수 문자 이스케이프
* @param {string} value - 원본 문자열
* @returns {string} 이스케이프된 문자열
*/
const escapeHtml = (value) => value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
/**
* 인라인 마크다운 변환
* @param {string} value - 원본 문자열
* @returns {string} 변환된 HTML
*/
const parseInlineMarkdown = (value) => escapeHtml(value)
.replace(/`([^`]+)`/g, '<code class="admin-markdown-preview__inline-code">$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
/**
* 목록 HTML 변환
* @param {Array<string>} items - 목록 아이템
* @param {'ul' | 'ol'} tagName - 목록 태그명
* @returns {string} 목록 HTML
*/
const renderList = (items, tagName) => {
const listItems = items
.map((item) => `<li>${parseInlineMarkdown(item)}</li>`)
.join('')
return `<${tagName}>${listItems}</${tagName}>`
}
/**
* 기본 마크다운을 미리보기 HTML로 변환
* @param {string} markdown - 마크다운 문자열
* @returns {string} 미리보기 HTML
*/
const renderMarkdown = (markdown) => {
const lines = markdown.split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const trimmedLine = line.trim()
if (!trimmedLine) {
index += 1
continue
}
if (trimmedLine.startsWith('```')) {
const codeLines = []
index += 1
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index])
index += 1
}
blocks.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`)
index += 1
continue
}
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
if (headingMatch) {
const level = headingMatch[1].length
blocks.push(`<h${level}>${parseInlineMarkdown(headingMatch[2])}</h${level}>`)
index += 1
continue
}
if (trimmedLine.startsWith('> ')) {
const quotes = []
while (index < lines.length && lines[index].trim().startsWith('> ')) {
quotes.push(lines[index].trim().replace(/^>\s?/, ''))
index += 1
}
blocks.push(`<blockquote>${quotes.map((quote) => `<p>${parseInlineMarkdown(quote)}</p>`).join('')}</blockquote>`)
continue
}
if (/^- /.test(trimmedLine)) {
const items = []
while (index < lines.length && /^- /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^- /, ''))
index += 1
}
blocks.push(renderList(items, 'ul'))
continue
}
if (/^\d+\. /.test(trimmedLine)) {
const items = []
while (index < lines.length && /^\d+\. /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^\d+\. /, ''))
index += 1
}
blocks.push(renderList(items, 'ol'))
continue
}
const paragraphs = []
while (
index < lines.length &&
lines[index].trim() &&
!lines[index].trim().startsWith('```') &&
!lines[index].trim().match(/^(#{1,3})\s+(.+)$/) &&
!lines[index].trim().startsWith('> ') &&
!/^- /.test(lines[index].trim()) &&
!/^\d+\. /.test(lines[index].trim())
) {
paragraphs.push(lines[index].trim())
index += 1
}
blocks.push(`<p>${parseInlineMarkdown(paragraphs.join(' '))}</p>`)
}
return blocks.join('')
}
const previewHtml = computed(() => renderMarkdown(props.content))
</script>
<template>
<article
class="admin-markdown-preview post-prose min-h-[28rem] rounded border border-line bg-white px-5 py-4 text-sm leading-7"
v-html="previewHtml"
/>
</template>

View File

@@ -17,8 +17,6 @@ const props = defineProps({
const emit = defineEmits(['submit'])
const slugTouched = ref(Boolean(props.initialPost.slug))
const editorMode = ref('write')
const contentTextarea = ref(null)
const form = reactive({
title: props.initialPost.title || '',
@@ -68,77 +66,6 @@ const parseTags = (value) => [...new Set(value
.map((tag) => toSlug(tag))
.filter(Boolean))]
/**
* 본문 선택 영역에 마크다운 문법 삽입
* @param {string} before - 선택 영역 앞에 넣을 문자열
* @param {string} after - 선택 영역 뒤에 넣을 문자열
* @param {string} fallback - 선택 영역이 없을 때 넣을 문자열
* @returns {void}
*/
const insertMarkdown = (before, after = '', fallback = '') => {
const textarea = contentTextarea.value
if (!textarea) {
form.content += fallback || before
return
}
const start = textarea.selectionStart
const end = textarea.selectionEnd
const selectedText = form.content.slice(start, end)
const insertText = selectedText
? `${before}${selectedText}${after}`
: (fallback || `${before}${after}`)
form.content = `${form.content.slice(0, start)}${insertText}${form.content.slice(end)}`
nextTick(() => {
textarea.focus()
const cursor = start + insertText.length
textarea.setSelectionRange(cursor, cursor)
})
}
/**
* 제목 문법 삽입
* @returns {void}
*/
const insertHeading = () => {
insertMarkdown('## ', '', '## 제목')
}
/**
* 굵게 문법 삽입
* @returns {void}
*/
const insertBold = () => {
insertMarkdown('**', '**', '**강조**')
}
/**
* 목록 문법 삽입
* @returns {void}
*/
const insertList = () => {
insertMarkdown('- ', '', '- 목록')
}
/**
* 인용 문법 삽입
* @returns {void}
*/
const insertQuote = () => {
insertMarkdown('> ', '', '> 인용')
}
/**
* 코드 블록 문법 삽입
* @returns {void}
*/
const insertCodeBlock = () => {
insertMarkdown('```\n', '\n```', '```\n코드\n```')
}
/**
* 게시물 입력값 제출
* @returns {void}
@@ -178,53 +105,10 @@ const submitPost = () => {
<div class="admin-post-form__field grid gap-2 text-sm">
<div class="admin-post-form__editor-header flex flex-wrap items-center justify-between gap-3">
<span class="admin-post-form__label font-medium">본문</span>
<div class="admin-post-form__mode flex rounded border border-line bg-white p-1 text-xs font-semibold">
<button
class="admin-post-form__mode-button rounded px-3 py-1"
:class="editorMode === 'write' ? 'bg-[#15171a] text-white' : 'text-muted'"
type="button"
@click="editorMode = 'write'"
>
작성
</button>
<button
class="admin-post-form__mode-button rounded px-3 py-1"
:class="editorMode === 'preview' ? 'bg-[#15171a] text-white' : 'text-muted'"
type="button"
@click="editorMode = 'preview'"
>
미리보기
</button>
</div>
<span class="admin-post-form__editor-note text-xs text-muted">/ 명령과 마크다운 단축 입력 지원</span>
</div>
<div v-if="editorMode === 'write'" class="admin-post-form__editor grid gap-2">
<div class="admin-post-form__toolbar flex flex-wrap gap-2">
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertHeading">
제목
</button>
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertBold">
굵게
</button>
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertList">
목록
</button>
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertQuote">
인용
</button>
<button class="admin-post-form__tool rounded border border-line bg-white px-3 py-1.5 text-xs font-semibold" type="button" @click="insertCodeBlock">
코드
</button>
</div>
<textarea
ref="contentTextarea"
v-model="form.content"
class="admin-post-form__textarea min-h-[28rem] rounded border border-line bg-white px-3 py-3 font-mono text-sm leading-6"
required
/>
</div>
<AdminMarkdownPreview v-else :content="form.content" />
<AdminBlockEditor v-model="form.content" />
</div>
</section>

View File

@@ -0,0 +1,123 @@
<script setup>
const props = defineProps({
content: {
type: String,
default: ''
}
})
/**
* 마크다운 블록을 생성
* @param {string} type - 블록 타입
* @param {string|Array<string>} text - 블록 텍스트
* @param {number|null} level - 제목 레벨
* @param {string} id - 블록 ID
* @returns {{ id: string, type: string, text: string|Array<string>, level: number|null }} 블록
*/
const createBlock = (type = 'paragraph', text = '', level = null, id = '') => ({
id,
type,
text,
level
})
/**
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
* @param {string} markdown - 마크다운 문자열
* @returns {Array<Object>} 블록 목록
*/
const parseMarkdownBlocks = (markdown) => {
const lines = markdown.split('\n')
const blocks = []
let index = 0
while (index < lines.length) {
const line = lines[index]
const trimmedLine = line.trim()
if (!trimmedLine) {
index += 1
continue
}
if (trimmedLine.startsWith('```')) {
const codeLines = []
index += 1
while (index < lines.length && !lines[index].trim().startsWith('```')) {
codeLines.push(lines[index])
index += 1
}
blocks.push(createBlock('code', codeLines.join('\n'), null, `block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine === '---') {
blocks.push(createBlock('divider', '', null, `block-${blocks.length}`))
index += 1
continue
}
const headingMatch = trimmedLine.match(/^(#{1,3})\s+(.+)$/)
if (headingMatch) {
blocks.push(createBlock('heading', headingMatch[2], headingMatch[1].length, `block-${blocks.length}`))
index += 1
continue
}
if (trimmedLine.startsWith('> ')) {
blocks.push(createBlock('quote', trimmedLine.replace(/^>\s?/, ''), null, `block-${blocks.length}`))
index += 1
continue
}
if (/^- /.test(trimmedLine)) {
const items = []
while (index < lines.length && /^- /.test(lines[index].trim())) {
items.push(lines[index].trim().replace(/^- /, ''))
index += 1
}
blocks.push(createBlock('list', items, null, `block-${blocks.length}`))
continue
}
blocks.push(createBlock('paragraph', trimmedLine, null, `block-${blocks.length}`))
index += 1
}
return blocks
}
const blocks = computed(() => parseMarkdownBlocks(props.content))
</script>
<template>
<div class="content-markdown-renderer">
<template v-for="block in blocks" :key="block.id">
<ProseHeading v-if="block.type === 'heading'" :level="block.level">
{{ block.text }}
</ProseHeading>
<ProseBlockquote v-else-if="block.type === 'quote'">
{{ block.text }}
</ProseBlockquote>
<ProseList v-else-if="block.type === 'list'">
<li v-for="(item, itemIndex) in block.text" :key="`${block.id}-${itemIndex}`">
{{ item }}
</li>
</ProseList>
<pre
v-else-if="block.type === 'code'"
class="content-markdown-renderer__code my-6 overflow-x-auto rounded bg-[#15171a] px-4 py-3 text-sm leading-6 text-white"
><code>{{ block.text }}</code></pre>
<hr v-else-if="block.type === 'divider'" class="content-markdown-renderer__divider my-10 border-line">
<p v-else class="content-markdown-renderer__paragraph my-5 leading-8">
{{ block.text }}
</p>
</template>
</div>
</template>

View File

@@ -25,6 +25,7 @@
- TailwindCSS 기본 사용
- 주요 요소: Tailwind + 고유 className 동시 적용
- 관리자 글 에디터는 블록 단위 UI로 작성하되 저장 값은 기존 마크다운 문자열을 유지
```html
<main class="site-main w-[720px]">

View File

@@ -1,5 +1,15 @@
# 의사결정 이력
## 2026-05-01 v0.0.9
### 관리자 블록형 글쓰기 방식 결정
관리자 글 작성은 순수 마크다운 textarea가 아니라 Ghost 스타일에 가까운 블록형 에디터를 기준으로 전환한다. 사용자가 `/` 명령으로 블록을 선택하고, `##` 같은 마크다운 단축 입력을 즉시 제목 블록으로 변환해 작성 화면과 결과 화면의 차이를 줄이기 위해서다.
다만 현재 데이터베이스와 API의 `content` 필드는 그대로 유지한다. 블록 에디터 내부에서는 문단, 제목, 인용, 목록, 코드, 구분선을 블록으로 다루고 저장 시 마크다운 문자열로 직렬화한다. 이렇게 하면 기존 게시물 저장 구조를 깨지 않으면서도 이후 이미지, 임베드, 콜아웃 같은 Ghost 카드형 블록을 단계적으로 확장할 수 있다.
공개 게시물과 고정 페이지 본문도 같은 마크다운 렌더러를 사용하도록 연결한다. 작성 화면과 보는 화면을 완전히 동일하게 만드는 것은 이미지 업로드와 전체 콘텐츠 컴포넌트 구현 이후 다시 보정하되, 이번 단계에서는 제목, 목록, 인용, 코드 등 기본 블록의 시각 차이를 먼저 줄인다.
## 2026-05-01 v0.0.8
### 관리자 마크다운 미리보기 방식 결정

View File

@@ -27,7 +27,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 |
| components/admin/AdminMarkdownPreview.vue | 관리자 글 마크다운 미리보기 |
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
## 콘텐츠 컴포넌트
@@ -35,6 +35,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/content/ContentRenderer.vue | 게시물/페이지 본문 |
| components/content/ContentMarkdownRenderer.vue | 마크다운 문자열 기반 본문 렌더링 |
| components/content/ProseHeading.vue | h1~h6 제목 |
| components/content/ProseImage.vue | 본문 내 이미지 |
| components/content/ProseList.vue | 목록 |

View File

@@ -201,10 +201,12 @@ components/content/
### 관리자 글 편집
- 글 작성/수정 화면은 textarea 기반 마크다운 입력을 사용한다.
- 작성 탭과 미리보기 탭을 제공한다.
- 미리보기는 관리자 화면에서만 사용하는 기본 렌더링이며 저장 데이터는 원본 마크다운 문자열을 유지한다.
- 편집 편의 버튼은 제목, 굵게, 목록, 인용, 코드 블록 문법 삽입을 제공한다.
- 글 작성/수정 화면은 Ghost 스타일을 참고한 블록형 에디터를 사용한다.
- 저장 데이터는 기존 `content` 필드의 마크다운 문자열을 유지한다.
- `/` 입력 시 블록 선택 메뉴를 표시한다.
- 블록 메뉴는 문단, 제목 2, 제목 3, 인용, 목록, 코드, 구분선을 제공한다.
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
### 관리자 인증
@@ -283,6 +285,6 @@ APP_PORT=43118
## 버전 관리
- 현재 버전: v0.0.6
- 현재 버전: v0.0.9
- 첫 커밋 이후 변경사항을 커밋할 때마다 패치 버전 증가
- 메이저/마이너 버전은 구조 변경 또는 기능 묶음 단위로 결정

View File

@@ -27,7 +27,6 @@
## 콘텐츠 스타일 구현
- [ ] 공개 게시물 본문 마크다운 렌더링 연결
- [ ] ProseHeading 실제 스타일 세부 조정
- [ ] ProseList 실제 스타일 세부 조정
- [ ] ProseBlockquote 실제 스타일 세부 조정

View File

@@ -1,5 +1,13 @@
# 업데이트 이력
## v0.0.9
- 관리자 글 작성/수정 폼을 textarea 방식에서 블록형 에디터 방식으로 변경.
- 관리자 블록 에디터에 `/` 명령 메뉴 추가.
- 관리자 블록 에디터에 `#`, `##`, `###`, `>`, `-` 입력 단축 변환 추가.
- 공개 게시물과 고정 페이지 본문을 마크다운 렌더러에 연결.
- 패키지 버전을 0.0.9로 갱신.
## v0.0.8
- 관리자 글 작성/수정 폼에 마크다운 미리보기 탭 추가.

4
package-lock.json generated
View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "sori.studio",
"version": "0.0.8",
"version": "0.0.9",
"private": true,
"type": "module",
"scripts": {

View File

@@ -24,8 +24,8 @@ if (!page.value) {
<h1 class="static-page__title mt-4 text-5xl font-semibold leading-tight">
{{ page.title }}
</h1>
<p class="static-page__description mt-6 whitespace-pre-line text-lg leading-8 text-muted">
{{ page.content }}
</p>
<ContentRenderer class="static-page__content mt-8">
<ContentMarkdownRenderer :content="page.content" />
</ContentRenderer>
</article>
</template>

View File

@@ -29,8 +29,6 @@ const postTag = computed(() => post.value.tags?.[0]?.toUpperCase() || 'POST')
</h1>
</ProseHeaderCard>
<p class="post-detail__content whitespace-pre-line">
{{ post.content }}
</p>
<ContentMarkdownRenderer class="post-detail__content" :content="post.content" />
</ContentRenderer>
</template>