관리자 마크다운 미리보기 추가
This commit is contained in:
152
components/admin/AdminMarkdownPreview.vue
Normal file
152
components/admin/AdminMarkdownPreview.vue
Normal 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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 인라인 마크다운 변환
|
||||||
|
* @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>
|
||||||
@@ -17,6 +17,8 @@ const props = defineProps({
|
|||||||
const emit = defineEmits(['submit'])
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
const slugTouched = ref(Boolean(props.initialPost.slug))
|
const slugTouched = ref(Boolean(props.initialPost.slug))
|
||||||
|
const editorMode = ref('write')
|
||||||
|
const contentTextarea = ref(null)
|
||||||
|
|
||||||
const form = reactive({
|
const form = reactive({
|
||||||
title: props.initialPost.title || '',
|
title: props.initialPost.title || '',
|
||||||
@@ -66,6 +68,77 @@ const parseTags = (value) => [...new Set(value
|
|||||||
.map((tag) => toSlug(tag))
|
.map((tag) => toSlug(tag))
|
||||||
.filter(Boolean))]
|
.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}
|
* @returns {void}
|
||||||
@@ -102,14 +175,57 @@ const submitPost = () => {
|
|||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<label class="admin-post-form__field grid gap-2 text-sm">
|
<div class="admin-post-form__field grid gap-2 text-sm">
|
||||||
<span class="admin-post-form__label font-medium">본문</span>
|
<div class="admin-post-form__editor-header flex flex-wrap items-center justify-between gap-3">
|
||||||
<textarea
|
<span class="admin-post-form__label font-medium">본문</span>
|
||||||
v-model="form.content"
|
<div class="admin-post-form__mode flex rounded border border-line bg-white p-1 text-xs font-semibold">
|
||||||
class="admin-post-form__textarea min-h-[28rem] rounded border border-line bg-white px-3 py-3 font-mono text-sm leading-6"
|
<button
|
||||||
required
|
class="admin-post-form__mode-button rounded px-3 py-1"
|
||||||
/>
|
:class="editorMode === 'write' ? 'bg-[#15171a] text-white' : 'text-muted'"
|
||||||
</label>
|
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>
|
</section>
|
||||||
|
|
||||||
<aside class="admin-post-form__settings grid content-start gap-4">
|
<aside class="admin-post-form__settings grid content-start gap-4">
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# 의사결정 이력
|
# 의사결정 이력
|
||||||
|
|
||||||
|
## 2026-05-01 v0.0.8
|
||||||
|
|
||||||
|
### 관리자 마크다운 미리보기 방식 결정
|
||||||
|
|
||||||
|
관리자 글 편집의 미리보기는 저장 형식을 바꾸지 않고 textarea 입력 위에 작성/미리보기 탭을 추가하는 방식으로 시작한다. 현재 공개 게시물 렌더링이 아직 완전한 마크다운 파서를 사용하지 않기 때문에, 관리자 미리보기는 기본 문법 확인용으로 제한하고 원본 마크다운 문자열을 그대로 저장한다.
|
||||||
|
|
||||||
|
편집 편의 기능은 제목, 굵게, 목록, 인용, 코드 블록 삽입 버튼으로 시작한다. 별도 에디터 패키지는 이미지 업로드와 공개 렌더링 방향이 확정된 뒤 필요성을 다시 판단한다.
|
||||||
|
|
||||||
## 2026-05-01 v0.0.7
|
## 2026-05-01 v0.0.7
|
||||||
|
|
||||||
### 관리자 글 작성/수정 구조 결정
|
### 관리자 글 작성/수정 구조 결정
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
| 파일 | 화면 위치 |
|
| 파일 | 화면 위치 |
|
||||||
|------|-----------|
|
|------|-----------|
|
||||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 |
|
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 |
|
||||||
|
| components/admin/AdminMarkdownPreview.vue | 관리자 글 마크다운 미리보기 |
|
||||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||||
|
|
||||||
## 콘텐츠 컴포넌트
|
## 콘텐츠 컴포넌트
|
||||||
|
|||||||
@@ -199,6 +199,13 @@ components/content/
|
|||||||
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
|
> 태그 목록은 `sort_order ASC, name ASC` 기준으로 정렬한다.
|
||||||
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
> 태그 `color`는 `#RRGGBB` 형식이며 사용자 화면 태그 색상 표시와 배지 배경색에 사용한다.
|
||||||
|
|
||||||
|
### 관리자 글 편집
|
||||||
|
|
||||||
|
- 글 작성/수정 화면은 textarea 기반 마크다운 입력을 사용한다.
|
||||||
|
- 작성 탭과 미리보기 탭을 제공한다.
|
||||||
|
- 미리보기는 관리자 화면에서만 사용하는 기본 렌더링이며 저장 데이터는 원본 마크다운 문자열을 유지한다.
|
||||||
|
- 편집 편의 버튼은 제목, 굵게, 목록, 인용, 코드 블록 문법 삽입을 제공한다.
|
||||||
|
|
||||||
### 관리자 인증
|
### 관리자 인증
|
||||||
|
|
||||||
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
|
- 초기 관리자 인증은 `ADMIN_EMAIL`, `ADMIN_PASSWORD` 환경 변수를 사용
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
## 1차 관리자 개발
|
## 1차 관리자 개발
|
||||||
|
|
||||||
- [ ] 마크다운 에디터 미리보기 및 편집 편의 기능 고도화
|
|
||||||
- [ ] 이미지 업로드
|
- [ ] 이미지 업로드
|
||||||
|
|
||||||
## 2차 관리자 개발
|
## 2차 관리자 개발
|
||||||
@@ -28,6 +27,7 @@
|
|||||||
|
|
||||||
## 콘텐츠 스타일 구현
|
## 콘텐츠 스타일 구현
|
||||||
|
|
||||||
|
- [ ] 공개 게시물 본문 마크다운 렌더링 연결
|
||||||
- [ ] ProseHeading 실제 스타일 세부 조정
|
- [ ] ProseHeading 실제 스타일 세부 조정
|
||||||
- [ ] ProseList 실제 스타일 세부 조정
|
- [ ] ProseList 실제 스타일 세부 조정
|
||||||
- [ ] ProseBlockquote 실제 스타일 세부 조정
|
- [ ] ProseBlockquote 실제 스타일 세부 조정
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v0.0.8
|
||||||
|
|
||||||
|
- 관리자 글 작성/수정 폼에 마크다운 미리보기 탭 추가.
|
||||||
|
- 관리자 글 작성/수정 폼에 제목, 굵게, 목록, 인용, 코드 문법 삽입 버튼 추가.
|
||||||
|
- 패키지 버전을 0.0.8로 갱신.
|
||||||
|
|
||||||
## v0.0.7
|
## v0.0.7
|
||||||
|
|
||||||
- 태그 정렬 순서와 색상 코드 필드 추가.
|
- 태그 정렬 순서와 색상 코드 필드 추가.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "0.0.7",
|
"version": "0.0.8",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
Reference in New Issue
Block a user