Files
sori.studio/composables/useAdminFeaturedImagePicker.js

341 lines
9.2 KiB
JavaScript

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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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<void>}
*/
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
}
}