관리자 에디터를 마크다운 우선 방식으로 개편
This commit is contained in:
573
components/admin/AdminMarkdownEditor.vue
Normal file
573
components/admin/AdminMarkdownEditor.vue
Normal file
@@ -0,0 +1,573 @@
|
||||
<script setup>
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
default: ''
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const textareaRef = ref(null)
|
||||
const activeMode = ref('write')
|
||||
const mediaItems = ref([])
|
||||
const isMediaPickerOpen = ref(false)
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const mediaPickerTarget = ref('image')
|
||||
const selectedMediaUrls = ref([])
|
||||
const selectedImageWidth = ref('regular')
|
||||
|
||||
const imageWidthOptions = [
|
||||
{ value: 'regular', label: '기본' },
|
||||
{ value: 'wide', label: '와이드' },
|
||||
{ value: 'full', label: '풀사이즈' }
|
||||
]
|
||||
|
||||
const markdownValue = computed({
|
||||
get: () => props.modelValue || '',
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
})
|
||||
|
||||
/**
|
||||
* 본문 에디터에 포커스한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const focusFirstBlock = () => {
|
||||
nextTick(() => {
|
||||
textareaRef.value?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
focusFirstBlock
|
||||
})
|
||||
|
||||
/**
|
||||
* textarea 선택 영역 정보를 반환한다.
|
||||
* @returns {{ start: number, end: number, value: string }} 선택 정보
|
||||
*/
|
||||
const getSelectionState = () => {
|
||||
const textarea = textareaRef.value
|
||||
const value = markdownValue.value
|
||||
|
||||
if (!textarea) {
|
||||
return {
|
||||
start: value.length,
|
||||
end: value.length,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
start: textarea.selectionStart,
|
||||
end: textarea.selectionEnd,
|
||||
value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* textarea 커서 위치를 갱신한다.
|
||||
* @param {number} start - 시작 위치
|
||||
* @param {number} end - 끝 위치
|
||||
* @returns {void}
|
||||
*/
|
||||
const setTextareaSelection = (start, end = start) => {
|
||||
nextTick(() => {
|
||||
const textarea = textareaRef.value
|
||||
if (!textarea) {
|
||||
return
|
||||
}
|
||||
|
||||
textarea.focus()
|
||||
textarea.setSelectionRange(start, end)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 영역을 지정 문자열로 교체한다.
|
||||
* @param {string} replacement - 대체 문자열
|
||||
* @param {number} cursorOffset - 교체 문자열 안 커서 위치
|
||||
* @param {number|null} selectionLength - 선택 길이
|
||||
* @returns {void}
|
||||
*/
|
||||
const replaceSelection = (replacement, cursorOffset = replacement.length, selectionLength = null) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
|
||||
const nextStart = start + cursorOffset
|
||||
setTextareaSelection(nextStart, nextStart + (selectionLength ?? 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* 블록형 마크다운 조각을 커서 위치에 삽입한다.
|
||||
* @param {string} snippet - 삽입할 마크다운
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertBlockSnippet = (snippet) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const needsBeforeLine = start > 0 && value[start - 1] !== '\n'
|
||||
const needsAfterLine = end < value.length && value[end] !== '\n'
|
||||
const replacement = `${needsBeforeLine ? '\n\n' : ''}${snippet}${needsAfterLine ? '\n\n' : ''}`
|
||||
replaceSelection(replacement)
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택 텍스트를 인라인 마크다운으로 감싼다.
|
||||
* @param {string} prefix - 앞 표식
|
||||
* @param {string} suffix - 뒤 표식
|
||||
* @param {string} placeholder - 선택이 없을 때 들어갈 텍스트
|
||||
* @returns {void}
|
||||
*/
|
||||
const wrapInline = (prefix, suffix, placeholder) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const selected = value.slice(start, end)
|
||||
const inner = selected || placeholder
|
||||
const replacement = `${prefix}${inner}${suffix}`
|
||||
markdownValue.value = `${value.slice(0, start)}${replacement}${value.slice(end)}`
|
||||
const selectionStart = start + prefix.length
|
||||
setTextareaSelection(selectionStart, selectionStart + inner.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택된 줄을 변환한다.
|
||||
* @param {(line: string) => string} transformLine - 줄 변환 함수
|
||||
* @returns {void}
|
||||
*/
|
||||
const transformSelectedLines = (transformLine) => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const lineStart = value.lastIndexOf('\n', Math.max(0, start - 1)) + 1
|
||||
const nextLineBreak = value.indexOf('\n', end)
|
||||
const lineEnd = nextLineBreak === -1 ? value.length : nextLineBreak
|
||||
const selectedBlock = value.slice(lineStart, lineEnd)
|
||||
const replacement = selectedBlock.split('\n').map(transformLine).join('\n')
|
||||
|
||||
markdownValue.value = `${value.slice(0, lineStart)}${replacement}${value.slice(lineEnd)}`
|
||||
setTextareaSelection(lineStart, lineStart + replacement.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 제목 마크다운을 적용한다.
|
||||
* @param {number} level - 제목 레벨
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyHeading = (level) => {
|
||||
transformSelectedLines((line) => {
|
||||
const text = line.replace(/^#{1,6}\s+/, '').trimStart()
|
||||
return `${'#'.repeat(level)} ${text || '제목'}`
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 인용 마크다운을 적용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyQuote = () => {
|
||||
transformSelectedLines((line) => line.startsWith('> ') ? line : `> ${line}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 목록 마크다운을 적용한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyList = () => {
|
||||
transformSelectedLines((line) => line.startsWith('- ') ? line : `- ${line}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* 코드 블록을 삽입한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertCodeBlock = () => {
|
||||
const { start, end, value } = getSelectionState()
|
||||
const selected = value.slice(start, end) || 'code'
|
||||
const snippet = `\`\`\`\n${selected}\n\`\`\``
|
||||
replaceSelection(snippet, 4, selected.length)
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운 문자열을 생성한다.
|
||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||
* @returns {string} 이미지 마크다운
|
||||
*/
|
||||
const createImageMarkdown = (image) => {
|
||||
const width = image.width && image.width !== 'regular'
|
||||
? `{width=${image.width}}`
|
||||
: ''
|
||||
return `${width}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 마크다운을 삽입한다.
|
||||
* @param {{ url: string, alt?: string, width?: string }} image - 이미지 정보
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertImage = (image) => {
|
||||
insertBlockSnippet(createImageMarkdown(image))
|
||||
}
|
||||
|
||||
/**
|
||||
* 갤러리 마크다운을 삽입한다.
|
||||
* @param {Array<{ url: string, alt?: string, width?: string }>} images - 이미지 목록
|
||||
* @returns {void}
|
||||
*/
|
||||
const insertGallery = (images) => {
|
||||
if (!images.length) {
|
||||
return
|
||||
}
|
||||
|
||||
insertBlockSnippet([':::gallery', ...images.map(createImageMarkdown), ':::'].join('\n'))
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 목록을 불러온다.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchMediaItems = async () => {
|
||||
isLoadingMedia.value = true
|
||||
|
||||
try {
|
||||
mediaItems.value = await $fetch('/admin/api/media')
|
||||
} finally {
|
||||
isLoadingMedia.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 창을 연다.
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const openMediaPicker = async (target) => {
|
||||
mediaPickerTarget.value = target
|
||||
selectedMediaUrls.value = []
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 창을 닫는다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
selectedMediaUrls.value = []
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 선택 상태를 토글한다.
|
||||
* @param {Object} mediaItem - 미디어 항목
|
||||
* @returns {void}
|
||||
*/
|
||||
const toggleMediaSelection = (mediaItem) => {
|
||||
if (mediaPickerTarget.value === 'image') {
|
||||
selectedMediaUrls.value = [mediaItem.url]
|
||||
return
|
||||
}
|
||||
|
||||
selectedMediaUrls.value = selectedMediaUrls.value.includes(mediaItem.url)
|
||||
? selectedMediaUrls.value.filter((url) => url !== mediaItem.url)
|
||||
: [...selectedMediaUrls.value, mediaItem.url]
|
||||
}
|
||||
|
||||
/**
|
||||
* 선택한 미디어를 마크다운에 삽입한다.
|
||||
* @returns {void}
|
||||
*/
|
||||
const applyMediaSelection = () => {
|
||||
const selectedItems = selectedMediaUrls.value
|
||||
.map((url) => mediaItems.value.find((item) => item.url === url))
|
||||
.filter(Boolean)
|
||||
|
||||
if (mediaPickerTarget.value === 'image') {
|
||||
const [item] = selectedItems
|
||||
if (item) {
|
||||
insertImage({
|
||||
url: item.url,
|
||||
alt: item.name || '',
|
||||
width: selectedImageWidth.value
|
||||
})
|
||||
}
|
||||
} else {
|
||||
insertGallery(selectedItems.map((item) => ({
|
||||
url: item.url,
|
||||
alt: item.name || ''
|
||||
})))
|
||||
}
|
||||
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미지 파일을 업로드한다.
|
||||
* @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 {FileList|Array<File>} files - 파일 목록
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadAndInsert = async (files, target = 'image') => {
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
isUploading.value = true
|
||||
|
||||
try {
|
||||
const uploadedFiles = await uploadImages(files)
|
||||
const images = uploadedFiles.map((file) => ({
|
||||
url: file.url,
|
||||
alt: file.name || '',
|
||||
width: target === 'image' ? selectedImageWidth.value : 'regular'
|
||||
}))
|
||||
|
||||
if (target === 'gallery') {
|
||||
insertGallery(images)
|
||||
} else if (images[0]) {
|
||||
insertImage(images[0])
|
||||
}
|
||||
|
||||
mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 파일 입력 변경 처리
|
||||
* @param {Event} event - 파일 입력 이벤트
|
||||
* @param {'image'|'gallery'} target - 삽입 대상
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleFileInput = async (event, target) => {
|
||||
await uploadAndInsert(event.target.files, target)
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙여넣은 이미지 파일을 업로드한다.
|
||||
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handlePaste = async (event) => {
|
||||
const imageFiles = Array.from(event.clipboardData?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!imageFiles.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
|
||||
}
|
||||
|
||||
/**
|
||||
* 드롭한 이미지 파일을 업로드한다.
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleDrop = async (event) => {
|
||||
const imageFiles = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!imageFiles.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
await uploadAndInsert(imageFiles, imageFiles.length > 1 ? 'gallery' : 'image')
|
||||
}
|
||||
|
||||
/**
|
||||
* 키보드 단축키를 처리한다.
|
||||
* @param {KeyboardEvent} event - 키보드 이벤트
|
||||
* @returns {void}
|
||||
*/
|
||||
const handleKeydown = (event) => {
|
||||
if (!(event.metaKey || event.ctrlKey)) {
|
||||
return
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase()
|
||||
|
||||
if (key === 'b') {
|
||||
event.preventDefault()
|
||||
wrapInline('**', '**', '굵은 글씨')
|
||||
} else if (key === 'i') {
|
||||
event.preventDefault()
|
||||
wrapInline('*', '*', '기울임')
|
||||
} else if (key === 'e') {
|
||||
event.preventDefault()
|
||||
wrapInline('`', '`', 'code')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-markdown-editor grid gap-3">
|
||||
<div class="admin-markdown-editor__toolbar flex flex-wrap items-center gap-1.5 rounded border border-[#e3e6e8] bg-white p-2">
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(1)">
|
||||
H1
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(2)">
|
||||
H2
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyHeading(3)">
|
||||
H3
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-bold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('**', '**', '굵은 글씨')">
|
||||
B
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm italic text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('*', '*', '기울임')">
|
||||
I
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 font-mono text-sm text-[#394047] hover:bg-[#eff1f2]" type="button" @click="wrapInline('`', '`', 'code')">
|
||||
Code
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyQuote">
|
||||
인용
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="applyList">
|
||||
목록
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertCodeBlock">
|
||||
코드블록
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="insertBlockSnippet('---')">
|
||||
구분선
|
||||
</button>
|
||||
<span class="admin-markdown-editor__divider mx-1 h-6 w-px bg-[#e3e6e8]" aria-hidden="true" />
|
||||
<select v-model="selectedImageWidth" class="admin-markdown-editor__image-width rounded border border-[#d7dde2] bg-white px-2 py-1.5 text-sm text-[#394047]">
|
||||
<option v-for="option in imageWidthOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('image')">
|
||||
미디어 이미지
|
||||
</button>
|
||||
<button class="admin-markdown-editor__tool rounded px-2.5 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="openMediaPicker('gallery')">
|
||||
미디어 갤러리
|
||||
</button>
|
||||
<label class="admin-markdown-editor__upload cursor-pointer rounded bg-[#15171a] px-2.5 py-1.5 text-sm font-semibold text-white">
|
||||
이미지 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" @change="handleFileInput($event, 'image')">
|
||||
</label>
|
||||
<label class="admin-markdown-editor__upload cursor-pointer rounded bg-[#15171a] px-2.5 py-1.5 text-sm font-semibold text-white">
|
||||
갤러리 업로드
|
||||
<input class="sr-only" type="file" accept="image/*" multiple @change="handleFileInput($event, 'gallery')">
|
||||
</label>
|
||||
<div class="admin-markdown-editor__mode ml-auto inline-flex rounded border border-[#d7dde2] bg-[#f6f7f8] p-0.5">
|
||||
<button
|
||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||
:class="activeMode === 'write' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||
type="button"
|
||||
@click="activeMode = 'write'"
|
||||
>
|
||||
작성
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__mode-button rounded px-3 py-1.5 text-sm font-semibold"
|
||||
:class="activeMode === 'preview' ? 'bg-white text-black shadow-sm' : 'text-[#6b7280]'"
|
||||
type="button"
|
||||
@click="activeMode = 'preview'"
|
||||
>
|
||||
미리보기
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="markdownValue"
|
||||
class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y rounded border border-[#e3e6e8] bg-white px-5 py-5 font-mono text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:border-[#15171a]"
|
||||
placeholder="마크다운으로 글을 작성하세요."
|
||||
spellcheck="false"
|
||||
@keydown="handleKeydown"
|
||||
@paste="handlePaste"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent
|
||||
/>
|
||||
<div v-if="isUploading" class="admin-markdown-editor__uploading absolute right-3 top-3 rounded bg-[#15171a] px-3 py-1.5 text-xs font-semibold text-white">
|
||||
업로드 중
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="admin-markdown-editor__preview min-h-[620px] rounded border border-[#e3e6e8] bg-white px-6 py-5">
|
||||
<ContentMarkdownRenderer v-if="markdownValue.trim()" :content="markdownValue" />
|
||||
<p v-else class="admin-markdown-editor__preview-empty text-sm text-[#8e9cac]">
|
||||
미리볼 본문이 없습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isMediaPickerOpen" class="admin-markdown-editor__media-modal fixed inset-0 z-50 grid place-items-center bg-black/40 px-4 py-6" @click.self="closeMediaPicker">
|
||||
<div class="admin-markdown-editor__media-panel flex max-h-[86vh] w-full max-w-5xl flex-col overflow-hidden rounded bg-white shadow-2xl">
|
||||
<header class="admin-markdown-editor__media-header flex items-center justify-between border-b border-[#e3e6e8] px-5 py-4">
|
||||
<div>
|
||||
<h2 class="admin-markdown-editor__media-title text-lg font-bold text-black">
|
||||
{{ mediaPickerTarget === 'gallery' ? '갤러리 미디어 선택' : '이미지 미디어 선택' }}
|
||||
</h2>
|
||||
<p class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
|
||||
{{ selectedMediaUrls.length }}개 선택됨
|
||||
</p>
|
||||
</div>
|
||||
<button class="admin-markdown-editor__media-close rounded px-3 py-1.5 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
|
||||
닫기
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-markdown-editor__media-body overflow-y-auto p-5">
|
||||
<div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]">
|
||||
불러오는 중
|
||||
</div>
|
||||
<div v-else-if="mediaItems.length" class="admin-markdown-editor__media-grid grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||
<button
|
||||
v-for="item in mediaItems"
|
||||
:key="item.url"
|
||||
class="admin-markdown-editor__media-item group overflow-hidden rounded border bg-white text-left transition"
|
||||
:class="selectedMediaUrls.includes(item.url) ? 'border-[#15171a] ring-2 ring-[#15171a]/20' : 'border-[#e3e6e8] hover:border-[#8e9cac]'"
|
||||
type="button"
|
||||
@click="toggleMediaSelection(item)"
|
||||
>
|
||||
<img class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover" :src="item.url" :alt="item.name || ''">
|
||||
<span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
|
||||
{{ item.name || item.url }}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
|
||||
선택할 미디어가 없습니다.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
|
||||
<button class="admin-markdown-editor__media-cancel rounded px-4 py-2 text-sm font-semibold text-[#394047] hover:bg-[#eff1f2]" type="button" @click="closeMediaPicker">
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__media-apply rounded bg-[#15171a] px-4 py-2 text-sm font-semibold text-white disabled:cursor-not-allowed disabled:opacity-40"
|
||||
type="button"
|
||||
:disabled="selectedMediaUrls.length === 0"
|
||||
@click="applyMediaSelection"
|
||||
>
|
||||
삽입
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -905,7 +905,7 @@ defineExpose({
|
||||
>
|
||||
|
||||
<div class="admin-post-form__field admin-post-form__content-editor text-sm">
|
||||
<AdminBlockEditor ref="blockEditor" v-model="form.content" />
|
||||
<AdminMarkdownEditor ref="blockEditor" v-model="form.content" />
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user