import { getPostBodyPreviewThumbnailUrl, isPageCoverThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl, withGeneratedImageCacheVersion } from './post-card-thumbnail-url.js' import { formatUploadSizeLimit, getMaxUploadBytesForKind, getUploadFilesTotalSize, getUploadKind } from './upload-size-limit.js' /** * 파일 크기를 표시용 문자열로 변환한다. * @param {number} size - 바이트 크기 * @returns {string} 표시용 크기 */ export 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'} 미디어 종류 */ export 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 - 미디어 항목 * @param {string} target - 선택 대상 * @returns {boolean} 선택 가능 여부 */ export const isMediaItemSelectableForTarget = (item, target) => { if (isPostCardThumbnailUrl(item?.url) || isPostBodyThumbnailUrl(item?.url) || isPageCoverThumbnailUrl(item?.url)) { return false } const kind = getMediaItemKind(item) if (['image', 'gallery', 'active-gallery'].includes(target)) { return kind === 'image' } return kind === target } /** * 미디어 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다. * 선택·삽입 값은 원본 URL을 유지하고, 화면 표시만 파생 이미지를 우선 사용한다. * @param {Object} item - 미디어 항목 * @returns {string} 미리보기 URL */ export const getMediaPickerPreviewUrl = (item) => { const url = String(item?.url || '') if (getMediaItemKind(item) !== 'image') { return url } if (item?.bodyThumbnailStatus?.hasThumbnail) { return withGeneratedImageCacheVersion(getPostBodyPreviewThumbnailUrl(url), `${item?.updatedAt || ''}-${item?.size || ''}`) } if (item?.thumbnailStatus?.hasThumbnail && item.thumbnailStatus.thumbnailUrl) { return withGeneratedImageCacheVersion(item.thumbnailStatus.thumbnailUrl, `${item?.updatedAt || ''}-${item?.size || ''}`) } return url } /** * 드롭된 파일이 현재 미디어 선택 대상에 맞는지 확인한다. * @param {{ type?: string }} file - 브라우저 파일 * @param {string} target - 선택 대상 * @returns {boolean} 허용 여부 */ export const isUploadFileAllowedForMediaTarget = (file, target) => { const type = String(file?.type || '') if (['image', 'gallery', 'active-gallery'].includes(target)) { return type.startsWith('image/') } if (target === 'video') { return type.startsWith('video/') } if (target === 'audio') { return type.startsWith('audio/') } return !type.startsWith('image/') && !type.startsWith('video/') && !type.startsWith('audio/') } /** * 미디어 선택 대상이 갤러리 다중 선택인지 반환한다. * @param {string} target - 선택 대상 * @returns {boolean} 갤러리 다중 선택 여부 */ export const isGalleryMediaPickerTarget = (target) => target === 'gallery' || target === 'active-gallery' /** * 미디어 선택 대상에 맞는 파일 입력 accept 값을 반환한다. * @param {string} target - 선택 대상 * @returns {string} 파일 입력 accept 값 */ export const getMediaPickerAcceptForTarget = (target) => { if (['image', 'gallery', 'active-gallery'].includes(target)) { return 'image/*' } if (target === 'video') { return 'video/*' } if (target === 'audio') { return 'audio/*' } return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx' } /** * 미디어 선택 대상에 맞는 미디어 API kind 값을 반환한다. * @param {string} target - 선택 대상 * @returns {string} API kind 값 */ export const getMediaPickerKindForTarget = (target) => { if (['image', 'gallery', 'active-gallery'].includes(target)) { return 'image' } if (['video', 'audio', 'file'].includes(target)) { return target } return 'all' } /** * 미디어 선택 대상별 업로드 안내 문구를 만든다. * @param {string} target - 선택 대상 * @param {{ image: number, video: number, audio: number, document: number }} uploadSizeLimits - 종류별 업로드 한도 * @returns {string} 업로드 안내 문구 */ export const buildMediaPickerUploadHint = (target, uploadSizeLimits) => { if (isGalleryMediaPickerTarget(target)) { return `여러 이미지를 한 번에 선택할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.image)})` } if (target === 'video') { return `MP4, WebM, MOV 비디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.video)})` } if (target === 'audio') { return `MP3, WAV, OGG, M4A 오디오를 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.audio)})` } if (target === 'file') { return `PDF, ZIP, 문서 파일을 업로드할 수 있습니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.document)})` } return `단일 이미지가 본문에 삽입됩니다. (최대 ${formatUploadSizeLimit(uploadSizeLimits.image)})` } /** * 미디어 항목으로 Prose 미디어 블록을 만든다. * @param {'video'|'audio'|'file'} blockType - 블록 유형 * @param {Object} item - 미디어 항목 * @returns {string[]} 마크다운 줄 */ export 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=${item.name || title}`, 'description=', `name=${item.name || title}`, `size=${formatMediaFileSize(item.size)}`, ':::' ] } /** * 갤러리 미디어 선택 순서를 반환한다. * @param {string[]} selectedUrls - 선택된 URL 목록 * @param {string} url - 미디어 URL * @returns {number} 1부터 시작하는 선택 순서, 선택되지 않았으면 0 */ export const getGallerySelectionOrder = (selectedUrls, url) => { const index = Array.isArray(selectedUrls) ? selectedUrls.indexOf(url) : -1 return index >= 0 ? index + 1 : 0 } /** * 미디어 선택 상태를 토글한다. * @param {Object} options - 토글 옵션 * @param {string[]} options.selectedUrls - 선택된 URL 목록 * @param {Record} options.selectedItemMap - 선택된 미디어 항목 맵 * @param {Object} options.mediaItem - 토글 대상 미디어 항목 * @param {boolean} options.isGallery - 갤러리 다중 선택 여부 * @returns {{ selectedUrls: string[], selectedItemMap: Record }} 다음 선택 상태 */ export const toggleMediaSelectionState = ({ selectedUrls, selectedItemMap, mediaItem, isGallery }) => { const url = mediaItem?.url if (!url) { return { selectedUrls: Array.isArray(selectedUrls) ? selectedUrls : [], selectedItemMap: selectedItemMap && typeof selectedItemMap === 'object' ? selectedItemMap : {} } } if (!isGallery) { return { selectedUrls: [url], selectedItemMap: { [url]: mediaItem } } } const currentUrls = Array.isArray(selectedUrls) ? selectedUrls : [] const currentMap = selectedItemMap && typeof selectedItemMap === 'object' ? selectedItemMap : {} if (currentUrls.includes(url)) { const nextMap = { ...currentMap } delete nextMap[url] return { selectedUrls: currentUrls.filter((selectedUrl) => selectedUrl !== url), selectedItemMap: nextMap } } return { selectedUrls: [...currentUrls, url], selectedItemMap: { ...currentMap, [url]: mediaItem } } } /** * 선택된 URL 순서에 맞춰 미디어 항목 목록을 반환한다. * @param {string[]} selectedUrls - 선택된 URL 목록 * @param {Record} selectedItemMap - 선택된 미디어 항목 맵 * @param {Object[]} mediaItems - 현재 미디어 목록 * @returns {Object[]} 선택된 미디어 항목 목록 */ export const resolveSelectedMediaItems = (selectedUrls, selectedItemMap, mediaItems) => { const itemMap = selectedItemMap && typeof selectedItemMap === 'object' ? selectedItemMap : {} const items = Array.isArray(mediaItems) ? mediaItems : [] return (Array.isArray(selectedUrls) ? selectedUrls : []) .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 } /** * 미디어 선택 모달의 페이지 범위 표시 문구를 만든다. * @param {Object} options - 범위 표시 옵션 * @param {Object} options.pagination - 서버 페이지네이션 응답 * @param {number} options.currentPage - 현재 페이지 fallback * @param {number} options.pageCount - 전체 페이지 수 * @param {number} options.defaultPageSize - 기본 페이지 크기 * @returns {string} 범위 표시 문구 */ export const buildMediaPickerRangeLabel = ({ pagination, currentPage, pageCount, defaultPageSize }) => { const total = Number(pagination?.total) || 0 if (total <= 0) { return '0개' } const normalizedPageCount = Math.max(1, Number(pageCount) || 1) const page = Math.min(Math.max(Number(pagination?.page) || currentPage, 1), normalizedPageCount) const pageSize = Number(pagination?.pageSize) || defaultPageSize const start = ((page - 1) * pageSize) + 1 const end = Math.min(start + pageSize - 1, total) return `${total}개 중 ${start}-${end}개` } /** * 미디어 선택 모달 페이지네이션 항목을 만든다. * @param {Object} options - 페이지 항목 옵션 * @param {number} options.currentPage - 현재 페이지 * @param {number} options.pageCount - 전체 페이지 수 * @returns {Array} 페이지 번호와 ellipsis 키 목록 */ export const buildMediaPickerPageItems = ({ currentPage, pageCount }) => { const total = Math.max(1, Number(pageCount) || 1) const current = Math.min(Math.max(Number(currentPage) || 1, 1), total) const pages = new Set([1, total, current]) for (let page = current - 2; page <= current + 2; page += 1) { pages.add(page) } if (current <= 3) { pages.add(2) pages.add(3) pages.add(4) } if (current >= total - 2) { pages.add(total - 3) pages.add(total - 2) pages.add(total - 1) } const orderedPages = [...pages].filter((page) => page >= 1 && page <= total).sort((left, right) => left - right) return orderedPages.reduce((items, page, index) => { const previousPage = orderedPages[index - 1] if (previousPage && page - previousPage > 1) { items.push(`ellipsis-${previousPage}-${page}`) } items.push(page) return items }, []) } /** * 미디어 선택 모달의 기본 페이지네이션 상태를 만든다. * @param {number} pageSize - 기본 페이지 크기 * @param {number} [total=0] - 전체 항목 수 * @returns {{ page: number, pageSize: number, total: number, pageCount: number }} 페이지네이션 상태 */ export const createDefaultMediaPickerPagination = (pageSize, total = 0) => ({ page: 1, pageSize, total, pageCount: 1 }) /** * 미디어 선택 API 응답을 에디터 상태용 목록과 페이지네이션으로 정규화한다. * @param {Object|null|undefined} response - 미디어 API 응답 * @param {number} pageSize - 기본 페이지 크기 * @returns {{ items: Object[], pagination: { page: number, pageSize: number, total: number, pageCount: number } }} 정규화된 응답 */ export const normalizeMediaPickerResponse = (response, pageSize) => { const items = Array.isArray(response?.items) ? response.items : [] const pagination = response?.pagination && typeof response.pagination === 'object' ? response.pagination : createDefaultMediaPickerPagination(pageSize, items.length) return { items, pagination: { page: Math.max(1, Number(pagination.page) || 1), pageSize: Math.max(1, Number(pagination.pageSize) || pageSize), total: Math.max(0, Number(pagination.total) || 0), pageCount: Math.max(1, Number(pagination.pageCount) || 1) } } }