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|import('vue').ComputedRef} options.uploadSizeLimits - 업로드 한도 * @param {import('vue').Ref|import('vue').ComputedRef} [options.uploadMaxFileCount] - 한 번에 업로드 가능한 파일 수 * @param {import('vue').Ref|import('vue').ComputedRef} [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} */ 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} files - 업로드 파일 목록 * @returns {Promise} 업로드된 파일 목록 */ 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} files - 업로드 파일 목록 * @returns {Promise} 업로드된 파일 목록 */ 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} files - 업로드 파일 목록 * @returns {Promise} 업로드된 파일 목록 */ 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 } }