관리자 글쓰기·목록 UX 개선 및 POST 설정 추가(v1.1.14~v1.1.18)
Ghost형 툴바·초안 자동 저장·발행 모달, private 제거, 미디어 모달 통합, 발행일·수정일 표시 설정과 DB 마이그레이션 025·026을 반영한다. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -5,6 +5,11 @@ const props = defineProps({
|
||||
modelValue: {
|
||||
type: [String, Array, Object],
|
||||
default: ''
|
||||
},
|
||||
/** 헤더 아래 고정 툴바 슬롯(AdminPostForm `#admin-post-form-editor-toolbar-host`) */
|
||||
toolbarTeleportTo: {
|
||||
type: String,
|
||||
default: '#admin-post-form-editor-toolbar-host'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -22,20 +27,14 @@ const isMediaPickerOpen = ref(false)
|
||||
const isLoadingMedia = ref(false)
|
||||
const isUploading = ref(false)
|
||||
const mediaPickerTarget = ref('image')
|
||||
const activeMediaPickerTab = ref('library')
|
||||
const selectedMediaUrls = ref([])
|
||||
const selectedImageWidth = ref('regular')
|
||||
const lastSelectionState = ref({
|
||||
start: 0,
|
||||
end: 0,
|
||||
scrollTop: 0
|
||||
})
|
||||
|
||||
const imageWidthOptions = [
|
||||
{ value: 'regular', label: '기본' },
|
||||
{ value: 'wide', label: '와이드' },
|
||||
{ value: 'full', label: '풀사이즈' }
|
||||
]
|
||||
|
||||
const markdownValue = computed({
|
||||
get: () => normalizeMarkdownContent(props.modelValue),
|
||||
set: (value) => emit('update:modelValue', value)
|
||||
@@ -500,12 +499,7 @@ const insertCodeBlock = () => {
|
||||
* @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}`
|
||||
}
|
||||
const createImageMarkdown = (image) => ``
|
||||
|
||||
/**
|
||||
* 지정 줄 범위를 새 줄 목록으로 교체한다.
|
||||
@@ -680,6 +674,7 @@ const fetchMediaItems = async () => {
|
||||
*/
|
||||
const openMediaPicker = async (target) => {
|
||||
mediaPickerTarget.value = target
|
||||
activeMediaPickerTab.value = 'library'
|
||||
selectedMediaUrls.value = []
|
||||
isMediaPickerOpen.value = true
|
||||
await fetchMediaItems()
|
||||
@@ -692,6 +687,7 @@ const openMediaPicker = async (target) => {
|
||||
const closeMediaPicker = () => {
|
||||
isMediaPickerOpen.value = false
|
||||
selectedMediaUrls.value = []
|
||||
activeMediaPickerTab.value = 'library'
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -724,8 +720,7 @@ const applyMediaSelection = () => {
|
||||
if (item) {
|
||||
insertImage({
|
||||
url: item.url,
|
||||
alt: item.name || '',
|
||||
width: selectedImageWidth.value
|
||||
alt: item.name || ''
|
||||
})
|
||||
}
|
||||
} else if (mediaPickerTarget.value === 'active-gallery') {
|
||||
@@ -779,8 +774,7 @@ const uploadAndInsert = async (files, target = 'image') => {
|
||||
const uploadedFiles = await uploadImages(files)
|
||||
const images = uploadedFiles.map((file) => ({
|
||||
url: file.url,
|
||||
alt: file.name || '',
|
||||
width: target === 'image' ? selectedImageWidth.value : 'regular'
|
||||
alt: file.name || ''
|
||||
}))
|
||||
|
||||
if (target === 'gallery') {
|
||||
@@ -931,6 +925,59 @@ const handleFileInput = async (event, target) => {
|
||||
event.target.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 모달 업로드 탭에서 파일을 삽입한다.
|
||||
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const uploadFromMediaModal = async (files) => {
|
||||
if (!files?.length) {
|
||||
return
|
||||
}
|
||||
|
||||
const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
|
||||
? 'gallery'
|
||||
: 'image'
|
||||
|
||||
await uploadAndInsert(files, target)
|
||||
closeMediaPicker()
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 모달 업로드 영역에 파일을 드롭한다.
|
||||
* @param {DragEvent} event - 드롭 이벤트
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const handleMediaModalDrop = async (event) => {
|
||||
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
|
||||
|
||||
if (!files.length) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
await uploadFromMediaModal(files)
|
||||
}
|
||||
|
||||
/**
|
||||
* 미디어 모달의 삽입 대상 라벨을 반환한다.
|
||||
* @returns {string} 모달 제목
|
||||
*/
|
||||
const mediaPickerTitle = computed(() => {
|
||||
if (mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery') {
|
||||
return '갤러리 이미지 선택'
|
||||
}
|
||||
|
||||
return '이미지 선택'
|
||||
})
|
||||
|
||||
/**
|
||||
* 미디어 모달에서 다중 선택 여부를 반환한다.
|
||||
* @returns {boolean} 다중 선택 여부
|
||||
*/
|
||||
const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery'
|
||||
|| mediaPickerTarget.value === 'active-gallery')
|
||||
|
||||
/**
|
||||
* 붙여넣은 이미지 파일을 업로드한다.
|
||||
* @param {ClipboardEvent} event - 붙여넣기 이벤트
|
||||
@@ -1002,7 +1049,12 @@ const handleKeydown = (event) => {
|
||||
|
||||
<template>
|
||||
<div ref="editorRootRef" class="admin-markdown-editor grid gap-3">
|
||||
<div v-if="activeMode === 'write'" class="admin-markdown-editor__toolbar flex flex-wrap items-center gap-1.5 rounded border border-[#e3e6e8] bg-white p-2">
|
||||
<Teleport :to="toolbarTeleportTo">
|
||||
<div class="admin-markdown-editor__toolbar flex w-full min-h-[40px] items-center gap-1.5 border-b border-[#e3e6e8] bg-white px-8 py-1.5">
|
||||
<div
|
||||
v-show="activeMode === 'write'"
|
||||
class="admin-markdown-editor__toolbar-tools flex min-w-0 flex-1 flex-wrap items-center gap-1.5"
|
||||
>
|
||||
<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>
|
||||
@@ -1034,50 +1086,62 @@ const handleKeydown = (event) => {
|
||||
구분선
|
||||
</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">
|
||||
</div>
|
||||
<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]'"
|
||||
class="admin-markdown-editor__mode-toggle ml-auto inline-flex size-8 shrink-0 items-center justify-center rounded text-[#394047] transition-colors hover:bg-[#f1f3f4] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-[#8e9cac]"
|
||||
type="button"
|
||||
@click="setEditorMode('write')"
|
||||
:aria-label="activeMode === 'write' ? '미리보기로 전환' : '작성 모드로 전환'"
|
||||
@click="toggleEditorMode"
|
||||
>
|
||||
작성
|
||||
</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="setEditorMode('preview')"
|
||||
>
|
||||
미리보기
|
||||
<svg
|
||||
v-if="activeMode === 'write'"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="admin-markdown-editor__mode-icon lucide-book-open"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M12 7v14" />
|
||||
<path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z" />
|
||||
</svg>
|
||||
<svg
|
||||
v-else
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="admin-markdown-editor__mode-icon lucide-edit-3"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M13 21h8" />
|
||||
<path d="M21.174 6.812a1 1 0 0 0-3.986-3.987L3.842 16.174a2 2 0 0 0-.5.83l-1.321 4.352a.5.5 0 0 0 .623.622l4.353-1.32a2 2 0 0 0 .83-.497z" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
|
||||
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative pl-12">
|
||||
<div v-if="activeMode === 'write'" class="admin-markdown-editor__write relative">
|
||||
<div class="admin-markdown-editor__editor-surface min-h-[620px]">
|
||||
<div
|
||||
ref="gutterRef"
|
||||
class="admin-markdown-editor__gutter absolute bottom-0 left-0 top-0 w-10 select-none overflow-y-hidden overflow-x-hidden py-5 font-mono text-[13px] leading-7 text-[#a0a8b0]"
|
||||
class="admin-markdown-editor__gutter absolute bottom-0 left-[-40px] top-0 w-10 select-none overflow-y-hidden overflow-x-hidden py-5 font-mono text-[13px] leading-7 text-[#a0a8b0]"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<div
|
||||
@@ -1091,7 +1155,7 @@ const handleKeydown = (event) => {
|
||||
<textarea
|
||||
ref="textareaRef"
|
||||
v-model="markdownValue"
|
||||
class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y border-0 bg-transparent py-5 pl-0 pr-5 font-mono text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
|
||||
class="admin-markdown-editor__textarea min-h-[620px] w-full resize-y border-0 bg-transparent py-5 pl-0 pr-5 text-[15px] leading-7 text-[#15171a] outline-none transition-colors placeholder:text-[#8e9cac] focus:ring-0"
|
||||
placeholder="마크다운으로 글을 작성하세요."
|
||||
spellcheck="false"
|
||||
@keydown="handleKeydown"
|
||||
@@ -1161,15 +1225,6 @@ const handleKeydown = (event) => {
|
||||
>
|
||||
</label>
|
||||
<div class="admin-markdown-editor__media-editor-actions flex flex-wrap items-center gap-2">
|
||||
<select
|
||||
class="admin-markdown-editor__media-editor-select rounded border border-[#d7dde2] bg-white px-2 py-1.5 text-sm text-[#394047]"
|
||||
:value="image.width"
|
||||
@change="updateActiveMediaImage(imageIndex, { width: $event.target.value })"
|
||||
>
|
||||
<option v-for="option in imageWidthOptions" :key="option.value" :value="option.value">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<button
|
||||
v-if="activeMediaBlock.type === 'gallery'"
|
||||
class="admin-markdown-editor__media-editor-action rounded px-2.5 py-1.5 text-xs font-semibold text-[#394047] hover:bg-[#eff1f2] disabled:cursor-not-allowed disabled:opacity-40"
|
||||
@@ -1223,9 +1278,9 @@ const handleKeydown = (event) => {
|
||||
<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 === 'image' ? '이미지 미디어 선택' : '갤러리 미디어 선택' }}
|
||||
{{ mediaPickerTitle }}
|
||||
</h2>
|
||||
<p class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
|
||||
<p v-if="activeMediaPickerTab === 'library'" class="admin-markdown-editor__media-count mt-1 text-sm text-[#6b7280]">
|
||||
{{ selectedMediaUrls.length }}개 선택됨
|
||||
</p>
|
||||
</div>
|
||||
@@ -1234,7 +1289,27 @@ const handleKeydown = (event) => {
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="admin-markdown-editor__media-body overflow-y-auto p-5">
|
||||
<div class="admin-markdown-editor__media-tabs flex border-b border-[#e3e6e8] px-5">
|
||||
<button
|
||||
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'library' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'library'"
|
||||
>
|
||||
미디어 라이브러리
|
||||
</button>
|
||||
<button
|
||||
class="admin-markdown-editor__media-tab border-b-2 px-3 py-3 text-sm font-semibold transition-colors"
|
||||
:class="activeMediaPickerTab === 'upload' ? 'border-[#15171a] text-[#15171a]' : 'border-transparent text-[#6b7280] hover:text-[#15171a]'"
|
||||
type="button"
|
||||
@click="activeMediaPickerTab = 'upload'"
|
||||
>
|
||||
업로드
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="admin-markdown-editor__media-body flex-1 overflow-y-auto p-5">
|
||||
<template v-if="activeMediaPickerTab === 'library'">
|
||||
<div v-if="isLoadingMedia" class="admin-markdown-editor__media-loading py-12 text-center text-sm text-[#8e9cac]">
|
||||
불러오는 중
|
||||
</div>
|
||||
@@ -1253,12 +1328,41 @@ const handleKeydown = (event) => {
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
|
||||
선택할 미디어가 없습니다.
|
||||
<div v-else class="admin-markdown-editor__media-empty py-12 text-center text-sm text-[#8e9cac]">
|
||||
선택할 미디어가 없습니다. 업로드 탭에서 이미지를 추가할 수 있습니다.
|
||||
</div>
|
||||
</template>
|
||||
<div
|
||||
v-else
|
||||
class="admin-markdown-editor__media-upload-zone grid min-h-[420px] place-items-center rounded border border-dashed border-[#cfd5da] bg-[#fafafa] text-center"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleMediaModalDrop"
|
||||
>
|
||||
<div class="admin-markdown-editor__media-upload-inner grid gap-3 px-6">
|
||||
<p class="admin-markdown-editor__media-upload-title text-lg font-semibold text-[#15171a]">
|
||||
파일을 끌어 업로드
|
||||
</p>
|
||||
<p class="admin-markdown-editor__media-upload-or text-sm text-[#6b7280]">
|
||||
또는
|
||||
</p>
|
||||
<label class="admin-markdown-editor__media-upload-button mx-auto inline-flex h-10 cursor-pointer items-center justify-center rounded border border-[#2b78d0] px-8 text-sm font-semibold text-[#1f6fbf] transition-colors hover:bg-blue-50">
|
||||
{{ isUploading ? '업로드 중' : '파일 선택' }}
|
||||
<input
|
||||
class="sr-only"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
:multiple="isGalleryMediaPicker"
|
||||
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
|
||||
>
|
||||
</label>
|
||||
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
|
||||
{{ isGalleryMediaPicker ? '여러 이미지를 한 번에 선택할 수 있습니다.' : '단일 이미지가 본문에 삽입됩니다.' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="admin-markdown-editor__media-footer flex items-center justify-end gap-2 border-t border-[#e3e6e8] px-5 py-4">
|
||||
<footer v-if="activeMediaPickerTab === 'library'" 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>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user