Files
sori.studio/composables/useAdminMarkdownMediaPicker.js

382 lines
12 KiB
JavaScript

import { computed, ref, unref } from 'vue'
import {
buildMediaPickerApplyState,
buildMediaPickerEmptyMessage,
buildMediaPickerPageItems,
buildMediaPickerRangeLabel,
buildMediaPickerUploadHint,
createMediaPickerFetchQuery,
createDefaultMediaPickerPagination,
createMediaUploadFormData,
findUploadSizeLimitError,
getGallerySelectionOrder as getGallerySelectionOrderFromSelection,
getMediaPickerAcceptForTarget,
getMediaPickerKindForTarget,
getMediaPickerTitleForTarget,
isGalleryMediaPickerTarget,
isMediaItemSelectableForTarget,
isUploadFileAllowedForMediaTarget,
normalizeMediaPickerPage,
normalizeMediaPickerResponse,
resolveUploadedMediaFiles,
resolveSelectedMediaItems,
toggleMediaSelectionState
} from '../lib/markdown-editor-media.js'
import { useImeSearchInput } from './useImeSearchInput.js'
export const ADMIN_MARKDOWN_MEDIA_PICKER_PAGE_SIZE = 50
/**
* 관리자 Markdown 에디터의 미디어 선택 모달 상태를 관리한다.
* @param {Object} options - 상태 옵션
* @param {import('vue').Ref<Object>|import('vue').ComputedRef<Object>} options.uploadSizeLimits - 업로드 한도
* @param {import('vue').Ref<number>|import('vue').ComputedRef<number>} [options.uploadMaxFileCount] - 한 번에 업로드 가능한 파일 수
* @param {import('vue').Ref<number>|import('vue').ComputedRef<number>} [options.uploadMaxTotalSize] - 한 번에 업로드 가능한 전체 용량
* @param {number} [options.pageSize=50] - 페이지 크기
* @returns {Object} 미디어 선택 모달 상태와 액션
*/
export const useAdminMarkdownMediaPicker = ({
uploadSizeLimits,
uploadMaxFileCount,
uploadMaxTotalSize,
pageSize = ADMIN_MARKDOWN_MEDIA_PICKER_PAGE_SIZE
}) => {
const mediaItems = ref([])
const isMediaPickerOpen = ref(false)
const isLoadingMedia = ref(false)
const isUploading = ref(false)
const mediaPickerTarget = ref('image')
const activeMediaPickerTab = ref('upload')
const mediaSearchQuery = ref('')
const mediaPickerPage = ref(1)
const mediaPickerPagination = ref(createDefaultMediaPickerPagination(pageSize))
const selectedMediaUrls = ref([])
const selectedMediaItemMap = ref({})
const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
const mediaPickerAccept = computed(() => getMediaPickerAcceptForTarget(mediaPickerTarget.value))
const mediaPickerKind = computed(() => getMediaPickerKindForTarget(mediaPickerTarget.value))
const mediaPickerTitle = computed(() => getMediaPickerTitleForTarget(mediaPickerTarget.value))
const isGalleryMediaPicker = computed(() => isGalleryMediaPickerTarget(mediaPickerTarget.value))
const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1))
const filteredMediaItems = computed(() => mediaItems.value.filter(isMediaItemSelectableForCurrentTarget))
const mediaPickerRangeLabel = computed(() => buildMediaPickerRangeLabel({
pagination: mediaPickerPagination.value,
currentPage: mediaPickerPage.value,
pageCount: mediaPickerPageCount.value,
defaultPageSize: pageSize
}))
const mediaPickerPageItems = computed(() => buildMediaPickerPageItems({
currentPage: mediaPickerPage.value,
pageCount: mediaPickerPageCount.value
}))
const mediaPickerUploadHint = computed(() => buildMediaPickerUploadHint(
mediaPickerTarget.value,
unref(uploadSizeLimits)
))
const mediaPickerEmptyMessage = computed(() => buildMediaPickerEmptyMessage(mediaSearchQuery.value))
const mediaPickerApplyState = computed(() => buildMediaPickerApplyState({
selectedUrls: selectedMediaUrls.value,
isUploading: isUploading.value,
isGallery: isGalleryMediaPicker.value
}))
/**
* 미디어 항목이 현재 선택 대상과 맞는지 확인한다.
* @param {Object} item - 미디어 항목
* @returns {boolean} 선택 가능 여부
*/
function isMediaItemSelectableForCurrentTarget(item) {
return isMediaItemSelectableForTarget(item, mediaPickerTarget.value)
}
/**
* 업로드 파일이 현재 선택 대상에 맞는지 확인한다.
* @param {File} file - 브라우저 파일
* @returns {boolean} 허용 여부
*/
const isUploadFileAllowedForPicker = (file) => {
return isUploadFileAllowedForMediaTarget(file, mediaPickerTarget.value)
}
/**
* 미디어 선택 모달 상태를 연다.
* @param {string} target - 삽입 대상
* @returns {void}
*/
const openMediaPickerState = (target) => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaPickerPagination.value = createDefaultMediaPickerPagination(pageSize)
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
isMediaPickerOpen.value = true
}
/**
* 미디어 선택 모달 상태를 닫는다.
* @returns {boolean} 닫힘 처리 여부
*/
const closeMediaPickerState = () => {
if (isUploading.value) {
return false
}
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
selectedMediaItemMap.value = {}
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
mediaItems.value = []
mediaPickerPagination.value = createDefaultMediaPickerPagination(pageSize)
return true
}
/**
* 미디어 API 응답을 현재 상태에 반영한다.
* @param {Object} response - 미디어 API 응답
* @returns {void}
*/
const applyMediaPickerResponse = (response) => {
const normalizedResponse = normalizeMediaPickerResponse(response, pageSize)
mediaItems.value = normalizedResponse.items
mediaPickerPagination.value = normalizedResponse.pagination
mediaPickerPage.value = mediaPickerPagination.value.page || 1
}
/**
* 미디어 라이브러리 목록을 조회한다.
* @returns {Promise<void>}
*/
const fetchMediaItems = async () => {
isLoadingMedia.value = true
try {
const response = await $fetch('/admin/api/media', {
query: createMediaPickerFetchQuery({
page: mediaPickerPage.value,
pageSize,
kind: mediaPickerKind.value,
searchQuery: mediaSearchQuery.value
})
})
applyMediaPickerResponse(response)
} finally {
isLoadingMedia.value = false
}
}
/**
* 미디어 파일을 업로드한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<Object[]>} 업로드된 파일 목록
*/
const uploadMediaFiles = async (files) => {
const sizeLimitError = findUploadSizeLimitError({
files,
maxFileCount: unref(uploadMaxFileCount),
maxTotalSize: unref(uploadMaxTotalSize),
uploadSizeLimits: unref(uploadSizeLimits)
})
if (sizeLimitError) {
throw new Error(sizeLimitError)
}
const result = await $fetch('/admin/api/uploads', {
method: 'POST',
body: createMediaUploadFormData(files)
})
return resolveUploadedMediaFiles(result)
}
/**
* 업로드 상태를 관리하며 미디어 파일을 업로드한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<Object[]>} 업로드된 파일 목록
*/
const uploadMediaFilesWithState = async (files) => {
if (!files?.length || isUploading.value) {
return []
}
isUploading.value = true
try {
return await uploadMediaFiles(files)
} finally {
isUploading.value = false
}
}
/**
* 현재 미디어 선택 모달에서 파일을 업로드하고 선택 상태를 갱신한다.
* @param {FileList|Array<File>} files - 업로드 파일 목록
* @returns {Promise<Object[]>} 업로드된 파일 목록
*/
const uploadFilesIntoMediaPicker = async (files) => {
if (!files?.length || isUploading.value) {
return []
}
isUploading.value = true
try {
const uploadedFiles = await uploadMediaFiles(files)
selectUploadedMediaFiles(uploadedFiles)
activeMediaPickerTab.value = 'library'
mediaSearchQuery.value = ''
mediaPickerPage.value = 1
try {
await fetchMediaItems()
} catch {
prependUploadedMediaItems(uploadedFiles)
}
return uploadedFiles
} finally {
isUploading.value = false
}
}
/**
* 미디어 선택 페이지를 변경한다.
* @param {number} page - 이동할 페이지
* @returns {void}
*/
const setMediaPickerPage = (page) => {
mediaPickerPage.value = normalizeMediaPickerPage(page, mediaPickerPageCount.value)
}
/**
* 미디어 선택 상태를 토글한다.
* @param {Object} mediaItem - 미디어 항목
* @returns {void}
*/
const toggleMediaSelection = (mediaItem) => {
const nextSelection = toggleMediaSelectionState({
selectedUrls: selectedMediaUrls.value,
selectedItemMap: selectedMediaItemMap.value,
mediaItem,
isGallery: isGalleryMediaPicker.value
})
selectedMediaUrls.value = nextSelection.selectedUrls
selectedMediaItemMap.value = nextSelection.selectedItemMap
}
/**
* 선택된 미디어 항목을 현재 목록과 선택 맵에서 해석한다.
* @returns {Object[]} 선택된 미디어 항목
*/
const resolveCurrentSelectedMediaItems = () => resolveSelectedMediaItems(
selectedMediaUrls.value,
selectedMediaItemMap.value,
mediaItems.value
)
/**
* 업로드 직후 선택 상태를 갱신한다.
* @param {Object[]} uploadedFiles - 업로드된 파일 목록
* @returns {string[]} 선택된 URL 목록
*/
const selectUploadedMediaFiles = (uploadedFiles) => {
const uploadedUrls = (Array.isArray(uploadedFiles) ? uploadedFiles : [])
.filter(isMediaItemSelectableForCurrentTarget)
.map((file) => file.url)
selectedMediaItemMap.value = (Array.isArray(uploadedFiles) ? uploadedFiles : []).reduce((map, file) => {
if (uploadedUrls.includes(file.url)) {
map[file.url] = file
}
return map
}, {})
selectedMediaUrls.value = isGalleryMediaPicker.value
? uploadedUrls
: uploadedUrls.slice(0, 1)
return uploadedUrls
}
/**
* 업로드된 파일을 목록 앞에 임시로 합친다.
* @param {Object[]} uploadedFiles - 업로드된 파일 목록
* @returns {void}
*/
const prependUploadedMediaItems = (uploadedFiles) => {
mediaItems.value = [
...(Array.isArray(uploadedFiles) ? uploadedFiles : []),
...mediaItems.value
]
}
/**
* 갤러리 미디어 선택 순서를 반환한다.
* @param {string} url - 미디어 URL
* @returns {number} 1부터 시작하는 선택 순서
*/
const getGallerySelectionOrder = (url) => {
if (!isGalleryMediaPicker.value) {
return 0
}
return getGallerySelectionOrderFromSelection(selectedMediaUrls.value, url)
}
return {
mediaItems,
isMediaPickerOpen,
isLoadingMedia,
isUploading,
mediaPickerTarget,
activeMediaPickerTab,
mediaSearchQuery,
mediaSearchInput,
onMediaSearchInput,
onMediaSearchCompositionEnd,
mediaPickerPage,
mediaPickerPageSize: pageSize,
mediaPickerPagination,
selectedMediaUrls,
selectedMediaItemMap,
mediaPickerAccept,
mediaPickerKind,
mediaPickerTitle,
isGalleryMediaPicker,
mediaPickerPageCount,
filteredMediaItems,
mediaPickerRangeLabel,
mediaPickerPageItems,
mediaPickerUploadHint,
mediaPickerEmptyMessage,
mediaPickerApplyState,
isMediaItemSelectableForCurrentTarget,
isUploadFileAllowedForPicker,
openMediaPickerState,
closeMediaPickerState,
applyMediaPickerResponse,
fetchMediaItems,
uploadMediaFiles,
uploadMediaFilesWithState,
uploadFilesIntoMediaPicker,
setMediaPickerPage,
toggleMediaSelection,
resolveCurrentSelectedMediaItems,
selectUploadedMediaFiles,
prependUploadedMediaItems,
getGallerySelectionOrder
}
}