관리자 이미지와 갤러리 블록 추가
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,7 @@ node_modules/
|
||||
.output/
|
||||
.nuxt/
|
||||
dist/
|
||||
public/uploads/
|
||||
|
||||
# Environment
|
||||
.env
|
||||
@@ -26,4 +27,4 @@ Thumbs.db
|
||||
npm-debug.log*
|
||||
|
||||
# Test
|
||||
coverage/
|
||||
coverage/
|
||||
|
||||
@@ -15,8 +15,15 @@ const slashQuery = ref('')
|
||||
const slashMenuDirection = ref('down')
|
||||
const highlightedCommandIndex = ref(0)
|
||||
const isApplyingExternalValue = ref(false)
|
||||
const uploadingBlockIds = ref([])
|
||||
let blockIdSeed = 0
|
||||
|
||||
const imageWidthOptions = [
|
||||
{ value: 'regular', label: '기본' },
|
||||
{ value: 'wide', label: '와이드' },
|
||||
{ value: 'full', label: '풀사이즈' }
|
||||
]
|
||||
|
||||
const blockCommands = [
|
||||
{
|
||||
type: 'paragraph',
|
||||
@@ -38,6 +45,18 @@ const blockCommands = [
|
||||
description: '작은 섹션 제목',
|
||||
keywords: ['h3', 'heading', 'subtitle', '제목']
|
||||
},
|
||||
{
|
||||
type: 'image',
|
||||
label: '이미지',
|
||||
description: '단일 이미지 업로드',
|
||||
keywords: ['image', 'photo', '이미지', '사진']
|
||||
},
|
||||
{
|
||||
type: 'gallery',
|
||||
label: '갤러리',
|
||||
description: '여러 이미지 업로드',
|
||||
keywords: ['gallery', 'images', '갤러리', '사진']
|
||||
},
|
||||
{
|
||||
type: 'quote',
|
||||
label: '인용',
|
||||
@@ -70,15 +89,39 @@ const blockCommands = [
|
||||
* @param {string} text - 블록 텍스트
|
||||
* @param {number|null} level - 제목 레벨
|
||||
* @param {string} id - 블록 ID
|
||||
* @returns {{ id: string, type: string, text: string, level: number|null }} 에디터 블록
|
||||
* @param {Object} options - 추가 블록 옵션
|
||||
* @returns {Object} 에디터 블록
|
||||
*/
|
||||
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '') => ({
|
||||
const createEditorBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||
id: id || `editor-block-new-${blockIdSeed += 1}`,
|
||||
type,
|
||||
text,
|
||||
level
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 블록 옵션으로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {Object|null} 이미지 블록 옵션
|
||||
*/
|
||||
const parseImageLine = (line) => {
|
||||
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
alt: match[1],
|
||||
url: match[2],
|
||||
width: match[3] || 'regular'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 저장된 마크다운 문자열을 에디터 블록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
@@ -98,6 +141,33 @@ const parseMarkdownToBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const images = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
const image = parseImageLine(lines[index])
|
||||
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createEditorBlock('gallery', '', null, `editor-block-${blocks.length}`, { images }))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
blocks.push(createEditorBlock('image', '', null, `editor-block-${blocks.length}`, image))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const codeLines = []
|
||||
index += 1
|
||||
@@ -145,6 +215,19 @@ const parseMarkdownToBlocks = (markdown) => {
|
||||
return blocks.length ? blocks : [createEditorBlock('paragraph', '', null, 'editor-block-0')]
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 블록을 마크다운 문자열로 변환
|
||||
* @param {Object} image - 이미지 데이터
|
||||
* @returns {string} 이미지 마크다운
|
||||
*/
|
||||
const serializeImage = (image) => {
|
||||
const width = image.width && image.width !== 'regular'
|
||||
? `{width=${image.width}}`
|
||||
: ''
|
||||
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 에디터 블록 목록을 저장용 마크다운으로 변환
|
||||
* @returns {string} 마크다운 문자열
|
||||
@@ -158,6 +241,25 @@ const serializeBlocks = () => {
|
||||
return { type: block.type, value: '---' }
|
||||
}
|
||||
|
||||
if (block.type === 'image') {
|
||||
return block.url
|
||||
? { type: block.type, value: serializeImage(block) }
|
||||
: null
|
||||
}
|
||||
|
||||
if (block.type === 'gallery') {
|
||||
const images = block.images.filter((image) => image.url)
|
||||
|
||||
if (!images.length) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
type: block.type,
|
||||
value: [':::gallery', ...images.map((image) => serializeImage(image)), ':::'].join('\n')
|
||||
}
|
||||
}
|
||||
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
@@ -204,6 +306,13 @@ const emitContent = () => {
|
||||
emit('update:modelValue', serializeBlocks())
|
||||
}
|
||||
|
||||
/**
|
||||
* 텍스트 입력 블록 여부 반환
|
||||
* @param {Object} block - 에디터 블록
|
||||
* @returns {boolean} 텍스트 입력 블록 여부
|
||||
*/
|
||||
const isTextBlock = (block) => !['divider', 'image', 'gallery'].includes(block.type)
|
||||
|
||||
/**
|
||||
* 블록 DOM 요소를 저장
|
||||
* @param {Element|null} element - 블록 DOM 요소
|
||||
@@ -214,7 +323,7 @@ const setBlockRef = (element, index) => {
|
||||
if (element) {
|
||||
blockRefs.value[index] = element
|
||||
|
||||
if (element.innerText !== editorBlocks.value[index]?.text) {
|
||||
if (isTextBlock(editorBlocks.value[index]) && element.innerText !== editorBlocks.value[index]?.text) {
|
||||
element.innerText = editorBlocks.value[index]?.text || ''
|
||||
}
|
||||
}
|
||||
@@ -305,6 +414,23 @@ const getBlockClass = (block) => [
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 이미지 너비 클래스 반환
|
||||
* @param {string} width - 이미지 너비 옵션
|
||||
* @returns {string} 클래스 문자열
|
||||
*/
|
||||
const getImageWidthClass = (width) => {
|
||||
if (width === 'full') {
|
||||
return 'admin-block-editor__image--full lg:-mx-20'
|
||||
}
|
||||
|
||||
if (width === 'wide') {
|
||||
return 'admin-block-editor__image--wide lg:-mx-10'
|
||||
}
|
||||
|
||||
return 'admin-block-editor__image--regular'
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 텍스트 입력 처리
|
||||
* @param {InputEvent} event - 입력 이벤트
|
||||
@@ -411,6 +537,10 @@ const applyCommand = (command) => {
|
||||
block.type = command.type
|
||||
block.level = command.level || null
|
||||
block.text = ''
|
||||
block.url = ''
|
||||
block.alt = ''
|
||||
block.width = 'regular'
|
||||
block.images = []
|
||||
const element = blockRefs.value[index]
|
||||
|
||||
if (element) {
|
||||
@@ -427,7 +557,132 @@ const applyCommand = (command) => {
|
||||
}
|
||||
|
||||
emitContent()
|
||||
focusBlock(index)
|
||||
|
||||
if (isTextBlock(block)) {
|
||||
focusBlock(index)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 업로드 진행 상태 설정
|
||||
* @param {string} blockId - 블록 ID
|
||||
* @param {boolean} isUploading - 업로드 진행 여부
|
||||
* @returns {void}
|
||||
*/
|
||||
const setUploading = (blockId, isUploading) => {
|
||||
uploadingBlockIds.value = isUploading
|
||||
? [...new Set([...uploadingBlockIds.value, blockId])]
|
||||
: uploadingBlockIds.value.filter((id) => id !== blockId)
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록 업로드 진행 상태 반환
|
||||
* @param {string} blockId - 블록 ID
|
||||
* @returns {boolean} 업로드 진행 여부
|
||||
*/
|
||||
const isUploading = (blockId) => uploadingBlockIds.value.includes(blockId)
|
||||
|
||||
/**
|
||||
* 이미지 파일 업로드
|
||||
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
||||
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
|
||||
*/
|
||||
const uploadImages = async (files) => {
|
||||
const formData = new FormData()
|
||||
|
||||
Array.from(files).forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
const result = await $fetch('/admin/api/uploads', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
|
||||
return result.files || []
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 이미지 파일 선택 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleImageUpload = async (event, block) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(block.id, true)
|
||||
|
||||
try {
|
||||
const [file] = await uploadImages(files)
|
||||
|
||||
if (file) {
|
||||
block.url = file.url
|
||||
block.alt = block.alt || file.name.replace(/\.[^.]+$/g, '')
|
||||
emitContent()
|
||||
}
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
setUploading(block.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 파일 선택 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleGalleryUpload = async (event, block) => {
|
||||
const files = event.target.files
|
||||
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
setUploading(block.id, true)
|
||||
|
||||
try {
|
||||
const uploadedFiles = await uploadImages(files)
|
||||
block.images = [
|
||||
...block.images,
|
||||
...uploadedFiles.map((file) => ({
|
||||
url: file.url,
|
||||
alt: file.name.replace(/\.[^.]+$/g, ''),
|
||||
width: 'regular'
|
||||
}))
|
||||
]
|
||||
emitContent()
|
||||
} finally {
|
||||
event.target.value = ''
|
||||
setUploading(block.id, false)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 너비 옵션 변경
|
||||
* @param {Object} block - 이미지 블록
|
||||
* @param {string} width - 너비 옵션
|
||||
* @returns {void}
|
||||
*/
|
||||
const updateImageWidth = (block, width) => {
|
||||
block.width = width
|
||||
emitContent()
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 이미지 삭제
|
||||
* @param {Object} block - 갤러리 블록
|
||||
* @param {number} imageIndex - 이미지 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const removeGalleryImage = (block, imageIndex) => {
|
||||
block.images.splice(imageIndex, 1)
|
||||
emitContent()
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -481,7 +736,7 @@ const handleEnter = (event, index) => {
|
||||
|
||||
event.preventDefault()
|
||||
|
||||
if (currentBlock.type === 'divider') {
|
||||
if (['divider', 'image', 'gallery'].includes(currentBlock.type)) {
|
||||
editorBlocks.value.splice(index + 1, 0, createEditorBlock())
|
||||
emitContent()
|
||||
focusBlock(index + 1)
|
||||
@@ -511,7 +766,11 @@ const handleEnter = (event, index) => {
|
||||
const handleBackspace = (event, index) => {
|
||||
const block = editorBlocks.value[index]
|
||||
|
||||
if (block.text || editorBlocks.value.length <= 1) {
|
||||
if ((isTextBlock(block) && block.text) || editorBlocks.value.length <= 1) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!isTextBlock(block) && (block.url || block.images.length)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -579,6 +838,80 @@ defineExpose({
|
||||
class="admin-block-editor__row relative"
|
||||
>
|
||||
<hr v-if="block.type === 'divider'" class="admin-block-editor__divider my-6 border-line">
|
||||
|
||||
<figure
|
||||
v-else-if="block.type === 'image'"
|
||||
class="admin-block-editor__media group my-6"
|
||||
:class="getImageWidthClass(block.width)"
|
||||
tabindex="0"
|
||||
@focus="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<div v-if="block.url" class="admin-block-editor__image-frame relative overflow-hidden rounded bg-surface">
|
||||
<img class="admin-block-editor__image w-full object-cover" :src="block.url" :alt="block.alt">
|
||||
<div class="admin-block-editor__media-toolbar absolute inset-x-3 top-3 flex flex-wrap items-center gap-2 opacity-0 transition-opacity group-hover:opacity-100 group-focus:opacity-100">
|
||||
<button
|
||||
v-for="option in imageWidthOptions"
|
||||
:key="option.value"
|
||||
class="admin-block-editor__media-option rounded bg-white/95 px-3 py-1 text-xs font-semibold shadow"
|
||||
:class="block.width === option.value ? 'text-ink ring-1 ring-ink' : 'text-muted'"
|
||||
type="button"
|
||||
@click="updateImageWidth(block, option.value)"
|
||||
>
|
||||
{{ option.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
v-else
|
||||
class="admin-block-editor__upload grid cursor-pointer place-items-center rounded border border-dashed border-line bg-surface px-6 py-14 text-center text-sm font-semibold text-muted"
|
||||
>
|
||||
{{ isUploading(block.id) ? '업로드 중' : '이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" @change="handleImageUpload($event, block)">
|
||||
</label>
|
||||
<input
|
||||
v-if="block.url"
|
||||
v-model="block.alt"
|
||||
class="admin-block-editor__caption mt-3 w-full border-0 bg-transparent text-center text-sm text-muted outline-none placeholder:text-soft"
|
||||
type="text"
|
||||
placeholder="이미지 설명"
|
||||
@input="emitContent"
|
||||
>
|
||||
</figure>
|
||||
|
||||
<figure
|
||||
v-else-if="block.type === 'gallery'"
|
||||
class="admin-block-editor__gallery group my-6"
|
||||
tabindex="0"
|
||||
@focus="activateBlock(block)"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
<div v-if="block.images.length" class="admin-block-editor__gallery-grid grid grid-cols-2 gap-2 md:grid-cols-3">
|
||||
<div
|
||||
v-for="(image, imageIndex) in block.images"
|
||||
:key="`${block.id}-${image.url}`"
|
||||
class="admin-block-editor__gallery-item group/item relative overflow-hidden rounded bg-surface"
|
||||
>
|
||||
<img class="admin-block-editor__gallery-image aspect-[4/3] w-full object-cover" :src="image.url" :alt="image.alt">
|
||||
<button
|
||||
class="admin-block-editor__gallery-remove absolute right-2 top-2 rounded bg-white/95 px-2 py-1 text-xs font-semibold text-ink opacity-0 shadow transition-opacity group-hover/item:opacity-100"
|
||||
type="button"
|
||||
@click="removeGalleryImage(block, imageIndex)"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<label class="admin-block-editor__gallery-upload mt-3 inline-flex cursor-pointer rounded border border-line bg-white px-3 py-2 text-sm font-semibold text-ink">
|
||||
{{ isUploading(block.id) ? '업로드 중' : block.images.length ? '이미지 추가' : '갤러리 이미지 업로드' }}
|
||||
<input class="sr-only" type="file" accept="image/*" multiple @change="handleGalleryUpload($event, block)">
|
||||
</label>
|
||||
</figure>
|
||||
|
||||
<component
|
||||
:is="getBlockTag(block)"
|
||||
v-else
|
||||
@@ -602,6 +935,7 @@ defineExpose({
|
||||
type="button"
|
||||
@click="activateBlock(block)"
|
||||
@keydown.enter="handleEnter($event, index)"
|
||||
@keydown.backspace="handleBackspace($event, index)"
|
||||
>
|
||||
구분선
|
||||
</button>
|
||||
|
||||
@@ -6,21 +6,48 @@ const props = defineProps({
|
||||
}
|
||||
})
|
||||
|
||||
const activeLightboxImages = ref([])
|
||||
const activeLightboxIndex = ref(0)
|
||||
|
||||
/**
|
||||
* 마크다운 블록을 생성
|
||||
* @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 }} 블록
|
||||
* @param {Object} options - 추가 블록 옵션
|
||||
* @returns {Object} 블록
|
||||
*/
|
||||
const createBlock = (type = 'paragraph', text = '', level = null, id = '') => ({
|
||||
const createBlock = (type = 'paragraph', text = '', level = null, id = '', options = {}) => ({
|
||||
id,
|
||||
type,
|
||||
text,
|
||||
level
|
||||
level,
|
||||
url: options.url || '',
|
||||
alt: options.alt || '',
|
||||
width: options.width || 'regular',
|
||||
images: options.images || []
|
||||
})
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 행을 이미지 데이터로 변환
|
||||
* @param {string} line - 마크다운 행
|
||||
* @returns {Object|null} 이미지 데이터
|
||||
*/
|
||||
const parseImageLine = (line) => {
|
||||
const match = line.trim().match(/^!\[([^\]]*)\]\(([^)]+)\)(?:\{width=(regular|wide|full)\})?$/)
|
||||
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
alt: match[1],
|
||||
url: match[2],
|
||||
width: match[3] || 'regular'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 마크다운 문자열을 렌더링용 블록 목록으로 변환
|
||||
* @param {string} markdown - 마크다운 문자열
|
||||
@@ -40,6 +67,33 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine === ':::gallery') {
|
||||
const images = []
|
||||
index += 1
|
||||
|
||||
while (index < lines.length && lines[index].trim() !== ':::') {
|
||||
const image = parseImageLine(lines[index])
|
||||
|
||||
if (image) {
|
||||
images.push(image)
|
||||
}
|
||||
|
||||
index += 1
|
||||
}
|
||||
|
||||
blocks.push(createBlock('gallery', '', null, `block-${blocks.length}`, { images }))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
const image = parseImageLine(trimmedLine)
|
||||
|
||||
if (image) {
|
||||
blocks.push(createBlock('image', '', null, `block-${blocks.length}`, image))
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
|
||||
if (trimmedLine.startsWith('```')) {
|
||||
const codeLines = []
|
||||
index += 1
|
||||
@@ -94,6 +148,45 @@ const parseMarkdownBlocks = (markdown) => {
|
||||
}
|
||||
|
||||
const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
const activeLightboxImage = computed(() => activeLightboxImages.value[activeLightboxIndex.value])
|
||||
|
||||
/**
|
||||
* 라이트박스를 연다
|
||||
* @param {Array<Object>} images - 이미지 목록
|
||||
* @param {number} index - 시작 인덱스
|
||||
* @returns {void}
|
||||
*/
|
||||
const openLightbox = (images, index) => {
|
||||
activeLightboxImages.value = images
|
||||
activeLightboxIndex.value = index
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스를 닫는다
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeLightbox = () => {
|
||||
activeLightboxImages.value = []
|
||||
activeLightboxIndex.value = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스 이전 이미지로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const showPreviousImage = () => {
|
||||
activeLightboxIndex.value = activeLightboxIndex.value === 0
|
||||
? activeLightboxImages.value.length - 1
|
||||
: activeLightboxIndex.value - 1
|
||||
}
|
||||
|
||||
/**
|
||||
* 라이트박스 다음 이미지로 이동
|
||||
* @returns {void}
|
||||
*/
|
||||
const showNextImage = () => {
|
||||
activeLightboxIndex.value = (activeLightboxIndex.value + 1) % activeLightboxImages.value.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -110,6 +203,20 @@ const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
{{ item }}
|
||||
</li>
|
||||
</ProseList>
|
||||
<ProseImage v-else-if="block.type === 'image'" :src="block.url" :alt="block.alt" :variant="block.width">
|
||||
{{ block.alt }}
|
||||
</ProseImage>
|
||||
<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"
|
||||
:key="`${block.id}-${image.url}`"
|
||||
class="content-markdown-renderer__gallery-button overflow-hidden rounded bg-surface"
|
||||
type="button"
|
||||
@click="openLightbox(block.images, imageIndex)"
|
||||
>
|
||||
<img class="content-markdown-renderer__gallery-image aspect-[4/3] w-full object-cover transition-transform hover:scale-[1.02]" :src="image.url" :alt="image.alt">
|
||||
</button>
|
||||
</div>
|
||||
<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"
|
||||
@@ -119,5 +226,34 @@ const blocks = computed(() => parseMarkdownBlocks(props.content))
|
||||
{{ block.text }}
|
||||
</p>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="activeLightboxImage"
|
||||
class="content-markdown-renderer__lightbox fixed inset-0 z-50 grid place-items-center bg-black/90 px-5 py-8"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
@click.self="closeLightbox"
|
||||
>
|
||||
<button class="content-markdown-renderer__lightbox-close absolute right-5 top-5 rounded bg-white px-3 py-2 text-sm font-semibold text-ink" type="button" @click="closeLightbox">
|
||||
닫기
|
||||
</button>
|
||||
<button
|
||||
v-if="activeLightboxImages.length > 1"
|
||||
class="content-markdown-renderer__lightbox-prev absolute left-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||
type="button"
|
||||
@click="showPreviousImage"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<img class="content-markdown-renderer__lightbox-image max-h-[84vh] max-w-[92vw] object-contain" :src="activeLightboxImage.url" :alt="activeLightboxImage.alt">
|
||||
<button
|
||||
v-if="activeLightboxImages.length > 1"
|
||||
class="content-markdown-renderer__lightbox-next absolute right-5 top-1/2 rounded bg-white px-3 py-2 text-sm font-semibold text-ink"
|
||||
type="button"
|
||||
@click="showNextImage"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -141,9 +141,18 @@ docker run -d -p 3000:3000 sori.studio:latest
|
||||
- 로컬 개발 Docker Compose 실행 시 `ENV_FILE=.env.development`와 `--env-file .env.development`를 함께 사용
|
||||
- 로컬 개발 DB 마이그레이션은 `npm run db:migrate:dev`로 실행
|
||||
|
||||
## 업로드 파일
|
||||
|
||||
- 관리자 글쓰기에서 업로드한 이미지는 `/uploads/posts/YYYY/MM/` URL로 제공한다.
|
||||
- 로컬 개발에서는 실제 파일이 `public/uploads/posts/YYYY/MM/` 아래 저장된다.
|
||||
- `public/uploads/`는 Git에 포함하지 않는다.
|
||||
- NAS 운영에서는 업로드 파일이 컨테이너 재생성으로 사라지지 않도록 별도 볼륨 연결을 확정해야 한다.
|
||||
- `MAX_FILE_SIZE` 환경 변수로 관리자 이미지 업로드 최대 크기를 제한한다.
|
||||
|
||||
## 사용자 액션 필요 항목
|
||||
|
||||
- NAS SSH 접속 주소 확인.
|
||||
- NAS 프로젝트 루트 경로 확정.
|
||||
- 운영 DB 이름, 계정, 권한 확정.
|
||||
- 운영 업로드 볼륨 경로 확정.
|
||||
- 도메인 `sori.studio`의 NAS 연결 방식 확정.
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
# 의사결정 이력
|
||||
|
||||
## 2026-05-01 v0.0.14
|
||||
|
||||
### 이미지와 갤러리 블록 구현 범위 결정
|
||||
|
||||
관리자 글쓰기의 이미지 기능은 기존 `content` 필드를 유지하면서 마크다운 확장 문법으로 저장한다. 단일 이미지는 기본 마크다운 이미지 문법에 `{width=wide}` 같은 너비 옵션만 붙이고, 갤러리는 `:::gallery` fenced block 안에 여러 이미지 행을 넣는다. 이렇게 하면 DB 구조를 바꾸지 않고 공개 렌더러와 관리자 에디터를 함께 확장할 수 있다.
|
||||
|
||||
이번 단계에서는 게시물 작성 중 새 이미지를 업로드하고 글에 삽입하는 흐름을 먼저 구현한다. 워드프레스처럼 이미 업로드된 미디어를 선택하거나 파일명 변경, 개별 삭제, 카테고리 분류를 관리하는 기능은 별도 미디어 라이브러리 메뉴에서 다룬다. 글쓰기 화면이 이후 미디어 라이브러리를 호출할 수 있도록 업로드 API와 저장 URL 기준을 먼저 고정한다.
|
||||
|
||||
## 2026-05-01 v0.0.13
|
||||
|
||||
### 개발 서버 로그 요약 방식 결정
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
| 파일 | 화면 위치 |
|
||||
|------|-----------|
|
||||
| components/admin/AdminPostForm.vue | 관리자 글 작성/수정 폼 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터 |
|
||||
| components/admin/AdminBlockEditor.vue | 관리자 글 블록형 에디터, 이미지/갤러리 블록 |
|
||||
| components/admin/AdminTagForm.vue | 관리자 태그 생성/수정 폼 |
|
||||
|
||||
## 콘텐츠 컴포넌트
|
||||
@@ -91,6 +91,7 @@
|
||||
| server/routes/admin/api/posts/[id].get.js | 관리자 게시물 상세 API |
|
||||
| server/routes/admin/api/posts/[id].put.js | 관리자 게시물 수정 API |
|
||||
| server/routes/admin/api/posts/[id].delete.js | 관리자 게시물 삭제 API |
|
||||
| server/routes/admin/api/uploads.post.js | 관리자 이미지 업로드 API |
|
||||
| server/routes/admin/api/tags.get.js | 관리자 태그 목록 API |
|
||||
| server/routes/admin/api/tags.post.js | 관리자 태그 생성 API |
|
||||
| server/routes/admin/api/tags/[id].get.js | 관리자 태그 상세 API |
|
||||
|
||||
13
docs/spec.md
13
docs/spec.md
@@ -188,6 +188,7 @@ components/content/
|
||||
- `GET /admin/api/posts/:id` - 글 상세
|
||||
- `PUT /admin/api/posts/:id` - 글 수정
|
||||
- `DELETE /admin/api/posts/:id` - 글 삭제
|
||||
- `POST /admin/api/uploads` - 관리자 이미지 업로드
|
||||
- `GET /admin/api/tags` - 태그 목록
|
||||
- `POST /admin/api/tags` - 태그 생성
|
||||
- `GET /admin/api/tags/:id` - 태그 상세
|
||||
@@ -207,7 +208,7 @@ components/content/
|
||||
- `/` 명령 메뉴는 화면 하단 공간이 부족하면 현재 블록 위쪽으로 표시한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 Enter를 누르면 현재 강조된 메뉴 항목을 적용한다.
|
||||
- `/` 명령 메뉴가 열린 상태에서 위/아래 방향키로 강조 항목을 이동한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- 블록 메뉴는 문단, 제목 2, 제목 3, 이미지, 갤러리, 인용, 목록, 코드, 구분선을 제공한다.
|
||||
- `#`, `##`, `###`, `>`, `-` 입력 후 공백을 누르면 현재 블록 타입을 즉시 변환한다.
|
||||
- 빈 문단에서 Enter를 누르면 다음 빈 문단 블록을 생성한다.
|
||||
- 빈 블록 placeholder는 현재 활성 블록 또는 첫 빈 블록에만 표시한다.
|
||||
@@ -215,6 +216,11 @@ components/content/
|
||||
- 제목 입력에서 Enter를 누르면 본문 첫 블록으로 포커스를 이동한다.
|
||||
- 관리자 글 에디터의 실제 입력 텍스트 색상은 placeholder보다 진하게 표시한다.
|
||||
- 관리자 작성 화면과 공개 본문은 같은 마크다운 렌더링 기준을 사용한다.
|
||||
- 이미지 블록은 관리자 업로드 API로 이미지를 업로드하고 `{width=wide}` 형식으로 저장한다.
|
||||
- 이미지 블록 표시 옵션은 `regular`, `wide`, `full` 값을 사용하며 `regular`는 width 옵션을 생략한다.
|
||||
- 갤러리 블록은 `:::gallery` fenced block 안에 이미지 마크다운 행을 여러 개 저장한다.
|
||||
- 공개 갤러리는 한 줄에 2~3개 이미지 그리드로 표시하고 클릭 시 라이트박스로 크게 확인한다.
|
||||
- 현재 글쓰기 화면은 새 이미지 업로드를 우선 지원하며, 기존 업로드 미디어 선택은 미디어 라이브러리 구현 시 추가한다.
|
||||
|
||||
### 관리자 인증
|
||||
|
||||
@@ -236,6 +242,11 @@ components/content/
|
||||
/uploads/system/favicon.png
|
||||
```
|
||||
|
||||
- 관리자 이미지 업로드 API는 `image/jpeg`, `image/png`, `image/webp`, `image/gif`만 허용한다.
|
||||
- 업로드 파일 크기 제한은 `MAX_FILE_SIZE` 환경 변수를 따른다.
|
||||
- 로컬 개발 업로드 파일은 `public/uploads/posts/YYYY/MM/` 아래 저장하고 `/uploads/posts/YYYY/MM/filename` URL로 제공한다.
|
||||
- 향후 미디어 라이브러리는 업로드 이미지 목록, 기존 미디어 선택, 파일명 변경, 개별 삭제, 카테고리 분류를 제공한다.
|
||||
|
||||
---
|
||||
|
||||
## 환경 변수 (.env)
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
|
||||
- [ ] 블록 에디터 브라우저 수동 QA: 빈 줄 Enter, `/` 메뉴 필터, 방향키, Enter 선택, 한글 조합 입력 확인
|
||||
- [ ] 블록 에디터 저장/수정 왕복 QA: 기존 글 수정 시 블록 파싱, 저장 후 다시 열기 확인
|
||||
- [ ] 이미지 업로드 블록 구현
|
||||
- [ ] 이미지/갤러리 블록 브라우저 수동 QA: 업로드, 너비 옵션, 저장 후 공개 렌더링, 갤러리 라이트박스 확인
|
||||
- [ ] 게시물 작성 시 기존 미디어 선택 또는 새 미디어 업로드 선택 흐름 추가
|
||||
- [ ] 콜아웃, 토글, 임베드 블록 추가
|
||||
- [ ] 글 작성 중 자동 저장
|
||||
|
||||
@@ -13,7 +14,7 @@
|
||||
- [ ] 페이지 관리 (CRUD)
|
||||
- [ ] 사이트 설정
|
||||
- [ ] 메뉴/네비게이션 관리
|
||||
- [ ] 미디어 라이브러리
|
||||
- [ ] 미디어 라이브러리: 업로드 이미지 목록, 검색, 선택, 파일명 변경, 개별 삭제, 카테고리 분류
|
||||
|
||||
## 3차 관리자 개발
|
||||
|
||||
|
||||
@@ -1,5 +1,17 @@
|
||||
# 업데이트 이력
|
||||
|
||||
## v0.0.14
|
||||
|
||||
- 관리자 블록 에디터에 단일 이미지 블록 추가.
|
||||
- 관리자 블록 에디터에 복수 이미지 갤러리 블록 추가.
|
||||
- 이미지 블록의 기본/와이드/풀사이즈 표시 옵션 추가.
|
||||
- 관리자 이미지 업로드 API 추가.
|
||||
- 공개 본문 렌더러에 이미지와 갤러리 렌더링 추가.
|
||||
- 공개 갤러리 이미지 클릭 시 라이트박스로 크게 보는 기능 추가.
|
||||
- 업로드 파일이 Git에 포함되지 않도록 `public/uploads/` 제외 처리.
|
||||
- 향후 미디어 라이브러리 관리 기능 범위 정리.
|
||||
- 패키지 버전을 0.0.14로 갱신.
|
||||
|
||||
## v0.0.13
|
||||
|
||||
- 개발 서버 실행 로그를 프로젝트 전용 링크 요약 출력으로 정리.
|
||||
|
||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.14",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.14",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "sori.studio",
|
||||
"version": "0.0.13",
|
||||
"version": "0.0.14",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
102
server/routes/admin/api/uploads.post.js
Normal file
102
server/routes/admin/api/uploads.post.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import { extname, join } from 'node:path'
|
||||
import { createError, readMultipartFormData } from 'h3'
|
||||
import { requireAdminSession } from '../../../utils/admin-auth'
|
||||
|
||||
const allowedImageTypes = new Map([
|
||||
['image/jpeg', '.jpg'],
|
||||
['image/png', '.png'],
|
||||
['image/webp', '.webp'],
|
||||
['image/gif', '.gif']
|
||||
])
|
||||
|
||||
/**
|
||||
* 업로드 경로 조각을 URL 안전 문자열로 정리
|
||||
* @param {string} value - 원본 경로 조각
|
||||
* @returns {string} 정리된 경로 조각
|
||||
*/
|
||||
const sanitizePathPart = (value) => value
|
||||
.replace(/[^a-zA-Z0-9가-힣._-]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '')
|
||||
|
||||
/**
|
||||
* 파일 확장자 조회
|
||||
* @param {Object} file - multipart 파일 파트
|
||||
* @returns {string} 확장자
|
||||
*/
|
||||
const getUploadExtension = (file) => {
|
||||
const extension = extname(file.filename || '').toLowerCase()
|
||||
|
||||
if (allowedImageTypes.has(file.type)) {
|
||||
return allowedImageTypes.get(file.type)
|
||||
}
|
||||
|
||||
return extension
|
||||
}
|
||||
|
||||
/**
|
||||
* 관리자 이미지 업로드 API
|
||||
* @param {import('h3').H3Event} event - 요청 이벤트
|
||||
* @returns {Promise<{ files: Array<{ url: string, name: string, size: number }> }>} 업로드 결과
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
requireAdminSession(event)
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const maxFileSize = Number(config.maxFileSize || 10485760)
|
||||
const formData = await readMultipartFormData(event)
|
||||
const files = (formData || []).filter((part) => part.name === 'files' && part.filename)
|
||||
|
||||
if (!files.length) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '업로드할 이미지가 없습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const now = new Date()
|
||||
const year = String(now.getFullYear())
|
||||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||||
const uploadBaseUrl = String(config.uploadDir || '/uploads').replace(/\/+$/g, '') || '/uploads'
|
||||
const publicBasePath = uploadBaseUrl.replace(/^\/+/, '')
|
||||
const directoryPath = join(process.cwd(), 'public', publicBasePath, 'posts', year, month)
|
||||
|
||||
await mkdir(directoryPath, { recursive: true })
|
||||
|
||||
const uploadedFiles = []
|
||||
|
||||
for (const file of files) {
|
||||
if (!allowedImageTypes.has(file.type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '이미지 파일만 업로드할 수 있습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
if (file.data.length > maxFileSize) {
|
||||
throw createError({
|
||||
statusCode: 413,
|
||||
message: '업로드 가능한 파일 크기를 초과했습니다.'
|
||||
})
|
||||
}
|
||||
|
||||
const originalName = sanitizePathPart(file.filename.replace(/\.[^.]+$/g, '')) || 'image'
|
||||
const extension = getUploadExtension(file)
|
||||
const fileName = `${originalName}-${randomUUID()}${extension}`
|
||||
const filePath = join(directoryPath, fileName)
|
||||
|
||||
await writeFile(filePath, file.data)
|
||||
|
||||
uploadedFiles.push({
|
||||
url: `${uploadBaseUrl}/posts/${year}/${month}/${fileName}`,
|
||||
name: file.filename,
|
||||
size: file.data.length
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
files: uploadedFiles
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user