관리자 글쓰기·목록 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:
2026-05-15 16:26:48 +09:00
parent ca1e17890b
commit 2074b0b93a
26 changed files with 1184 additions and 393 deletions

View File

@@ -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 `![${image.alt || ''}](${image.url})${width}`
}
const createImageMarkdown = (image) => `![${image.alt || ''}](${image.url})`
/**
* 지정 줄 범위를 새 줄 목록으로 교체한다.
@@ -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