725 lines
22 KiB
JavaScript
725 lines
22 KiB
JavaScript
import {
|
|
getPostBodyPreviewThumbnailUrl,
|
|
isPageCoverThumbnailUrl,
|
|
isPostBodyThumbnailUrl,
|
|
isPostCardThumbnailUrl,
|
|
withGeneratedImageCacheVersion
|
|
} from './post-card-thumbnail-url.js'
|
|
import { serializeImageMarkdown } from './markdown-image.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 - 선택 대상
|
|
* @returns {string} 모달 제목
|
|
*/
|
|
export const getMediaPickerTitleForTarget = (target) => {
|
|
if (isGalleryMediaPickerTarget(target)) {
|
|
return '갤러리 이미지 선택'
|
|
}
|
|
|
|
if (target === 'video') {
|
|
return '비디오 선택'
|
|
}
|
|
|
|
if (target === 'audio') {
|
|
return '오디오 선택'
|
|
}
|
|
|
|
if (target === 'file') {
|
|
return '파일 선택'
|
|
}
|
|
|
|
return '이미지 선택'
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 대상별 업로드 안내 문구를 만든다.
|
|
* @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)})`
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 라이브러리 빈 상태 문구를 만든다.
|
|
* @param {string} searchQuery - 검색어
|
|
* @returns {string} 빈 상태 문구
|
|
*/
|
|
export const buildMediaPickerEmptyMessage = (searchQuery) => {
|
|
const normalizedQuery = String(searchQuery || '').trim()
|
|
|
|
if (normalizedQuery) {
|
|
return `「${normalizedQuery}」에 맞는 미디어가 없습니다.`
|
|
}
|
|
|
|
return '선택할 미디어가 없습니다. 업로드 탭에서 파일을 추가할 수 있습니다.'
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 적용 버튼 상태를 만든다.
|
|
* @param {Object} options - 적용 버튼 상태 옵션
|
|
* @param {string[]} options.selectedUrls - 선택된 URL 목록
|
|
* @param {boolean} options.isUploading - 업로드 중 여부
|
|
* @param {boolean} options.isGallery - 갤러리 다중 선택 여부
|
|
* @returns {{ disabled: boolean, label: string }} 적용 버튼 상태
|
|
*/
|
|
export const buildMediaPickerApplyState = ({
|
|
selectedUrls,
|
|
isUploading,
|
|
isGallery
|
|
}) => {
|
|
const selectedCount = Array.isArray(selectedUrls) ? selectedUrls.length : 0
|
|
|
|
return {
|
|
disabled: Boolean(isUploading) || selectedCount <= 0,
|
|
label: isGallery && selectedCount > 0 ? `${selectedCount}개 삽입` : '삽입'
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 모달의 목록 조회 query를 만든다.
|
|
* @param {Object} options - 조회 옵션
|
|
* @param {number} options.page - 페이지 번호
|
|
* @param {number} options.pageSize - 페이지 크기
|
|
* @param {string} options.kind - 미디어 종류
|
|
* @param {string} options.searchQuery - 검색어
|
|
* @returns {Record<string, string|number|undefined>} 미디어 API query
|
|
*/
|
|
export const createMediaPickerFetchQuery = ({
|
|
page,
|
|
pageSize,
|
|
kind,
|
|
searchQuery
|
|
}) => {
|
|
const normalizedSearchQuery = String(searchQuery || '').trim()
|
|
|
|
return {
|
|
paged: '1',
|
|
tab: 'library',
|
|
page,
|
|
pageSize,
|
|
kind,
|
|
search: normalizedSearchQuery || undefined
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 미디어 항목으로 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 {Object} item - 미디어 항목
|
|
* @returns {{ url: string, useAlt: boolean, caption: string }} 이미지 데이터
|
|
*/
|
|
export const createMediaImageInsertData = (item) => ({
|
|
url: item.url,
|
|
useAlt: false,
|
|
caption: ''
|
|
})
|
|
|
|
/**
|
|
* 선택된 미디어를 에디터 본문에 적용할 계획으로 변환한다.
|
|
* @param {Object} options - 계획 옵션
|
|
* @param {string} options.target - 미디어 선택 대상
|
|
* @param {Object[]} options.selectedItems - 선택된 미디어 항목
|
|
* @param {number|null} options.liveSlashLine - 슬래시 명령으로 삽입할 줄
|
|
* @returns {{ kind: 'none' }|{ kind: 'insert-at-line', line: number, lines: string[] }|{ kind: 'image', image: { url: string, useAlt: boolean, caption: string } }|{ kind: 'active-gallery', images: Array<{ url: string, useAlt: boolean, caption: string }> }|{ kind: 'gallery', images: Array<{ url: string, useAlt: boolean, caption: string }> }|{ kind: 'block', lines: string[] }} 적용 계획
|
|
*/
|
|
export const createSelectedMediaInsertPlan = ({
|
|
target,
|
|
selectedItems,
|
|
liveSlashLine
|
|
}) => {
|
|
const items = (Array.isArray(selectedItems) ? selectedItems : []).filter((item) => item?.url)
|
|
const line = Number.isFinite(liveSlashLine) ? Math.max(0, Math.floor(liveSlashLine)) : null
|
|
|
|
if (line !== null) {
|
|
if (target === 'image' && items[0]) {
|
|
return {
|
|
kind: 'insert-at-line',
|
|
line,
|
|
lines: [serializeImageMarkdown(createMediaImageInsertData(items[0]))]
|
|
}
|
|
}
|
|
|
|
if (target === 'gallery' && items.length) {
|
|
return {
|
|
kind: 'insert-at-line',
|
|
line,
|
|
lines: [
|
|
':::gallery',
|
|
...items.map((item) => serializeImageMarkdown(createMediaImageInsertData(item))),
|
|
':::'
|
|
]
|
|
}
|
|
}
|
|
|
|
if (['video', 'audio', 'file'].includes(target) && items[0]) {
|
|
return {
|
|
kind: 'insert-at-line',
|
|
line,
|
|
lines: createMediaBlockMarkdown(target, items[0])
|
|
}
|
|
}
|
|
|
|
return { kind: 'none' }
|
|
}
|
|
|
|
if (target === 'image' && items[0]) {
|
|
return {
|
|
kind: 'image',
|
|
image: createMediaImageInsertData(items[0])
|
|
}
|
|
}
|
|
|
|
if (target === 'active-gallery' && items.length) {
|
|
return {
|
|
kind: 'active-gallery',
|
|
images: items.map(createMediaImageInsertData)
|
|
}
|
|
}
|
|
|
|
if (target === 'gallery' && items.length) {
|
|
return {
|
|
kind: 'gallery',
|
|
images: items.map(createMediaImageInsertData)
|
|
}
|
|
}
|
|
|
|
if (['video', 'audio', 'file'].includes(target) && items[0]) {
|
|
return {
|
|
kind: 'block',
|
|
lines: createMediaBlockMarkdown(target, items[0])
|
|
}
|
|
}
|
|
|
|
return { kind: 'none' }
|
|
}
|
|
|
|
/**
|
|
* 업로드 입력 파일 목록을 배열로 정규화한다.
|
|
* @param {FileList|Array<File>|null|undefined} files - 업로드 입력 파일 목록
|
|
* @returns {File[]} 파일 배열
|
|
*/
|
|
export const normalizeMediaUploadFiles = (files) => {
|
|
return Array.from(files || []).filter(Boolean)
|
|
}
|
|
|
|
/**
|
|
* 업로드 파일 목록으로 multipart FormData를 만든다.
|
|
* @param {FileList|Array<File>} files - 업로드 파일 목록
|
|
* @returns {FormData} 업로드 요청 본문
|
|
*/
|
|
export const createMediaUploadFormData = (files) => {
|
|
const formData = new FormData()
|
|
|
|
normalizeMediaUploadFiles(files).forEach((file) => {
|
|
formData.append('files', file)
|
|
})
|
|
|
|
return formData
|
|
}
|
|
|
|
/**
|
|
* 업로드 API 응답에서 파일 목록을 추출한다.
|
|
* @param {Object|null|undefined} response - 업로드 API 응답
|
|
* @returns {Object[]} 업로드된 파일 목록
|
|
*/
|
|
export const resolveUploadedMediaFiles = (response) => {
|
|
return Array.isArray(response?.files) ? response.files : []
|
|
}
|
|
|
|
/**
|
|
* 이미지 파일 목록에 맞는 기본 삽입 대상을 반환한다.
|
|
* @param {FileList|Array<File>} files - 이미지 파일 목록
|
|
* @returns {'image'|'gallery'} 삽입 대상
|
|
*/
|
|
export const resolveImageUploadInsertTarget = (files) => {
|
|
return normalizeMediaUploadFiles(files).length > 1 ? 'gallery' : 'image'
|
|
}
|
|
|
|
/**
|
|
* 파일 목록에서 이미지 파일만 반환한다.
|
|
* @param {FileList|Array<File>} files - 입력 파일 목록
|
|
* @returns {File[]} 이미지 파일 목록
|
|
*/
|
|
export const filterImageUploadFiles = (files) => {
|
|
return normalizeMediaUploadFiles(files).filter((file) => String(file?.type || '').startsWith('image/'))
|
|
}
|
|
|
|
/**
|
|
* 갤러리 미디어 선택 순서를 반환한다.
|
|
* @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<string, Object>} options.selectedItemMap - 선택된 미디어 항목 맵
|
|
* @param {Object} options.mediaItem - 토글 대상 미디어 항목
|
|
* @param {boolean} options.isGallery - 갤러리 다중 선택 여부
|
|
* @returns {{ selectedUrls: string[], selectedItemMap: Record<string, Object> }} 다음 선택 상태
|
|
*/
|
|
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<string, Object>} 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<File>} 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 {number|string} page - 요청 페이지
|
|
* @param {number|string} pageCount - 전체 페이지 수
|
|
* @returns {number} 보정된 페이지
|
|
*/
|
|
export const normalizeMediaPickerPage = (page, pageCount) => {
|
|
const total = Math.max(1, Number(pageCount) || 1)
|
|
return Math.min(Math.max(Number(page) || 1, 1), total)
|
|
}
|
|
|
|
/**
|
|
* 미디어 선택 모달의 페이지 범위 표시 문구를 만든다.
|
|
* @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 page = normalizeMediaPickerPage(Number(pagination?.page) || currentPage, pageCount)
|
|
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<number|string>} 페이지 번호와 ellipsis 키 목록
|
|
*/
|
|
export const buildMediaPickerPageItems = ({
|
|
currentPage,
|
|
pageCount
|
|
}) => {
|
|
const total = Math.max(1, Number(pageCount) || 1)
|
|
const current = normalizeMediaPickerPage(currentPage, 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)
|
|
}
|
|
}
|
|
}
|