대표 이미지 선택 로직 분리
This commit is contained in:
@@ -5,13 +5,6 @@ import {
|
|||||||
toAdminPostStoredTitle
|
toAdminPostStoredTitle
|
||||||
} from '../../lib/admin-post-title.js'
|
} from '../../lib/admin-post-title.js'
|
||||||
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
import { normalizeMarkdownContent } from '../../lib/markdown-content-normalizer.js'
|
||||||
import {
|
|
||||||
getPostBodyPreviewThumbnailUrl,
|
|
||||||
isPageCoverThumbnailUrl,
|
|
||||||
isPostBodyThumbnailUrl,
|
|
||||||
isPostCardThumbnailUrl,
|
|
||||||
withGeneratedImageCacheVersion
|
|
||||||
} from '../../lib/post-card-thumbnail-url.js'
|
|
||||||
import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js'
|
import { DEFAULT_POST_TAG_LIMIT } from '../../lib/post-tag-limit.js'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -66,29 +59,8 @@ const blockEditor = ref(null)
|
|||||||
const editorBlockPanel = ref({ open: false, panel: null })
|
const editorBlockPanel = ref({ open: false, panel: null })
|
||||||
/** 본문 에디터 모드: write | preview */
|
/** 본문 에디터 모드: write | preview */
|
||||||
const editorMode = ref('write')
|
const editorMode = ref('write')
|
||||||
const mediaItems = ref([])
|
|
||||||
const isMediaPickerOpen = ref(false)
|
|
||||||
const mediaPickerTarget = ref('featuredImage')
|
|
||||||
const isLoadingMedia = ref(false)
|
|
||||||
const isUploadingFeaturedImage = ref(false)
|
|
||||||
const isSettingsOpen = ref(true)
|
const isSettingsOpen = ref(true)
|
||||||
const isTitleInputComposing = ref(false)
|
const isTitleInputComposing = 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 = 50
|
|
||||||
const mediaPickerPagination = ref({
|
|
||||||
page: 1,
|
|
||||||
pageSize: mediaPickerPageSize,
|
|
||||||
total: 0,
|
|
||||||
pageCount: 1
|
|
||||||
})
|
|
||||||
const savedPostSnapshot = ref('')
|
const savedPostSnapshot = ref('')
|
||||||
const failedDraftAutosaveSnapshot = ref('')
|
const failedDraftAutosaveSnapshot = ref('')
|
||||||
const slugFieldError = ref('')
|
const slugFieldError = ref('')
|
||||||
@@ -109,15 +81,6 @@ const { data: siteSettings } = useFetch('/admin/api/settings', {
|
|||||||
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
|
/** @type {number} 한국어 본문 예상 읽기 속도(분당 공백 제외 문자 수) */
|
||||||
const READING_CHARS_PER_MINUTE = 600
|
const READING_CHARS_PER_MINUTE = 600
|
||||||
|
|
||||||
/**
|
|
||||||
* 자동 생성 파생 이미지 URL인지 확인한다.
|
|
||||||
* @param {string|null|undefined} imageUrl - 검사할 이미지 URL
|
|
||||||
* @returns {boolean} 자동 생성 이미지이면 true
|
|
||||||
*/
|
|
||||||
const isGeneratedThumbnailUrl = (imageUrl) => isPostCardThumbnailUrl(imageUrl)
|
|
||||||
|| isPostBodyThumbnailUrl(imageUrl)
|
|
||||||
|| isPageCoverThumbnailUrl(imageUrl)
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ISO 날짜를 datetime-local 입력값으로 변환
|
* ISO 날짜를 datetime-local 입력값으로 변환
|
||||||
* @param {string} value - ISO 날짜 문자열
|
* @param {string} value - ISO 날짜 문자열
|
||||||
@@ -469,6 +432,34 @@ const {
|
|||||||
siteSettings
|
siteSettings
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeMediaPickerTab,
|
||||||
|
applyPickedImage,
|
||||||
|
closeMediaPicker,
|
||||||
|
dropFeaturedImage,
|
||||||
|
featuredImageMediaItems,
|
||||||
|
getFeaturedMediaPreviewUrl,
|
||||||
|
isLoadingMedia,
|
||||||
|
isMediaPickerOpen,
|
||||||
|
isUploadingFeaturedImage,
|
||||||
|
mediaPickerPage,
|
||||||
|
mediaPickerPageCount,
|
||||||
|
mediaPickerPageItems,
|
||||||
|
mediaPickerRangeLabel,
|
||||||
|
mediaSearchInput,
|
||||||
|
mediaSearchQuery,
|
||||||
|
onMediaSearchCompositionEnd,
|
||||||
|
onMediaSearchInput,
|
||||||
|
openMediaPicker,
|
||||||
|
removeFeaturedImage,
|
||||||
|
selectPickedImage,
|
||||||
|
selectedMediaPickerUrl,
|
||||||
|
setMediaPickerPage,
|
||||||
|
uploadFeaturedImage
|
||||||
|
} = useAdminFeaturedImagePicker({
|
||||||
|
form
|
||||||
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 예약 발행 여부 확인
|
* 예약 발행 여부 확인
|
||||||
* @returns {boolean} 예약 발행 여부
|
* @returns {boolean} 예약 발행 여부
|
||||||
@@ -722,272 +713,6 @@ const {
|
|||||||
onLeaveConfirmed: clearAutosaveLocal
|
onLeaveConfirmed: clearAutosaveLocal
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
|
||||||
* 미디어 라이브러리 목록 조회
|
|
||||||
* @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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대표 이미지 선택 창 열기
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const openMediaPicker = async (target = 'featuredImage') => {
|
|
||||||
mediaPickerTarget.value = target
|
|
||||||
activeMediaPickerTab.value = 'upload'
|
|
||||||
mediaSearchQuery.value = ''
|
|
||||||
mediaPickerPage.value = 1
|
|
||||||
mediaPickerPagination.value = {
|
|
||||||
page: 1,
|
|
||||||
pageSize: mediaPickerPageSize,
|
|
||||||
total: 0,
|
|
||||||
pageCount: 1
|
|
||||||
}
|
|
||||||
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 = ''
|
|
||||||
mediaPickerPage.value = 1
|
|
||||||
mediaPickerPagination.value = {
|
|
||||||
page: 1,
|
|
||||||
pageSize: mediaPickerPageSize,
|
|
||||||
total: 0,
|
|
||||||
pageCount: 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 대표 이미지로 선택 가능한 원본 이미지 목록
|
|
||||||
* @returns {Array<Object>} 자동 생성 이미지를 제외한 이미지 목록
|
|
||||||
*/
|
|
||||||
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 pageSize = Number(mediaPickerPagination.value.pageSize) || mediaPickerPageSize
|
|
||||||
const start = ((page - 1) * pageSize) + 1
|
|
||||||
const end = Math.min(start + pageSize - 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을 반환한다.
|
|
||||||
* 선택 값은 원본 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])
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 날짜·시간 입력의 네이티브 선택 UI를 연다.
|
* 날짜·시간 입력의 네이티브 선택 UI를 연다.
|
||||||
* @param {MouseEvent} event - 클릭 이벤트
|
* @param {MouseEvent} event - 클릭 이벤트
|
||||||
|
|||||||
340
composables/useAdminFeaturedImagePicker.js
Normal file
340
composables/useAdminFeaturedImagePicker.js
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
| composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) |
|
| composables/useImeSearchInput.js | 한글 등 IME 조합 중(`compositionupdate`)에도 검색어가 즉시 반영되는 검색 입력 처리기(표시값·`onInput`·`onCompositionEnd`, NFC 정규화·선택적 디바운스) |
|
||||||
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
|
| composables/useAdminSettingsNavigation.js | 관리자 설정 화면 내비게이션 검색·필터·활성 구역·스크롤 이동·스크롤 스파이 상태 |
|
||||||
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
|
| composables/useAdminPostTags.js | 관리자 게시물 폼 태그 파싱·추천·선택·삭제·키보드 처리 상태 |
|
||||||
|
| composables/useAdminFeaturedImagePicker.js | 관리자 게시물 폼 대표 이미지 선택 모달의 검색·페이지네이션·업로드·선택 상태 |
|
||||||
|
|
||||||
## 공유 라이브러리(서버·클라이언트 공통)
|
## 공유 라이브러리(서버·클라이언트 공통)
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- 거대 컴포넌트 책임 분리 진행.
|
- 거대 컴포넌트 책임 분리 진행.
|
||||||
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
- `components/admin/AdminMarkdownEditor.vue`: 미디어 모달, 슬래시 명령, 히스토리·선택 복원, 라이브/소스 전환 로직을 단계적으로 composable 또는 하위 컴포넌트로 분리.
|
||||||
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
- `components/content/ContentMarkdownRenderer.vue`: 파싱·블록 렌더링·라이브 편집 중개·라이트박스·광고 삽입 책임을 분리.
|
||||||
- `components/admin/AdminPostForm.vue`: 대표 이미지 선택, 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
- `components/admin/AdminPostForm.vue`: 발행/초안 저장 상태, 우측 설정 패널 책임을 분리.
|
||||||
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
- `pages/admin/media/index.vue`: 미디어 목록, 폴더 트리, 상세 모달, 재생성 액션을 작은 단위로 분리.
|
||||||
- 미디어 카탈로그 전환 코드 수명 정리.
|
- 미디어 카탈로그 전환 코드 수명 정리.
|
||||||
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
- 운영 `MEDIA_CATALOG_MODE=database` 안정화 후 `shadow`/`filesystem` 런타임 분기 축소 가능 여부 검토.
|
||||||
|
|||||||
@@ -1,5 +1,11 @@
|
|||||||
# 업데이트 이력
|
# 업데이트 이력
|
||||||
|
|
||||||
|
## v1.11.41
|
||||||
|
|
||||||
|
- 관리자 게시물 폼의 대표 이미지 선택·검색·페이지네이션·업로드 로직을 `useAdminFeaturedImagePicker` composable로 분리.
|
||||||
|
- 대표 이미지 모달 상태와 미디어 API 조회 흐름을 게시물 폼 외부에서 관리하도록 정리.
|
||||||
|
- 글쓰기 폼의 스크립트 책임을 줄이고 대표 이미지 선택 UI 연결만 남김.
|
||||||
|
|
||||||
## v1.11.40
|
## v1.11.40
|
||||||
|
|
||||||
- 관리자 게시물 폼의 태그 파싱·추천·선택·삭제 로직을 `useAdminPostTags` composable로 분리.
|
- 관리자 게시물 폼의 태그 파싱·추천·선택·삭제 로직을 `useAdminPostTags` composable로 분리.
|
||||||
|
|||||||
4
package-lock.json
generated
4
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.40",
|
"version": "1.11.41",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.40",
|
"version": "1.11.41",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nuxtjs/tailwindcss": "^6.14.0",
|
"@nuxtjs/tailwindcss": "^6.14.0",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "sori.studio",
|
"name": "sori.studio",
|
||||||
"version": "1.11.40",
|
"version": "1.11.41",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"imports": {
|
"imports": {
|
||||||
|
|||||||
Reference in New Issue
Block a user