diff --git a/.env.example b/.env.example index e30846f..5aa6df9 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,9 @@ MEMBER_SESSION_SECRET=replace-with-random-password # Upload UPLOAD_DIR=/uploads MAX_FILE_SIZE=10485760 +MAX_VIDEO_FILE_SIZE=209715200 +MAX_AUDIO_FILE_SIZE=52428800 +MAX_DOCUMENT_FILE_SIZE=52428800 AVATAR_MIN_WIDTH=96 AVATAR_MIN_HEIGHT=96 AVATAR_MAX_WIDTH=512 diff --git a/components/admin/AdminBlockEditor.vue b/components/admin/AdminBlockEditor.vue index 7d4f6db..8558e2d 100644 --- a/components/admin/AdminBlockEditor.vue +++ b/components/admin/AdminBlockEditor.vue @@ -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 } diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index c954c05..809739d 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -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} 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} 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} files - 업로드 파일 목록 * @returns {Promise>} 업로드된 파일 목록 */ -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} files - 파일 목록 - * @param {'image'|'gallery'} target - 삽입 대상 + * @param {'image'|'gallery'|'video'|'audio'|'file'} target - 삽입 대상 * @returns {Promise} */ 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} */ 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} */ 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)" > - + + + {{ getMediaItemKind(item) }} + {{ item.name || item.url }} @@ -1892,7 +2233,7 @@ const handleKeydown = (event) => { 「{{ mediaSearchQuery.trim() }}」에 맞는 미디어가 없습니다. @@ -1914,13 +2255,13 @@ const handleKeydown = (event) => {

- {{ isGalleryMediaPicker ? '여러 이미지를 한 번에 선택할 수 있습니다.' : '단일 이미지가 본문에 삽입됩니다.' }} + {{ mediaPickerUploadHint }}

@@ -1942,6 +2283,19 @@ const handleKeydown = (event) => { + +
+ {{ toast.message }} +