관리자 마크다운 미리보기 추가

This commit is contained in:
2026-05-01 18:07:36 +09:00
parent 787747aa7f
commit 0fd18bfb48
9 changed files with 302 additions and 12 deletions

View File

@@ -0,0 +1,152 @@
<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,6 +17,8 @@ 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 || '',
@@ -66,6 +68,77 @@ 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}
@@ -102,14 +175,57 @@ const submitPost = () => {
>
</label>
<label class="admin-post-form__field grid gap-2 text-sm">
<span class="admin-post-form__label font-medium">본문</span>
<textarea
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
/>
</label>
<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>
</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" />
</div>
</section>
<aside class="admin-post-form__settings grid content-start gap-4">

View File

@@ -1,5 +1,13 @@
# 의사결정 이력
## 2026-05-01 v0.0.8
### 관리자 마크다운 미리보기 방식 결정
관리자 글 편집의 미리보기는 저장 형식을 바꾸지 않고 textarea 입력 위에 작성/미리보기 탭을 추가하는 방식으로 시작한다. 현재 공개 게시물 렌더링이 아직 완전한 마크다운 파서를 사용하지 않기 때문에, 관리자 미리보기는 기본 문법 확인용으로 제한하고 원본 마크다운 문자열을 그대로 저장한다.
편집 편의 기능은 제목, 굵게, 목록, 인용, 코드 블록 삽입 버튼으로 시작한다. 별도 에디터 패키지는 이미지 업로드와 공개 렌더링 방향이 확정된 뒤 필요성을 다시 판단한다.
## 2026-05-01 v0.0.7
### 관리자 글 작성/수정 구조 결정

View File

@@ -27,6 +27,7 @@
| 파일 | 화면 위치 |
|------|-----------|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 |
| components/admin/AdminMarkdownPreview.vue | 관리자 글 마크다운 미리보기 |
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
## 콘텐츠 컴포넌트

View File

@@ -199,6 +199,13 @@ components/content/
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
### 관리자 글 편집
- 글 작성/수정 화면은 textarea 기반 마크다운 입력을 사용한다.
- 작성 탭과 미리보기 탭을 제공한다.
- 미리보기는 관리자 화면에서만 사용하는 기본 렌더링이며 저장 데이터는 원본 마크다운 문자열을 유지한다.
- 편집 편의 버튼은 제목, 굵게, 목록, 인용, 코드 블록 문법 삽입을 제공한다.
### 관리자 인증
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용

View File

@@ -2,7 +2,6 @@
## 1차 관리자 개발
- [ ] 마크다운 에디터 미리보기 및 편집 편의 기능 고도화
- [ ] 이미지 업로드
## 2차 관리자 개발
@@ -28,6 +27,7 @@
## 콘텐츠 스타일 구현
- [ ] 공개 게시물 본문 마크다운 렌더링 연결
- [ ] ProseHeading 실제 스타일 세부 조정
- [ ] ProseList 실제 스타일 세부 조정
- [ ] ProseBlockquote 실제 스타일 세부 조정

View File

@@ -1,5 +1,11 @@
# 업데이트 이력
## v0.0.8
- 관리자 글 작성/수정 폼에 마크다운 미리보기 탭 추가.
- 관리자 글 작성/수정 폼에 제목, 굵게, 목록, 인용, 코드 문법 삽입 버튼 추가.
- 패키지 버전을 0.0.8로 갱신.
## v0.0.7
- 태그 정렬 순서와 색상 코드 필드 추가.

4
package-lock.json generated
View File

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

View File

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