v1.4.1: 관리자 미디어 업로드 한도·라이브 에디터 UX 개선

종류별 업로드 크기 한도와 413 안내를 추가하고, 임베드·미디어 라이브 프리뷰·제목 Enter 포커스·스크롤 동작을 보정한다.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-21 15:33:23 +09:00
parent f8e04003fd
commit 095a8fa5f0
25 changed files with 1445 additions and 103 deletions

View File

@@ -421,7 +421,7 @@ const serializeBlocks = () => {
if (block.type === 'embed') {
return block.url.trim()
? { type: block.type, value: `:::embed\n${block.url.trim()}\n:::` }
? { type: block.type, value: block.url.trim() }
: null
}

View File

@@ -10,8 +10,23 @@ import {
resolveSlashCommand
} from '../../lib/markdown-slash-commands.js'
import { getTextareaCaretCoordinates } from '../../lib/textarea-caret-coordinates.js'
import {
buildDefaultUploadSizeLimits,
formatUploadSizeLimit,
getMaxUploadBytesForKind,
getUploadKind
} from '../../lib/upload-size-limit.js'
import AdminSlashCommandIcon from './AdminSlashCommandIcon.vue'
const { toast, showToast } = useAdminToast()
const runtimeConfig = useRuntimeConfig()
const uploadSizeLimits = computed(() => buildDefaultUploadSizeLimits({
image: Number(runtimeConfig.public.maxFileSize || 10485760),
video: Number(runtimeConfig.public.maxVideoFileSize || 209715200),
audio: Number(runtimeConfig.public.maxAudioFileSize || 52428800),
document: Number(runtimeConfig.public.maxDocumentFileSize || 52428800)
}))
const props = defineProps({
modelValue: {
type: [String, Array, Object],
@@ -75,6 +90,21 @@ const liveSlashVisible = computed(() => liveSlashSourceLine.value !== null)
const liveSlashSuppressedLineList = computed(() => [...liveSlashSuppressedLines.value])
const visibleLiveSlashCommands = computed(() => filterSlashCommands(liveSlashQuery.value))
const mediaPickerAccept = computed(() => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return 'image/*'
}
if (mediaPickerTarget.value === 'video') {
return 'video/*'
}
if (mediaPickerTarget.value === 'audio') {
return 'audio/*'
}
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
})
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
@@ -346,15 +376,65 @@ watch(activeMode, (mode) => {
nextTick(() => {
syncTextareaHeight()
restoreTextareaFocus()
if (textareaRef.value) {
textareaRef.value.scrollTop = 0
}
})
return
}
nextTick(() => {
previewRef.value?.focus()
scrollPreviewToTop()
})
})
/**
* 라이브 미리보기 스크롤을 맨 위로 맞춘다.
* @returns {void}
*/
const scrollPreviewToTop = () => {
if (previewRef.value) {
previewRef.value.scrollTop = 0
}
}
/**
* 본문 작성 시작 시 포커스할 첫 줄 인덱스를 반환한다.
* @returns {number} 줄 번호(0-based)
*/
const getInitialContentFocusLineIndex = () => {
const lines = (markdownValue.value ?? '').split('\n')
if (!lines.length) {
return 0
}
const firstLine = lines[0]?.trim() ?? ''
if (/^#{1,6}\s+/.test(firstLine)) {
return 1
}
return 0
}
/**
* 본문 작성 시작 줄이 없으면 빈 줄을 추가한다.
* @param {number} lineIndex - 포커스할 줄
* @returns {number} 보정된 줄 번호
*/
const ensureContentFocusLineExists = (lineIndex) => {
const lines = (markdownValue.value ?? '').split('\n')
if (lineIndex < lines.length) {
return lineIndex
}
markdownValue.value = lines.length ? `${markdownValue.value}\n` : ''
return lines.length
}
/**
* 작성 모드와 미리보기 모드를 전환한다.
* @returns {void}
@@ -484,16 +564,36 @@ onMounted(() => {
})
/**
* 본문 에디터에 포커스한다.
* 본문 에디터에 포커스한다. 현재 모드(write/preview)를 유지한다.
* @returns {void}
*/
const focusFirstBlock = () => {
if (activeMode.value !== 'write') {
activeMode.value = 'write'
const focusLine = ensureContentFocusLineExists(getInitialContentFocusLineIndex())
if (activeMode.value === 'preview') {
scrollPreviewToTop()
nextTick(() => {
nextTick(() => {
previewRendererRef.value?.focusEditableAtLine(focusLine, 0, 'start')
})
})
return
}
nextTick(() => {
textareaRef.value?.focus()
const textarea = textareaRef.value
const lines = (markdownValue.value ?? '').split('\n')
const lineStart = lines.slice(0, focusLine).join('\n').length + (focusLine > 0 ? 1 : 0)
if (textarea) {
textarea.focus()
textarea.setSelectionRange(lineStart, lineStart)
textarea.scrollTop = 0
}
activeLogicalLineIndex.value = focusLine
refreshCaretLogicalLine()
})
}
@@ -647,6 +747,120 @@ const insertCodeBlock = () => {
*/
const createImageMarkdown = (image) => serializeImageMarkdown(image)
/**
* 파일 크기를 표시용 문자열로 변환한다.
* @param {number} size - 바이트 크기
* @returns {string} 표시용 크기
*/
const formatMediaFileSize = (size) => {
const value = Number(size || 0)
if (!Number.isFinite(value) || value <= 0) {
return ''
}
if (value < 1024) {
return `${value} B`
}
if (value < 1024 * 1024) {
return `${Math.round(value / 1024)} KB`
}
return `${(value / 1024 / 1024).toFixed(1).replace(/\.0$/, '')} MB`
}
/**
* 미디어 항목 종류를 반환한다.
* @param {Object} item - 미디어 항목
* @returns {'image'|'video'|'audio'|'file'} 미디어 종류
*/
const getMediaItemKind = (item) => {
if (item?.kind) {
return item.kind
}
const source = `${item?.extension || ''} ${item?.url || ''}`.toLowerCase()
if (/\.(jpe?g|png|webp|gif)(\?|$)/i.test(source)) {
return 'image'
}
if (/\.(mp4|webm|mov)(\?|$)/i.test(source)) {
return 'video'
}
if (/\.(mp3|wav|ogg|m4a)(\?|$)/i.test(source)) {
return 'audio'
}
return 'file'
}
/**
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 가능 여부
*/
const isMediaItemSelectableForTarget = (item) => {
const kind = getMediaItemKind(item)
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return kind === 'image'
}
return kind === mediaPickerTarget.value
}
/**
* 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다.
* @param {File} file - 브라우저 파일
* @returns {boolean} 허용 여부
*/
const isUploadFileAllowedForPicker = (file) => {
if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
return file.type.startsWith('image/')
}
if (mediaPickerTarget.value === 'video') {
return file.type.startsWith('video/')
}
if (mediaPickerTarget.value === 'audio') {
return file.type.startsWith('audio/')
}
return !file.type.startsWith('image/') && !file.type.startsWith('video/') && !file.type.startsWith('audio/')
}
/**
* 미디어 항목으로 Prose 미디어 블록을 만든다.
* @param {'video'|'audio'|'file'} blockType - 블록 유형
* @param {Object} item - 미디어 항목
* @returns {string[]} 마크다운 줄
*/
const createMediaBlockMarkdown = (blockType, item) => {
const title = item.title || item.name || ''
if (blockType === 'video') {
return [':::video', `url=${item.url}`, `title=${title}`, 'caption=', ':::']
}
if (blockType === 'audio') {
return [':::audio', `url=${item.url}`, `title=${title}`, 'description=', ':::']
}
return [
':::file',
`url=${item.url}`,
`title=${title}`,
'description=',
`name=${item.name || title}`,
`size=${formatMediaFileSize(item.size)}`,
':::'
]
}
/**
* 지정 줄 범위를 새 줄 목록으로 교체한다.
* @param {number} startLine - 시작 줄
@@ -1143,11 +1357,11 @@ const applyLiveSlashCommand = (command) => {
const line = liveSlashSourceLine.value
if (command.action === 'media-image' || command.action === 'media-gallery') {
if (['media-image', 'media-gallery', 'media-video', 'media-audio', 'media-file'].includes(command.action)) {
liveSlashInsertLine.value = line
replaceLineRange(line, line, [], false)
onLiveSlashEnd()
openMediaPicker(command.action === 'media-image' ? 'image' : 'gallery')
openMediaPicker(command.action.replace('media-', ''))
return
}
@@ -1295,11 +1509,7 @@ const updateActiveEmbedUrl = (url) => {
return
}
replaceLineRange(block.startLine, block.endLine, [
':::embed',
trimmed,
':::'
], false)
replaceLineRange(block.startLine, block.endLine, [trimmed], false)
}
/**
@@ -1400,12 +1610,13 @@ const mediaItemMatchesQuery = (item, query) => {
*/
const filteredMediaItems = computed(() => {
const query = mediaSearchQuery.value.trim().toLowerCase()
const targetItems = mediaItems.value.filter(isMediaItemSelectableForTarget)
if (!query) {
return mediaItems.value
return targetItems
}
return mediaItems.value.filter((item) => mediaItemMatchesQuery(item, query))
return targetItems.filter((item) => mediaItemMatchesQuery(item, query))
})
/**
@@ -1414,7 +1625,7 @@ const filteredMediaItems = computed(() => {
* @returns {void}
*/
const toggleMediaSelection = (mediaItem) => {
if (mediaPickerTarget.value === 'image') {
if (!isGalleryMediaPicker.value) {
selectedMediaUrls.value = [mediaItem.url]
return
}
@@ -1425,14 +1636,11 @@ const toggleMediaSelection = (mediaItem) => {
}
/**
* 선택한 미디어를 마크다운에 삽입한다.
* 미디어 항목 목록을 마크다운에 삽입한다.
* @param {Array<Object>} selectedItems - 선택 또는 업로드된 미디어 항목
* @returns {void}
*/
const applyMediaSelection = () => {
const selectedItems = selectedMediaUrls.value
.map((url) => mediaItems.value.find((item) => item.url === url))
.filter(Boolean)
const insertSelectedMediaItems = (selectedItems) => {
if (liveSlashInsertLine.value !== null) {
const line = liveSlashInsertLine.value
@@ -1446,7 +1654,7 @@ const applyMediaSelection = () => {
caption: ''
})])
}
} else if (selectedItems.length) {
} else if (mediaPickerTarget.value === 'gallery' && selectedItems.length) {
insertMarkdownAtLine(line, [
':::gallery',
...selectedItems.map((item) => createImageMarkdown({
@@ -1456,10 +1664,15 @@ const applyMediaSelection = () => {
})),
':::'
])
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertMarkdownAtLine(line, createMediaBlockMarkdown(mediaPickerTarget.value, item))
}
}
liveSlashInsertLine.value = null
closeMediaPicker()
return
}
@@ -1478,23 +1691,87 @@ const applyMediaSelection = () => {
useAlt: false,
caption: ''
})))
} else {
} else if (mediaPickerTarget.value === 'gallery') {
insertGallery(selectedItems.map((item) => ({
url: item.url,
useAlt: false,
caption: ''
})))
}
} else if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
const [item] = selectedItems
if (item) {
insertBlockSnippet(createMediaBlockMarkdown(mediaPickerTarget.value, item).join('\n'))
}
}
}
/**
* 선택한 미디어를 마크다운에 삽입한다.
* @returns {void}
*/
const applyMediaSelection = () => {
const selectedItems = selectedMediaUrls.value
.map((url) => mediaItems.value.find((item) => item.url === url))
.filter(Boolean)
insertSelectedMediaItems(selectedItems)
closeMediaPicker()
}
/**
* 이미지 파일을 업로드한다.
* 업로드 API 오류 메시지를 사용자용 문장으로 변환한다.
* @param {unknown} error - fetch 오류
* @returns {string} 오류 메시지
*/
const resolveUploadFetchErrorMessage = (error) => {
const responseMessage = error?.data?.message || error?.response?._data?.message
if (typeof responseMessage === 'string' && responseMessage.trim()) {
return responseMessage.trim()
}
if (error?.statusCode === 413 || error?.status === 413) {
return '업로드 가능한 파일 크기를 초과했습니다.'
}
if (error instanceof Error && error.message) {
return error.message
}
return '파일 업로드에 실패했습니다.'
}
/**
* 선택 파일이 업로드 한도를 넘지 않는지 검사한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {string|null} 초과 시 오류 메시지, 아니면 null
*/
const findUploadSizeLimitError = (files) => {
for (const file of Array.from(files)) {
const uploadKind = getUploadKind(file.type, file.name)
const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits.value)
if (file.size > maxBytes) {
return `파일 크기가 너무 큽니다. (최대 ${formatUploadSizeLimit(maxBytes)})`
}
}
return null
}
/**
* 미디어 파일을 업로드한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<Array<Object>>} 업로드된 파일 목록
*/
const uploadImages = async (files) => {
const uploadMediaFiles = async (files) => {
const sizeLimitError = findUploadSizeLimitError(files)
if (sizeLimitError) {
throw new Error(sizeLimitError)
}
const formData = new FormData()
Array.from(files).forEach((file) => {
formData.append('files', file)
@@ -1511,7 +1788,7 @@ const uploadImages = async (files) => {
/**
* 업로드 파일을 현재 커서 위치에 삽입한다.
* @param {FileList|Array<File>} files - 파일 목록
* @param {'image'|'gallery'} target - 삽입 대상
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
* @returns {Promise<void>}
*/
const uploadAndInsert = async (files, target = 'image') => {
@@ -1522,20 +1799,24 @@ const uploadAndInsert = async (files, target = 'image') => {
isUploading.value = true
try {
const uploadedFiles = await uploadImages(files)
const images = uploadedFiles.map((file) => ({
const uploadedFiles = await uploadMediaFiles(files)
const imageItems = uploadedFiles.map((file) => ({
url: file.url,
useAlt: false,
caption: ''
}))
if (target === 'gallery') {
insertGallery(images)
} else if (images[0]) {
insertImage(images[0])
insertGallery(imageItems)
} else if (target === 'image' && imageItems[0]) {
insertImage(imageItems[0])
} else if (['video', 'audio', 'file'].includes(target) && uploadedFiles[0]) {
insertBlockSnippet(createMediaBlockMarkdown(target, uploadedFiles[0]).join('\n'))
}
mediaItems.value = await $fetch('/admin/api/media').catch(() => mediaItems.value)
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
isUploading.value = false
}
@@ -1544,7 +1825,7 @@ const uploadAndInsert = async (files, target = 'image') => {
/**
* 파일 입력 변경 처리
* @param {Event} event - 파일 입력 이벤트
* @param {'image'|'gallery'} target - 삽입 대상
* @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상
* @returns {Promise<void>}
*/
const handleFileInput = async (event, target) => {
@@ -1564,10 +1845,23 @@ const uploadFromMediaModal = async (files) => {
const target = mediaPickerTarget.value === 'gallery' || mediaPickerTarget.value === 'active-gallery'
? 'gallery'
: 'image'
: mediaPickerTarget.value
await uploadAndInsert(files, target)
closeMediaPicker()
isUploading.value = true
try {
const uploadedFiles = await uploadMediaFiles(files)
mediaItems.value = await $fetch('/admin/api/media').catch(() => [
...uploadedFiles,
...mediaItems.value
])
insertSelectedMediaItems(target === 'gallery' ? uploadedFiles : uploadedFiles.slice(0, 1))
closeMediaPicker()
} catch (error) {
showToast('error', resolveUploadFetchErrorMessage(error))
} finally {
isUploading.value = false
}
}
/**
@@ -1576,7 +1870,7 @@ const uploadFromMediaModal = async (files) => {
* @returns {Promise<void>}
*/
const handleMediaModalDrop = async (event) => {
const files = Array.from(event.dataTransfer?.files || []).filter((file) => file.type.startsWith('image/'))
const files = Array.from(event.dataTransfer?.files || []).filter(isUploadFileAllowedForPicker)
if (!files.length) {
return
@@ -1595,6 +1889,18 @@ const mediaPickerTitle = computed(() => {
return '갤러리 이미지 선택'
}
if (mediaPickerTarget.value === 'video') {
return '비디오 선택'
}
if (mediaPickerTarget.value === 'audio') {
return '오디오 선택'
}
if (mediaPickerTarget.value === 'file') {
return '파일 선택'
}
return '이미지 선택'
})
@@ -1605,6 +1911,30 @@ const mediaPickerTitle = computed(() => {
const isGalleryMediaPicker = computed(() => mediaPickerTarget.value === 'gallery'
|| mediaPickerTarget.value === 'active-gallery')
/**
* 미디어 업로드 안내 문구를 반환한다.
* @returns {string} 안내 문구
*/
const mediaPickerUploadHint = computed(() => {
if (isGalleryMediaPicker.value) {
return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
}
if (mediaPickerTarget.value === 'video') {
return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.video)})`
}
if (mediaPickerTarget.value === 'audio') {
return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.audio)})`
}
if (mediaPickerTarget.value === 'file') {
return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.document)})`
}
return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.value.image)})`
})
/**
* 붙여넣은 이미지 파일을 업로드한다.
* @param {ClipboardEvent} event - 붙여넣기 이벤트
@@ -1881,7 +2211,18 @@ const handleKeydown = (event) => {
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 || ''">
<img
v-if="getMediaItemKind(item) === 'image'"
class="admin-markdown-editor__media-thumb aspect-[4/3] w-full bg-[#f6f7f8] object-cover"
:src="item.url"
:alt="item.name || ''"
>
<span
v-else
class="admin-markdown-editor__media-thumb flex aspect-[4/3] w-full items-center justify-center bg-[#f6f7f8] text-xs font-bold uppercase tracking-[0.18em] text-[#6b7280]"
>
{{ getMediaItemKind(item) }}
</span>
<span class="admin-markdown-editor__media-name block truncate px-3 py-2 text-xs font-semibold text-[#394047]">
{{ item.name || item.url }}
</span>
@@ -1892,7 +2233,7 @@ const handleKeydown = (event) => {
{{ mediaSearchQuery.trim() }} 맞는 미디어가 없습니다.
</template>
<template v-else>
선택할 미디어가 없습니다. 업로드 탭에서 이미지를 추가할 있습니다.
선택할 미디어가 없습니다. 업로드 탭에서 파일을 추가할 있습니다.
</template>
</div>
</template>
@@ -1914,13 +2255,13 @@ const handleKeydown = (event) => {
<input
class="sr-only"
type="file"
accept="image/*"
:accept="mediaPickerAccept"
:multiple="isGalleryMediaPicker"
@change="uploadFromMediaModal($event.target.files); $event.target.value = ''"
>
</label>
<p class="admin-markdown-editor__media-upload-hint text-xs text-[#8e9cac]">
{{ isGalleryMediaPicker ? '여러 이미지를 한 번에 선택할 수 있습니다.' : '단일 이미지가 본문에 삽입됩니다.' }}
{{ mediaPickerUploadHint }}
</p>
</div>
</div>
@@ -1942,6 +2283,19 @@ const handleKeydown = (event) => {
</div>
</div>
</div>
<div
v-if="toast"
class="admin-markdown-editor__toast fixed right-5 top-5 z-[100] max-w-[min(24rem,calc(100vw-2.5rem))] rounded border px-4 py-3 text-sm font-semibold shadow-lg"
:class="{
'border-green-200 bg-green-50 text-green-800': toast.type === 'success',
'border-red-200 bg-red-50 text-red-800': toast.type === 'error',
'border-[#e3e6e8] bg-white text-[#15171a]': toast.type === 'info'
}"
role="status"
>
{{ toast.message }}
</div>
</template>
<style scoped>