diff --git a/app.vue b/app.vue
index b8ce315..002e13b 100644
--- a/app.vue
+++ b/app.vue
@@ -18,6 +18,31 @@ const siteAccentStyle = computed(() => ({
'--site-accent': siteAccentColor.value
}))
+const isSitePageNavigating = ref(false)
+
+if (import.meta.client) {
+ const nuxtApp = useNuxtApp()
+
+ /**
+ * 공개 페이지 이동 로딩 오버레이를 표시한다.
+ * @returns {void}
+ */
+ const showSitePageNavigationOverlay = () => {
+ isSitePageNavigating.value = true
+ }
+
+ /**
+ * 공개 페이지 이동 로딩 오버레이를 숨긴다.
+ * @returns {void}
+ */
+ const hideSitePageNavigationOverlay = () => {
+ isSitePageNavigating.value = false
+ }
+
+ nuxtApp.hook('page:loading:start', showSitePageNavigationOverlay)
+ nuxtApp.hook('page:loading:end', hideSitePageNavigationOverlay)
+}
+
useHead(() => ({
titleTemplate: (titleChunk) => titleChunk || appSiteSettings.value.title,
link: appSiteSettings.value.faviconUrl
@@ -35,8 +60,40 @@ useHead(() => ({
+
+
diff --git a/components/admin/AdminMarkdownEditor.vue b/components/admin/AdminMarkdownEditor.vue
index 89dd322..2d0e3ba 100644
--- a/components/admin/AdminMarkdownEditor.vue
+++ b/components/admin/AdminMarkdownEditor.vue
@@ -102,8 +102,17 @@ const {
displayValue: mediaSearchInput,
onInput: onMediaSearchInput,
onCompositionEnd: onMediaSearchCompositionEnd
-} = useImeSearchInput(mediaSearchQuery)
+} = useImeSearchInput(mediaSearchQuery, { debounce: 250 })
+const mediaPickerPage = ref(1)
+const mediaPickerPageSize = 50
+const mediaPickerPagination = ref({
+ page: 1,
+ pageSize: mediaPickerPageSize,
+ total: 0,
+ pageCount: 1
+})
const selectedMediaUrls = ref([])
+const selectedMediaItemMap = ref({})
const lastSelectionState = ref({
start: 0,
end: 0
@@ -160,6 +169,18 @@ const mediaPickerAccept = computed(() => {
return '.pdf,.zip,.txt,.csv,.docx,.xlsx,.pptx'
})
+const mediaPickerKind = computed(() => {
+ if (['image', 'gallery', 'active-gallery'].includes(mediaPickerTarget.value)) {
+ return 'image'
+ }
+
+ if (['video', 'audio', 'file'].includes(mediaPickerTarget.value)) {
+ return mediaPickerTarget.value
+ }
+
+ return 'all'
+})
+
/** 작성 textarea 최소 높이(px) */
const MIN_TEXTAREA_HEIGHT_PX = 620
@@ -3084,7 +3105,25 @@ const fetchMediaItems = async () => {
isLoadingMedia.value = true
try {
- mediaItems.value = await $fetch('/admin/api/media')
+ const response = await $fetch('/admin/api/media', {
+ query: {
+ paged: '1',
+ tab: 'library',
+ page: mediaPickerPage.value,
+ pageSize: mediaPickerPageSize,
+ kind: mediaPickerKind.value,
+ 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
}
@@ -3103,9 +3142,16 @@ const openMediaPicker = async (target) => {
mediaPickerTarget.value = target
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
+ mediaPickerPage.value = 1
+ mediaPickerPagination.value = {
+ page: 1,
+ pageSize: mediaPickerPageSize,
+ total: 0,
+ pageCount: 1
+ }
selectedMediaUrls.value = []
+ selectedMediaItemMap.value = {}
isMediaPickerOpen.value = true
- await fetchMediaItems()
}
defineExpose({
@@ -3139,39 +3185,103 @@ const closeMediaPicker = () => {
isMediaPickerOpen.value = false
selectedMediaUrls.value = []
+ selectedMediaItemMap.value = {}
activeMediaPickerTab.value = 'upload'
mediaSearchQuery.value = ''
+ mediaPickerPage.value = 1
+ mediaItems.value = []
+ mediaPickerPagination.value = {
+ page: 1,
+ pageSize: mediaPickerPageSize,
+ total: 0,
+ pageCount: 1
+ }
liveSlashInsertLine.value = null
}
/**
- * 미디어 항목이 검색어와 일치하는지 확인한다.
- * @param {Object} item - 미디어 항목
- * @param {string} query - 소문자 검색어
- * @returns {boolean} 일치 여부
+ * 현재 미디어 선택 페이지의 표시 목록
+ * @returns {Array