import { computed, ref, watch } from 'vue' import { getPostBodyPreviewThumbnailUrl, isPageCoverThumbnailUrl, isPostBodyThumbnailUrl, isPostCardThumbnailUrl, withGeneratedImageCacheVersion } from '../lib/post-card-thumbnail-url.js' /** * 자동 생성 파생 이미지 URL인지 확인한다. * @param {string|null|undefined} imageUrl - 검사할 이미지 URL * @returns {boolean} 자동 생성 이미지이면 true */ const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl) || isPostBodyThumbnailUrl(imageUrl) || isPageCoverThumbnailUrl(imageUrl) /** * 관리자 게시물 폼의 대표 이미지 선택 모달 상태를 만든다. * @param {Object} options - 대표 이미지 선택 옵션 * @param {Object} options.form - 게시물 폼 reactive 객체 * @param {number} [options.pageSize=50] - 미디어 페이지 크기 * @returns {Object} 대표 이미지 선택 상태와 이벤트 처리기 */ export const useAdminFeaturedImagePicker = ({ form, pageSize = 50 }) => { const mediaItems = ref([]) const isMediaPickerOpen = ref(false) const mediaPickerTarget = ref('featuredImage') const isLoadingMedia = ref(false) const isUploadingFeaturedImage = ref(false) const activeMediaPickerTab = ref('upload') const selectedMediaPickerUrl = ref('') const mediaSearchQuery = ref('') const { displayValue: mediaSearchInput, onInput: onMediaSearchInput, onCompositionEnd: onMediaSearchCompositionEnd } = useImeSearchInput(mediaSearchQuery, { debounce: 250 }) const mediaPickerPage = ref(1) const mediaPickerPageSize = pageSize const mediaPickerPagination = ref({ page: 1, pageSize: mediaPickerPageSize, total: 0, pageCount: 1 }) /** * 미디어 선택 페이지네이션을 초기값으로 되돌린다. * @returns {void} */ const resetMediaPickerPagination = () => { mediaPickerPage.value = 1 mediaPickerPagination.value = { page: 1, pageSize: mediaPickerPageSize, total: 0, pageCount: 1 } } /** * 미디어 라이브러리 목록을 조회한다. * @returns {Promise} */ const fetchMediaItems = async () => { isLoadingMedia.value = true try { const response = await $fetch('/admin/api/media', { query: { paged: '1', tab: 'library', page: mediaPickerPage.value, pageSize: mediaPickerPageSize, kind: 'image', search: mediaSearchQuery.value.trim() || undefined } }) mediaItems.value = response?.items || [] mediaPickerPagination.value = response?.pagination || { page: 1, pageSize: mediaPickerPageSize, total: mediaItems.value.length, pageCount: 1 } mediaPickerPage.value = mediaPickerPagination.value.page || 1 } finally { isLoadingMedia.value = false } } /** * 대표 이미지 선택 창을 연다. * @param {string} target - 선택 값을 적용할 폼 필드 * @returns {Promise} */ const openMediaPicker = async (target = 'featuredImage') => { mediaPickerTarget.value = target activeMediaPickerTab.value = 'upload' mediaSearchQuery.value = '' resetMediaPickerPagination() selectedMediaPickerUrl.value = isGeneratedThumbnailUrl(form[target]) ? '' : form[target] || '' isMediaPickerOpen.value = true } /** * 대표 이미지 선택 창을 닫는다. * @returns {void} */ const closeMediaPicker = () => { if (isUploadingFeaturedImage.value) { return } isMediaPickerOpen.value = false mediaItems.value = [] mediaSearchQuery.value = '' resetMediaPickerPagination() } const featuredImageMediaItems = computed(() => mediaItems.value.filter((item) => ( item?.kind === 'image' && !isGeneratedThumbnailUrl(item.url) ))) const mediaPickerPageCount = computed(() => Math.max(1, Number(mediaPickerPagination.value.pageCount) || 1)) const mediaPickerRangeLabel = computed(() => { const total = Number(mediaPickerPagination.value.total) || 0 if (total <= 0) { return '0개' } const page = Math.min(Math.max(Number(mediaPickerPagination.value.page) || mediaPickerPage.value, 1), mediaPickerPageCount.value) const start = ((page - 1) * mediaPickerPageSize) + 1 const end = Math.min(start + mediaPickerPageSize - 1, total) return `${total}개 중 ${start}-${end}개` }) const mediaPickerPageItems = computed(() => { const total = mediaPickerPageCount.value const current = Math.min(Math.max(Number(mediaPickerPage.value) || 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} page - 이동할 페이지 * @returns {Promise} */ const setMediaPickerPage = async (page) => { mediaPickerPage.value = Math.min(Math.max(Number(page) || 1, 1), mediaPickerPageCount.value) await fetchMediaItems() } /** * 대표 이미지 선택 모달에서 사용할 가벼운 이미지 미리보기 URL을 반환한다. * @param {Object} item - 미디어 항목 * @returns {string} 미리보기 URL */ const getFeaturedMediaPreviewUrl = (item) => { const url = String(item?.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 } watch(mediaSearchQuery, async () => { if (!isMediaPickerOpen.value || activeMediaPickerTab.value !== 'library') { return } mediaPickerPage.value = 1 await fetchMediaItems() }) watch(activeMediaPickerTab, async (tab) => { if (!isMediaPickerOpen.value || tab !== 'library') { return } mediaPickerPage.value = 1 await fetchMediaItems() }) /** * 대표 이미지 선택 상태를 변경한다. * @param {Object} item - 미디어 항목 * @returns {void} */ const selectPickedImage = (item) => { selectedMediaPickerUrl.value = item.url } /** * 선택한 대표 이미지를 적용한다. * @returns {void} */ const applyPickedImage = () => { if (!selectedMediaPickerUrl.value || isGeneratedThumbnailUrl(selectedMediaPickerUrl.value)) { return } form[mediaPickerTarget.value] = selectedMediaPickerUrl.value closeMediaPicker() } /** * 대표 이미지를 삭제한다. * @returns {void} */ const removeFeaturedImage = () => { form.featuredImage = '' } /** * 대표 이미지 파일을 업로드한다. * @param {File} file - 업로드 파일 * @returns {Promise} */ const uploadFeaturedImageFile = async (file) => { const formData = new FormData() formData.append('files', file) isUploadingFeaturedImage.value = true try { const result = await $fetch('/admin/api/uploads', { method: 'POST', body: formData }) selectedMediaPickerUrl.value = result.files?.[0]?.url || '' await fetchMediaItems() activeMediaPickerTab.value = 'library' } finally { isUploadingFeaturedImage.value = false } } /** * 대표 이미지 파일 입력을 처리한다. * @param {Event} event - 파일 입력 이벤트 * @returns {Promise} */ const uploadFeaturedImage = async (event) => { const files = event.target.files if (!files?.length) { return } try { await uploadFeaturedImageFile(files[0]) } finally { event.target.value = '' } } /** * 대표 이미지 드롭 업로드를 처리한다. * @param {DragEvent} event - 드롭 이벤트 * @returns {Promise} */ const dropFeaturedImage = async (event) => { const files = event.dataTransfer?.files if (!files?.length) { return } await uploadFeaturedImageFile(files[0]) } return { activeMediaPickerTab, applyPickedImage, closeMediaPicker, dropFeaturedImage, featuredImageMediaItems, getFeaturedMediaPreviewUrl, isLoadingMedia, isMediaPickerOpen, isUploadingFeaturedImage, mediaPickerPage, mediaPickerPageCount, mediaPickerPageItems, mediaPickerRangeLabel, mediaSearchInput, mediaSearchQuery, onMediaSearchCompositionEnd, onMediaSearchInput, openMediaPicker, removeFeaturedImage, selectPickedImage, selectedMediaPickerUrl, setMediaPickerPage, uploadFeaturedImage } }