diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue index 7505c71..a04e0fa 100644 --- a/components/admin/AdminMarkdownEditor.vue +++ b/components/admin/AdminMarkdownEditor.vue @@ -23,12 +23,14 @@ import { } from '../../lib/markdown-editor-history.js' import { createMediaBlockMarkdown, + findUploadSizeLimitError, getGallerySelectionOrder as getGallerySelectionOrderFromSelection, getMediaItemKind, getMediaPickerPreviewUrl, isMediaItemSelectableForTarget, isUploadFileAllowedForMediaTarget, resolveSelectedMediaItems, + resolveUploadFetchErrorMessage, toggleMediaSelectionState } from '../../lib/markdown-editor-media.js' import { @@ -49,9 +51,6 @@ import { DEFAULT_UPLOAD_MAX_FILE_COUNT, DEFAULT_UPLOAD_MAX_TOTAL_SIZE, formatUploadSizeLimit, - getUploadFilesTotalSize, - getMaxUploadBytesForKind, - getUploadKind, normalizeUploadMaxFileCount, normalizeUploadMaxTotalSize } from '../../lib/upload-size-limit.js' @@ -3268,66 +3267,18 @@ const applyMediaSelection = () => { 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) => { - const fileList = Array.from(files || []) - - if (fileList.length > uploadMaxFileCount.value) { - return `한 번에 업로드할 수 있는 파일은 최대 ${uploadMaxFileCount.value}개입니다.` - } - - const uploadTotalSize = getUploadFilesTotalSize(fileList) - - if (uploadTotalSize > uploadMaxTotalSize.value) { - return `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(uploadMaxTotalSize.value)})` - } - - for (const file of fileList) { - 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 uploadMediaFiles = async (files) => { - const sizeLimitError = findUploadSizeLimitError(files) + const sizeLimitError = findUploadSizeLimitError({ + files, + maxFileCount: uploadMaxFileCount.value, + maxTotalSize: uploadMaxTotalSize.value, + uploadSizeLimits: uploadSizeLimits.value + }) if (sizeLimitError) { throw new Error(sizeLimitError) diff --git a/docs/map.md b/docs/map.md index 3df54e2..4f82d61 100644 --- a/docs/map.md +++ b/docs/map.md @@ -48,7 +48,7 @@ | lib/navigation-editor-tree.js | 관리자 메뉴 UI·서버 `renumberSortOrderByTree`가 쓰는 `buildNavigationEditorTree`, 관리자 상단 네비 평면 표용 `flattenNavigationEditorWrappers` | | lib/markdown-content-normalizer.js | 관리자 Markdown-first 전환 후 레거시 블록 배열·객체 본문 값을 저장용 마크다운 문자열로 변환 | | lib/markdown-editor-history.js | 관리자 Markdown-first 에디터 undo/redo 히스토리 항목 생성, 레거시 문자열 항목 본문 추출, 선택 범위 보정 | -| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 순서·토글·항목 해석, fenced 미디어 블록 생성 | +| lib/markdown-editor-media.js | 관리자 Markdown-first 에디터 미디어 선택 모달의 미디어 종류 판별, 대상별 선택 가능 여부, 미리보기 URL, 업로드 MIME 허용, 선택 순서·토글·항목 해석, 업로드 오류·한도 검증, fenced 미디어 블록 생성 | | lib/markdown-block-parser.js | 공개 Markdown 본문 문자열을 제목·문단·이미지·갤러리·fenced 블록·인용·목록·코드·표 렌더링 블록으로 변환 | | lib/markdown-block-context.js | 관리자 Markdown textarea·라이브 편집 포커스 위치 기준 이미지·갤러리·링크·iframe·HTML·인용·콜아웃·코드·토글 블록 설정 패널 대상 판별, 인용 옵션 공통 파서 연결, 코드 fenced 블록 여닫는 펜스 쌍 판별 | | lib/markdown-iframe-embed.js | 게시물 본문 iframe 블록의 코드 판별과 안전 속성 추출, `allowfullscreen`을 `allow=fullscreen` 권한으로 정규화 | diff --git a/docs/update.md b/docs/update.md index ad841f4..250f486 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,10 @@ # 업데이트 이력 +## v1.11.71 + +- 관리자 Markdown 에디터 미디어 업로드 API 오류 메시지 변환과 파일 개수·총량·개별 크기 한도 검증 로직을 공통 helper로 분리. +- 미디어 업로드 오류 메시지와 업로드 한도 검증 단위 테스트 추가. + ## v1.11.70 - 관리자 Markdown 에디터 미디어 선택 모달의 선택 순서·단일/갤러리 선택 토글·선택 URL 기반 항목 해석 로직을 공통 helper로 분리. diff --git a/lib/markdown-editor-media.js b/lib/markdown-editor-media.js index 59c2a84..91f43f7 100644 --- a/lib/markdown-editor-media.js +++ b/lib/markdown-editor-media.js @@ -5,6 +5,12 @@ import { isPostCardThumbnailUrl, withGeneratedImageCacheVersion } from './post-card-thumbnail-url.js' +import { + formatUploadSizeLimit, + getMaxUploadBytesForKind, + getUploadFilesTotalSize, + getUploadKind +} from './upload-size-limit.js' /** * 파일 크기를 표시용 문자열로 변환한다. @@ -233,3 +239,65 @@ export const resolveSelectedMediaItems = (selectedUrls, selectedItemMap, mediaIt .map((url) => itemMap[url] || items.find((item) => item?.url === url)) .filter(Boolean) } + +/** + * 업로드 API 오류 메시지를 사용자용 문장으로 변환한다. + * @param {unknown} error - fetch 오류 + * @returns {string} 오류 메시지 + */ +export 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 {Object} options - 업로드 한도 검사 옵션 + * @param {FileList|Array} options.files - 업로드 파일 목록 + * @param {number} options.maxFileCount - 최대 파일 수 + * @param {number} options.maxTotalSize - 최대 전체 용량 + * @param {{ image: number, video: number, audio: number, document: number }} options.uploadSizeLimits - 종류별 업로드 한도 + * @returns {string|null} 초과 시 오류 메시지, 아니면 null + */ +export const findUploadSizeLimitError = ({ + files, + maxFileCount, + maxTotalSize, + uploadSizeLimits +}) => { + const fileList = Array.from(files || []) + + if (fileList.length > maxFileCount) { + return `한 번에 업로드할 수 있는 파일은 최대 ${maxFileCount}개입니다.` + } + + const uploadTotalSize = getUploadFilesTotalSize(fileList) + + if (uploadTotalSize > maxTotalSize) { + return `한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 ${formatUploadSizeLimit(maxTotalSize)})` + } + + for (const file of fileList) { + const uploadKind = getUploadKind(file.type, file.name) + const maxBytes = getMaxUploadBytesForKind(uploadKind, uploadSizeLimits) + + if (file.size > maxBytes) { + return `파일 크기가 너무 큽니다. (최대 ${formatUploadSizeLimit(maxBytes)})` + } + } + + return null +} diff --git a/package-lock.json b/package-lock.json index 7b047e8..b8fac7d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.11.70", + "version": "1.11.71", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.11.70", + "version": "1.11.71", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 401a2b4..7fbf627 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.11.70", + "version": "1.11.71", "private": true, "type": "module", "imports": { diff --git a/tests/markdown-editor-media.test.js b/tests/markdown-editor-media.test.js index a7895a0..ac62ffb 100644 --- a/tests/markdown-editor-media.test.js +++ b/tests/markdown-editor-media.test.js @@ -2,6 +2,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { createMediaBlockMarkdown, + findUploadSizeLimitError, formatMediaFileSize, getGallerySelectionOrder, getMediaItemKind, @@ -9,8 +10,10 @@ import { isMediaItemSelectableForTarget, isUploadFileAllowedForMediaTarget, resolveSelectedMediaItems, + resolveUploadFetchErrorMessage, toggleMediaSelectionState } from '../lib/markdown-editor-media.js' +import { buildDefaultUploadSizeLimits } from '../lib/upload-size-limit.js' test('미디어 파일 크기를 표시 문자열로 변환한다', () => { assert.equal(formatMediaFileSize(512), '512 B') @@ -127,3 +130,58 @@ test('선택된 URL 순서대로 미디어 항목을 해석한다', () => { { url: '/b.png', source: 'list' } ]) }) + +test('업로드 API 오류를 사용자 메시지로 변환한다', () => { + assert.equal(resolveUploadFetchErrorMessage({ + data: { + message: ' 서버 오류 ' + } + }), '서버 오류') + assert.equal(resolveUploadFetchErrorMessage({ statusCode: 413 }), '업로드 가능한 파일 크기를 초과했습니다.') + assert.equal(resolveUploadFetchErrorMessage(new Error('네트워크 오류')), '네트워크 오류') + assert.equal(resolveUploadFetchErrorMessage({}), '파일 업로드에 실패했습니다.') +}) + +test('업로드 파일 한도 초과 메시지를 계산한다', () => { + const uploadSizeLimits = buildDefaultUploadSizeLimits({ + image: 100 + }) + + assert.equal(findUploadSizeLimitError({ + files: [ + { name: 'a.png', type: 'image/png', size: 10 }, + { name: 'b.png', type: 'image/png', size: 10 } + ], + maxFileCount: 1, + maxTotalSize: 1000, + uploadSizeLimits + }), '한 번에 업로드할 수 있는 파일은 최대 1개입니다.') + + assert.equal(findUploadSizeLimitError({ + files: [ + { name: 'a.png', type: 'image/png', size: 60 }, + { name: 'b.png', type: 'image/png', size: 60 } + ], + maxFileCount: 5, + maxTotalSize: 100, + uploadSizeLimits + }), '한 번에 업로드 가능한 전체 용량을 초과했습니다. (최대 100B)') + + assert.equal(findUploadSizeLimitError({ + files: [ + { name: 'a.png', type: 'image/png', size: 101 } + ], + maxFileCount: 5, + maxTotalSize: 1000, + uploadSizeLimits + }), '파일 크기가 너무 큽니다. (최대 100B)') + + assert.equal(findUploadSizeLimitError({ + files: [ + { name: 'a.png', type: 'image/png', size: 100 } + ], + maxFileCount: 5, + maxTotalSize: 1000, + uploadSizeLimits + }), null) +})